@plannotator/ui 0.38.2 → 0.40.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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * What a link click inside a raw-HTML annotate document means.
3
+ *
4
+ * A srcdoc document has no URL of its own: its base URL is the PARENT page's,
5
+ * so `<a href="02-detail.html">` resolves onto the Plannotator server and the
6
+ * catch-all answers with the app itself — the whole editor rendered inside the
7
+ * annotated frame. The bridge therefore never lets the frame navigate and
8
+ * hands the RAW href to the parent, which is the trust boundary; this module
9
+ * is the pure decision it makes.
10
+ *
11
+ * Deliberately pure (no DOM, no fetch, no `window`): the caller passes the
12
+ * server origin and the directories, so every branch is unit-testable.
13
+ */
14
+
15
+ import { hasLinkedDocExtension } from "./markdownExtensions";
16
+
17
+ /** Longest href the parent will look at. Matches the bridge's own cap. */
18
+ export const MAX_HTML_LINK_HREF_LENGTH = 2048;
19
+
20
+ export type HtmlLinkIntent =
21
+ /**
22
+ * A local document to open in the linked-doc overlay. `path` is absolute.
23
+ * `rendersHtml` is whether it will open as another raw-HTML surface rather
24
+ * than as markdown, which is what decides whether the sidebar is revealed:
25
+ * HTML surfaces keep it closed and carry their own Back control.
26
+ */
27
+ | { kind: "document"; path: string; hash: string; rendersHtml: boolean }
28
+ /** Another origin. Opens in a new tab; the frame never navigates. */
29
+ | { kind: "external"; url: string }
30
+ /** A local file Plannotator cannot render as a document (`.pdf`, `.zip`, …). */
31
+ | { kind: "unsupported"; path: string; label: string }
32
+ /** Nothing to do: empty, fragment-only, or a scheme we do not follow. */
33
+ | { kind: "ignored"; reason: HtmlLinkIgnoreReason };
34
+
35
+ export type HtmlLinkIgnoreReason =
36
+ | "empty"
37
+ | "too-long"
38
+ | "fragment"
39
+ | "scheme"
40
+ | "invalid"
41
+ | "no-base";
42
+
43
+ export interface HtmlLinkContext {
44
+ /** Directory of the document the click happened in. */
45
+ baseDir?: string | null;
46
+ /**
47
+ * Directory the session was opened from. Server-absolute paths
48
+ * (`/01-entry-point.html`, and the same path spelled with the server's own
49
+ * origin) resolve against this, because that is the site root the author
50
+ * meant when they wrote a root-relative link.
51
+ */
52
+ rootDir?: string | null;
53
+ /** The Plannotator server's own origin, e.g. `http://localhost:19601`. */
54
+ serverOrigin: string;
55
+ /** The session's `--markdown` preference: HTML is Turndowned by `/api/doc`,
56
+ * so an `.html` target renders as markdown rather than as an HTML surface. */
57
+ convertHtml?: boolean;
58
+ }
59
+
60
+ /** Control characters never appear in a real href; they are how structure gets smuggled. */
61
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
62
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
63
+
64
+ export function resolveHtmlLinkIntent(
65
+ href: string,
66
+ context: HtmlLinkContext,
67
+ ): HtmlLinkIntent {
68
+ if (typeof href !== "string") return { kind: "ignored", reason: "invalid" };
69
+ const raw = href.trim();
70
+ if (!raw) return { kind: "ignored", reason: "empty" };
71
+ if (raw.length > MAX_HTML_LINK_HREF_LENGTH) return { kind: "ignored", reason: "too-long" };
72
+ if (CONTROL_CHARS.test(raw)) return { kind: "ignored", reason: "invalid" };
73
+ // The bridge scrolls in-page fragments itself; one reaching here is a no-op.
74
+ if (raw.startsWith("#")) return { kind: "ignored", reason: "fragment" };
75
+
76
+ // Scheme-relative (`//host/x`) and absolute URLs both go through URL
77
+ // parsing, which is the only reliable way to compare origins.
78
+ if (raw.startsWith("//") || HAS_SCHEME.test(raw)) {
79
+ return resolveAbsolute(raw, context);
80
+ }
81
+
82
+ const rootRelative = raw.startsWith("/");
83
+ const base = rootRelative ? context.rootDir : context.baseDir;
84
+ return resolveRelative(raw, base, context);
85
+ }
86
+
87
+ function resolveAbsolute(raw: string, context: HtmlLinkContext): HtmlLinkIntent {
88
+ let url: URL;
89
+ try {
90
+ url = new URL(raw, context.serverOrigin);
91
+ } catch {
92
+ return { kind: "ignored", reason: "invalid" };
93
+ }
94
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
95
+ // javascript:, data:, mailto:, tel:, file:, blob: — none of them name a
96
+ // document this session can annotate, and none may navigate the frame.
97
+ return { kind: "ignored", reason: "scheme" };
98
+ }
99
+ if (url.origin !== context.serverOrigin) {
100
+ return { kind: "external", url: url.href };
101
+ }
102
+ // The server's own origin: the author wrote `http://localhost:<port>/x.html`
103
+ // meaning "the file x.html of this site", so treat it as root-relative.
104
+ // Left alone it would hit the catch-all and render the app in the frame.
105
+ return resolveRelative(`${url.pathname}${url.hash}`, context.rootDir, context);
106
+ }
107
+
108
+ function resolveRelative(
109
+ raw: string,
110
+ baseDir: string | null | undefined,
111
+ context: HtmlLinkContext,
112
+ ): HtmlLinkIntent {
113
+ const { path: pathPart, hash } = splitHash(stripQuery(raw));
114
+ if (!pathPart) return { kind: "ignored", reason: "fragment" };
115
+ const decoded = decodePath(pathPart);
116
+ if (decoded === null || CONTROL_CHARS.test(decoded)) {
117
+ return { kind: "ignored", reason: "invalid" };
118
+ }
119
+ if (!baseDir) return { kind: "ignored", reason: "no-base" };
120
+ const resolved = joinPath(baseDir, decoded);
121
+ if (!resolved) return { kind: "ignored", reason: "invalid" };
122
+ // Containment is the server's call (`/api/doc` answers 403 for an escaping
123
+ // path). The extension gate is ours: a `.pdf` would be a pointless fetch
124
+ // and a confusing server error, so it is reported as unsupported here.
125
+ if (!hasLinkedDocExtension(resolved)) {
126
+ return { kind: "unsupported", path: resolved, label: basename(resolved) };
127
+ }
128
+ return {
129
+ kind: "document",
130
+ path: resolved,
131
+ hash,
132
+ rendersHtml: documentRendersHtml(resolved, context.convertHtml),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Whether opening `path` lands on another raw-HTML surface rather than a
138
+ * markdown one. `--markdown` sessions convert HTML on the way in, so nothing
139
+ * renders as HTML there. This is the one rule behind `rendersHtml`, and the
140
+ * same question the annotations panel's cross-file jump asks before deciding
141
+ * whether to reveal the sidebar — so it lives here rather than being spelled
142
+ * twice.
143
+ */
144
+ export function documentRendersHtml(path: string, convertHtml?: boolean): boolean {
145
+ return /\.html?$/i.test(path) && !convertHtml;
146
+ }
147
+
148
+ function stripQuery(value: string): string {
149
+ const q = value.indexOf("?");
150
+ if (q < 0) return value;
151
+ const h = value.indexOf("#");
152
+ // `a.html?x=1#frag` — drop the query, keep the fragment.
153
+ return h > q ? value.slice(0, q) + value.slice(h) : value.slice(0, q);
154
+ }
155
+
156
+ function splitHash(value: string): { path: string; hash: string } {
157
+ const h = value.indexOf("#");
158
+ if (h < 0) return { path: value, hash: "" };
159
+ return { path: value.slice(0, h), hash: value.slice(h + 1) };
160
+ }
161
+
162
+ function decodePath(value: string): string | null {
163
+ try {
164
+ return decodeURIComponent(value);
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function basename(path: string): string {
171
+ const parts = path.split("/");
172
+ return parts[parts.length - 1] || path;
173
+ }
174
+
175
+ /**
176
+ * Join a relative path onto a directory, resolving `.` and `..` lexically.
177
+ * A root-relative input (`/x.html`) is taken as relative to `baseDir` too —
178
+ * the caller has already chosen which directory plays the role of root.
179
+ */
180
+ function joinPath(baseDir: string, relative: string): string | null {
181
+ const base = baseDir.replace(/\\/g, "/").replace(/\/+$/, "");
182
+ const rel = relative.replace(/\\/g, "/").replace(/^\/+/, "");
183
+ if (!rel) return null;
184
+ const segments = base.split("/");
185
+ for (const segment of rel.split("/")) {
186
+ if (!segment || segment === ".") continue;
187
+ if (segment === "..") {
188
+ // Never pop past the root marker (the leading "" of an absolute base).
189
+ if (segments.length > 1) segments.pop();
190
+ continue;
191
+ }
192
+ segments.push(segment);
193
+ }
194
+ const joined = segments.join("/");
195
+ return joined || null;
196
+ }
@@ -3,23 +3,25 @@
3
3
  * at module evaluation (exactly where the old module-scope
4
4
  * `mermaid.initialize` ran) and fills the slot in `./mermaid`.
5
5
  *
6
- * `packages/editor/App.tsx` imports this module for its side effect, by
7
- * policy: Plannotator's own surfaces keep Mermaid in their entry chunk so it
8
- * can never fail separately from the app (on the share portal this is what
9
- * keeps `mermaid.core` out of a lazy chunk). The review editor does not import
10
- * it because it never renders a Mermaid block; adding the runtime there would
11
- * grow that bundle. A host that wants the same import adds:
6
+ * Plannotator does NOT import this module any more. Through @plannotator/ui
7
+ * 0.39.0 `packages/editor/App.tsx` imported it by policy so Mermaid stayed in
8
+ * the plan editor's entry chunk; with Mermaid 12 (ELK layout by default) the
9
+ * runtime is about 1.8 MB larger and the plan editor loads it on the first
10
+ * diagram instead, through the lazy path in `./mermaid`. The module is kept
11
+ * for hosts that want the runtime registered and initialized before the
12
+ * first render (a host that gates first paint on it, or that would rather
13
+ * not have a separate chunk that can fail on its own):
12
14
  *
13
15
  * import '@plannotator/ui/utils/mermaid-eager';
14
16
  *
15
17
  * A host that does not import it gets the lazy path in `./mermaid`.
16
18
  *
17
19
  * The source tag passed below doubles as a build marker: the literal only
18
- * reaches a bundle when this module is evaluated in it, so a dropped or
19
- * tree-shaken side-effect import is caught by the built-HTML check in
20
- * tests/entry-assets.test.ts (the runtime itself stays inlined in a
21
- * single-file build through the loader's import(), so a Mermaid diagram id
22
- * cannot prove registration).
20
+ * reaches a bundle when this module is evaluated in it, which is how
21
+ * tests/entry-assets.test.ts proves on the built HTML that neither of
22
+ * Plannotator's bundles registers the runtime eagerly (the runtime itself is
23
+ * still inlined in a single-file build through the loader's import(), so a
24
+ * Mermaid diagram id cannot prove or disprove registration).
23
25
  */
24
26
  import mermaid from 'mermaid';
25
27
  import { MERMAID_CONFIG, setMermaidRuntime } from './mermaid';
package/utils/mermaid.ts CHANGED
@@ -2,14 +2,20 @@
2
2
  * Mermaid runtime slot.
3
3
  *
4
4
  * ONE code path feeds `MermaidBlock`: `loadMermaidRuntime()`. It resolves at
5
- * once from a filled slot and otherwise imports the runtime lazily. Plannotator
6
- * fills the slot at module evaluation through `./mermaid-eager` (imported by
7
- * `packages/editor/App.tsx`), which keeps the runtime in its entry chunk on the
8
- * share portal exactly as it was with the static import, so it cannot fail
9
- * separately from the app. A host that does not import the eager entry gets
10
- * the lazy path: the runtime is fetched on the first diagram, a failed import
11
- * is dropped from the memo so the next call issues a fresh `import()`, and
12
- * the block re-attempts once and offers Retry.
5
+ * once from a filled slot and otherwise imports the runtime lazily: the
6
+ * runtime is fetched on the first diagram, a failed import is dropped from
7
+ * the memo so the next call issues a fresh `import()`, and the block
8
+ * re-attempts once and offers Retry.
9
+ *
10
+ * Since Mermaid 12 (ELK layout by default, about 1.8 MB more runtime than 11)
11
+ * the lazy path IS Plannotator's own path: `packages/editor/App.tsx` no longer
12
+ * imports `./mermaid-eager`, so a plan with no diagram never downloads the
13
+ * runtime in a chunked build (the share portal, any host that bundles by
14
+ * route). The single-file builds inline the `import('mermaid')` target through
15
+ * `inlineDynamicImports`, so there the lazy import resolves from the bundle
16
+ * itself and nothing is fetched. A host that wants the runtime registered at
17
+ * startup imports `./mermaid-eager`, which fills the slot at module
18
+ * evaluation; the slot then short-circuits this loader.
13
19
  *
14
20
  * This module has NO static import of `mermaid`; the only place the
15
21
  * dependency is named at runtime is the default loader's `import('mermaid')`.
@@ -49,7 +55,8 @@ export const MERMAID_CONFIG: MermaidConfig = {
49
55
  /**
50
56
  * Who filled the slot. The eager value doubles as a build marker: the literal
51
57
  * only reaches a bundle when `./mermaid-eager` is evaluated in it, which is
52
- * what `tests/entry-assets.test.ts` asserts on the built HTML.
58
+ * how `tests/entry-assets.test.ts` proves on the built HTML that Plannotator's
59
+ * own bundles do NOT register the runtime eagerly.
53
60
  */
54
61
  export type MermaidRuntimeSource = 'plannotator-mermaid-eager' | 'loader' | 'host';
55
62
 
@@ -69,7 +76,9 @@ let pending: Promise<Mermaid> | null = null;
69
76
 
70
77
  /**
71
78
  * Delay before the block's one automatic re-attempt after a failed lazy
72
- * import. Only chunking hosts can fail here; a filled slot never loads.
79
+ * import. Only chunked builds can fail here (the share portal, a host that
80
+ * bundles by route); a single-file build resolves the import from itself and
81
+ * a filled slot never loads.
73
82
  */
74
83
  let retryDelayMs = 750;
75
84