@nowline/embed 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ import { type NowlineFile } from '@nowline/core';
2
+ import { type ThemeName } from '@nowline/layout';
3
+ export interface EmbedRenderOptions {
4
+ theme?: ThemeName;
5
+ today?: Date;
6
+ locale?: string;
7
+ width?: number;
8
+ /**
9
+ * Override the deterministic id prefix used for in-SVG `<style>`
10
+ * scoping. Each block on a page should use a unique prefix so two
11
+ * roadmaps cannot bleed styles into each other; the auto-scan path
12
+ * generates a per-block prefix and threads it here.
13
+ */
14
+ idPrefix?: string;
15
+ }
16
+ export interface EmbedParseResult {
17
+ ast: NowlineFile;
18
+ /** Lexer + parser + Langium validation diagnostics, normalized to strings. */
19
+ errors: string[];
20
+ }
21
+ export declare function parseSource(source: string): Promise<EmbedParseResult>;
22
+ export declare function renderSource(source: string, options?: EmbedRenderOptions): Promise<string>;
23
+ export declare class EmbedRenderError extends Error {
24
+ readonly details: string[];
25
+ constructor(message: string, details: string[]);
26
+ }
27
+ export declare function __resetEmbedPipelineForTests(): void;
28
+ //# sourceMappingURL=pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"AAOA,OAAO,EAEH,KAAK,WAAW,EAGnB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAKhE,MAAM,WAAW,kBAAkB;IAC/B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,KAAK,CAAC,EAAE,IAAI,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC7B,GAAG,EAAE,WAAW,CAAC;IACjB,8EAA8E;IAC9E,MAAM,EAAE,MAAM,EAAE,CAAC;CACpB;AAoBD,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAa3E;AAED,wBAAsB,YAAY,CAC9B,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GACjC,OAAO,CAAC,MAAM,CAAC,CAiDjB;AAED,qBAAa,gBAAiB,SAAQ,KAAK;aAGnB,OAAO,EAAE,MAAM,EAAE;gBADjC,OAAO,EAAE,MAAM,EACC,OAAO,EAAE,MAAM,EAAE;CAKxC;AAKD,wBAAgB,4BAA4B,IAAI,IAAI,CAInD"}
@@ -0,0 +1,94 @@
1
+ // Render pipeline shared by `nowline.render(source)` and the auto-scan
2
+ // path. Mirrors the shape of `packages/vscode-extension/src/preview/
3
+ // render-pipeline.ts` but stripped of every Node-only dependency: no
4
+ // `fs`, no `path`, no asset resolver. Includes are resolved against a
5
+ // no-op `readFile` so a file containing `include "./other.nowline"`
6
+ // renders the parts that survive without a network fetch.
7
+ import { createNowlineServices, resolveIncludes, } from '@nowline/core';
8
+ import { layoutRoadmap } from '@nowline/layout';
9
+ import { renderSvg } from '@nowline/renderer';
10
+ import { URI } from 'langium';
11
+ import { isNoOpIncludeDiagnosticMessage, noOpIncludeReadFile } from './no-op-include-resolver.js';
12
+ let cachedServices;
13
+ let docCounter = 0;
14
+ let includeWarningEmitted = false;
15
+ function getServices() {
16
+ if (!cachedServices)
17
+ cachedServices = createNowlineServices();
18
+ return cachedServices;
19
+ }
20
+ function freshUri() {
21
+ return URI.parse(`memory:///nowline-embed-${++docCounter}.nowline`);
22
+ }
23
+ export async function parseSource(source) {
24
+ const services = getServices();
25
+ const docFactory = services.shared.workspace.LangiumDocumentFactory;
26
+ const doc = docFactory.fromString(source, freshUri());
27
+ await services.shared.workspace.DocumentBuilder.build([doc], { validation: true });
28
+ const errors = [];
29
+ for (const e of doc.parseResult.lexerErrors)
30
+ errors.push(e.message);
31
+ for (const e of doc.parseResult.parserErrors)
32
+ errors.push(e.message);
33
+ for (const d of doc.diagnostics ?? []) {
34
+ if (d.severity === 1)
35
+ errors.push(d.message);
36
+ }
37
+ return { ast: doc.parseResult.value, errors };
38
+ }
39
+ export async function renderSource(source, options = {}) {
40
+ const parsed = await parseSource(source);
41
+ if (parsed.errors.length > 0) {
42
+ throw new EmbedRenderError(`Failed to parse Nowline source: ${parsed.errors.join('; ')}`, parsed.errors);
43
+ }
44
+ const services = getServices();
45
+ const resolved = await resolveIncludes(parsed.ast, '/embed.nowline', {
46
+ services: services.Nowline,
47
+ readFile: noOpIncludeReadFile,
48
+ });
49
+ let sawIncludeWarning = false;
50
+ const blockingErrors = [];
51
+ for (const diag of resolved.diagnostics) {
52
+ if (diag.severity !== 'error')
53
+ continue;
54
+ if (isNoOpIncludeDiagnosticMessage(diag.message)) {
55
+ sawIncludeWarning = true;
56
+ continue;
57
+ }
58
+ blockingErrors.push(diag.message);
59
+ }
60
+ if (blockingErrors.length > 0) {
61
+ throw new EmbedRenderError(`Failed to resolve Nowline source: ${blockingErrors.join('; ')}`, blockingErrors);
62
+ }
63
+ if (sawIncludeWarning && !includeWarningEmitted) {
64
+ includeWarningEmitted = true;
65
+ console.warn('nowline: `include` directives are skipped in the browser embed (single-file mode). ' +
66
+ 'Render multi-file roadmaps with the CLI or the GitHub Action.');
67
+ }
68
+ const model = layoutRoadmap(parsed.ast, resolved, {
69
+ theme: options.theme,
70
+ today: options.today,
71
+ locale: options.locale,
72
+ width: options.width,
73
+ });
74
+ return renderSvg(model, {
75
+ idPrefix: options.idPrefix,
76
+ });
77
+ }
78
+ export class EmbedRenderError extends Error {
79
+ details;
80
+ constructor(message, details) {
81
+ super(message);
82
+ this.details = details;
83
+ this.name = 'EmbedRenderError';
84
+ }
85
+ }
86
+ // Test-only escape hatch. The console.warn is intentionally emitted at
87
+ // most once per page load; tests that exercise the warning path need to
88
+ // reset the latch between cases.
89
+ export function __resetEmbedPipelineForTests() {
90
+ includeWarningEmitted = false;
91
+ cachedServices = undefined;
92
+ docCounter = 0;
93
+ }
94
+ //# sourceMappingURL=pipeline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline.js","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,qEAAqE;AACrE,qEAAqE;AACrE,sEAAsE;AACtE,oEAAoE;AACpE,0DAA0D;AAE1D,OAAO,EACH,qBAAqB,EAGrB,eAAe,GAClB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,aAAa,EAAkB,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,GAAG,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,EAAE,8BAA8B,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AA2BlG,IAAI,cAA0C,CAAC;AAC/C,IAAI,UAAU,GAAG,CAAC,CAAC;AACnB,IAAI,qBAAqB,GAAG,KAAK,CAAC;AAElC,SAAS,WAAW;IAChB,IAAI,CAAC,cAAc;QAAE,cAAc,GAAG,qBAAqB,EAAE,CAAC;IAC9D,OAAO,cAAc,CAAC;AAC1B,CAAC;AAED,SAAS,QAAQ;IACb,OAAO,GAAG,CAAC,KAAK,CAAC,2BAA2B,EAAE,UAAU,UAAU,CAAC,CAAC;AACxE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAc;IAC5C,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAC;IAC/B,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,sBAAsB,CAAC;IACpE,MAAM,GAAG,GAAG,UAAU,CAAC,UAAU,CAAc,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;IACnE,MAAM,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;IAEnF,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,WAAW,CAAC,WAAW;QAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACpE,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,WAAW,CAAC,YAAY;QAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACrE,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,WAAW,IAAI,EAAE,EAAE,CAAC;QACpC,IAAI,CAAC,CAAC,QAAQ,KAAK,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACjD,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;AAClD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAC9B,MAAc,EACd,UAA8B,EAAE;IAEhC,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;IACzC,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,gBAAgB,CACtB,mCAAmC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAC7D,MAAM,CAAC,MAAM,CAChB,CAAC;IACN,CAAC;IAED,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAC;IAC/B,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,MAAM,CAAC,GAAG,EAAE,gBAAgB,EAAE;QACjE,QAAQ,EAAE,QAAQ,CAAC,OAAO;QAC1B,QAAQ,EAAE,mBAAmB;KAChC,CAAC,CAAC;IAEH,IAAI,iBAAiB,GAAG,KAAK,CAAC;IAC9B,MAAM,cAAc,GAAa,EAAE,CAAC;IACpC,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;QACtC,IAAI,IAAI,CAAC,QAAQ,KAAK,OAAO;YAAE,SAAS;QACxC,IAAI,8BAA8B,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAC/C,iBAAiB,GAAG,IAAI,CAAC;YACzB,SAAS;QACb,CAAC;QACD,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;IACD,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,gBAAgB,CACtB,qCAAqC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAChE,cAAc,CACjB,CAAC;IACN,CAAC;IACD,IAAI,iBAAiB,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAC9C,qBAAqB,GAAG,IAAI,CAAC;QAC7B,OAAO,CAAC,IAAI,CACR,qFAAqF;YACjF,+DAA+D,CACtE,CAAC;IACN,CAAC;IAED,MAAM,KAAK,GAAG,aAAa,CAAC,MAAM,CAAC,GAAG,EAAE,QAAQ,EAAE;QAC9C,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,KAAK,EAAE,OAAO,CAAC,KAAK;KACvB,CAAC,CAAC;IAEH,OAAO,SAAS,CAAC,KAAK,EAAE;QACpB,QAAQ,EAAE,OAAO,CAAC,QAAQ;KAC7B,CAAC,CAAC;AACP,CAAC;AAED,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAGnB;IAFpB,YACI,OAAe,EACC,OAAiB;QAEjC,KAAK,CAAC,OAAO,CAAC,CAAC;QAFC,YAAO,GAAP,OAAO,CAAU;QAGjC,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IACnC,CAAC;CACJ;AAED,uEAAuE;AACvE,wEAAwE;AACxE,iCAAiC;AACjC,MAAM,UAAU,4BAA4B;IACxC,qBAAqB,GAAG,KAAK,CAAC;IAC9B,cAAc,GAAG,SAAS,CAAC;IAC3B,UAAU,GAAG,CAAC,CAAC;AACnB,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { ThemeName } from '@nowline/layout';
2
+ export type EmbedTheme = ThemeName | 'auto';
3
+ export declare function resolveSystemTheme(): ThemeName;
4
+ export declare function effectiveTheme(theme: EmbedTheme | undefined, systemTheme: ThemeName): ThemeName;
5
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,MAAM,CAAC;AAE5C,wBAAgB,kBAAkB,IAAI,SAAS,CAS9C;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,EAAE,WAAW,EAAE,SAAS,GAAG,SAAS,CAG/F"}
package/dist/theme.js ADDED
@@ -0,0 +1,32 @@
1
+ // Theme resolution for the embed.
2
+ //
3
+ // Precedence (highest to lowest):
4
+ // 1. `initialize({ theme })` flag (light / dark / auto).
5
+ // 2. The file's own `nowline v1 theme:` directive — handled inside
6
+ // layout, so we just don't override it when the embed config says
7
+ // `'auto'` and we have no system preference reading.
8
+ // 3. The browser's `prefers-color-scheme` media query.
9
+ //
10
+ // `prefers-color-scheme` is read **once on init**, not reactively, so
11
+ // flipping the OS theme mid-session does not cause every embedded
12
+ // roadmap on the page to repaint. This matches Mermaid's posture and
13
+ // keeps the embed deterministic for screenshot tools.
14
+ export function resolveSystemTheme() {
15
+ if (typeof globalThis === 'undefined')
16
+ return 'light';
17
+ const win = globalThis.matchMedia;
18
+ if (typeof win !== 'function')
19
+ return 'light';
20
+ try {
21
+ return win('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
22
+ }
23
+ catch {
24
+ return 'light';
25
+ }
26
+ }
27
+ export function effectiveTheme(theme, systemTheme) {
28
+ if (theme === 'light' || theme === 'dark')
29
+ return theme;
30
+ return systemTheme;
31
+ }
32
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,EAAE;AACF,kCAAkC;AAClC,2DAA2D;AAC3D,qEAAqE;AACrE,uEAAuE;AACvE,0DAA0D;AAC1D,yDAAyD;AACzD,EAAE;AACF,sEAAsE;AACtE,kEAAkE;AAClE,qEAAqE;AACrE,sDAAsD;AAMtD,MAAM,UAAU,kBAAkB;IAC9B,IAAI,OAAO,UAAU,KAAK,WAAW;QAAE,OAAO,OAAO,CAAC;IACtD,MAAM,GAAG,GAAI,UAAmE,CAAC,UAAU,CAAC;IAC5F,IAAI,OAAO,GAAG,KAAK,UAAU;QAAE,OAAO,OAAO,CAAC;IAC9C,IAAI,CAAC;QACD,OAAO,GAAG,CAAC,8BAA8B,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;IAC1E,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,OAAO,CAAC;IACnB,CAAC;AACL,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAA6B,EAAE,WAAsB;IAChF,IAAI,KAAK,KAAK,OAAO,IAAI,KAAK,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACxD,OAAO,WAAW,CAAC;AACvB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@nowline/embed",
3
+ "version": "0.2.2",
4
+ "description": "Browser embed bundle for Nowline. Drop a <script> tag and ```nowline``` blocks render in place.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist/",
18
+ "src/"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/lolay/nowline.git",
23
+ "directory": "packages/embed"
24
+ },
25
+ "homepage": "https://github.com/lolay/nowline",
26
+ "bugs": {
27
+ "url": "https://github.com/lolay/nowline/issues"
28
+ },
29
+ "keywords": [
30
+ "nowline",
31
+ "roadmap",
32
+ "gantt",
33
+ "embed",
34
+ "browser",
35
+ "cdn"
36
+ ],
37
+ "dependencies": {
38
+ "langium": "~4.2.2",
39
+ "@nowline/layout": "0.2.2",
40
+ "@nowline/core": "0.2.2",
41
+ "@nowline/renderer": "0.2.2"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.0.0",
45
+ "esbuild": "^0.27.0",
46
+ "happy-dom": "^15.11.0",
47
+ "typescript": "~5.7.0",
48
+ "vitest": "^3.1.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -b tsconfig.json && node scripts/bundle.mjs",
52
+ "watch": "tsc -b tsconfig.json --watch",
53
+ "typecheck": "tsc --noEmit -p tsconfig.json",
54
+ "bundle": "node scripts/bundle.mjs",
55
+ "check-size": "node scripts/check-size.mjs",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest"
58
+ }
59
+ }
@@ -0,0 +1,107 @@
1
+ // DOM scanner that finds `<pre><code class="language-nowline">…</code></pre>`
2
+ // blocks (or whatever selector the caller registered) and replaces each
3
+ // with its rendered SVG. Each block gets a unique `idPrefix` so two
4
+ // roadmaps on the same page never share `<style>` ids.
5
+
6
+ import type { ThemeName } from '@nowline/layout';
7
+ import { type EmbedRenderOptions, renderSource } from './pipeline.js';
8
+
9
+ export interface AutoScanInputs {
10
+ selector: string;
11
+ theme?: ThemeName;
12
+ locale?: string;
13
+ width?: number;
14
+ today?: Date;
15
+ /**
16
+ * Document to scan. Defaults to `globalThis.document`. Tests inject
17
+ * a happy-dom document; the IIFE running on a real page picks up
18
+ * the live document.
19
+ */
20
+ document?: Document;
21
+ }
22
+
23
+ export interface AutoScanResult {
24
+ /** Number of code blocks that were successfully replaced with SVG. */
25
+ rendered: number;
26
+ /** Number of blocks that failed to render (logged to console.error). */
27
+ failed: number;
28
+ }
29
+
30
+ let runCounter = 0;
31
+
32
+ export async function runAutoScan(inputs: AutoScanInputs): Promise<AutoScanResult> {
33
+ const doc = inputs.document ?? (globalThis as { document?: Document }).document;
34
+ if (!doc) {
35
+ return { rendered: 0, failed: 0 };
36
+ }
37
+
38
+ const blocks = doc.querySelectorAll<HTMLElement>(inputs.selector);
39
+ let rendered = 0;
40
+ let failed = 0;
41
+ const baseRunId = ++runCounter;
42
+
43
+ const tasks: Array<Promise<void>> = [];
44
+ let blockIndex = 0;
45
+ for (const code of Array.from(blocks)) {
46
+ const target = pickReplacementTarget(code);
47
+ if (!target) {
48
+ continue;
49
+ }
50
+ const source = readBlockSource(code);
51
+ const idPrefix = `nl-r${baseRunId}-${blockIndex++}`;
52
+ const opts: EmbedRenderOptions = {
53
+ theme: inputs.theme,
54
+ locale: inputs.locale,
55
+ width: inputs.width,
56
+ today: inputs.today,
57
+ idPrefix,
58
+ };
59
+ tasks.push(
60
+ renderSource(source, opts).then(
61
+ (svg) => {
62
+ replaceWithSvg(target, svg);
63
+ rendered++;
64
+ },
65
+ (err: unknown) => {
66
+ failed++;
67
+ console.error('nowline: render failed', err);
68
+ },
69
+ ),
70
+ );
71
+ }
72
+
73
+ await Promise.all(tasks);
74
+ return { rendered, failed };
75
+ }
76
+
77
+ // Markdown-rendered Nowline blocks usually look like
78
+ // `<pre><code class="language-nowline">…</code></pre>`. We replace the
79
+ // outer `<pre>` so the spacing the markdown processor reserved for the
80
+ // fenced block is reused by the SVG. When the matched element has no
81
+ // `<pre>` ancestor (custom hosting) we replace the matched element
82
+ // itself.
83
+ function pickReplacementTarget(matched: HTMLElement): HTMLElement | null {
84
+ const parent = matched.parentElement;
85
+ if (parent && parent.tagName.toUpperCase() === 'PRE') {
86
+ return parent;
87
+ }
88
+ return matched;
89
+ }
90
+
91
+ function readBlockSource(code: HTMLElement): string {
92
+ // `textContent` preserves whitespace and newlines; `innerText` would
93
+ // collapse them per CSS, which would corrupt indentation-sensitive
94
+ // .nowline source.
95
+ return code.textContent ?? '';
96
+ }
97
+
98
+ function replaceWithSvg(target: HTMLElement, svg: string): void {
99
+ // `outerHTML` parses the SVG string into a real `<svg>` element and
100
+ // swaps it into the DOM, preserving each render's per-`idPrefix`
101
+ // `<style>` scope so two blocks on the page can never bleed styles.
102
+ target.outerHTML = svg;
103
+ }
104
+
105
+ export function __resetAutoScanForTests(): void {
106
+ runCounter = 0;
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,176 @@
1
+ // Public API for `@nowline/embed`. Mirrors Mermaid's surface
2
+ // (`initialize`, `render`, `parse`, `init`/`run`) so users coming from
3
+ // Mermaid don't have to relearn anything.
4
+ //
5
+ // The IIFE bundle exposes everything below as `window.nowline.*`. ESM
6
+ // consumers import named exports from the package root.
7
+
8
+ import {
9
+ __resetAutoScanForTests,
10
+ type AutoScanInputs,
11
+ type AutoScanResult,
12
+ runAutoScan,
13
+ } from './auto-scan.js';
14
+ import {
15
+ __resetEmbedPipelineForTests,
16
+ type EmbedParseResult,
17
+ EmbedRenderError,
18
+ type EmbedRenderOptions,
19
+ parseSource,
20
+ renderSource,
21
+ } from './pipeline.js';
22
+ import { type EmbedTheme, effectiveTheme, resolveSystemTheme } from './theme.js';
23
+
24
+ export { type AutoScanResult, type EmbedParseResult, EmbedRenderError, type EmbedTheme };
25
+
26
+ const DEFAULT_SELECTOR = 'pre code.language-nowline, code.language-nowline';
27
+
28
+ export interface InitializeOptions {
29
+ /** `light`, `dark`, or `auto` (read once via `prefers-color-scheme`). */
30
+ theme?: EmbedTheme;
31
+ /**
32
+ * Auto-run `init()` on `DOMContentLoaded`. Defaults to `true`.
33
+ * Setting this to `false` defers rendering until the page calls
34
+ * `nowline.init()` (or `nowline.run()`) manually.
35
+ */
36
+ startOnLoad?: boolean;
37
+ /**
38
+ * CSS selector used to locate Nowline blocks. The default matches
39
+ * markdown-renderer output (`<pre><code class="language-nowline">…</code></pre>`)
40
+ * plus standalone `<code class="language-nowline">` for hosts that
41
+ * skip the `<pre>` wrapper.
42
+ */
43
+ selector?: string;
44
+ /** BCP-47 locale forwarded to the layout engine for axis labels and the now-pill. */
45
+ locale?: string;
46
+ /** Layout canvas width in pixels. Layout's default is 1280. */
47
+ width?: number;
48
+ /** Pin a `today` for deterministic snapshots; defaults to live `new Date()` per render. */
49
+ today?: Date;
50
+ }
51
+
52
+ interface ResolvedConfig {
53
+ theme: EmbedTheme;
54
+ startOnLoad: boolean;
55
+ selector: string;
56
+ locale?: string;
57
+ width?: number;
58
+ today?: Date;
59
+ /** System theme captured at init; not reactive to OS theme flips mid-session. */
60
+ systemTheme: 'light' | 'dark';
61
+ }
62
+
63
+ const initialConfig: ResolvedConfig = {
64
+ theme: 'auto',
65
+ startOnLoad: true,
66
+ selector: DEFAULT_SELECTOR,
67
+ systemTheme: resolveSystemTheme(),
68
+ };
69
+
70
+ let config: ResolvedConfig = { ...initialConfig };
71
+ let autoStartScheduled = false;
72
+
73
+ export function initialize(options: InitializeOptions = {}): void {
74
+ config = {
75
+ theme: options.theme ?? config.theme,
76
+ startOnLoad: options.startOnLoad ?? config.startOnLoad,
77
+ selector: options.selector ?? config.selector,
78
+ locale: options.locale ?? config.locale,
79
+ width: options.width ?? config.width,
80
+ today: options.today ?? config.today,
81
+ // Re-read `prefers-color-scheme` on every initialize() so callers
82
+ // who explicitly want the latest system theme can ask for it by
83
+ // calling initialize() again. Auto-scan paths still use the value
84
+ // captured at initialize time.
85
+ systemTheme: resolveSystemTheme(),
86
+ };
87
+ }
88
+
89
+ /** Build the EmbedRenderOptions used by `render` and the auto-scan path. */
90
+ function renderOptionsFromConfig(): EmbedRenderOptions {
91
+ return {
92
+ theme: effectiveTheme(config.theme, config.systemTheme),
93
+ locale: config.locale,
94
+ width: config.width,
95
+ today: config.today,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Render a single Nowline source string to an SVG. Useful for
101
+ * applications that want to control exactly when and where the SVG
102
+ * lands (custom `<div>` containers, dynamically loaded blocks, etc.).
103
+ */
104
+ export async function render(source: string, options: EmbedRenderOptions = {}): Promise<string> {
105
+ const merged: EmbedRenderOptions = {
106
+ ...renderOptionsFromConfig(),
107
+ ...options,
108
+ };
109
+ return renderSource(source, merged);
110
+ }
111
+
112
+ /**
113
+ * Parse a Nowline source string. Returns the AST and any lexer /
114
+ * parser / validator errors. Does not run layout or render — useful for
115
+ * editor experiences that want diagnostics without paying the render
116
+ * cost.
117
+ */
118
+ export async function parse(source: string): Promise<EmbedParseResult> {
119
+ return parseSource(source);
120
+ }
121
+
122
+ /**
123
+ * Scan the DOM for Nowline blocks and replace each with its rendered
124
+ * SVG. Aliased as `run` for parity with Mermaid's recent API.
125
+ */
126
+ export async function init(overrides?: Partial<AutoScanInputs>): Promise<AutoScanResult> {
127
+ const inputs: AutoScanInputs = {
128
+ selector: overrides?.selector ?? config.selector,
129
+ theme: overrides?.theme ?? renderOptionsFromConfig().theme,
130
+ locale: overrides?.locale ?? config.locale,
131
+ width: overrides?.width ?? config.width,
132
+ today: overrides?.today ?? config.today,
133
+ document: overrides?.document,
134
+ };
135
+ return runAutoScan(inputs);
136
+ }
137
+
138
+ /** Alias for `init`, matching Mermaid's `mermaid.run()`. */
139
+ export const run = init;
140
+
141
+ // IIFE-bundle bootstrap. When the bundled script tag loads on a page,
142
+ // the bundler invokes the module body; we schedule auto-scan to fire on
143
+ // DOMContentLoaded unless the page disables it with
144
+ // `nowline.initialize({ startOnLoad: false })` synchronously after the
145
+ // script tag.
146
+ //
147
+ // Guarded by `typeof document !== 'undefined'` so ESM consumers
148
+ // (Node tests, build pipelines, server-side rendering) don't trigger
149
+ // the auto-scan branch.
150
+ if (typeof document !== 'undefined' && !autoStartScheduled) {
151
+ autoStartScheduled = true;
152
+ const start = (): void => {
153
+ if (!config.startOnLoad) return;
154
+ // Fire-and-forget; render errors are surfaced via `console.error`
155
+ // inside `runAutoScan`.
156
+ void init();
157
+ };
158
+ if (document.readyState === 'loading') {
159
+ document.addEventListener('DOMContentLoaded', start, { once: true });
160
+ } else {
161
+ // Document is already parsed; defer to next microtask so any
162
+ // synchronous `initialize({ startOnLoad: false })` after the
163
+ // script tag still wins the race.
164
+ queueMicrotask(start);
165
+ }
166
+ }
167
+
168
+ // Test-only escape hatch. Exposed as a named export so tests can reset
169
+ // the module's hidden state (config, system-theme cache, the once-only
170
+ // console.warn latch) between cases.
171
+ export function __resetForTests(): void {
172
+ config = { ...initialConfig, systemTheme: resolveSystemTheme() };
173
+ autoStartScheduled = false;
174
+ __resetEmbedPipelineForTests();
175
+ __resetAutoScanForTests();
176
+ }
@@ -0,0 +1,22 @@
1
+ // Browser-side `readFile` that always rejects with a stable, sniff-able
2
+ // error. The render pipeline catches matching diagnostics and converts
3
+ // them into a single `console.warn` (deduped), so authors notice when
4
+ // they ship a file with `include` directives that the embed cannot
5
+ // satisfy without a network fetch.
6
+ //
7
+ // A real HTTP-fetch resolver is intentionally out of scope for m4 (see
8
+ // `specs/handoffs/handoff-m4-embed.md`): CORS, relative-URL semantics,
9
+ // and waterfall performance each warrant their own decision and are
10
+ // best handled behind an opt-in flag in a follow-up.
11
+
12
+ export const NOWLINE_EMBED_NOOP_INCLUDE_TAG = '__nowline_embed_noop_include__';
13
+
14
+ export async function noOpIncludeReadFile(absPath: string): Promise<string> {
15
+ throw new Error(
16
+ `${NOWLINE_EMBED_NOOP_INCLUDE_TAG}: include "${absPath}" was skipped — the embed runs in single-file mode.`,
17
+ );
18
+ }
19
+
20
+ export function isNoOpIncludeDiagnosticMessage(message: string): boolean {
21
+ return message.includes(NOWLINE_EMBED_NOOP_INCLUDE_TAG);
22
+ }