Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/ember-repl/src/services/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,39 @@ export default class CompilerService {
): Promise<CompileResult> {
return this.compile('md', source, options);
}

/**
* @public
*
* Build-time variant of {@link CompilerService.compile}: returns the
* compiled JS module source as a string instead of an evaluated component.
*
* Intended for SSG / pre-rendering pipelines that want to take the output
* of a live demo (or a `gmd` document containing many demos) and hand it
* to their own bundler, rather than evaluating it in the browser at boot.
*
* The returned string is a `.gjs`-shaped ES module — top-level imports
* plus an `export default` — that the consuming app's content-tag + babel
* pipeline can precompile to wire format.
*/
@waitFor
async compileToSource(
ext: string,
text: string,
options?: Record<string, unknown>
): Promise<{ source: string }> {
this.messages = [];

await Promise.resolve();

const opts = { ...(options ?? {}) };

if (ext === 'hbs') {
opts.flavor = 'ember';
}

return this.compiler.compileToSource(ext, text, opts);
}
}

function getGlobal() {
Expand Down
1 change: 1 addition & 0 deletions packages/repl-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
],
"license": "MIT",
"devDependencies": {
"@babel/standalone": "file:babel-standalone.tgz",
"@nullvoxpopuli/eslint-configs": "^5.4.0",
"@tsconfig/ember": "^3.0.7",
"@types/common-tags": "^1.8.4",
Expand Down
98 changes: 65 additions & 33 deletions packages/repl-sdk/src/compilers/ember/gmd.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @typedef {import('unified').Plugin} Plugin
*/
import { buildGmdModule } from '../../render-to-string.js';
import { assert, isRecord } from '../../utils.js';
import { buildCodeFenceMetaUtils } from '../markdown/utils.js';
import { makeOwner } from './owner.js';
Expand Down Expand Up @@ -56,6 +57,19 @@ export async function compiler(config, api) {
* @type {import('../../types.ts').Compiler}
*/
const gmdCompiler = {
/**
* Two shapes come out of here.
*
* The runtime form returns a live component for the prose alone. Each
* live codefence stays a separate island that `render` compiles and
* mounts into its placeholder, so demos keep their own module, their own
* owner, and the caller's `scope` object by reference.
*
* The renderToString form has no runtime to lean on: it has to hand back
* one self-contained module, so every demo is compiled to source and
* inlined. A live `scope` object cannot survive that trip, so build-time
* demos see an empty scope.
*/
compile: async (text, options) => {
const compileOptions = filterOptions(options);
const result = await parseMarkdown(text, {
Expand All @@ -69,19 +83,49 @@ export async function compiler(config, api) {
getFlavorFromMeta,
});

const { template } = await api.tryResolve('@ember/template-compiler/runtime');

const scope = {
...filterOptions(userOptions).scope,
...filterOptions(options).scope,
...userOptions.scope,
...compileOptions.scope,
};

if (isRecord(options) && options.renderToString === true) {
/** @type {Array<{ name: string, placeholderId: string, source: string }>} */
const demos = [];

let nth = 0;

for (const info of result.codeBlocks) {
const { format, flavor, code, placeholderId } = info;

if (!api.canCompile(format, flavor).result) continue;

nth++;

const sub = await api.compileToSource(format, code, {
...(options ?? {}),
flavor,
});

demos.push({ name: `Demo${nth}`, placeholderId, source: sub.source });
}

// Merging demo modules is the only thing here that needs an AST, so a
// document with no live demos never asks the host for babel.
let babel;

if (demos.length) {
const resolved = await api.tryResolve('@babel/standalone');

babel = 'packages' in resolved ? resolved : resolved.default;
}

return { source: buildGmdModule({ babel, prose: result.text, demos }) };
}

const { template } = await api.tryResolve('@ember/template-compiler/runtime');

const component = template(result.text, {
scope: () => ({
...scope,
// TODO: compile all the components from "result" and add them to scope here
// would this be better than the markdown style multiple islands
}),
scope: () => ({ ...scope }),
});

return { compiled: component, ...result, scope };
Expand Down Expand Up @@ -129,39 +173,27 @@ export async function compiler(config, api) {
/** @type {Record<string, unknown>} */
const infoObj = /** @type {Record<string, unknown>} */ (info);

if (
!api.canCompile(
/** @type {string} */ (infoObj.format),
/** @type {string} */ (infoObj.flavor)
)
) {
const format = /** @type {string} */ (infoObj.format);
const flavor = /** @type {string} */ (infoObj.flavor);

if (!api.canCompile(format, flavor).result) {
return;
}

const flavor = /** @type {string} */ (infoObj.flavor);
const hasScope =
flavor === 'ember' || infoObj.format === 'gjs' || infoObj.format === 'hbs';
const subRender = await compiler.compile(
/** @type {string} */ (infoObj.format),
/** @type {string} */ (infoObj.code),
{
...compiler.optionsFor(/** @type {string} */ (infoObj.format), flavor),
flavor: flavor,
// @ts-ignore
...(hasScope
? {
scope: extra.scope,
}
: {}),
}
);
const hasScope = flavor === 'ember' || format === 'gjs' || format === 'hbs';
const subRender = await compiler.compile(format, /** @type {string} */ (infoObj.code), {
...compiler.optionsFor(format, flavor),
flavor: flavor,
// @ts-ignore
...(hasScope ? { scope: extra.scope } : {}),
});

const selector = `#${/** @type {string} */ (infoObj.placeholderId)}`;
const target = element.querySelector(selector);

assert(
`Could not find placeholder / target element (using selector: \`${selector}\`). ` +
`Could not render ${/** @type {string} */ (infoObj.format)} block.`,
`Could not render ${format} block.`,
target
);

Expand Down
16 changes: 16 additions & 0 deletions packages/repl-sdk/src/compilers/ember/hbs.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ export async function compiler(config, api) {
*/
const hbsCompiler = {
compile: async (text, options) => {
if (isRecord(options) && options.renderToString) {
// Build-time form: emit a JS module that imports `template` from the
// build-time template-compiler. The host app's content-tag/babel
// pipeline will precompile the `template(...)` call to wire format.
//
// A runtime `scope` object cannot be serialized into source, so the
// emitted module declares an empty scope. Identifiers the hbs body
// references have to be in scope at the consumer instead.
const source =
`import { template } from '@ember/template-compiler';\n` +
`const _component = template(${JSON.stringify(text)}, { scope: () => ({}) });\n` +
`export default _component;\n`;

return source;
}

const { template } = await api.tryResolve('@ember/template-compiler/runtime');

const component = template(text, {
Expand Down
26 changes: 20 additions & 6 deletions packages/repl-sdk/src/compilers/markdown/parse.d.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import type { InternalOptions } from './types';

/**
* One live code fence's worth of information collected during the parse pass.
* Mirrors what `liveCodeExtraction` pushes onto `file.data.liveCode`.
*/
export interface LiveCodeBlock {
/** Language id from the code fence (e.g. `gjs`, `hbs`, `python`). */
format: string;
/** Optional flavor parsed from the meta (e.g. `ember`, `react`). */
flavor: string | undefined;
/** The fence body, trimmed. */
code: string;
/** Stable id used by the placeholder `<div id="…">` in the rendered HTML. */
placeholderId: string;
/** Raw meta string from the fence. */
meta: string | undefined;
}

export function parseMarkdown(
input: string,
options: InternalOptions
): Promise<{
text: string;
codeBlocks: Array<{
lang: string;
format: string;
code: string;
name: string;
}>;
codeBlocks: LiveCodeBlock[];
}>;

export function buildCompiler(options: InternalOptions): unknown;
15 changes: 15 additions & 0 deletions packages/repl-sdk/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,24 @@ export class Compiler {
options?: {
flavor?: string;
fileName?: string;
[key: string]: unknown;
}
): Promise<{ element: HTMLElement; destroy: () => void }>;

/**
* Build-time variant of {@link Compiler.compile}: returns the compiled JS
* module source as a string instead of evaluating and rendering.
*
* Intended for SSG / pre-rendering pipelines that want to hand the
* compiled output to their own bundler rather than evaluate it in the
* browser at boot.
*/
compileToSource(
format: string,
text: string,
options?: Record<string, unknown>
): Promise<{ source: string }>;

optionsFor(format: string, flavor?: string): Omit<CompilerConfig, 'compiler'>;

/**
Expand Down
95 changes: 94 additions & 1 deletion packages/repl-sdk/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,43 @@ export class Compiler {
* @returns {Promise<{ element: HTMLElement, destroy: () => void }>}
*/
async compile(format, text, options = {}) {
return this.#runCompile(this.#compile, format, text, options);
}

/**
* Build-time variant of {@link Compiler.compile}: returns the compiled JS
* module source as a string instead of evaluating and rendering. Each
* configured compiler sees `renderToString: true` on its options and is
* expected to emit a source string (`string` or `{ source: string, … }`)
* rather than a runtime component.
*
* @param {string} format
* @param {string} text
* @param {Record<string, unknown>} [options]
* @returns {Promise<{ source: string }>}
*/
async compileToSource(format, text, options = {}) {
return this.#runCompile(this.#compileToSource, format, text, options);
}

/**
* Shared wrapper for `compile` / `compileToSource`: announces lifecycle
* messages and forwards any thrown error through `on.log` before letting
* it propagate. Both public entry points share this so the user-visible
* logging is identical whether they're rendering or just getting source.
*
* @template T
* @param {(format: string, text: string, options: Record<string, unknown>) => Promise<T>} impl
* @param {string} format
* @param {string} text
* @param {Record<string, unknown>} options
* @returns {Promise<T>}
*/
async #runCompile(impl, format, text, options) {
this.#announce('info', `Compiling ${format}`);

try {
return await this.#compile(format, text, options);
return await impl.call(this, format, text, options);
} catch (e) {
// for on.log usage
this.#announce('error', errorMessage(e));
Expand All @@ -365,6 +398,57 @@ export class Compiler {
}
}

/**
* Build-time variant of `#compile`: returns the compiled JavaScript source
* as a string rather than loading it via a blob URL and rendering.
*
* Useful for SSG / pre-rendering pipelines that want to take the compiled
* output of a live demo (or a `gmd` document containing live demos) and
* hand it to their own bundler instead of evaluating it in the browser.
*
* Each compiler is asked to `compile(text, { renderToString: true, ... })`
* — it's the compiler's responsibility to honor the flag and return a
* source string (`string` or `{ source: string }`). The `gmd` compiler
* recursively threads `renderToString` through its per-format dispatch and
* inlines every demo into one self-contained module.
*
* @param {string} format
* @param {string} text
* @param {Record<string, unknown>} options
* @returns {Promise<{ source: string }>}
*/
async #compileToSource(format, text, options) {
const flavor = typeof options.flavor === 'string' ? options.flavor : undefined;
const fileName = typeof options.fileName === 'string' ? options.fileName : `dynamic.${format}`;
const opts = { ...options, fileName, renderToString: true };

const compiler = await this.#getCompiler(format, flavor);
const compiled = await compiler.compile(text, opts);

if (typeof compiled === 'string') {
return { source: compiled };
}

if (
compiled !== null &&
typeof compiled === 'object' &&
'source' in compiled &&
typeof compiled.source === 'string'
) {
return { source: compiled.source };
}

const shape =
compiled !== null && typeof compiled === 'object'
? Object.keys(compiled).join(', ')
: typeof compiled;

throw new Error(
`Compiler for format '${format}' was asked to renderToString but returned ` +
`${shape} instead of a source string.`
);
}

/**
* @param {string} format
* @param {string} text
Expand Down Expand Up @@ -670,6 +754,15 @@ export class Compiler {
* @param {Parameters<Compiler['compile']>} args
*/
compile: (...args) => this.compile(...args),
/**
* Build-time variant of `compile` — returns the compiled JS source as a
* string instead of rendering. Exposed on the public API so compilers
* (e.g. `gmd`) can recursively ask other compilers to renderToString.
*
* @param {Parameters<Compiler['compileToSource']>} args
*/
compileToSource: (...args) => this.compileToSource(...args),

/**
* @param {Parameters<Compiler['optionsFor']>} args
*/
Expand Down
Loading
Loading