@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.
- package/LICENSE +190 -0
- package/README.md +118 -0
- package/dist/auto-scan.d.ts +23 -0
- package/dist/auto-scan.d.ts.map +1 -0
- package/dist/auto-scan.js +71 -0
- package/dist/auto-scan.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/meta.json +10226 -0
- package/dist/no-op-include-resolver.d.ts +4 -0
- package/dist/no-op-include-resolver.d.ts.map +1 -0
- package/dist/no-op-include-resolver.js +18 -0
- package/dist/no-op-include-resolver.js.map +1 -0
- package/dist/nowline.esm.js +38295 -0
- package/dist/nowline.esm.js.map +7 -0
- package/dist/nowline.min.js +2663 -0
- package/dist/nowline.min.js.map +7 -0
- package/dist/pipeline.d.ts +28 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +94 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +32 -0
- package/dist/theme.js.map +1 -0
- package/package.json +59 -0
- package/src/auto-scan.ts +107 -0
- package/src/index.ts +176 -0
- package/src/no-op-include-resolver.ts +22 -0
- package/src/pipeline.ts +143 -0
- package/src/theme.ts +33 -0
|
@@ -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"}
|
package/dist/pipeline.js
ADDED
|
@@ -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":"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"}
|
package/dist/theme.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/auto-scan.ts
ADDED
|
@@ -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
|
+
}
|