@nowline/embed 0.4.1 → 0.5.0

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.
Files changed (42) hide show
  1. package/README.md +14 -26
  2. package/dist/auto-scan.d.ts +11 -0
  3. package/dist/auto-scan.d.ts.map +1 -1
  4. package/dist/auto-scan.js +28 -0
  5. package/dist/auto-scan.js.map +1 -1
  6. package/dist/index.d.ts +18 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +8 -20
  9. package/dist/index.js.map +1 -1
  10. package/dist/meta.json +116 -59
  11. package/dist/nowline.esm.js +1020 -133
  12. package/dist/nowline.esm.js.map +4 -4
  13. package/dist/nowline.min.js +82 -82
  14. package/dist/nowline.min.js.map +4 -4
  15. package/dist/share.d.ts +45 -0
  16. package/dist/share.d.ts.map +1 -0
  17. package/dist/share.js +75 -0
  18. package/dist/share.js.map +1 -0
  19. package/dist/theme.d.ts +3 -3
  20. package/dist/theme.d.ts.map +1 -1
  21. package/dist/theme.js +5 -3
  22. package/dist/theme.js.map +1 -1
  23. package/package.json +6 -7
  24. package/src/auto-scan.ts +42 -0
  25. package/src/index.ts +43 -31
  26. package/src/share.ts +108 -0
  27. package/src/theme.ts +7 -5
  28. package/dist/auth/allowlist.d.ts +0 -20
  29. package/dist/auth/allowlist.d.ts.map +0 -1
  30. package/dist/auth/allowlist.js +0 -33
  31. package/dist/auth/allowlist.js.map +0 -1
  32. package/dist/auth/env.d.ts +0 -23
  33. package/dist/auth/env.d.ts.map +0 -1
  34. package/dist/auth/env.js +0 -24
  35. package/dist/auth/env.js.map +0 -1
  36. package/dist/auth/firebase-auth.client.d.ts +0 -21
  37. package/dist/auth/firebase-auth.client.d.ts.map +0 -1
  38. package/dist/auth/firebase-auth.client.js +0 -142
  39. package/dist/auth/firebase-auth.client.js.map +0 -1
  40. package/src/auth/allowlist.ts +0 -32
  41. package/src/auth/env.ts +0 -37
  42. package/src/auth/firebase-auth.client.ts +0 -174
@@ -0,0 +1,45 @@
1
+ export declare const DEFAULT_SHARE_BASE = "https://free.nowline.io/open";
2
+ /**
3
+ * The `share` initialize option selects where share links point.
4
+ *
5
+ * - `true` — use DEFAULT_SHARE_BASE (the default).
6
+ * - `string` — a base URL with optional path; built via the URL API so
7
+ * `https://foo.com/open` → `https://foo.com/open#text=…`.
8
+ * - `false` / `'none'` — disable the share anchor entirely.
9
+ * - `{ textUrl, remoteUrl }` — escape hatch for non-hash URL shapes;
10
+ * `{text}` substituted with the base64url payload, `{url}` with the
11
+ * percent-encoded source URL.
12
+ */
13
+ export type ShareOption = boolean | 'none' | string | {
14
+ textUrl: string;
15
+ remoteUrl: string;
16
+ };
17
+ /**
18
+ * Encode source text → `#text=<base64url(zlib(utf8(source)))>`.
19
+ *
20
+ * The return value includes the `#text=` key so callers can use it
21
+ * directly as a URL fragment.
22
+ *
23
+ * Sync, single code path, no feature-detect.
24
+ */
25
+ export declare function encodeText(source: string): string;
26
+ export interface BuildShareLinkOptions {
27
+ /** The roadmap source text (used to build the #text= fragment). */
28
+ source: string;
29
+ /**
30
+ * Resolved source URL for the block (per-block → global → undefined).
31
+ * Only `https:` URLs are emitted as `#url=`; anything else falls
32
+ * back to the inline `#text=` encoding.
33
+ */
34
+ sourceUrl?: string | undefined;
35
+ /** The `share` option from InitializeOptions. */
36
+ share: ShareOption;
37
+ }
38
+ /**
39
+ * Build the full "Share on Nowline" URL for a rendered block.
40
+ *
41
+ * Returns `null` when `share` is `false` or `'none'`, signalling that
42
+ * no anchor should be rendered.
43
+ */
44
+ export declare function buildShareLink({ source, sourceUrl, share }: BuildShareLinkOptions): string | null;
45
+ //# sourceMappingURL=share.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"AAeA,eAAO,MAAM,kBAAkB,iCAAiC,CAAC;AAEjE;;;;;;;;;;GAUG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7F;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEjD;AAgBD,MAAM,WAAW,qBAAqB;IAClC,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,iDAAiD;IACjD,KAAK,EAAE,WAAW,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,qBAAqB,GAAG,MAAM,GAAG,IAAI,CAwBjG"}
package/dist/share.js ADDED
@@ -0,0 +1,75 @@
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
+ import { zlibSync } from 'fflate';
14
+ export const DEFAULT_SHARE_BASE = 'https://free.nowline.io/open';
15
+ /**
16
+ * Encode source text → `#text=<base64url(zlib(utf8(source)))>`.
17
+ *
18
+ * The return value includes the `#text=` key so callers can use it
19
+ * directly as a URL fragment.
20
+ *
21
+ * Sync, single code path, no feature-detect.
22
+ */
23
+ export function encodeText(source) {
24
+ return `#text=${_encodePayload(source)}`;
25
+ }
26
+ /** base64url(zlib(utf8(source))) without the `#text=` prefix. */
27
+ function _encodePayload(source) {
28
+ const bytes = new TextEncoder().encode(source);
29
+ const compressed = zlibSync(bytes);
30
+ // Convert Uint8Array to binary string for btoa. Chunked to avoid
31
+ // call-stack limits on large payloads.
32
+ const chunk = 0x8000; // 32 KB — safe below JS engine stack limits
33
+ let bin = '';
34
+ for (let i = 0; i < compressed.length; i += chunk) {
35
+ bin += String.fromCharCode(...compressed.subarray(i, i + chunk));
36
+ }
37
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
+ }
39
+ /**
40
+ * Build the full "Share on Nowline" URL for a rendered block.
41
+ *
42
+ * Returns `null` when `share` is `false` or `'none'`, signalling that
43
+ * no anchor should be rendered.
44
+ */
45
+ export function buildShareLink({ source, sourceUrl, share }) {
46
+ if (share === false || share === 'none') {
47
+ return null;
48
+ }
49
+ if (typeof share === 'object') {
50
+ // Template mode: { textUrl, remoteUrl }
51
+ if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
52
+ return share.remoteUrl.replace('{url}', encodeURIComponent(sourceUrl));
53
+ }
54
+ return share.textUrl.replace('{text}', _encodePayload(source));
55
+ }
56
+ // share === true → DEFAULT_SHARE_BASE; share is a string → custom base URL
57
+ const base = share === true ? DEFAULT_SHARE_BASE : share;
58
+ const url = new URL(base);
59
+ if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
60
+ url.hash = `url=${encodeURIComponent(sourceUrl)}`;
61
+ }
62
+ else {
63
+ url.hash = `text=${_encodePayload(source)}`;
64
+ }
65
+ return url.toString();
66
+ }
67
+ function _isHttps(url) {
68
+ try {
69
+ return new URL(url).protocol === 'https:';
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ //# sourceMappingURL=share.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share.js","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,kDAAkD;AAClD,EAAE;AACF,4DAA4D;AAC5D,wCAAwC;AACxC,qBAAqB;AACrB,EAAE;AACF,mEAAmE;AACnE,mEAAmE;AACnE,WAAW;AACX,EAAE;AACF,mDAAmD;AAEnD,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAElC,MAAM,CAAC,MAAM,kBAAkB,GAAG,8BAA8B,CAAC;AAejE;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,MAAc;IACrC,OAAO,SAAS,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC;AAC7C,CAAC;AAED,iEAAiE;AACjE,SAAS,cAAc,CAAC,MAAc;IAClC,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC/C,MAAM,UAAU,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IACnC,iEAAiE;IACjE,uCAAuC;IACvC,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,4CAA4C;IAClE,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC;QAChD,GAAG,IAAI,MAAM,CAAC,YAAY,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC;IACrE,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;AAChF,CAAC;AAeD;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAyB;IAC9E,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC5B,wCAAwC;QACxC,IAAI,SAAS,KAAK,SAAS,IAAI,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;YACjD,OAAO,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC,OAAO,EAAE,kBAAkB,CAAC,SAAS,CAAC,CAAC,CAAC;QAC3E,CAAC;QACD,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC;IACnE,CAAC;IAED,2EAA2E;IAC3E,MAAM,IAAI,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,KAAK,CAAC;IACzD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;IAE1B,IAAI,SAAS,KAAK,SAAS,IAAI,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACjD,GAAG,CAAC,IAAI,GAAG,OAAO,kBAAkB,CAAC,SAAS,CAAC,EAAE,CAAC;IACtD,CAAC;SAAM,CAAC;QACJ,GAAG,CAAC,IAAI,GAAG,QAAQ,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC;IAChD,CAAC;IAED,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAC;AAC1B,CAAC;AAED,SAAS,QAAQ,CAAC,GAAW;IACzB,IAAI,CAAC;QACD,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC"}
package/dist/theme.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ThemeName } from '@nowline/layout';
2
- export type EmbedTheme = ThemeName | 'auto';
3
- export declare function resolveSystemTheme(): ThemeName;
1
+ import { type ThemeName } from '@nowline/layout';
2
+ export type EmbedTheme = ThemeName | 'greyscale' | 'auto';
3
+ export declare function resolveSystemTheme(): 'light' | 'dark';
4
4
  export declare function effectiveTheme(theme: EmbedTheme | undefined, systemTheme: ThemeName): ThemeName;
5
5
  //# sourceMappingURL=theme.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAGrE,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,WAAW,GAAG,MAAM,CAAC;AAE1D,wBAAgB,kBAAkB,IAAI,OAAO,GAAG,MAAM,CASrD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,EAAE,WAAW,EAAE,SAAS,GAAG,SAAS,CAG/F"}
package/dist/theme.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Theme resolution for the embed.
2
2
  //
3
3
  // Precedence (highest to lowest):
4
- // 1. `initialize({ theme })` flag (light / dark / auto).
4
+ // 1. `initialize({ theme })` flag (light / dark / grayscale / auto;
5
+ // `greyscale` is accepted and canonicalized to `grayscale`).
5
6
  // 2. The file's own `nowline v1 theme:` directive — handled inside
6
7
  // layout, so we just don't override it when the embed config says
7
8
  // `'auto'` and we have no system preference reading.
@@ -11,6 +12,7 @@
11
12
  // flipping the OS theme mid-session does not cause every embedded
12
13
  // roadmap on the page to repaint. This matches Mermaid's posture and
13
14
  // keeps the embed deterministic for screenshot tools.
15
+ import { normalizeThemeName } from '@nowline/layout';
14
16
  export function resolveSystemTheme() {
15
17
  if (typeof globalThis === 'undefined')
16
18
  return 'light';
@@ -25,8 +27,8 @@ export function resolveSystemTheme() {
25
27
  }
26
28
  }
27
29
  export function effectiveTheme(theme, systemTheme) {
28
- if (theme === 'light' || theme === 'dark')
29
- return theme;
30
+ if (theme && theme !== 'auto')
31
+ return normalizeThemeName(theme) ?? systemTheme;
30
32
  return systemTheme;
31
33
  }
32
34
  //# sourceMappingURL=theme.js.map
package/dist/theme.js.map CHANGED
@@ -1 +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"}
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,EAAE;AACF,kCAAkC;AAClC,sEAAsE;AACtE,kEAAkE;AAClE,qEAAqE;AACrE,uEAAuE;AACvE,0DAA0D;AAC1D,yDAAyD;AACzD,EAAE;AACF,sEAAsE;AACtE,kEAAkE;AAClE,qEAAqE;AACrE,sDAAsD;AAEtD,OAAO,EAAE,kBAAkB,EAAkB,MAAM,iBAAiB,CAAC;AAKrE,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,IAAI,KAAK,KAAK,MAAM;QAAE,OAAO,kBAAkB,CAAC,KAAK,CAAC,IAAI,WAAW,CAAC;IAC/E,OAAO,WAAW,CAAC;AACvB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowline/embed",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Browser embed bundle for Nowline. Drop a <script> tag and ```nowline``` blocks render in place.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -39,16 +39,16 @@
39
39
  "cdn"
40
40
  ],
41
41
  "dependencies": {
42
+ "fflate": "^0.8.3",
42
43
  "langium": "~4.2.4",
43
- "@nowline/browser": "0.4.1",
44
- "@nowline/core": "0.4.1",
45
- "@nowline/renderer": "0.4.1",
46
- "@nowline/layout": "0.4.1"
44
+ "@nowline/browser": "0.5.0",
45
+ "@nowline/core": "0.5.0",
46
+ "@nowline/layout": "0.5.0",
47
+ "@nowline/renderer": "0.5.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^25.9.1",
50
51
  "esbuild": "^0.28.0",
51
- "firebase": "^12.13.0",
52
52
  "happy-dom": "^20.9.0",
53
53
  "typescript": "^6.0.3",
54
54
  "vitest": "^4.1.7"
@@ -58,7 +58,6 @@
58
58
  "watch": "tsc -b tsconfig.json --watch",
59
59
  "typecheck": "tsc --noEmit -p tsconfig.json",
60
60
  "bundle": "node scripts/bundle.mjs",
61
- "bundle:dev": "node scripts/bundle.mjs --dev",
62
61
  "check-size": "node scripts/check-size.mjs",
63
62
  "test": "vitest run",
64
63
  "test:watch": "vitest"
package/src/auto-scan.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { ThemeName } from '@nowline/layout';
7
7
  import { type EmbedRenderOptions, renderSource } from './pipeline.js';
8
+ import { buildShareLink, type ShareOption } from './share.js';
8
9
 
9
10
  export interface AutoScanInputs {
10
11
  selector: string;
@@ -12,6 +13,16 @@ export interface AutoScanInputs {
12
13
  locale?: string;
13
14
  width?: number;
14
15
  today?: Date;
16
+ /**
17
+ * Controls the "Share on Nowline" anchor appended after each rendered
18
+ * SVG. Defaults to `true` when omitted (mirrors the config default).
19
+ */
20
+ share?: ShareOption;
21
+ /**
22
+ * Global source URL for all blocks. Per-block `data-nowline-source-url`
23
+ * overrides this for individual blocks.
24
+ */
25
+ sourceUrl?: string;
15
26
  /**
16
27
  * Document to scan. Defaults to `globalThis.document`. Tests inject
17
28
  * a happy-dom document; the IIFE running on a real page picks up
@@ -40,6 +51,9 @@ export async function runAutoScan(inputs: AutoScanInputs): Promise<AutoScanResul
40
51
  let failed = 0;
41
52
  const baseRunId = ++runCounter;
42
53
 
54
+ // share defaults to true when omitted (mirrors config.share default)
55
+ const share: ShareOption = inputs.share ?? true;
56
+
43
57
  const tasks: Array<Promise<void>> = [];
44
58
  let blockIndex = 0;
45
59
  for (const code of Array.from(blocks)) {
@@ -56,11 +70,39 @@ export async function runAutoScan(inputs: AutoScanInputs): Promise<AutoScanResul
56
70
  today: inputs.today,
57
71
  idPrefix,
58
72
  };
73
+
74
+ // Capture DOM position before the async render. outerHTML replacement
75
+ // detaches `target` from the tree, so parent and nextSibling must be
76
+ // read now. The share anchor is inserted at the saved nextSibling
77
+ // position, which lands it as the immediate next sibling of the SVG.
78
+ const parent = target.parentElement;
79
+ const nextSibling = target.nextSibling;
80
+
81
+ // Per-block resolution order: data-nowline-source-url → global sourceUrl
82
+ const perBlockUrl = code.getAttribute('data-nowline-source-url') ?? undefined;
83
+ const resolvedSourceUrl = perBlockUrl ?? inputs.sourceUrl;
84
+
59
85
  tasks.push(
60
86
  renderSource(source, opts).then(
61
87
  (svg) => {
62
88
  replaceWithSvg(target, svg);
63
89
  rendered++;
90
+ if (parent !== null) {
91
+ const href = buildShareLink({
92
+ source,
93
+ sourceUrl: resolvedSourceUrl,
94
+ share,
95
+ });
96
+ if (href !== null) {
97
+ const a = doc.createElement('a');
98
+ a.className = 'nowline-share';
99
+ a.href = href;
100
+ a.target = '_blank';
101
+ a.rel = 'noopener noreferrer';
102
+ a.textContent = 'Share on Nowline';
103
+ parent.insertBefore(a, nextSibling);
104
+ }
105
+ }
64
106
  },
65
107
  (err: unknown) => {
66
108
  failed++;
package/src/index.ts CHANGED
@@ -5,17 +5,12 @@
5
5
  // The IIFE bundle exposes everything below as `window.nowline.*`. ESM
6
6
  // consumers import named exports from the package root.
7
7
 
8
- import { EMBED_SHA, EMBED_VERSION } from './auth/env.js';
9
-
10
- // Build-time constant substituted by esbuild's `define` (see
11
- // `scripts/bundle.mjs`). Reading it directly at the call site (rather
12
- // than via an `IS_DEV` re-export from env.ts) means esbuild can fold
13
- // the `if` below to a literal `true`/`false` at minify time and
14
- // dead-code-eliminate the dynamic-import branch in the prod bundle,
15
- // stripping `firebase/app` + `firebase/auth` from `dist/nowline.min.js`.
16
- // The `typeof` guard keeps the file safe to import under vitest, where
17
- // esbuild's define never runs and the identifier is undeclared.
18
- declare const __NOWLINE_EMBED_ENV__: string;
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;
19
14
 
20
15
  import {
21
16
  __resetAutoScanForTests,
@@ -31,9 +26,16 @@ import {
31
26
  parseSource,
32
27
  renderSource,
33
28
  } from './pipeline.js';
29
+ import type { ShareOption } from './share.js';
34
30
  import { type EmbedTheme, effectiveTheme, resolveSystemTheme } from './theme.js';
35
31
 
36
- export { type AutoScanResult, type EmbedParseResult, EmbedRenderError, type EmbedTheme };
32
+ export {
33
+ type AutoScanResult,
34
+ type EmbedParseResult,
35
+ EmbedRenderError,
36
+ type EmbedTheme,
37
+ type ShareOption,
38
+ };
37
39
 
38
40
  /**
39
41
  * Bundle provenance, mirroring the legal-comment banner that
@@ -44,8 +46,10 @@ export { type AutoScanResult, type EmbedParseResult, EmbedRenderError, type Embe
44
46
  * pages and bug reports can identify the exact build without scraping
45
47
  * the comment banner.
46
48
  */
47
- export const version: string = EMBED_VERSION;
48
- export const sha: string = EMBED_SHA;
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';
49
53
 
50
54
  const DEFAULT_SELECTOR = 'pre code.language-nowline, code.language-nowline';
51
55
 
@@ -71,6 +75,22 @@ export interface InitializeOptions {
71
75
  width?: number;
72
76
  /** Pin a `today` for deterministic snapshots; defaults to live `new Date()` per render. */
73
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;
74
94
  }
75
95
 
76
96
  interface ResolvedConfig {
@@ -82,6 +102,10 @@ interface ResolvedConfig {
82
102
  today?: Date;
83
103
  /** System theme captured at init; not reactive to OS theme flips mid-session. */
84
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;
85
109
  }
86
110
 
87
111
  const initialConfig: ResolvedConfig = {
@@ -89,6 +113,7 @@ const initialConfig: ResolvedConfig = {
89
113
  startOnLoad: true,
90
114
  selector: DEFAULT_SELECTOR,
91
115
  systemTheme: resolveSystemTheme(),
116
+ share: true,
92
117
  };
93
118
 
94
119
  let config: ResolvedConfig = { ...initialConfig };
@@ -102,6 +127,8 @@ export function initialize(options: InitializeOptions = {}): void {
102
127
  locale: options.locale ?? config.locale,
103
128
  width: options.width ?? config.width,
104
129
  today: options.today ?? config.today,
130
+ share: options.share ?? config.share,
131
+ sourceUrl: options.sourceUrl ?? config.sourceUrl,
105
132
  // Re-read `prefers-color-scheme` on every initialize() so callers
106
133
  // who explicitly want the latest system theme can ask for it by
107
134
  // calling initialize() again. Auto-scan paths still use the value
@@ -154,6 +181,8 @@ export async function init(overrides?: Partial<AutoScanInputs>): Promise<AutoSca
154
181
  locale: overrides?.locale ?? config.locale,
155
182
  width: overrides?.width ?? config.width,
156
183
  today: overrides?.today ?? config.today,
184
+ share: overrides?.share ?? config.share,
185
+ sourceUrl: overrides?.sourceUrl ?? config.sourceUrl,
157
186
  document: overrides?.document,
158
187
  };
159
188
  return runAutoScan(inputs);
@@ -174,23 +203,6 @@ export const run = init;
174
203
  if (typeof document !== 'undefined' && !autoStartScheduled) {
175
204
  autoStartScheduled = true;
176
205
 
177
- // Dev-only Firebase Auth gate (embed.nowline.dev). The condition
178
- // reads `__NOWLINE_EMBED_ENV__` directly so esbuild's `define`
179
- // substitutes a literal string at minify time — the prod bundle
180
- // sees `if ("prod" === "dev")` → `if (false)` and DCEs the dynamic
181
- // import that pulls in `firebase/app` + `firebase/auth`.
182
- // `check-size.mjs` asserts no `firebase` literal survives in the
183
- // prod IIFE as a belt-and-suspenders check.
184
- if (typeof __NOWLINE_EMBED_ENV__ !== 'undefined' && __NOWLINE_EMBED_ENV__ === 'dev') {
185
- void import('./auth/firebase-auth.client.js')
186
- .then(({ startDevAuthGate }) => {
187
- startDevAuthGate();
188
- })
189
- .catch((err) => {
190
- console.error('[nowline] dev auth gate failed to load:', err);
191
- });
192
- }
193
-
194
206
  const start = (): void => {
195
207
  if (!config.startOnLoad) return;
196
208
  // Fire-and-forget; render errors are surfaced via `console.error`
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 CHANGED
@@ -1,7 +1,8 @@
1
1
  // Theme resolution for the embed.
2
2
  //
3
3
  // Precedence (highest to lowest):
4
- // 1. `initialize({ theme })` flag (light / dark / auto).
4
+ // 1. `initialize({ theme })` flag (light / dark / grayscale / auto;
5
+ // `greyscale` is accepted and canonicalized to `grayscale`).
5
6
  // 2. The file's own `nowline v1 theme:` directive — handled inside
6
7
  // layout, so we just don't override it when the embed config says
7
8
  // `'auto'` and we have no system preference reading.
@@ -12,11 +13,12 @@
12
13
  // roadmap on the page to repaint. This matches Mermaid's posture and
13
14
  // keeps the embed deterministic for screenshot tools.
14
15
 
15
- import type { ThemeName } from '@nowline/layout';
16
+ import { normalizeThemeName, type ThemeName } from '@nowline/layout';
16
17
 
17
- export type EmbedTheme = ThemeName | 'auto';
18
+ // `'greyscale'` (UK) is an accepted alias for the canonical `'grayscale'`.
19
+ export type EmbedTheme = ThemeName | 'greyscale' | 'auto';
18
20
 
19
- export function resolveSystemTheme(): ThemeName {
21
+ export function resolveSystemTheme(): 'light' | 'dark' {
20
22
  if (typeof globalThis === 'undefined') return 'light';
21
23
  const win = (globalThis as { matchMedia?: (q: string) => { matches: boolean } }).matchMedia;
22
24
  if (typeof win !== 'function') return 'light';
@@ -28,6 +30,6 @@ export function resolveSystemTheme(): ThemeName {
28
30
  }
29
31
 
30
32
  export function effectiveTheme(theme: EmbedTheme | undefined, systemTheme: ThemeName): ThemeName {
31
- if (theme === 'light' || theme === 'dark') return theme;
33
+ if (theme && theme !== 'auto') return normalizeThemeName(theme) ?? systemTheme;
32
34
  return systemTheme;
33
35
  }
@@ -1,20 +0,0 @@
1
- /**
2
- * Allowlist for the dev embed bundle's Firebase Auth gate.
3
- *
4
- * An email is allowlisted if either:
5
- * 1. Its domain is in ALLOWED_DOMAINS (e.g. anything @nowline.io), OR
6
- * 2. The exact lowercase email is in ALLOWED_EMAILS.
7
- *
8
- * Mirrors the commercial site's `auth-allowlist.ts` so both Lolay
9
- * dev surfaces (the marketing site and the embed CDN dev tier) share
10
- * one allowlist policy. Keep this short; when it grows past ~5 entries,
11
- * migrate to Firebase custom claims (Admin SDK) or a Firestore
12
- * `allowlist` collection.
13
- *
14
- * See specs/embed.md § Bootstrap status (dev auth gate) and
15
- * the infrastructure deploy runbook § 4 for the deploy-side wiring.
16
- */
17
- export declare const ALLOWED_DOMAINS: readonly string[];
18
- export declare const ALLOWED_EMAILS: readonly string[];
19
- export declare function isAllowlisted(email: string | null | undefined): boolean;
20
- //# sourceMappingURL=allowlist.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"allowlist.d.ts","sourceRoot":"","sources":["../../src/auth/allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAAmB,CAAC;AAEjE,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAE3C,CAAC;AAEF,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAQvE"}
@@ -1,33 +0,0 @@
1
- /**
2
- * Allowlist for the dev embed bundle's Firebase Auth gate.
3
- *
4
- * An email is allowlisted if either:
5
- * 1. Its domain is in ALLOWED_DOMAINS (e.g. anything @nowline.io), OR
6
- * 2. The exact lowercase email is in ALLOWED_EMAILS.
7
- *
8
- * Mirrors the commercial site's `auth-allowlist.ts` so both Lolay
9
- * dev surfaces (the marketing site and the embed CDN dev tier) share
10
- * one allowlist policy. Keep this short; when it grows past ~5 entries,
11
- * migrate to Firebase custom claims (Admin SDK) or a Firestore
12
- * `allowlist` collection.
13
- *
14
- * See specs/embed.md § Bootstrap status (dev auth gate) and
15
- * the infrastructure deploy runbook § 4 for the deploy-side wiring.
16
- */
17
- export const ALLOWED_DOMAINS = ['nowline.io'];
18
- export const ALLOWED_EMAILS = [
19
- // Add additional allowlisted Google account emails here, one per line.
20
- ];
21
- export function isAllowlisted(email) {
22
- if (!email)
23
- return false;
24
- const normalized = email.trim().toLowerCase();
25
- if (ALLOWED_EMAILS.includes(normalized))
26
- return true;
27
- const at = normalized.lastIndexOf('@');
28
- if (at === -1)
29
- return false;
30
- const domain = normalized.slice(at + 1);
31
- return ALLOWED_DOMAINS.includes(domain);
32
- }
33
- //# sourceMappingURL=allowlist.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"allowlist.js","sourceRoot":"","sources":["../../src/auth/allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,CAAC,MAAM,eAAe,GAAsB,CAAC,YAAY,CAAC,CAAC;AAEjE,MAAM,CAAC,MAAM,cAAc,GAAsB;AAC7C,uEAAuE;CAC1E,CAAC;AAEF,MAAM,UAAU,aAAa,CAAC,KAAgC;IAC1D,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC9C,IAAI,cAAc,CAAC,QAAQ,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IACrD,MAAM,EAAE,GAAG,UAAU,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAC5B,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACxC,OAAO,eAAe,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAC5C,CAAC"}