@nowline/embed 0.0.0-dev.20260601071750.g04bdff9
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 +106 -0
- package/dist/auto-scan.d.ts +34 -0
- package/dist/auto-scan.d.ts.map +1 -0
- package/dist/auto-scan.js +99 -0
- package/dist/auto-scan.js.map +1 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +135 -0
- package/dist/index.js.map +1 -0
- package/dist/meta.json +10450 -0
- package/dist/nowline.esm.js +39912 -0
- package/dist/nowline.esm.js.map +7 -0
- package/dist/nowline.min.js +2664 -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 +65 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/share.d.ts +45 -0
- package/dist/share.d.ts.map +1 -0
- package/dist/share.js +75 -0
- package/dist/share.js.map +1 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +34 -0
- package/dist/theme.js.map +1 -0
- package/package.json +65 -0
- package/src/auto-scan.ts +149 -0
- package/src/index.ts +230 -0
- package/src/pipeline.ts +106 -0
- package/src/share.ts +108 -0
- package/src/theme.ts +35 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
// Build-time constants substituted by esbuild's `define` (see
|
|
9
|
+
// `scripts/bundle.mjs`). The `typeof` guards keep this file safe to
|
|
10
|
+
// import under vitest, where esbuild's define never runs and the
|
|
11
|
+
// identifiers are undeclared.
|
|
12
|
+
declare const __NOWLINE_EMBED_VERSION__: string;
|
|
13
|
+
declare const __NOWLINE_EMBED_SHA__: string;
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
__resetAutoScanForTests,
|
|
17
|
+
type AutoScanInputs,
|
|
18
|
+
type AutoScanResult,
|
|
19
|
+
runAutoScan,
|
|
20
|
+
} from './auto-scan.js';
|
|
21
|
+
import {
|
|
22
|
+
__resetEmbedPipelineForTests,
|
|
23
|
+
type EmbedParseResult,
|
|
24
|
+
EmbedRenderError,
|
|
25
|
+
type EmbedRenderOptions,
|
|
26
|
+
parseSource,
|
|
27
|
+
renderSource,
|
|
28
|
+
} from './pipeline.js';
|
|
29
|
+
import type { ShareOption } from './share.js';
|
|
30
|
+
import { type EmbedTheme, effectiveTheme, resolveSystemTheme } from './theme.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
type AutoScanResult,
|
|
34
|
+
type EmbedParseResult,
|
|
35
|
+
EmbedRenderError,
|
|
36
|
+
type EmbedTheme,
|
|
37
|
+
type ShareOption,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bundle provenance, mirroring the legal-comment banner that
|
|
42
|
+
* `scripts/bundle.mjs` injects at the top of every artifact:
|
|
43
|
+
* `@nowline/embed <version> sha=<short-sha> built=<iso-utc>`.
|
|
44
|
+
*
|
|
45
|
+
* Exposed on the IIFE global as `nowline.version` / `nowline.sha` so
|
|
46
|
+
* pages and bug reports can identify the exact build without scraping
|
|
47
|
+
* the comment banner.
|
|
48
|
+
*/
|
|
49
|
+
export const version: string =
|
|
50
|
+
typeof __NOWLINE_EMBED_VERSION__ !== 'undefined' ? __NOWLINE_EMBED_VERSION__ : '0.0.0';
|
|
51
|
+
export const sha: string =
|
|
52
|
+
typeof __NOWLINE_EMBED_SHA__ !== 'undefined' ? __NOWLINE_EMBED_SHA__ : 'unknown';
|
|
53
|
+
|
|
54
|
+
const DEFAULT_SELECTOR = 'pre code.language-nowline, code.language-nowline';
|
|
55
|
+
|
|
56
|
+
export interface InitializeOptions {
|
|
57
|
+
/** `light`, `dark`, or `auto` (read once via `prefers-color-scheme`). */
|
|
58
|
+
theme?: EmbedTheme;
|
|
59
|
+
/**
|
|
60
|
+
* Auto-run `init()` on `DOMContentLoaded`. Defaults to `true`.
|
|
61
|
+
* Setting this to `false` defers rendering until the page calls
|
|
62
|
+
* `nowline.init()` (or `nowline.run()`) manually.
|
|
63
|
+
*/
|
|
64
|
+
startOnLoad?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* CSS selector used to locate Nowline blocks. The default matches
|
|
67
|
+
* markdown-renderer output (`<pre><code class="language-nowline">…</code></pre>`)
|
|
68
|
+
* plus standalone `<code class="language-nowline">` for hosts that
|
|
69
|
+
* skip the `<pre>` wrapper.
|
|
70
|
+
*/
|
|
71
|
+
selector?: string;
|
|
72
|
+
/** BCP-47 locale forwarded to the layout engine for axis labels and the now-pill. */
|
|
73
|
+
locale?: string;
|
|
74
|
+
/** Layout canvas width in pixels. Layout's default is 1280. */
|
|
75
|
+
width?: number;
|
|
76
|
+
/** Pin a `today` for deterministic snapshots; defaults to live `new Date()` per render. */
|
|
77
|
+
today?: Date;
|
|
78
|
+
/**
|
|
79
|
+
* Controls the "Share on Nowline" anchor appended after each rendered SVG.
|
|
80
|
+
* - `true` (default) — link to the Free app open route
|
|
81
|
+
* (`https://free.nowline.io/open`).
|
|
82
|
+
* - string — a custom base URL (may include a path); the fragment is appended.
|
|
83
|
+
* - `false` / `'none'` — no anchor rendered.
|
|
84
|
+
* - `{ textUrl, remoteUrl }` — template with `{text}` / `{url}` substitution.
|
|
85
|
+
*/
|
|
86
|
+
share?: ShareOption;
|
|
87
|
+
/**
|
|
88
|
+
* Global source URL for all blocks on the page. When set, share links
|
|
89
|
+
* use `#url=<sourceUrl>` instead of `#text=` (inline encoding).
|
|
90
|
+
* Per-block `data-nowline-source-url` overrides this for individual blocks.
|
|
91
|
+
* Only `https:` URLs are emitted as `#url=`.
|
|
92
|
+
*/
|
|
93
|
+
sourceUrl?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface ResolvedConfig {
|
|
97
|
+
theme: EmbedTheme;
|
|
98
|
+
startOnLoad: boolean;
|
|
99
|
+
selector: string;
|
|
100
|
+
locale?: string;
|
|
101
|
+
width?: number;
|
|
102
|
+
today?: Date;
|
|
103
|
+
/** System theme captured at init; not reactive to OS theme flips mid-session. */
|
|
104
|
+
systemTheme: 'light' | 'dark';
|
|
105
|
+
/** Controls the "Share on Nowline" anchor. Defaults to `true`. */
|
|
106
|
+
share: ShareOption;
|
|
107
|
+
/** Global canonical source URL; per-block `data-nowline-source-url` takes priority. */
|
|
108
|
+
sourceUrl?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const initialConfig: ResolvedConfig = {
|
|
112
|
+
theme: 'auto',
|
|
113
|
+
startOnLoad: true,
|
|
114
|
+
selector: DEFAULT_SELECTOR,
|
|
115
|
+
systemTheme: resolveSystemTheme(),
|
|
116
|
+
share: true,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let config: ResolvedConfig = { ...initialConfig };
|
|
120
|
+
let autoStartScheduled = false;
|
|
121
|
+
|
|
122
|
+
export function initialize(options: InitializeOptions = {}): void {
|
|
123
|
+
config = {
|
|
124
|
+
theme: options.theme ?? config.theme,
|
|
125
|
+
startOnLoad: options.startOnLoad ?? config.startOnLoad,
|
|
126
|
+
selector: options.selector ?? config.selector,
|
|
127
|
+
locale: options.locale ?? config.locale,
|
|
128
|
+
width: options.width ?? config.width,
|
|
129
|
+
today: options.today ?? config.today,
|
|
130
|
+
share: options.share ?? config.share,
|
|
131
|
+
sourceUrl: options.sourceUrl ?? config.sourceUrl,
|
|
132
|
+
// Re-read `prefers-color-scheme` on every initialize() so callers
|
|
133
|
+
// who explicitly want the latest system theme can ask for it by
|
|
134
|
+
// calling initialize() again. Auto-scan paths still use the value
|
|
135
|
+
// captured at initialize time.
|
|
136
|
+
systemTheme: resolveSystemTheme(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Build the EmbedRenderOptions used by `render` and the auto-scan path. */
|
|
141
|
+
function renderOptionsFromConfig(): EmbedRenderOptions {
|
|
142
|
+
return {
|
|
143
|
+
theme: effectiveTheme(config.theme, config.systemTheme),
|
|
144
|
+
locale: config.locale,
|
|
145
|
+
width: config.width,
|
|
146
|
+
today: config.today,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render a single Nowline source string to an SVG. Useful for
|
|
152
|
+
* applications that want to control exactly when and where the SVG
|
|
153
|
+
* lands (custom `<div>` containers, dynamically loaded blocks, etc.).
|
|
154
|
+
*/
|
|
155
|
+
export async function render(source: string, options: EmbedRenderOptions = {}): Promise<string> {
|
|
156
|
+
const merged: EmbedRenderOptions = {
|
|
157
|
+
...renderOptionsFromConfig(),
|
|
158
|
+
...options,
|
|
159
|
+
};
|
|
160
|
+
return renderSource(source, merged);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse a Nowline source string. Returns the AST and any lexer /
|
|
165
|
+
* parser / validator errors. Does not run layout or render — useful for
|
|
166
|
+
* editor experiences that want diagnostics without paying the render
|
|
167
|
+
* cost.
|
|
168
|
+
*/
|
|
169
|
+
export async function parse(source: string): Promise<EmbedParseResult> {
|
|
170
|
+
return parseSource(source);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Scan the DOM for Nowline blocks and replace each with its rendered
|
|
175
|
+
* SVG. Aliased as `run` for parity with Mermaid's recent API.
|
|
176
|
+
*/
|
|
177
|
+
export async function init(overrides?: Partial<AutoScanInputs>): Promise<AutoScanResult> {
|
|
178
|
+
const inputs: AutoScanInputs = {
|
|
179
|
+
selector: overrides?.selector ?? config.selector,
|
|
180
|
+
theme: overrides?.theme ?? renderOptionsFromConfig().theme,
|
|
181
|
+
locale: overrides?.locale ?? config.locale,
|
|
182
|
+
width: overrides?.width ?? config.width,
|
|
183
|
+
today: overrides?.today ?? config.today,
|
|
184
|
+
share: overrides?.share ?? config.share,
|
|
185
|
+
sourceUrl: overrides?.sourceUrl ?? config.sourceUrl,
|
|
186
|
+
document: overrides?.document,
|
|
187
|
+
};
|
|
188
|
+
return runAutoScan(inputs);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Alias for `init`, matching Mermaid's `mermaid.run()`. */
|
|
192
|
+
export const run = init;
|
|
193
|
+
|
|
194
|
+
// IIFE-bundle bootstrap. When the bundled script tag loads on a page,
|
|
195
|
+
// the bundler invokes the module body; we schedule auto-scan to fire on
|
|
196
|
+
// DOMContentLoaded unless the page disables it with
|
|
197
|
+
// `nowline.initialize({ startOnLoad: false })` synchronously after the
|
|
198
|
+
// script tag.
|
|
199
|
+
//
|
|
200
|
+
// Guarded by `typeof document !== 'undefined'` so ESM consumers
|
|
201
|
+
// (Node tests, build pipelines, server-side rendering) don't trigger
|
|
202
|
+
// the auto-scan branch.
|
|
203
|
+
if (typeof document !== 'undefined' && !autoStartScheduled) {
|
|
204
|
+
autoStartScheduled = true;
|
|
205
|
+
|
|
206
|
+
const start = (): void => {
|
|
207
|
+
if (!config.startOnLoad) return;
|
|
208
|
+
// Fire-and-forget; render errors are surfaced via `console.error`
|
|
209
|
+
// inside `runAutoScan`.
|
|
210
|
+
void init();
|
|
211
|
+
};
|
|
212
|
+
if (document.readyState === 'loading') {
|
|
213
|
+
document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
214
|
+
} else {
|
|
215
|
+
// Document is already parsed; defer to next microtask so any
|
|
216
|
+
// synchronous `initialize({ startOnLoad: false })` after the
|
|
217
|
+
// script tag still wins the race.
|
|
218
|
+
queueMicrotask(start);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Test-only escape hatch. Exposed as a named export so tests can reset
|
|
223
|
+
// the module's hidden state (config, system-theme cache, the once-only
|
|
224
|
+
// console.warn latch) between cases.
|
|
225
|
+
export function __resetForTests(): void {
|
|
226
|
+
config = { ...initialConfig, systemTheme: resolveSystemTheme() };
|
|
227
|
+
autoStartScheduled = false;
|
|
228
|
+
__resetEmbedPipelineForTests();
|
|
229
|
+
__resetAutoScanForTests();
|
|
230
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Thin shim around `@nowline/browser`'s pipeline. The browser package
|
|
2
|
+
// owns parse / resolveIncludes / layout / render; the embed layers two
|
|
3
|
+
// embed-specific behaviours on top:
|
|
4
|
+
//
|
|
5
|
+
// - Throws `EmbedRenderError` on failure instead of returning a
|
|
6
|
+
// discriminated union. The Mermaid-compatible `nowline.render(source)`
|
|
7
|
+
// surface promises a string; throwing matches the documented v1
|
|
8
|
+
// contract and keeps the auto-scan path's per-block error handling
|
|
9
|
+
// simple.
|
|
10
|
+
// - Latches a once-per-page-load `console.warn` the first time an
|
|
11
|
+
// `include` directive is encountered. The browser pipeline emits a
|
|
12
|
+
// structured callback for each skip; the embed converts that into a
|
|
13
|
+
// single, deduped user-visible message.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
__resetBrowserPipelineForTests,
|
|
17
|
+
type RenderOptions as BrowserRenderOptions,
|
|
18
|
+
parseSource as browserParseSource,
|
|
19
|
+
renderSource as browserRenderSource,
|
|
20
|
+
type DiagnosticRow,
|
|
21
|
+
type ParseResult,
|
|
22
|
+
} from '@nowline/browser';
|
|
23
|
+
import type { ThemeName } from '@nowline/layout';
|
|
24
|
+
|
|
25
|
+
const EMBED_SOURCE_PATH = '/embed.nowline';
|
|
26
|
+
|
|
27
|
+
export interface EmbedRenderOptions {
|
|
28
|
+
theme?: ThemeName;
|
|
29
|
+
today?: Date;
|
|
30
|
+
locale?: string;
|
|
31
|
+
width?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Override the deterministic id prefix used for in-SVG `<style>`
|
|
34
|
+
* scoping. Each block on a page should use a unique prefix so two
|
|
35
|
+
* roadmaps cannot bleed styles into each other; the auto-scan path
|
|
36
|
+
* generates a per-block prefix and threads it here.
|
|
37
|
+
*/
|
|
38
|
+
idPrefix?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EmbedParseResult {
|
|
42
|
+
ast: ParseResult['ast'];
|
|
43
|
+
/** Lexer + parser + Langium validation diagnostics, normalized to strings. */
|
|
44
|
+
errors: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let includeWarningEmitted = false;
|
|
48
|
+
|
|
49
|
+
export async function parseSource(source: string): Promise<EmbedParseResult> {
|
|
50
|
+
const { ast, diagnostics } = await browserParseSource(source, {
|
|
51
|
+
filePath: EMBED_SOURCE_PATH,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
ast,
|
|
55
|
+
errors: diagnostics
|
|
56
|
+
.filter((d: DiagnosticRow) => d.severity === 'error')
|
|
57
|
+
.map((d) => d.message),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function renderSource(
|
|
62
|
+
source: string,
|
|
63
|
+
options: EmbedRenderOptions = {},
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
const browserOptions: BrowserRenderOptions = {
|
|
66
|
+
filePath: EMBED_SOURCE_PATH,
|
|
67
|
+
theme: options.theme,
|
|
68
|
+
today: options.today,
|
|
69
|
+
locale: options.locale,
|
|
70
|
+
width: options.width,
|
|
71
|
+
idPrefix: options.idPrefix,
|
|
72
|
+
onSkippedInclude: () => {
|
|
73
|
+
if (!includeWarningEmitted) {
|
|
74
|
+
includeWarningEmitted = true;
|
|
75
|
+
console.warn(
|
|
76
|
+
'nowline: `include` directives are skipped in the browser embed (single-file mode). ' +
|
|
77
|
+
'Render multi-file roadmaps with the CLI or the GitHub Action.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = await browserRenderSource(source, browserOptions);
|
|
84
|
+
if (result.kind === 'svg') return result.svg;
|
|
85
|
+
|
|
86
|
+
const messages = result.diagnostics.filter((d) => d.severity === 'error').map((d) => d.message);
|
|
87
|
+
throw new EmbedRenderError(`Failed to render Nowline source: ${messages.join('; ')}`, messages);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class EmbedRenderError extends Error {
|
|
91
|
+
constructor(
|
|
92
|
+
message: string,
|
|
93
|
+
public readonly details: string[],
|
|
94
|
+
) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'EmbedRenderError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Test-only escape hatch. The console.warn is intentionally emitted at
|
|
101
|
+
// most once per page load; tests that exercise the warning path need to
|
|
102
|
+
// reset the latch between cases.
|
|
103
|
+
export function __resetEmbedPipelineForTests(): void {
|
|
104
|
+
includeWarningEmitted = false;
|
|
105
|
+
__resetBrowserPipelineForTests();
|
|
106
|
+
}
|
package/src/share.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Share-link generation for the "Share on Nowline" anchor that the
|
|
2
|
+
// auto-scan loop appends after each rendered SVG.
|
|
3
|
+
//
|
|
4
|
+
// Encoding grammar (normative — defined in specs/embed.md):
|
|
5
|
+
// #text=base64url(zlib(utf8(source)))
|
|
6
|
+
// #url=<https-url>
|
|
7
|
+
//
|
|
8
|
+
// zlib = RFC 1950 via fflate zlibSync (byte-compatible with native
|
|
9
|
+
// CompressionStream('deflate')); base64url strips padding and maps
|
|
10
|
+
// +→- /→_.
|
|
11
|
+
//
|
|
12
|
+
// Sync, works on every browser, no feature-detect.
|
|
13
|
+
|
|
14
|
+
import { zlibSync } from 'fflate';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_SHARE_BASE = 'https://free.nowline.io/open';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The `share` initialize option selects where share links point.
|
|
20
|
+
*
|
|
21
|
+
* - `true` — use DEFAULT_SHARE_BASE (the default).
|
|
22
|
+
* - `string` — a base URL with optional path; built via the URL API so
|
|
23
|
+
* `https://foo.com/open` → `https://foo.com/open#text=…`.
|
|
24
|
+
* - `false` / `'none'` — disable the share anchor entirely.
|
|
25
|
+
* - `{ textUrl, remoteUrl }` — escape hatch for non-hash URL shapes;
|
|
26
|
+
* `{text}` substituted with the base64url payload, `{url}` with the
|
|
27
|
+
* percent-encoded source URL.
|
|
28
|
+
*/
|
|
29
|
+
export type ShareOption = boolean | 'none' | string | { textUrl: string; remoteUrl: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Encode source text → `#text=<base64url(zlib(utf8(source)))>`.
|
|
33
|
+
*
|
|
34
|
+
* The return value includes the `#text=` key so callers can use it
|
|
35
|
+
* directly as a URL fragment.
|
|
36
|
+
*
|
|
37
|
+
* Sync, single code path, no feature-detect.
|
|
38
|
+
*/
|
|
39
|
+
export function encodeText(source: string): string {
|
|
40
|
+
return `#text=${_encodePayload(source)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** base64url(zlib(utf8(source))) without the `#text=` prefix. */
|
|
44
|
+
function _encodePayload(source: string): string {
|
|
45
|
+
const bytes = new TextEncoder().encode(source);
|
|
46
|
+
const compressed = zlibSync(bytes);
|
|
47
|
+
// Convert Uint8Array to binary string for btoa. Chunked to avoid
|
|
48
|
+
// call-stack limits on large payloads.
|
|
49
|
+
const chunk = 0x8000; // 32 KB — safe below JS engine stack limits
|
|
50
|
+
let bin = '';
|
|
51
|
+
for (let i = 0; i < compressed.length; i += chunk) {
|
|
52
|
+
bin += String.fromCharCode(...compressed.subarray(i, i + chunk));
|
|
53
|
+
}
|
|
54
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BuildShareLinkOptions {
|
|
58
|
+
/** The roadmap source text (used to build the #text= fragment). */
|
|
59
|
+
source: string;
|
|
60
|
+
/**
|
|
61
|
+
* Resolved source URL for the block (per-block → global → undefined).
|
|
62
|
+
* Only `https:` URLs are emitted as `#url=`; anything else falls
|
|
63
|
+
* back to the inline `#text=` encoding.
|
|
64
|
+
*/
|
|
65
|
+
sourceUrl?: string | undefined;
|
|
66
|
+
/** The `share` option from InitializeOptions. */
|
|
67
|
+
share: ShareOption;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the full "Share on Nowline" URL for a rendered block.
|
|
72
|
+
*
|
|
73
|
+
* Returns `null` when `share` is `false` or `'none'`, signalling that
|
|
74
|
+
* no anchor should be rendered.
|
|
75
|
+
*/
|
|
76
|
+
export function buildShareLink({ source, sourceUrl, share }: BuildShareLinkOptions): string | null {
|
|
77
|
+
if (share === false || share === 'none') {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof share === 'object') {
|
|
82
|
+
// Template mode: { textUrl, remoteUrl }
|
|
83
|
+
if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
|
|
84
|
+
return share.remoteUrl.replace('{url}', encodeURIComponent(sourceUrl));
|
|
85
|
+
}
|
|
86
|
+
return share.textUrl.replace('{text}', _encodePayload(source));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// share === true → DEFAULT_SHARE_BASE; share is a string → custom base URL
|
|
90
|
+
const base = share === true ? DEFAULT_SHARE_BASE : share;
|
|
91
|
+
const url = new URL(base);
|
|
92
|
+
|
|
93
|
+
if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
|
|
94
|
+
url.hash = `url=${encodeURIComponent(sourceUrl)}`;
|
|
95
|
+
} else {
|
|
96
|
+
url.hash = `text=${_encodePayload(source)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _isHttps(url: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(url).protocol === 'https:';
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Theme resolution for the embed.
|
|
2
|
+
//
|
|
3
|
+
// Precedence (highest to lowest):
|
|
4
|
+
// 1. `initialize({ theme })` flag (light / dark / grayscale / auto;
|
|
5
|
+
// `greyscale` is accepted and canonicalized to `grayscale`).
|
|
6
|
+
// 2. The file's own `nowline v1 theme:` directive — handled inside
|
|
7
|
+
// layout, so we just don't override it when the embed config says
|
|
8
|
+
// `'auto'` and we have no system preference reading.
|
|
9
|
+
// 3. The browser's `prefers-color-scheme` media query.
|
|
10
|
+
//
|
|
11
|
+
// `prefers-color-scheme` is read **once on init**, not reactively, so
|
|
12
|
+
// flipping the OS theme mid-session does not cause every embedded
|
|
13
|
+
// roadmap on the page to repaint. This matches Mermaid's posture and
|
|
14
|
+
// keeps the embed deterministic for screenshot tools.
|
|
15
|
+
|
|
16
|
+
import { normalizeThemeName, type ThemeName } from '@nowline/layout';
|
|
17
|
+
|
|
18
|
+
// `'greyscale'` (UK) is an accepted alias for the canonical `'grayscale'`.
|
|
19
|
+
export type EmbedTheme = ThemeName | 'greyscale' | 'auto';
|
|
20
|
+
|
|
21
|
+
export function resolveSystemTheme(): 'light' | 'dark' {
|
|
22
|
+
if (typeof globalThis === 'undefined') return 'light';
|
|
23
|
+
const win = (globalThis as { matchMedia?: (q: string) => { matches: boolean } }).matchMedia;
|
|
24
|
+
if (typeof win !== 'function') return 'light';
|
|
25
|
+
try {
|
|
26
|
+
return win('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
27
|
+
} catch {
|
|
28
|
+
return 'light';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function effectiveTheme(theme: EmbedTheme | undefined, systemTheme: ThemeName): ThemeName {
|
|
33
|
+
if (theme && theme !== 'auto') return normalizeThemeName(theme) ?? systemTheme;
|
|
34
|
+
return systemTheme;
|
|
35
|
+
}
|