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.

Equivalent constructs across four generators A comparison matrix with three rows and four columns. For a layout snippet: Jekyll include, Hugo partial, Eleventy include, Astro component. For a content snippet: Jekyll include, Hugo shortcode, Eleventy shortcode, MDX component. For a wrapping snippet: Jekyll capture block, Hugo paired shortcode with dot Inner, Eleventy paired shortcode, MDX component with children. One idea, four spellings Jekyll Hugo Eleventy Astro In a layout include partial include component In content include (again) shortcode shortcode MDX component Wrapping content capture block paired · .Inner paired shortcode children / slot Jekyll's single include tag covering two rows is why its migrations need a manual split
The mapping is mechanical except for the first column: Jekyll uses one construct for two jobs, so each call site has to be classified before it can be ported.

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.

ShortcodeCall sitesDecision
figure412Port fully — carries src, alt, caption, width
callout268Port fully — three types in use
tabs / tab96Port fully — paired, nested
version-badge44Port, simplify to one parameter
api-table18Rewrite as a data-driven component
23 others61 totalInline 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));
Moving the fetch out of the component Before: a shortcode fetches an API at render time, so the generator's data API is part of the component and the build needs network access. After: a build step fetches once and writes a JSON file, and the component in any generator reads that file, so only the fetch script is generator-independent code. Fetch once in a script, not once per render Before shortcode fetches API build needs network flaky, per-generator After build step writes JSON component reads file offline, portable The JSON file can be committed, so a build with no network still produces identical output
Splitting fetch from render makes the component trivially portable and the build reproducible — two problems solved by one refactor you were going to do anyway.

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:

StageConstructs remainingPages rendering correctlyEffort
Conversion script only28 unported41%0.5 day
Stubs added for all28 stubbed100% (degraded)0.5 day
Top 4 ported24 stubbed92% (fully correct)2 days
Data-fetching ones moved to build step22 stubbed96%1 day
Tail inlined or deleted0100%1.5 days
Pages rendering correctly as porting progresses A line chart over five stages. After the conversion script only 41 percent of pages render correctly. Adding stubs takes every page to a readable state. Porting the top four shortcodes reaches 92 percent fully correct, moving data-fetching shortcodes to a build step reaches 96 percent, and inlining the tail reaches 100 percent. Stubs first: never let the build be the blocker 100% 0% 41% readable 92% 96% 100% converted stubbed top 4 ported data extracted tail inlined 1,400 pages, 28 shortcodes · the stub stage costs half a day and removes every blocked page
The second stage is the one teams skip. Stubbing everything means the rest of the migration proceeds against a fully building site, which is what lets template work and content review happen in parallel.

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.