@nowline/embed 0.4.1 → 0.4.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,46 @@
1
+ /** Default share destination — the Nowline Free app's open route. */
2
+ export declare const DEFAULT_SHARE_BASE = "https://free.nowline.io/open";
3
+ /**
4
+ * The `share` initialize option selects where share links point.
5
+ *
6
+ * - `true` — use DEFAULT_SHARE_BASE (the default).
7
+ * - `string` — a base URL with optional path; built via the URL API so
8
+ * `https://foo.com/open` → `https://foo.com/open#text=…`.
9
+ * - `false` / `'none'` — disable the share anchor entirely.
10
+ * - `{ textUrl, remoteUrl }` — escape hatch for non-hash URL shapes;
11
+ * `{text}` substituted with the base64url payload, `{url}` with the
12
+ * percent-encoded source URL.
13
+ */
14
+ export type ShareOption = boolean | 'none' | string | {
15
+ textUrl: string;
16
+ remoteUrl: string;
17
+ };
18
+ /**
19
+ * Encode source text → `#text=<base64url(zlib(utf8(source)))>`.
20
+ *
21
+ * The return value includes the `#text=` key so callers can use it
22
+ * directly as a URL fragment.
23
+ *
24
+ * Sync, single code path, no feature-detect.
25
+ */
26
+ export declare function encodeText(source: string): string;
27
+ export interface BuildShareLinkOptions {
28
+ /** The roadmap source text (used to build the #text= fragment). */
29
+ source: string;
30
+ /**
31
+ * Resolved source URL for the block (per-block → global → undefined).
32
+ * Only `https:` URLs are emitted as `#url=`; anything else falls
33
+ * back to the inline `#text=` encoding.
34
+ */
35
+ sourceUrl?: string | undefined;
36
+ /** The `share` option from InitializeOptions. */
37
+ share: ShareOption;
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 declare function buildShareLink({ source, sourceUrl, share }: BuildShareLinkOptions): string | null;
46
+ //# sourceMappingURL=share.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../src/share.ts"],"names":[],"mappings":"AAeA,qEAAqE;AACrE,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,76 @@
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
+ /** Default share destination — the Nowline Free app's open route. */
15
+ export const DEFAULT_SHARE_BASE = 'https://free.nowline.io/open';
16
+ /**
17
+ * Encode source text → `#text=<base64url(zlib(utf8(source)))>`.
18
+ *
19
+ * The return value includes the `#text=` key so callers can use it
20
+ * directly as a URL fragment.
21
+ *
22
+ * Sync, single code path, no feature-detect.
23
+ */
24
+ export function encodeText(source) {
25
+ return `#text=${_encodePayload(source)}`;
26
+ }
27
+ /** base64url(zlib(utf8(source))) without the `#text=` prefix. */
28
+ function _encodePayload(source) {
29
+ const bytes = new TextEncoder().encode(source);
30
+ const compressed = zlibSync(bytes);
31
+ // Convert Uint8Array to binary string for btoa. Chunked to avoid
32
+ // call-stack limits on large payloads.
33
+ const chunk = 0x8000; // 32 KB — safe below JS engine stack limits
34
+ let bin = '';
35
+ for (let i = 0; i < compressed.length; i += chunk) {
36
+ bin += String.fromCharCode(...compressed.subarray(i, i + chunk));
37
+ }
38
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
39
+ }
40
+ /**
41
+ * Build the full "Share on Nowline" URL for a rendered block.
42
+ *
43
+ * Returns `null` when `share` is `false` or `'none'`, signalling that
44
+ * no anchor should be rendered.
45
+ */
46
+ export function buildShareLink({ source, sourceUrl, share }) {
47
+ if (share === false || share === 'none') {
48
+ return null;
49
+ }
50
+ if (typeof share === 'object') {
51
+ // Template mode: { textUrl, remoteUrl }
52
+ if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
53
+ return share.remoteUrl.replace('{url}', encodeURIComponent(sourceUrl));
54
+ }
55
+ return share.textUrl.replace('{text}', _encodePayload(source));
56
+ }
57
+ // share === true → DEFAULT_SHARE_BASE; share is a string → custom base URL
58
+ const base = share === true ? DEFAULT_SHARE_BASE : share;
59
+ const url = new URL(base);
60
+ if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
61
+ url.hash = `url=${encodeURIComponent(sourceUrl)}`;
62
+ }
63
+ else {
64
+ url.hash = `text=${_encodePayload(source)}`;
65
+ }
66
+ return url.toString();
67
+ }
68
+ function _isHttps(url) {
69
+ try {
70
+ return new URL(url).protocol === 'https:';
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ //# 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,qEAAqE;AACrE,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowline/embed",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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,11 +39,12 @@
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/core": "0.4.2",
45
+ "@nowline/layout": "0.4.2",
46
+ "@nowline/renderer": "0.4.2",
47
+ "@nowline/browser": "0.4.2"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^25.9.1",
@@ -144,20 +144,24 @@ export function startDevAuthGate(): void {
144
144
 
145
145
  const provider = new GoogleAuthProvider();
146
146
 
147
- const handleSignIn = async (): Promise<void> => {
148
- try {
149
- await signInWithPopup(auth as Auth, provider);
150
- } catch (err) {
151
- console.error('[nowline-embed-dev-auth-gate] Sign-in failed:', err);
152
- }
147
+ const handleSignIn = (): void => {
148
+ void (async () => {
149
+ try {
150
+ await signInWithPopup(auth as Auth, provider);
151
+ } catch (err) {
152
+ console.error('[nowline-embed-dev-auth-gate] Sign-in failed:', err);
153
+ }
154
+ })();
153
155
  };
154
156
 
155
- const handleSignOut = async (): Promise<void> => {
156
- try {
157
- await signOut(auth as Auth);
158
- } catch (err) {
159
- console.error('[nowline-embed-dev-auth-gate] Sign-out failed:', err);
160
- }
157
+ const handleSignOut = (): void => {
158
+ void (async () => {
159
+ try {
160
+ await signOut(auth as Auth);
161
+ } catch (err) {
162
+ console.error('[nowline-embed-dev-auth-gate] Sign-out failed:', err);
163
+ }
164
+ })();
161
165
  };
162
166
 
163
167
  onAuthStateChanged(auth as Auth, (user: User | null) => {
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
@@ -31,9 +31,16 @@ import {
31
31
  parseSource,
32
32
  renderSource,
33
33
  } from './pipeline.js';
34
+ import type { ShareOption } from './share.js';
34
35
  import { type EmbedTheme, effectiveTheme, resolveSystemTheme } from './theme.js';
35
36
 
36
- export { type AutoScanResult, type EmbedParseResult, EmbedRenderError, type EmbedTheme };
37
+ export {
38
+ type AutoScanResult,
39
+ type EmbedParseResult,
40
+ EmbedRenderError,
41
+ type EmbedTheme,
42
+ type ShareOption,
43
+ };
37
44
 
38
45
  /**
39
46
  * Bundle provenance, mirroring the legal-comment banner that
@@ -71,6 +78,21 @@ export interface InitializeOptions {
71
78
  width?: number;
72
79
  /** Pin a `today` for deterministic snapshots; defaults to live `new Date()` per render. */
73
80
  today?: Date;
81
+ /**
82
+ * Controls the "Share on Nowline" anchor appended after each rendered SVG.
83
+ * - `true` (default) — link to `https://free.nowline.io/open#text=…`.
84
+ * - string — a custom base URL (may include a path); the fragment is appended.
85
+ * - `false` / `'none'` — no anchor rendered.
86
+ * - `{ textUrl, remoteUrl }` — template with `{text}` / `{url}` substitution.
87
+ */
88
+ share?: ShareOption;
89
+ /**
90
+ * Global source URL for all blocks on the page. When set, share links
91
+ * use `#url=<sourceUrl>` instead of `#text=` (inline encoding).
92
+ * Per-block `data-nowline-source-url` overrides this for individual blocks.
93
+ * Only `https:` URLs are emitted as `#url=`.
94
+ */
95
+ sourceUrl?: string;
74
96
  }
75
97
 
76
98
  interface ResolvedConfig {
@@ -82,6 +104,10 @@ interface ResolvedConfig {
82
104
  today?: Date;
83
105
  /** System theme captured at init; not reactive to OS theme flips mid-session. */
84
106
  systemTheme: 'light' | 'dark';
107
+ /** Controls the "Share on Nowline" anchor. Defaults to `true`. */
108
+ share: ShareOption;
109
+ /** Global canonical source URL; per-block `data-nowline-source-url` takes priority. */
110
+ sourceUrl?: string;
85
111
  }
86
112
 
87
113
  const initialConfig: ResolvedConfig = {
@@ -89,6 +115,7 @@ const initialConfig: ResolvedConfig = {
89
115
  startOnLoad: true,
90
116
  selector: DEFAULT_SELECTOR,
91
117
  systemTheme: resolveSystemTheme(),
118
+ share: true,
92
119
  };
93
120
 
94
121
  let config: ResolvedConfig = { ...initialConfig };
@@ -102,6 +129,8 @@ export function initialize(options: InitializeOptions = {}): void {
102
129
  locale: options.locale ?? config.locale,
103
130
  width: options.width ?? config.width,
104
131
  today: options.today ?? config.today,
132
+ share: options.share ?? config.share,
133
+ sourceUrl: options.sourceUrl ?? config.sourceUrl,
105
134
  // Re-read `prefers-color-scheme` on every initialize() so callers
106
135
  // who explicitly want the latest system theme can ask for it by
107
136
  // calling initialize() again. Auto-scan paths still use the value
@@ -154,6 +183,8 @@ export async function init(overrides?: Partial<AutoScanInputs>): Promise<AutoSca
154
183
  locale: overrides?.locale ?? config.locale,
155
184
  width: overrides?.width ?? config.width,
156
185
  today: overrides?.today ?? config.today,
186
+ share: overrides?.share ?? config.share,
187
+ sourceUrl: overrides?.sourceUrl ?? config.sourceUrl,
157
188
  document: overrides?.document,
158
189
  };
159
190
  return runAutoScan(inputs);
package/src/share.ts ADDED
@@ -0,0 +1,109 @@
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
+ /** Default share destination — the Nowline Free app's open route. */
17
+ export const DEFAULT_SHARE_BASE = 'https://free.nowline.io/open';
18
+
19
+ /**
20
+ * The `share` initialize option selects where share links point.
21
+ *
22
+ * - `true` — use DEFAULT_SHARE_BASE (the default).
23
+ * - `string` — a base URL with optional path; built via the URL API so
24
+ * `https://foo.com/open` → `https://foo.com/open#text=…`.
25
+ * - `false` / `'none'` — disable the share anchor entirely.
26
+ * - `{ textUrl, remoteUrl }` — escape hatch for non-hash URL shapes;
27
+ * `{text}` substituted with the base64url payload, `{url}` with the
28
+ * percent-encoded source URL.
29
+ */
30
+ export type ShareOption = boolean | 'none' | string | { textUrl: string; remoteUrl: string };
31
+
32
+ /**
33
+ * Encode source text → `#text=<base64url(zlib(utf8(source)))>`.
34
+ *
35
+ * The return value includes the `#text=` key so callers can use it
36
+ * directly as a URL fragment.
37
+ *
38
+ * Sync, single code path, no feature-detect.
39
+ */
40
+ export function encodeText(source: string): string {
41
+ return `#text=${_encodePayload(source)}`;
42
+ }
43
+
44
+ /** base64url(zlib(utf8(source))) without the `#text=` prefix. */
45
+ function _encodePayload(source: string): string {
46
+ const bytes = new TextEncoder().encode(source);
47
+ const compressed = zlibSync(bytes);
48
+ // Convert Uint8Array to binary string for btoa. Chunked to avoid
49
+ // call-stack limits on large payloads.
50
+ const chunk = 0x8000; // 32 KB — safe below JS engine stack limits
51
+ let bin = '';
52
+ for (let i = 0; i < compressed.length; i += chunk) {
53
+ bin += String.fromCharCode(...compressed.subarray(i, i + chunk));
54
+ }
55
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
56
+ }
57
+
58
+ export interface BuildShareLinkOptions {
59
+ /** The roadmap source text (used to build the #text= fragment). */
60
+ source: string;
61
+ /**
62
+ * Resolved source URL for the block (per-block → global → undefined).
63
+ * Only `https:` URLs are emitted as `#url=`; anything else falls
64
+ * back to the inline `#text=` encoding.
65
+ */
66
+ sourceUrl?: string | undefined;
67
+ /** The `share` option from InitializeOptions. */
68
+ share: ShareOption;
69
+ }
70
+
71
+ /**
72
+ * Build the full "Share on Nowline" URL for a rendered block.
73
+ *
74
+ * Returns `null` when `share` is `false` or `'none'`, signalling that
75
+ * no anchor should be rendered.
76
+ */
77
+ export function buildShareLink({ source, sourceUrl, share }: BuildShareLinkOptions): string | null {
78
+ if (share === false || share === 'none') {
79
+ return null;
80
+ }
81
+
82
+ if (typeof share === 'object') {
83
+ // Template mode: { textUrl, remoteUrl }
84
+ if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
85
+ return share.remoteUrl.replace('{url}', encodeURIComponent(sourceUrl));
86
+ }
87
+ return share.textUrl.replace('{text}', _encodePayload(source));
88
+ }
89
+
90
+ // share === true → DEFAULT_SHARE_BASE; share is a string → custom base URL
91
+ const base = share === true ? DEFAULT_SHARE_BASE : share;
92
+ const url = new URL(base);
93
+
94
+ if (sourceUrl !== undefined && _isHttps(sourceUrl)) {
95
+ url.hash = `url=${encodeURIComponent(sourceUrl)}`;
96
+ } else {
97
+ url.hash = `text=${_encodePayload(source)}`;
98
+ }
99
+
100
+ return url.toString();
101
+ }
102
+
103
+ function _isHttps(url: string): boolean {
104
+ try {
105
+ return new URL(url).protocol === 'https:';
106
+ } catch {
107
+ return false;
108
+ }
109
+ }