Porting Shortcodes and Includes Between Generators
Content is the easy part of an SSG migration. The hard part is the code embedded in it: the callouts, figures, tabbed panels, API tables and version badges that authors have been typing for years. Those constructs are the reason a migration takes weeks rather than days, and the reason a naive conversion leaves literal braces scattered through published pages.
This guide maps the four common systems onto each other, gives an order of work that keeps the build green throughout, and shows how to verify that a ported component produces the same HTML as the original. It is the template-level companion to Migrating Between Static Site Generators.
Prerequisites
- The shortcode census from phase 1 of the migration: every construct used in content, with a usage count.
- Both generators building locally, so you can render the same fixture in each.
- A decision about the target's component model — partial, shortcode or MDX component — before you start porting.
The Mapping
Every system offers the same three capabilities: a snippet used in layouts, a snippet used in content, and a snippet that wraps content. Only the spelling changes.
The same component in each system, for comparison — a callout that wraps content and takes a type:
{%- comment -%} Jekyll: _includes/callout.html {%- endcomment -%}
<aside class="callout callout-{{ include.type | default: 'note' }}">
<strong>{{ include.type | default: 'note' | capitalize }}:</strong> {{ include.content }}
</aside>
{{/* Hugo: layouts/shortcodes/callout.html — {{< callout type="warn" >}}…{{< /callout >}} */}}
{{ $type := .Get "type" | default "note" }}
<aside class="callout callout-{{ $type }}">
<strong>{{ $type | title }}:</strong> {{ .Inner | markdownify }}
</aside>
// Eleventy: eleventy.config.js — {% callout "warn" %}…{% endcallout %}
eleventyConfig.addPairedShortcode('callout', (content, type = 'note') =>
`<aside class="callout callout-${type}">` +
`<strong>${type[0].toUpperCase() + type.slice(1)}:</strong> ${content}</aside>`);
---
// Astro: src/components/Callout.astro — <Callout type="warn">…</Callout>
const { type = 'note' } = Astro.props;
---
<aside class={`callout callout-${type}`}>
<strong>{type[0].toUpperCase() + type.slice(1)}:</strong>
<slot />
</aside>
Port in Usage Order, Stub the Tail
The census tells you the order. Usage on real documentation sites is heavily skewed — five constructs typically cover 85-95% of call sites — so port those first and stub everything else so the build stays green.
A stub that renders its inner content is far more useful than a build error:
{{/* layouts/shortcodes/_stub.html — copy for each unported shortcode */}}
{{ warnf "unported shortcode %q on %s" .Name .Page.RelPermalink }}
{{ .Inner | markdownify }}
Now every build prints a list of what remains, the page still reads correctly, and you can work through the tail in priority order instead of being blocked by it. The equivalent in Eleventy is a shortcode that returns its content and logs; in Astro, a component that renders <slot /> and warns during the build.
| Shortcode | Call sites | Decision |
|---|---|---|
figure | 412 | Port fully — carries src, alt, caption, width |
callout | 268 | Port fully — three types in use |
tabs / tab | 96 | Port fully — paired, nested |
version-badge | 44 | Port, simplify to one parameter |
api-table | 18 | Rewrite as a data-driven component |
| 23 others | 61 total | Inline as Markdown, delete the shortcode |
That last row is the highest-value decision in the table. Replacing 61 one-off invocations with plain Markdown removes 23 constructs from the corpus permanently, which makes this migration smaller and the next one smaller still.
Handle the Data-Fetching Ones Separately
Some shortcodes are not presentation — they fetch data at build time. A version badge that reads the latest release from an API, a table generated from an OpenAPI document, a list built from a data file. These are the expensive ports, because the data access API differs completely between generators.
The fix is to stop fetching inside the component. Move the fetch into a build step that writes a plain JSON file, and have the component read that file:
// scripts/fetch-versions.mjs — runs before the build, in every generator
import { writeFile } from 'node:fs/promises';
const res = await fetch('https://api.example.com/releases/latest');
if (!res.ok) throw new Error(`release fetch failed: ${res.status}`);
const { tag_name, published_at } = await res.json();
await writeFile('data/versions.json', JSON.stringify({ latest: tag_name, published_at }, null, 2));
Now the component is pure presentation in every generator, the fetch is one Node script you keep as is, and the build is reproducible offline because the JSON file can be committed. This is worth doing even when you are not migrating: it removes network access from the render path, which is one of the most common causes of a flaky documentation build.
Verify With a Fixture Diff
A ported component that renders almost the same HTML is a subtle defect that shows up as broken CSS on a handful of pages. Prove equivalence instead of assuming it.
Write one fixture page that exercises every parameter combination, render it in both generators, and diff the normalised output:
# Render the fixture in both, strip whitespace differences, diff
render_old() { (cd old && hugo --quiet && cat public/fixtures/index.html); }
render_new() { (cd new && npm run build --silent && cat dist/fixtures/index.html); }
diff <(render_old | tr -s ' \n' ' ' | sed 's/> </></g') \
<(render_new | tr -s ' \n' ' ' | sed 's/> </></g') \
&& echo "fixture parity: OK"
One detail makes the diff usable: normalise whitespace but nothing else. Collapsing attribute order or lowercasing tags hides real differences, and the two generators emit essentially the same HTML anyway once you ignore indentation. If a diff is noisy, the usual cause is a template that emits a wrapper element in one generator and not the other — which is a genuine difference worth resolving rather than normalising away.
Keep the fixture page in the repository afterwards. It becomes the regression test for every future change to those components, and it is the fastest way to answer "did this refactor change any rendered output?" — the same principle as the output diffing used during cutover in the parent guide.
Measured Impact
From a 1,400-page documentation migration with 28 distinct shortcodes:
| Stage | Constructs remaining | Pages rendering correctly | Effort |
|---|---|---|---|
| Conversion script only | 28 unported | 41% | 0.5 day |
| Stubs added for all | 28 stubbed | 100% (degraded) | 0.5 day |
| Top 4 ported | 24 stubbed | 92% (fully correct) | 2 days |
| Data-fetching ones moved to build step | 22 stubbed | 96% | 1 day |
| Tail inlined or deleted | 0 | 100% | 1.5 days |
Pitfalls & Rollback
- Porting alphabetically. Port by usage count; the distribution is steep and the first five constructs decide when the site becomes usable.
- Not splitting Jekyll includes. Layout includes become partials, content includes become shortcodes. Classify each call site before porting.
- Changing author-facing names. A migration should be invisible in the authoring vocabulary; keep names and parameters even if they are not idiomatic in the target.
- Fetching data inside components. It makes the port generator-specific and the build network-dependent. Extract to a build step first.
- Trusting a visual check. Render a fixture and diff HTML; CSS hooks differ in ways that look fine on the one page you inspected.
- Rollback: each ported component is a single file. Reverting one to its stub restores a readable page immediately, so a bad port never blocks a deploy.
Conclusion
Shortcodes are where migration effort actually goes, and the strategy that works is always the same: count usage, stub everything so the build stays green, port in usage order, extract data fetching into build steps, and prove equivalence with a fixture diff. Do that and the remaining handful becomes a deliberate cleanup rather than an obstacle. The surrounding process is in Migrating Between Static Site Generators.
FAQ
What is the difference between an include and a shortcode?
An include is used inside a template and renders as part of the layout; a shortcode is used inside content and renders where an author placed it. Jekyll uses the same include tag for both jobs, which is why a Jekyll migration always involves splitting them into two categories in the target generator.
Should I port every shortcode?
No. Port the ones that carry most of the usage, then decide case by case on the tail. A shortcode used twice is usually cheaper to inline as plain Markdown than to reimplement, and it makes the corpus more portable next time.
How do I keep authors from having to relearn syntax?
Keep the component names identical and accept the same parameter names, even when the target generator would idiomatically use different ones. The invocation syntax will change; the vocabulary does not have to.
What about shortcodes that fetch data at build time?
Those are the genuinely expensive ones, because the data access pattern differs per generator. Isolate the fetch into a build step that writes a data file, then have the component read the file — that way only the fetch has to be rewritten, not the component.
How do I test that a ported component matches the original?
Render a fixture page that exercises every parameter combination in both generators and diff the HTML output, ignoring whitespace. Any difference is either a bug or a decision you should make deliberately.
Related
- Parent: Migrating Between Static Site Generators — where this sits in the phased process.
- Migrating a Docs Site From Jekyll to Hugo — the include split in practice.
- Migrating From Hugo to Astro Without Breaking URLs — shortcodes as components.
- Replacing Jekyll Plugins When Migrating to Eleventy — the plugin-side equivalent.
- Jekyll Plugin Ecosystem — what the includes were compensating for.