Skip to content

Add renderToString to repl-sdk + ember-repl#2165

Open
NullVoxPopuli-ai-agent wants to merge 7 commits into
NullVoxPopuli:mainfrom
NullVoxPopuli-ai-agent:add-render-to-string
Open

Add renderToString to repl-sdk + ember-repl#2165
NullVoxPopuli-ai-agent wants to merge 7 commits into
NullVoxPopuli:mainfrom
NullVoxPopuli-ai-agent:add-render-to-string

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Summary

Adds a build-time variant of Compiler#compile to repl-sdk, surfaced as Compiler#compileToSource(format, text, options) (and as options.renderToString: true on compile). Each compiler emits a JavaScript module string instead of evaluating and rendering — useful for SSG / pre-rendering pipelines that want to hand the output to their host app's bundler.

  • gjs — already returned its babel output as a string; the new path just propagates that without the blob/import dance.
  • hbs — emits a self-contained template(<source>, …) call sourced from @ember/template-compiler (build-time variant).
  • gmd — recursively asks each live code block for its renderToString form, then inlines them into one self-contained module:
    • Imports hoisted + deduped
    • Each demo wrapped in const Demo<N> = (() => { …; return _component; })();
    • Trailing template(prose, { scope: () => ({ …names… }) }) whose prose has the <div id="placeholderId"> HTML holes rewritten to <Demo<N> /> Glimmer invocations.

The output is a .gjs-shaped JS module that the host app's content-tag + babel pipeline precompiles to wire format. Pre-rendering pipelines can skip the runtime kolay → ember-repl → repl-sdk → parseMarkdown chain entirely.

ember-repl exposes the new path via CompilerService.compileToSource(ext, text, options).

Motivation

kolay's docs pre-render via vite-ember-ssr (SSG). Without this, even though the markdown HTML is server-rendered, each .gjs.md page still has to do a full runtime compile in the browser before its demos hydrate — leading to a visible flash of unstyled content during rehydration.

With renderToString, kolay's gjs-md build plugin can collapse the runtime chain into a single bundler-precompiled module per page.

Test plan

  • Unit tests for the lexical helpers (splitModule, mergeImports, wrapAsConst, replacePlaceholder, buildGmdModule) covering single-line / multi-line / side-effect / attributed imports, IIFE wrapping, import dedup, placeholder rewriting, scope-list generation, and zero-demo case.
  • Existing repl-sdk unit/parse tests still green.
  • Type-check: only pre-existing codemirror-lang-* missing-module errors remain.
  • End-to-end verification: wire kolay's gjs-md plugin to use this and confirm the docs-app FOUC disappears (follow-up PR).

🤖 Generated with Claude Code

NullVoxPopuli and others added 3 commits May 24, 2026 12:27
The build-time variant of `Compiler#compile`: when called with
`renderToString: true` (or via the new `compileToSource(...)` API),
each compiler emits a JavaScript module string instead of evaluating
the result and rendering into the DOM.

  - gjs already returned its babel output as a string; the new code
    path just propagates that without the blob/import dance.
  - hbs now emits a self-contained `template(<source>, …)` call from
    `@ember/template-compiler`.
  - gmd recursively asks every live code block for its
    renderToString form, then inlines each one into one self-contained
    module: imports hoisted + deduped, each demo wrapped in
    `const Demo<N> = (() => { …; return _component; })();`, and a
    trailing `template(prose, { scope: () => ({ …names… }) })` call
    whose prose has the `<div id="placeholderId">` HTML holes
    rewritten to `<Demo<N> />` Glimmer invocations.

The output is a `.gjs`-shaped JS module that the host app's
content-tag + babel pipeline can precompile to wire format — letting
SSG pipelines skip the runtime kolay → ember-repl → repl-sdk →
parseMarkdown chain entirely.

`ember-repl` exposes the new path via `CompilerService.compileToSource(ext, text, options)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ember-repl's rollup build runs `ember-tsc` against the published
`repl-sdk` type definitions (src/index.d.ts), not the source. The
first commit only added `compileToSource` to the internal
`#nestedPublicAPI` object — not to the `Compiler` class itself or
to the hand-maintained `.d.ts`. So ember-repl's
`this.compiler.compileToSource(...)` failed to type-check.

  - Add a real `compileToSource` method on the `Compiler` class
    forwarding to `compile(format, text, { ...options, renderToString: true })`.
  - Update the public API forwarder to call the new method.
  - Add `compileToSource` and the widened `compile` return type to
    `src/index.d.ts`.
  - Narrow the runtime-only `await this.#compile(...)` site in
    ember-repl's CompilerService to the rendered-element variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@github-actions

github-actions Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor
Project Preview URL1 Manage
Limber https://add-render-to-string.limber-glimdown.pages.dev on Cloudflare
Tutorial https://add-render-to-string.limber-glimmer-tutorial.pages.dev on Cloudflare

Logs

Footnotes

  1. if these branch preview links are not working, please check the logs for the commit-based preview link. There is a character limit of 28 for the branch subdomain, as well as some other heuristics, described here for the sake of implementation ease in deploy-preview.yml, that algo has been omitted. The URLs are logged in the wrangler output, but it's hard to get outputs from a matrix job.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

CI is green except one test: Output > Demos > The output frame renders every demo: All Frameworks in Markdown on both Chrome and Firefox.

The failure is TypeError: f.Buffer.isBuffer is not a function from mermaid → es-toolkit loaded via esm.sh CDN — unrelated to this PR's renderToString work. The same test loads mermaid + every framework from external CDNs; the Buffer shim used by es-toolkit's cleanAndMerge is missing in that bundle.

Verified the non-renderToString gmd path was not functionally changed — only TS narrowings.

Happy to rerun the failed jobs if you've got admin rights, or skip-the-flake / merge as-is.

NullVoxPopuli and others added 2 commits May 24, 2026 23:59
Previously the renderToString work was a parallel code path bolted on
next to gmd's existing runtime compile — `compileToSource()` rebuilt
the demos + prose alongside an unchanged `compile()` that built a
component via runtime `template()` and a render() that DOM-appended
sub-demos into placeholder divs.

Both paths now go through a single `buildGmdModule` call. The only
forks are:

  - Which `@ember/template-compiler` to import. Runtime form uses
    `/runtime`; renderToString uses the build-time form so the host
    app's babel pipeline precompiles the `template()` call.
  - How the live runtime scope crosses the source boundary. Runtime
    stashes scope on `globalThis[Symbol.for('repl-sdk:gmd-scope:N')]`
    and emits a module that destructures its keys into the prose's
    `scope: () => ({...})`. renderToString gets an empty scope.

gmd.compile() always returns `{ compiled: source, ... }` (or
`{ source }` when `renderToString: true`). The Compiler class's
existing blob-eval pipeline handles the runtime case — no more
special-casing for "compiler returned a component directly". gmd's
render() is now a thin renderComponent wrapper that also clears the
stashed scope on destroy. The placeholder div + appendChild loop is
gone; demo composition is via Glimmer scope.

`replacePlaceholder` now preserves the placeholder div's wrapping
(including its `class` attribute) so existing CSS targeting e.g.
`repl-sdk__demo` keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original PR widened `Compiler#compile`'s return type to a union of
`{ element, destroy } | { source }` so the same entry point could route
to either the runtime or build-time path. That widening forced every
caller (including ember-repl's `CompilerService` and the markdown
compiler's render path) to narrow the union with `as` / JSDoc casts.

Cleanup:

  - Stop auto-routing inside `compile()`. `compile()` is now narrowly
    typed to return `{ element, destroy }`; `compileToSource()` is its
    own entry point and returns `{ source }`. A shared `#runCompile`
    private method holds the announce/error wrapper so both methods
    behave identically around lifecycle logging.

  - In `#compileToSource`, replace the source-shape cast with `in`-
    based narrowing and return a fresh `{ source }` object instead of
    asserting the compiler's broader return value matches.

  - Fix `compilers/markdown/parse.d.ts` to accurately type the
    `codeBlocks` array (`{ format, flavor, code, placeholderId, meta }`)
    instead of the stale `{ lang, format, code, name }` declaration.
    With the right types in place, gmd's per-block destructure no
    longer needs `@type {string}` casts on every field.

  - In gmd's runtime path, store the live scope on `globalThis` under a
    string key (`__replSdk__gmdScope__<n>`) read/written via
    `Reflect.set` / `Reflect.deleteProperty`. `Reflect` accepts any
    `PropertyKey` without requiring an index signature on
    `typeof globalThis`, so the casts that wrapped the old
    `Symbol.for(...)` indexing go away too.

  - Drop the cast in ember-repl's `CompilerService#compile` and in
    `markdown.js`'s sub-render: with the narrowed `compile()` return
    type, both call sites read cleanly.

The diff against `main` for this PR no longer introduces a single `as`
or runtime-narrowing JSDoc cast; the only remaining `@type` annotations
introduced by this PR are variable-declaration types (equivalent to TS
`const foo: T = ...`), not cast expressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NullVoxPopuli and others added 2 commits May 25, 2026 11:25
Compilers shouldn't be hand-rolling globalThis namespaces; repl-sdk
already has the `manual:` URL resolver + `cache.resolves` plumbing for
exactly this — registering a JS value behind a virtual import
specifier so blob-eval'd modules can reach it via the regular ES
module graph.

Surface that as `api.provide(specifier, value)` on `PublicMethods`
(returns an unregister callback), and rewire gmd's runtime path to
use it:

  - gmd's `compile()` now calls `api.provide('repl-sdk:gmd-scope:N', scope)`
    and emits `import * as __scope__ from 'repl-sdk:gmd-scope:N'`. The
    Compiler's existing resolver chain handles the rest.

  - `buildGmdModule`'s `scope` shape is now `{ specifier, keys }`
    instead of `{ expression, keys }`; it emits a top-level
    `import * as __scope__ from '<specifier>'` rather than a const
    binding to a globalThis expression.

  - gmd no longer has any direct `globalThis` access — no
    `Reflect.set` / `Reflect.deleteProperty`, no string-keyed
    `globalThis[...]`. The unregister callback returned from
    `api.provide` is attached to the compile result so gmd's
    `render()` destroy can call it.

End-to-end verified against the kolay docs-app: prose stays mounted
through rehydration on every page (no FOUC); typedoc declarations
remain stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ycle

Previously `api.provide(specifier, value)` returned an unregister
callback that gmd had to thread through `extra` and remember to call
inside its render's destroy. That's the kind of bookkeeping individual
compilers should not have to get right — if one ever forgets (or
throws before the destroy chain is wired up), the cache.resolves entry
leaks indefinitely.

Refactor: the Compiler now hands a per-compile `CompileAPI` to every
`compile()` / `render()` invocation. It extends `PublicMethods` with
`provideScope(value) => { specifier }` — a Compiler-tracked
registration. The Compiler:

  - Generates a fresh `Set<string>` of provided specifiers at the
    start of every `#compile` / `#compileToSource` call.
  - Wraps the rendered-element destroy chain so the registered
    specifiers are released after the compiler's own teardown runs.
  - Also releases them on the unhappy paths — compile/blob-eval/render
    throwing before destroy is wired up, and `#compileToSource`
    returning a build-time source string with no render lifecycle.

gmd's `compile()` now takes the per-compile API as its third argument
and calls `compileApi.provideScope(scope)`; its `render()`'s destroy
shrinks back to `result.destroy()`. No more `__replSdkUnregisterScope`
on extras, no `scopeSpecifier`/`scopeNonce` helpers, no top-level
state to leak.

The existing `Compiler#compile` signature gains an optional third
`api?: CompileAPI` parameter; existing compilers (gjs, hbs, markdown,
mermaid, react, svelte, vue, js) accept two arguments and continue
to type-check against the optional-third overload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants