Skip to content

feat(ember-vite): static docs export (.md per page + llms.txt) - #210

Merged
josemarluedke merged 23 commits into
mainfrom
feat/static-docs-export
Jul 30, 2026
Merged

feat(ember-vite): static docs export (.md per page + llms.txt)#210
josemarluedke merged 23 commits into
mainfrom
feat/static-docs-export

Conversation

@josemarluedke

@josemarluedke josemarluedke commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an opt-in static text export to @docfy/ember-vite. When enabled, a production build emits a statically-servable text mirror of the docs alongside the app:

  • dist/<page-url>.md — one file per page, at the same path as the live route plus a .md suffix
  • dist/llms.txt — links-only index grouped by section, per the llms.txt convention
  • dist/llms-full.txt — every page's content concatenated

Why: the docs site is client-rendered only, so a plain HTTP fetch — a crawler, curl, or an AI agent's web-fetch tool — gets the SPA shell rather than content. This makes the docs readable without running JavaScript.

Off by default and build-only; nothing is emitted during vite dev.

docfy({
  staticExport: {
    enabled: true,
    projectName: 'Docfy',
    projectDescription: 'Docfy is a modular JavaScript tool to help build documentation sites.',
  },
});

Links default to root-relative (/docs/getting-started.md), which the llms.txt spec permits — its format definition is only [name](url) — and which stays correct on deploy previews, forks, staging, and localhost. Set the optional siteUrl to emit absolute links instead, for consumers that read the text detached from its origin.

No changes to @docfy/core.

Design notes

The one non-obvious decision worth calling out. The export payload is page.pluginData.staticMarkdown ?? page.markdown, with the frontmatter block stripped.

page.markdown is the raw, untouched file source — set once in Docfy.createPage() and never written by any core plugin (nothing else in the monorepo reads it). Consuming apps that need transformed output — for example replacing a <Signature @component="Button" /> tag with a real Markdown table — set pluginData.staticMarkdown from a Docfy plugin in runAfter.

Doing it in runAfter on Markdown text, rather than mutating the AST, is deliberate: by that point page.ast has already been destructively converted to hast, and page.rendered — which the live SPA route templates are built from — is stringified from that same object. Mutating the AST would corrupt the rendered app. Mutating markdown text is inherently isolated, so no cloning of result.content is needed.

Ordering for both llms.txt and llms-full.txt comes solely from result.nestedPageMetadata, which already encodes section labels and the resolved sections order config.

Changes

File Change
src/static-export.ts New. All content logic, no IO — pure and unit-testable.
src/file-manager.ts Extracted writeTextToPublic(content, fileName); writeJsonToPublic now delegates to it. Behavior preserved for its existing caller.
src/config.ts Added staticExport to DocfyViteOptions, destructured out so it can't leak into @docfy/core's options.
src/docfy-processor.ts handleStaticExport(), called from processAll(), gated build-only.
src/index.ts Option plumbing + eager siteUrl validation via this.error().
README.md Documented the option and the pluginData.staticMarkdown contract.
test-app-vite/vite.config.mjs Enabled, as the verification vehicle.

Testing

54 unit tests across 4 files (the package's first tests — vitest was configured but unused). yarn typecheck, yarn lint, and Prettier all clean.

Also verified against a real build of test-app-vite, which sources this repo's own docs/:

  • 15 .md files emitted; dist/docs/index.md and dist/docs/ember/index.md correct — trailing-slash index URLs become index.md rather than a broken docs/.md
  • llms.txt carries the required # Docfy H1, then the blockquote, then ## Documentation / ## Ember in the configured section order; all 15 root-relative links resolve to real emitted files
  • Zero frontmatter leaks across all 15 files, checked against sources that do have frontmatter
  • Dev mode confirmed a no-op — no files appear in public/
  • Misconfiguration confirmed to fail loudly: an invalid siteUrl (missing scheme, wrong protocol, or carrying a query/fragment) fails the build with a clear message and a non-zero exit, rather than silently emitting broken links

Note

test-app-vite's acceptance suite (testem ci) is currently red and I could not observe it green. It fails with Uncaught ReferenceError: define is not defined in @embroider/virtual/test-support.js — an AMD-loader ordering failure in the test bundle.

This looks pre-existing and unrelated: the failure reproduces identically with this branch's config change reverted, and the only runtime-affecting change here is emitFile calls for standalone .md/.txt assets, which cannot alter module format, chunk graph, or script order. It was not diagnosed, though, so it's worth a look in real CI and probably its own issue — flagging it explicitly rather than letting a red suite get silently inherited.

Follow-ups (deliberately not in this PR)

  • frontile side, which this unblocks: emit signature data as JSON, add the <Signature> → Markdown-table plugin writing to pluginData.staticMarkdown, enable the option.
  • frontile/docs/superpowers/specs/ sits inside that app's Docfy source root with pattern **/*.md, and core's DEFAULT_IGNORE only covers node_modules/.git/dist — so design docs are already built as docs pages and would land in llms.txt. Needs ignore: ['superpowers/**'].
  • Rewriting internal links to .md so an agent crawling the mirror stays in the mirror.
  • Generated .gjs templates under test-app-vite/app/templates/docs/ are git-tracked and get rewritten by every build. Pre-existing; worth gitignoring.
  • Tighten handleAssets(result: any) / processAll(): Promise<any> now that DocfyResult is imported.

🤖 Generated with Claude Code

josemarluedke and others added 22 commits July 29, 2026 19:52
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Validate staticExport eagerly in buildStart via a new
validateStaticExportOptions helper and this.error(...), so a missing
siteUrl fails the Vite build instead of being swallowed silently.
Also add an optional projectName to emit the llms.txt-convention H1,
warn in the README against the meta.pluginData footgun, and add doc
comments plus options-table clarifications.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Test step only ran packages/core, so ember-vite's suite never
executed in CI. Added as an explicit step rather than switching to the
root `yarn test` (`lerna run --parallel test`), which would also pull in
test-app-vite's `testem ci` browser suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e links

llms.txt and llms-full.txt now default to root-relative links (e.g.
/docs/about.md) instead of requiring an absolute siteUrl, so deploy
previews, forks, staging, and local builds all work without
configuration. siteUrl remains available as an opt-in for absolute
links, and is now validated as an absolute http(s) URL when provided.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A siteUrl like "https://docfy.dev/foo?x=1" passed validation but produced
"https://docfy.dev/foo?x=1/docs/about.md", since the page path is appended
to the configured origin. A path is still allowed — docs served under a
subpath concatenate correctly — only a query or fragment is rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The llms.txt convention treats the H1 project name as its only required
element, but projectName was optional, so a minimal `{ enabled: true }`
emitted an llms.txt with no H1 while the docs claimed convention
adherence.

@docfy/core has no project-name option to reuse, so this falls back to
the consuming app's package.json name — the same source `repository`
already defaults from, adding no new user-facing config. An explicit
projectName still wins.

Also documents the whole static export feature in docs/ember/ember-vite.md
so it appears on the docs site, not just in the package README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces yarn 1 workspaces with pnpm. Three latent bugs that yarn's flat
node_modules had been masking had to be fixed for a clean install:

- Cross-workspace deps used plain semver ranges. pnpm 10+ defaults
  link-workspace-packages to false, so those resolved from the registry
  instead of linking locally — test-app-vite was building against the
  published @docfy/ember-vite. Converted all 9 declarations to the
  workspace: protocol.
- packages/plugin-with-prose/tsconfig.json hardcoded typeRoots to the root
  node_modules/@types, which only exists under a hoisted layout. Removed
  the override; TypeScript's default resolution finds the package's own
  @types. The "types" dir it also listed does not exist.
- @docfy/ember-cli imports from 'unist' but declared no @types at all,
  relying on a sibling's hoisted copy. Declared @types/unist directly.

pnpm 11 reads `overrides` from pnpm-workspace.yaml, not package.json, so
yarn's `resolutions` pin moved there. Without it @types/unist resolves to
2.0.11, whose generic Node breaks @docfy/ember-cli's compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root scripts move to pnpm's native recursive runner; lerna.json switches
npmClient to pnpm and drops the dead command.bootstrap block (lerna 9 has
no bootstrap command).

Two more phantom dependencies had to be declared before `pnpm compile`
would succeed — both were satisfied by accident under yarn's flat tree:

- @docfy/ember imports @docfy/core/lib/types in four source files but
  declared @docfy/core nowhere. Added to dependencies rather than
  devDependencies because its published declarations/ reference those
  types, so consumers must be able to resolve them.
- @docfy/ember-vite imports from 'unist' in src/types.ts without
  declaring @types/unist. Added to devDependencies, matching how
  @docfy/core already declares it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more phantom dependencies: @docfy/ember-vite and @docfy/ember-cli both
import '@eslint/js' in their eslint.config.mjs without declaring it. Every
other package in the repo declares it at ^9.32.0; these two relied on
yarn hoisting it from a sibling.

pnpm also blocks dependency build scripts by default. esbuild's postinstall
installs its platform binary (vite cannot build without it) and nx is
lerna's task engine, so both are allowed via allowBuilds. core-js 2.x's
postinstall only prints a funding banner and stays off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tsconfigs hardcoded typeRoots to the repo-root node_modules/@types,
which only exists under a hoisted layout. Under pnpm this left
@types/jest unresolvable, so @docfy/core's 11 jest suites failed to run
with "Cannot find name 'describe'".

Each now points at its own package's node_modules/@types. The local
"types" entries are preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pnpm/action-setup precedes setup-node so `cache: pnpm` can resolve the
store path, and takes its version from the root packageManager field.
Drops `npm install -g yarn` and gains dependency caching.

This is the change the migration exists for: yarn 1 fetches from
registry.yarnpkg.com, which the runners could not reach; pnpm fetches
from registry.npmjs.org.

Test steps stay explicit per package rather than `pnpm -r run test`,
which would pull in the two browser suites — one of which is red for an
unrelated, undiagnosed reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consumer-facing install docs are deliberately left on npm/yarn — how a
user installs @docfy/* into their own project is not this repo's concern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@docfy/ember-vite and @docfy/plugin-with-prose both import
eslint-plugin-jest in their eslint.config.mjs without declaring it,
relying on yarn hoisting it from @docfy/core.

Found by auditing every package's eslint config imports against its
declared dependencies rather than fixing them one failure at a time;
that audit now reports all imports declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@josemarluedke josemarluedke added the Type: Enhancement New feature or request label Jul 30, 2026
@josemarluedke
josemarluedke merged commit 2eb9aca into main Jul 30, 2026
9 checks passed
@josemarluedke
josemarluedke deleted the feat/static-docs-export branch July 30, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant