Plugins are small Ruby classes that run at build time to inject dynamic values into your linkyee site (GitHub stars, latest blog posts, last commit date, anything you can fetch from a URL). The build runs once on push and once a day via GitHub Actions, so plugins do not run in the user's browser — they just bake values into the generated HTML.
This wiki is the canonical reference for writing your own plugin. It is
also the source of truth that the linkyee-plugin-builder
Claude skill reads when an AI generates a plugin for you.
- How it works
- Plugin contract
- Quick start: your first plugin in 30 seconds
- Built-in plugins
- Helpers provided by the base class
- Common patterns (HTTP, JSON, scraping, caching)
- Rendering output in Liquid
- Debugging
- Letting AI write your plugin
config.yml ──► scaffold.rb ──► instantiate plugin ──► execute() ──► vars.<PluginName>
│
▼
rendered into Liquid templates
- You enable a plugin in
config.ymlunderplugins:. scaffold.rbrequires./plugins/<PluginName>.rb, instantiates the class with the YAML values, and callsexecute().- The return value is stored in
settings["vars"][<PluginName>]. - Every string in
config.yml(links, socials, title, footer…) and the theme'sindex.htmlis rendered through Liquid, so you can reference{{ vars.<PluginName> }}anywhere. - If a plugin raises,
scaffold.rblogs the error and sets the value tonil— the build still succeeds.
A plugin is a Ruby class that:
| Rule | Detail |
|---|---|
Inherits from Plugin |
class MyPlugin < Plugin |
Lives at ./plugins/MyPlugin.rb |
Filename must match the class name |
Implements execute |
Returns a Liquid-renderable value (String, Number, Hash with String keys, Array, or nested combos) |
| Is defensive | Returns a safe default on network/parse failure — never raise |
| Has no side effects | Don't write files; just return data |
That's it. No registration, no manifest.
Add this to plugins/HelloPlugin.rb:
require_relative 'Plugin'
class HelloPlugin < Plugin
def execute
"Hello, #{params['name'] || 'world'}!"
end
endEnable it in config.yml:
plugins:
- HelloPlugin:
name: linkyee
footer: "{{ vars.HelloPlugin }}"Run ruby scaffold.rb and open _output/index.html — the footer reads
Hello, linkyee!.
| Plugin | Purpose | Argument style |
|---|---|---|
GithubRepoStarsCountPlugin |
Star counts for one or more repos | List of owner/repo |
GithubLastCommitPlugin |
Latest commit sha / date / message | List of owner/repo |
GithubProfilePlugin |
Followers and public-repo count | List of GitHub usernames |
RSSFeedPlugin |
Latest items from an RSS / Atom feed (Medium, blogs, YouTube, podcasts) | List of feed URLs |
CountdownPlugin |
Days until / since target dates | Hash of label: YYYY-MM-DD |
YouTubeChannelLatestVideoPlugin |
Latest video for one or more YouTube channels | List of channel ID / @handle / channel URL |
Each file is short — read them as templates when writing your own.
Defined in Plugin.rb.
args # => first argument list, e.g. ["repo1", "repo2"]
params # => first argument when it is a Hash, e.g. {"url" => "...", "limit" => 5}
data # => the raw arguments array (escape hatch)YAML list-style → use args:
- MyPlugin:
- first
- secondYAML hash-style → use params:
- MyPlugin:
url: https://example.com
limit: 5GET an HTTP(S) URL with redirect following and a sensible User-Agent.
Returns a Net::HTTPResponse or nil. Never raises.
res = http_get("https://example.com/data")
return [] unless res.is_a?(Net::HTTPSuccess)
process(res.body)Same as http_get but parses the body as JSON. Returns default on any
failure (network, non-2xx, malformed JSON).
data = http_get_json("https://api.github.com/repos/#{repo}", default: {})
data["stargazers_count"] || 0Memoize across plugin instances within a single build. Useful when several plugins share data (e.g. multiple GitHub plugins hitting the same repo).
cache("repo:#{repo}") { http_get_json("https://api.github.com/repos/#{repo}") }Prints to stderr with the plugin class as prefix. Visible in GitHub Actions logs.
log("falling back to cached value")class WeatherPlugin < Plugin
def execute
args.each_with_object({}) do |city, out|
json = http_get_json("https://wttr.in/#{city}?format=j1", default: {})
out[city] = json.dig("current_condition", 0, "temp_C") || "?"
end
end
endrequire 'nokogiri'
class HNFrontPagePlugin < Plugin
def execute
res = http_get("https://news.ycombinator.com/")
return [] unless res.is_a?(Net::HTTPSuccess)
Nokogiri::HTML(res.body).css(".titleline > a").first(5).map do |a|
{ "title" => a.text, "url" => a["href"] }
end
end
endAlways return the same shape on success and failure — Liquid templates break ugly when a Hash becomes a String:
empty = { "count" => 0, "label" => "" }
return empty unless res.is_a?(Net::HTTPSuccess)
⚠️ Never put credentials inconfig.ymlor in a plugin's.rbsource.config.ymlis committed to git and rendered into the public gh-pages site; once a token lands in commit history, treat it as compromised.
The only acceptable storage for an API key, PAT, OAuth token, or
similar is a GitHub Actions repository secret, exposed to the build
via env-var, read from ENV inside the plugin.
Three-step setup:
- Add the repo secret — GitHub → your repo → Settings → Secrets
and variables → Actions → New repository secret. Name it
(e.g.
MEDIUM_TOKEN), paste the value. - Pass it through the workflow — edit
.github/workflows/build.ymland add anenv:block to theDeploystep:- name: Deploy env: MEDIUM_TOKEN: ${{ secrets.MEDIUM_TOKEN }} run: bash deploy.sh
- Read it in the plugin — and bail gracefully if missing:
token = ENV["MEDIUM_TOKEN"] return [] if token.nil? || token.empty? http_get_json(url, headers: { "Authorization" => "Bearer #{token}" })
For local development, export the same env var in your shell before
running bundle exec ruby ./scaffold.rb. Do not check it into
.envrc / .env files that might be committed; if you use direnv,
add .envrc to .gitignore.
Document the secret name in the plugin file's header comment so the next maintainer knows what to provision.
Anything execute returns lands at vars.<PluginName>. Examples:
# Scalar
title: "{{ vars.HelloPlugin }}"
# Hash lookup by key
text: "{{ vars.GithubRepoStarsCountPlugin['ZhgChgLi/linkyee'] }} Stars"
# Nested hash
text: "Last update: {{ vars.GithubLastCommitPlugin['ZhgChgLi/linkyee'].date }}"
# Iteration (works in theme index.html, not in config.yml string fields)
{% for post in vars.RSSFeedPlugin['https://blog.zhgchg.li/feed'] %}
<a href="{{ post.url }}">{{ post.title }}</a>
{% endfor %}Important:
- Use String keys in returned hashes (
"name", not:name). Liquid cannot look up symbol keys. config.ymlonly renders inline{{ ... }}, not{% for %}blocks. For loops/conditionals, edit the theme'sindex.html.
Run the build locally and watch stderr:
ruby scaffold.rb
# look for: [MyPlugin] http_get(...) failed: ...
# or: [scaffold] Plugin 'MyPlugin' failed: ...Inspect the rendered output:
open _output/index.htmlIf a {{ vars.X }} reference renders as empty, the plugin probably
returned nil — check the build log for the failure reason.
You can also puts arbitrary debug data from inside execute; it goes
to the build log without affecting the page.
Open this repo with Claude Code and just describe what you want, e.g.:
"Add a plugin that shows the current weather in Taipei in the footer."
"Add a plugin that fetches the latest 3 posts from my Medium feed and lists them as additional links."
The bundled linkyee-plugin-builder
skill knows this contract and will: read your config.yml → confirm the
data source and shape → generate plugins/<YourPlugin>.rb → wire it up
in config.yml → run ruby scaffold.rb to verify.