@tnotesjs/ui 0.1.1 → 0.1.3

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 (41) hide show
  1. package/README.md +52 -29
  2. package/dist/code/highlight.d.ts +42 -0
  3. package/dist/code/highlight.js +138 -0
  4. package/dist/components/Footprints/parse.d.ts +17 -0
  5. package/dist/components/Footprints/parse.js +105 -0
  6. package/dist/components/Mindmap/markdown.d.ts +20 -0
  7. package/dist/components/Mindmap/markdown.js +60 -0
  8. package/dist/markdown/image.d.ts +33 -0
  9. package/dist/markdown/image.js +83 -0
  10. package/package.json +46 -6
  11. package/src/browser/clipboard.ts +32 -0
  12. package/src/browser/theme.ts +22 -0
  13. package/src/code/highlight.test.ts +55 -0
  14. package/src/code/highlight.ts +171 -0
  15. package/src/components/Badge/Badge.vue +43 -0
  16. package/src/components/CodeBlock/CodeBlock.vue +172 -0
  17. package/src/components/CodeGroup/CodeGroup.vue +96 -0
  18. package/src/components/ImagePreview/ImagePreview.vue +315 -0
  19. package/src/components/Mermaid/Mermaid.test.ts +54 -0
  20. package/src/components/Mermaid/Mermaid.vue +229 -56
  21. package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
  22. package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
  23. package/src/components/Mindmap/Mindmap.vue +2 -0
  24. package/src/components/sharedMedia.test.ts +139 -0
  25. package/src/entries/bilibili-video.ts +1 -0
  26. package/src/entries/footprints.ts +1 -0
  27. package/src/entries/mermaid.ts +1 -0
  28. package/src/entries/mindmap.ts +1 -0
  29. package/src/entries/notes-table.ts +2 -0
  30. package/src/entries/word-list.ts +1 -0
  31. package/src/icons/check.svg +3 -0
  32. package/src/icons/copy.svg +4 -0
  33. package/src/index.ts +31 -18
  34. package/src/markdown/image.test.ts +88 -0
  35. package/src/markdown/image.ts +115 -0
  36. package/src/styles/code.css +184 -0
  37. package/src/styles/prose.css +211 -0
  38. package/src/styles/swiper.css +151 -0
  39. package/src/styles/tokens.css +54 -18
  40. package/src/swiper/hydrate.test.ts +65 -0
  41. package/src/swiper/hydrate.ts +164 -0
package/README.md CHANGED
@@ -40,13 +40,36 @@ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file
40
40
 
41
41
  ## Components
42
42
 
43
+ ### Shared rendering contract
44
+
45
+ Core and Desk import the same token, prose, code-block, and code-group implementation. The
46
+ `SHARED_CODE_GROUP_CONTRACT` fixture is exercised in both repositories' CI suites so changes to
47
+ fence metadata, line numbering, titles, or generated markup cannot drift silently.
48
+
49
+ | Syntax / capability | Core page | Desk visual edit | Desk read-only | Desk source |
50
+ | --------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------- | --------------------- | ------------------ |
51
+ | Markdown prose (headings, links, lists, tables, quotes) | Shared `prose.css` | Shared typography + editor hit areas | Shared `prose.css` | Canonical Markdown |
52
+ | Fenced code + lazy Shiki language loading | Shared | Shared in code-group previews; CodeMirror while editing | Shared | Byte-preserved |
53
+ | `::: code-group` | Shared `CodeGroup` | Shared atomic preview | Shared | Byte-preserved |
54
+ | `:line-numbers`, `:line-numbers=N`, `:no-line-numbers` | Yes | Yes | Yes | Byte-preserved |
55
+ | `{N}` fence line highlights | Yes | Yes (CodeMirror) | Yes | Byte-preserved |
56
+ | Shiki `[!code]` annotations | No (aligned with Desk) | No | No | Shown as source |
57
+ | Inline `<Badge>` | Shared `Badge` | Shared inline projection | Shared | Byte-preserved |
58
+ | `NotesTable` | Shared component via Core data adapter | Shared component projection | Shared | Byte-preserved |
59
+ | Math | TNotes SSG MathJax pipeline | Milkdown/KaTeX | Milkdown/KaTeX | Byte-preserved |
60
+ | Image lightbox, navigation, zoom, and pan | Shared `ImagePreview` | Shared `ImagePreview` | Shared `ImagePreview` | N/A |
61
+ | Mermaid / Mindmap / Footprints / BilibiliVideo / WordList | Shared | Shared projection | Shared | Byte-preserved |
62
+
63
+ The hosts may add editing handles, navigation offsets, or knowledge-base data adapters. They must
64
+ not redefine the shared color tokens, prose typography, or code rendering shell.
65
+
43
66
  ### `BilibiliVideo` — `<BilibiliVideo id="BVxxxx" />`
44
67
 
45
- | Prop | Default | Notes |
46
- |------|---------|--------|
47
- | `id` | required | BV id |
48
- | `autoplay` | `false` | Embed URL sends `autoplay=0` unless enabled |
49
- | `muted` | `false` | Embed URL sends `muted=0\|1` |
68
+ | Prop | Default | Notes |
69
+ | ---------- | -------- | ------------------------------------------- |
70
+ | `id` | required | BV id |
71
+ | `autoplay` | `false` | Embed URL sends `autoplay=0` unless enabled |
72
+ | `muted` | `false` | Embed URL sends `muted=0\|1` |
50
73
 
51
74
  ```md
52
75
  <BilibiliVideo id="BV1QR4y1y7GG" :autoplay="true" :muted="true" />
@@ -54,13 +77,13 @@ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file
54
77
 
55
78
  ### `WordList` — `<WordList :words="[…]" />`
56
79
 
57
- | Prop | Default | Notes |
58
- |------|---------|--------|
59
- | `words` | `[]` | Word strings |
60
- | `needSort` | `false` | Sort A→Z by first letter |
61
- | `wordsBaseUrl` | TNotes.en-words blob URL | Optional host override |
62
- | `wordsRawBaseUrl` | TNotes.en-words raw URL | Optional host override |
63
- | `features` | `WORD_LIST_FEATURES_FULL` | Capability flags (see below) |
80
+ | Prop | Default | Notes |
81
+ | ----------------- | ------------------------- | ---------------------------- |
82
+ | `words` | `[]` | Word strings |
83
+ | `needSort` | `false` | Sort A→Z by first letter |
84
+ | `wordsBaseUrl` | en-words blob URL | Optional host override |
85
+ | `wordsRawBaseUrl` | en-words raw URL | Optional host override |
86
+ | `features` | `WORD_LIST_FEATURES_FULL` | Capability flags (see below) |
64
87
 
65
88
  ```md
66
89
  <WordList :words="['cancel', 'salary']" :needSort="true" />
@@ -68,30 +91,30 @@ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file
68
91
 
69
92
  **Features presets** (no consumer/`host` field — capability-based):
70
93
 
71
- | Preset | Cards | Fetch word data | Menu Pin | Menu Auto Show Card |
72
- |--------|-------|-----------------|----------|---------------------|
73
- | `WORD_LIST_FEATURES_FULL` (default) | ✓ | ✓ | ✓ | ✓ |
74
- | `WORD_LIST_FEATURES_STATIC` | — | — | — | — |
94
+ | Preset | Cards | Fetch word data | Menu Pin | Menu Auto Show Card |
95
+ | ----------------------------------- | ----- | --------------- | -------- | ------------------- |
96
+ | `WORD_LIST_FEATURES_FULL` (default) | ✓ | ✓ | ✓ | ✓ |
97
+ | `WORD_LIST_FEATURES_STATIC` | — | — | — | — |
75
98
 
76
99
  ### `Mermaid`
77
100
 
78
101
  Shared diagram preview for core (`tn:dev`) and Desk. Markdown fence language: `mermaid`.
79
102
 
80
- | Prop | Default | Notes |
81
- |------|---------|--------|
82
- | `source` | `''` | Plain Mermaid text (Desk) |
83
- | `graph` | `''` | URI-encoded source (VitePress fence) |
84
- | `id` | auto | Mermaid render id |
85
- | `center` | `false` | From fence keyword `center`; omit → not centered |
86
- | `isDark` | auto | Or pass explicitly |
87
- | `securityLevel` | `'loose'` | Desk preview uses `'strict'` |
88
- | `enableCopy` | `true` | Hover copy action |
89
- | `enableFullscreen` | `true` | Hover fullscreen action |
90
- | `enableCenterToggle` | `true` | Hover center toggle (left of fullscreen) |
103
+ | Prop | Default | Notes |
104
+ | -------------------- | --------- | ------------------------------------------------ |
105
+ | `source` | `''` | Plain Mermaid text (Desk) |
106
+ | `graph` | `''` | URI-encoded source (VitePress fence) |
107
+ | `id` | auto | Mermaid render id |
108
+ | `center` | `false` | From fence keyword `center`; omit → not centered |
109
+ | `isDark` | auto | Or pass explicitly |
110
+ | `securityLevel` | `'loose'` | Same default for Desk and site preview |
111
+ | `enableCopy` | `true` | Hover copy action |
112
+ | `enableFullscreen` | `true` | Hover fullscreen action |
113
+ | `enableCenterToggle` | `true` | Hover center toggle (left of fullscreen) |
91
114
 
92
115
  Fence:
93
116
 
94
- - `` ```mermaid `` → not centered
95
- - `` ```mermaid center `` → centered
117
+ - ` ```mermaid ` → not centered
118
+ - ` ```mermaid center ` → centered
96
119
 
97
120
  Icons for the toggle live in `src/components/Mermaid/icons/` (`icon__center_on.svg` / `icon__center_off.svg`).
@@ -0,0 +1,42 @@
1
+ import { HighlighterCore } from 'shiki/core';
2
+
3
+ interface CodeMeta {
4
+ language: string;
5
+ title: string;
6
+ lineNumbers: boolean;
7
+ startLine: number;
8
+ highlightedLines: number[];
9
+ }
10
+ /**
11
+ * A single cross-repository fixture. Core and Desk both consume this contract in
12
+ * their own test suites, so a parser or DOM change cannot silently diverge.
13
+ */
14
+ declare const SHARED_CODE_GROUP_CONTRACT: {
15
+ readonly source: string;
16
+ readonly items: readonly [{
17
+ readonly info: "js:line-numbers=4 {2} [one.js]";
18
+ readonly code: "console.log(1)\nconsole.log(2)";
19
+ readonly title: "one.js";
20
+ readonly language: "js";
21
+ readonly lineNumbers: true;
22
+ readonly startLine: 4;
23
+ readonly highlightedLines: readonly [2];
24
+ }, {
25
+ readonly info: "ts:no-line-numbers [two.ts]";
26
+ readonly code: "const value: number = 2";
27
+ readonly title: "two.ts";
28
+ readonly language: "ts";
29
+ readonly lineNumbers: false;
30
+ readonly startLine: 1;
31
+ readonly highlightedLines: readonly [];
32
+ }];
33
+ };
34
+ declare function parseCodeMeta(info?: string, defaultLineNumbers?: boolean): CodeMeta;
35
+ declare function normalizeCodeLanguage(language: string): string;
36
+ /** One engine and one grammar cache for build-time and browser consumers. */
37
+ declare function prepareCodeHighlighter(languages?: string[]): Promise<HighlighterCore>;
38
+ /** Markdown renderers prewarm document languages before their synchronous render pass. */
39
+ declare function highlightCodeSync(code: string, info?: string): string;
40
+ declare function highlightCode(code: string, info?: string): Promise<string>;
41
+
42
+ export { type CodeMeta, SHARED_CODE_GROUP_CONTRACT, highlightCode, highlightCodeSync, normalizeCodeLanguage, parseCodeMeta, prepareCodeHighlighter };
@@ -0,0 +1,138 @@
1
+ // src/code/highlight.ts
2
+ import {
3
+ createHighlighterCore
4
+ } from "shiki/core";
5
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
6
+ import { bundledLanguages, bundledLanguagesAlias } from "shiki/langs";
7
+ var SHARED_CODE_GROUP_CONTRACT = {
8
+ source: [
9
+ "::: code-group",
10
+ "```js:line-numbers=4 {2} [one.js]",
11
+ "console.log(1)",
12
+ "console.log(2)",
13
+ "```",
14
+ "```ts:no-line-numbers [two.ts]",
15
+ "const value: number = 2",
16
+ "```",
17
+ ":::"
18
+ ].join("\n"),
19
+ items: [
20
+ {
21
+ info: "js:line-numbers=4 {2} [one.js]",
22
+ code: "console.log(1)\nconsole.log(2)",
23
+ title: "one.js",
24
+ language: "js",
25
+ lineNumbers: true,
26
+ startLine: 4,
27
+ highlightedLines: [2]
28
+ },
29
+ {
30
+ info: "ts:no-line-numbers [two.ts]",
31
+ code: "const value: number = 2",
32
+ title: "two.ts",
33
+ language: "ts",
34
+ lineNumbers: false,
35
+ startLine: 1,
36
+ highlightedLines: []
37
+ }
38
+ ]
39
+ };
40
+ function parseCodeMeta(info = "", defaultLineNumbers = true) {
41
+ const language = info.trim().match(/^[^\s{\[]+/)?.[0]?.split(":")[0] || "text";
42
+ const highlightedLines = /* @__PURE__ */ new Set();
43
+ const ranges = info.match(/\{([\d,\s-]+)\}/)?.[1] || "";
44
+ for (const range of ranges.split(",")) {
45
+ const match = range.trim().match(/^(\d+)(?:-(\d+))?$/);
46
+ if (!match) continue;
47
+ const start = Number(match[1]);
48
+ const end = Number(match[2] ?? start);
49
+ if (start < 1 || end < start || end - start > 1e4) continue;
50
+ for (let line = start; line <= end; line++) highlightedLines.add(line);
51
+ }
52
+ return {
53
+ language,
54
+ title: info.match(/\[([^\]]*)\]/)?.[1]?.trim() || "",
55
+ lineNumbers: /:no-line-numbers\b/.test(info) ? false : /:line-numbers\b/.test(info) || defaultLineNumbers,
56
+ startLine: Math.max(1, Number(info.match(/:line-numbers=(\d+)/)?.[1] || 1)),
57
+ highlightedLines: [...highlightedLines].sort((a, b) => a - b)
58
+ };
59
+ }
60
+ var singleton;
61
+ var ready;
62
+ var loading = /* @__PURE__ */ new Map();
63
+ function normalizeCodeLanguage(language) {
64
+ const id = language.toLowerCase();
65
+ if (id === "plain" || id === "txt" || id === "plaintext" || !id)
66
+ return "text";
67
+ if (id in bundledLanguages || id in bundledLanguagesAlias) return id;
68
+ return "text";
69
+ }
70
+ async function prepareCodeHighlighter(languages = []) {
71
+ singleton ??= createHighlighterCore({
72
+ engine: createJavaScriptRegexEngine(),
73
+ themes: [
74
+ import("shiki/themes/github-light.mjs"),
75
+ import("shiki/themes/github-dark.mjs")
76
+ ],
77
+ langs: []
78
+ }).then((highlighter2) => ready = highlighter2);
79
+ const highlighter = await singleton;
80
+ await Promise.all(
81
+ languages.map(async (requested) => {
82
+ const language = normalizeCodeLanguage(requested);
83
+ if (language === "text" || highlighter.getLoadedLanguages().includes(language))
84
+ return;
85
+ let pending = loading.get(language);
86
+ if (!pending) {
87
+ const loader = bundledLanguages[language] ?? bundledLanguagesAlias[language];
88
+ if (!loader) return;
89
+ pending = highlighter.loadLanguage(loader).then(() => void 0);
90
+ loading.set(language, pending);
91
+ pending.catch(() => loading.delete(language));
92
+ }
93
+ await pending;
94
+ })
95
+ );
96
+ return highlighter;
97
+ }
98
+ function transformers(meta) {
99
+ return [
100
+ {
101
+ name: "tnotes-code-meta",
102
+ pre(node) {
103
+ this.addClassToHast(node, "tn-code-highlight");
104
+ node.properties.tabindex = 0;
105
+ },
106
+ line(node, line) {
107
+ node.properties["data-line"] = String(meta.startLine + line - 1);
108
+ if (meta.highlightedLines.includes(line))
109
+ this.addClassToHast(node, "highlighted");
110
+ }
111
+ }
112
+ ];
113
+ }
114
+ function highlightCodeSync(code, info = "") {
115
+ if (!ready)
116
+ throw new Error("Call prepareCodeHighlighter before rendering Markdown");
117
+ const meta = parseCodeMeta(info);
118
+ const normalizedLanguage = normalizeCodeLanguage(meta.language);
119
+ const language = ready.getLoadedLanguages().includes(normalizedLanguage) ? normalizedLanguage : "text";
120
+ return ready.codeToHtml(code.replace(/\n$/, ""), {
121
+ lang: language,
122
+ themes: { light: "github-light", dark: "github-dark" },
123
+ defaultColor: false,
124
+ transformers: transformers(meta)
125
+ });
126
+ }
127
+ async function highlightCode(code, info = "") {
128
+ await prepareCodeHighlighter([parseCodeMeta(info).language]);
129
+ return highlightCodeSync(code, info);
130
+ }
131
+ export {
132
+ SHARED_CODE_GROUP_CONTRACT,
133
+ highlightCode,
134
+ highlightCodeSync,
135
+ normalizeCodeLanguage,
136
+ parseCodeMeta,
137
+ prepareCodeHighlighter
138
+ };
@@ -0,0 +1,17 @@
1
+ /** Parse `::: footprints` containers into props for the shared Footprints UI. */
2
+ interface FootprintsPayload {
3
+ times: number[];
4
+ paragraphs: string[];
5
+ images: string[];
6
+ otherInfo: string;
7
+ }
8
+ /** `2025-01-22 23:47` / `2025-01-22` → [y,m,d,h?,min?,s?] */
9
+ declare function parseFootprintsDatetime(meta: string): number[];
10
+ /**
11
+ * Parse a full container source (`::: footprints …` … `:::`).
12
+ */
13
+ declare function parseFootprintsSource(source: string): FootprintsPayload;
14
+ /** Rebuild canonical `::: footprints` source from structured fields. */
15
+ declare function rebuildFootprintsSource(payload: FootprintsPayload): string;
16
+
17
+ export { type FootprintsPayload, parseFootprintsDatetime, parseFootprintsSource, rebuildFootprintsSource };
@@ -0,0 +1,105 @@
1
+ // src/components/Footprints/parse.ts
2
+ function parseFootprintsDatetime(meta) {
3
+ const m = meta.trim().match(
4
+ /^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/
5
+ );
6
+ if (!m) return [];
7
+ const parts = [Number(m[1]), Number(m[2]), Number(m[3])];
8
+ if (m[4] !== void 0) {
9
+ parts.push(Number(m[4]), Number(m[5]));
10
+ if (m[6] !== void 0) parts.push(Number(m[6]));
11
+ }
12
+ return parts;
13
+ }
14
+ function formatDatetime(times) {
15
+ if (times.length < 2) return "";
16
+ const [y, m, d, h, min, s] = times;
17
+ let out = `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}`;
18
+ if (d !== void 0) out += `-${String(d).padStart(2, "0")}`;
19
+ if (h !== void 0 && min !== void 0) {
20
+ out += ` ${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
21
+ if (s !== void 0) out += `:${String(s).padStart(2, "0")}`;
22
+ }
23
+ return out;
24
+ }
25
+ function parseFootprintsSource(source) {
26
+ const normalized = source.replace(/\r\n?/g, "\n");
27
+ const lines = normalized.split("\n");
28
+ const open = lines[0]?.match(/^ {0,3}:{3,}\s*footprints(?:\s+(.*))?$/i);
29
+ const times = parseFootprintsDatetime((open?.[1] ?? "").trim());
30
+ let end = lines.length - 1;
31
+ while (end > 0 && !/^ {0,3}:{3,}\s*$/.test(lines[end])) end -= 1;
32
+ const bodyLines = lines.slice(1, end);
33
+ const paragraphs = [];
34
+ const images = [];
35
+ let otherInfo = "";
36
+ let buf = [];
37
+ let inOther = false;
38
+ const flush = () => {
39
+ const text = buf.join("\n").trim();
40
+ buf = [];
41
+ if (!text) return;
42
+ if (inOther) {
43
+ otherInfo = otherInfo ? `${otherInfo}
44
+ ${text}` : text;
45
+ return;
46
+ }
47
+ const imgOnly = text.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/);
48
+ if (imgOnly) {
49
+ images.push(imgOnly[2]);
50
+ return;
51
+ }
52
+ const parts = text.split("\n");
53
+ let allImg = true;
54
+ const blockImgs = [];
55
+ for (const line of parts) {
56
+ const m = line.trim().match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/);
57
+ if (m) blockImgs.push(m[2]);
58
+ else if (line.trim()) {
59
+ allImg = false;
60
+ break;
61
+ }
62
+ }
63
+ if (allImg && blockImgs.length) {
64
+ images.push(...blockImgs);
65
+ return;
66
+ }
67
+ paragraphs.push(text);
68
+ };
69
+ for (const line of bodyLines) {
70
+ if (line.trim() === "---") {
71
+ flush();
72
+ inOther = true;
73
+ continue;
74
+ }
75
+ if (line.trim() === "") {
76
+ flush();
77
+ continue;
78
+ }
79
+ buf.push(line);
80
+ }
81
+ flush();
82
+ return { times, paragraphs, images, otherInfo };
83
+ }
84
+ function rebuildFootprintsSource(payload) {
85
+ const meta = formatDatetime(payload.times);
86
+ const open = meta ? `::: footprints ${meta}` : "::: footprints";
87
+ const chunks = [open, ""];
88
+ for (const p of payload.paragraphs) {
89
+ chunks.push(p, "");
90
+ }
91
+ for (const src of payload.images) {
92
+ chunks.push(`![](${src})`, "");
93
+ }
94
+ if (payload.otherInfo.trim()) {
95
+ chunks.push("---", "", payload.otherInfo.trim(), "");
96
+ }
97
+ chunks.push(":::");
98
+ return `${chunks.join("\n")}
99
+ `;
100
+ }
101
+ export {
102
+ parseFootprintsDatetime,
103
+ parseFootprintsSource,
104
+ rebuildFootprintsSource
105
+ };
@@ -0,0 +1,20 @@
1
+ interface MindmapFenceOptions {
2
+ title?: string;
3
+ initialExpandLevel?: number;
4
+ }
5
+ interface MindmapReference {
6
+ path: string;
7
+ title?: string;
8
+ }
9
+ /** Parse the canonical `mindmap [title] 2` fence metadata. */
10
+ declare function parseMindmapFence(openLine: string): MindmapFenceOptions | null;
11
+ /** @deprecated Mindmap fences no longer resolve `<<<` includes; kept for body-include parsers / tests. */
12
+ declare function parseMindmapReference(line: string): MindmapReference | null;
13
+ interface NormalizeMindmapOptions {
14
+ title?: string;
15
+ defaultTitle?: string;
16
+ }
17
+ /** Ensure canonical mindmap Markdown has exactly one H1 root title. */
18
+ declare function normalizeMindmapMarkdown(source: string, options?: NormalizeMindmapOptions): string;
19
+
20
+ export { type MindmapFenceOptions, type MindmapReference, type NormalizeMindmapOptions, normalizeMindmapMarkdown, parseMindmapFence, parseMindmapReference };
@@ -0,0 +1,60 @@
1
+ // src/components/Mindmap/markdown.ts
2
+ function cleanHeadingText(value) {
3
+ return value.trim().replace(/\s+#+\s*$/, "").trim();
4
+ }
5
+ function parseMindmapFence(openLine) {
6
+ const fenceBody = openLine.trim().replace(/^`+\s*/, "");
7
+ const nameMatch = fenceBody.match(/^mindmap(?=\s|\[|$)/);
8
+ if (!nameMatch) return null;
9
+ let rest = fenceBody.slice(nameMatch[0].length).trim();
10
+ const options = {};
11
+ const titleMatch = rest.match(/\[([^\]]+)\]/);
12
+ if (titleMatch) {
13
+ options.title = cleanHeadingText(titleMatch[1]) || void 0;
14
+ rest = `${rest.slice(0, titleMatch.index)} ${rest.slice((titleMatch.index ?? 0) + titleMatch[0].length)}`.trim();
15
+ }
16
+ if (rest && !/^\d+$/.test(rest)) return null;
17
+ if (rest) {
18
+ options.initialExpandLevel = Math.max(1, Number(rest));
19
+ }
20
+ return options;
21
+ }
22
+ function parseMindmapReference(line) {
23
+ const match = line.trim().match(/^<<<\s+(.+?)\s*$/);
24
+ if (!match) return null;
25
+ let rest = match[1].trim();
26
+ let title;
27
+ const titleMatch = rest.match(/\s+\[([^\]]+)\]\s*$/);
28
+ if (titleMatch) {
29
+ title = cleanHeadingText(titleMatch[1]) || void 0;
30
+ rest = rest.slice(0, titleMatch.index).trim();
31
+ }
32
+ const path = rest.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2").trim();
33
+ return path ? { path, title } : null;
34
+ }
35
+ function normalizeMindmapMarkdown(source, options = {}) {
36
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
37
+ let existingTitle = "";
38
+ let rootIndex = -1;
39
+ for (let index = 0; index < lines.length; index++) {
40
+ const match = lines[index].match(/^\s{0,3}#(?!#)\s+(.+?)\s*$/);
41
+ if (!match) continue;
42
+ existingTitle = cleanHeadingText(match[1]);
43
+ rootIndex = index;
44
+ break;
45
+ }
46
+ const rootTitle = cleanHeadingText(options.title || existingTitle || options.defaultTitle || "root") || "root";
47
+ const body = lines.filter((_, index) => index !== rootIndex);
48
+ while (body[0]?.trim() === "") body.shift();
49
+ while (body[body.length - 1]?.trim() === "") body.pop();
50
+ return body.length > 0 ? `# ${rootTitle}
51
+
52
+ ${body.join("\n")}
53
+ ` : `# ${rootTitle}
54
+ `;
55
+ }
56
+ export {
57
+ normalizeMindmapMarkdown,
58
+ parseMindmapFence,
59
+ parseMindmapReference
60
+ };
@@ -0,0 +1,33 @@
1
+ /** Shared image caption, width, and alignment helpers for SSG and Desk. */
2
+ type ImageAlign = 'left' | 'center' | 'right';
3
+ interface ImageMarkdown {
4
+ alt: string;
5
+ src: string;
6
+ title?: string;
7
+ /** Normalized CSS size, e.g. `50%` or `400px`. Empty means natural width. */
8
+ width?: string;
9
+ /** Omitted or `left` is the default and is not written to markdown. */
10
+ align?: ImageAlign;
11
+ }
12
+ interface ImageAttrs {
13
+ width: string;
14
+ align: ImageAlign;
15
+ rest: string;
16
+ }
17
+ declare function normalizeImageAlign(raw: string): ImageAlign;
18
+ declare function parseImageAttrs(text: string): ImageAttrs;
19
+ declare function parseImageWidthAttr(text: string): {
20
+ width: string;
21
+ rest: string;
22
+ };
23
+ declare function normalizeImageWidth(raw: string): string;
24
+ /** Logical CSS px for a pasted bitmap, folding device pixel ratio (4K/retina). */
25
+ declare function pasteDisplayWidthPx(naturalWidth: number, devicePixelRatio?: number): string;
26
+ declare function formatImageWidthAttr(width: string): string;
27
+ declare function formatImageAttrs(attrs: {
28
+ width?: string;
29
+ align?: ImageAlign | string;
30
+ }): string;
31
+ declare function serializeImageMarkdown(image: ImageMarkdown): string;
32
+
33
+ export { type ImageAlign, type ImageAttrs, type ImageMarkdown, formatImageAttrs, formatImageWidthAttr, normalizeImageAlign, normalizeImageWidth, parseImageAttrs, parseImageWidthAttr, pasteDisplayWidthPx, serializeImageMarkdown };
@@ -0,0 +1,83 @@
1
+ // src/markdown/image.ts
2
+ var ATTR_BLOCK = /^\s*\{([^}]*)\}\s*$/i;
3
+ function normalizeImageAlign(raw) {
4
+ const value = raw.trim().toLowerCase();
5
+ if (value === "center" || value === "middle") return "center";
6
+ if (value === "right") return "right";
7
+ return "left";
8
+ }
9
+ function parseImageAttrs(text) {
10
+ const match = text.match(ATTR_BLOCK);
11
+ if (!match) return { width: "", align: "left", rest: text };
12
+ const body = match[1].trim();
13
+ if (!body) return { width: "", align: "left", rest: "" };
14
+ if (!/(?:^|[\s,])(?:w|width|align|a)\s*=/i.test(` ${body}`)) {
15
+ return { width: "", align: "left", rest: text };
16
+ }
17
+ let width = "";
18
+ let align = "left";
19
+ for (const part of body.split(/[\s,]+/).filter(Boolean)) {
20
+ const eq = part.indexOf("=");
21
+ if (eq < 0) continue;
22
+ const key = part.slice(0, eq).trim().toLowerCase();
23
+ const value = part.slice(eq + 1).trim();
24
+ if (key === "w" || key === "width") width = normalizeImageWidth(value);
25
+ else if (key === "align" || key === "a") align = normalizeImageAlign(value);
26
+ }
27
+ return { width, align, rest: "" };
28
+ }
29
+ function parseImageWidthAttr(text) {
30
+ const parsed = parseImageAttrs(text);
31
+ return { width: parsed.width, rest: parsed.rest };
32
+ }
33
+ function normalizeImageWidth(raw) {
34
+ const value = raw.trim();
35
+ if (!value) return "";
36
+ const pixels = value.match(/^(\d+(?:\.\d+)?)px$/i);
37
+ if (pixels) {
38
+ const amount2 = Number(pixels[1]);
39
+ if (!Number.isFinite(amount2) || amount2 < 16) return "";
40
+ return `${Math.round(amount2)}px`;
41
+ }
42
+ const percent = value.match(/^(\d+(?:\.\d+)?)%?$/);
43
+ if (!percent) return "";
44
+ const amount = Number(percent[1]);
45
+ if (!Number.isFinite(amount) || amount <= 0 || amount > 100) return "";
46
+ const normalized = Number.isInteger(amount) ? String(amount) : String(Math.round(amount * 100) / 100);
47
+ return `${normalized}%`;
48
+ }
49
+ function pasteDisplayWidthPx(naturalWidth, devicePixelRatio = 1) {
50
+ if (!Number.isFinite(naturalWidth) || naturalWidth <= 0) return "";
51
+ const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
52
+ return normalizeImageWidth(`${Math.round(naturalWidth / dpr)}px`);
53
+ }
54
+ function formatImageWidthAttr(width) {
55
+ return formatImageAttrs({ width, align: "left" });
56
+ }
57
+ function formatImageAttrs(attrs) {
58
+ const width = normalizeImageWidth(attrs.width ?? "");
59
+ const align = normalizeImageAlign(attrs.align ?? "");
60
+ const parts = [];
61
+ if (width) parts.push(`w=${width}`);
62
+ if (align !== "left") parts.push(`align=${align}`);
63
+ return parts.length > 0 ? `{${parts.join(" ")}}` : "";
64
+ }
65
+ function serializeImageMarkdown(image) {
66
+ const alt = image.alt.replaceAll("[", "").replaceAll("]", "");
67
+ const title = image.title?.trim() ? ` "${image.title.replaceAll('"', "")}"` : "";
68
+ const suffix = formatImageAttrs({
69
+ width: image.width ?? "",
70
+ align: image.align ?? "left"
71
+ });
72
+ return `![${alt}](${image.src}${title})${suffix ? ` ${suffix}` : ""}`;
73
+ }
74
+ export {
75
+ formatImageAttrs,
76
+ formatImageWidthAttr,
77
+ normalizeImageAlign,
78
+ normalizeImageWidth,
79
+ parseImageAttrs,
80
+ parseImageWidthAttr,
81
+ pasteDisplayWidthPx,
82
+ serializeImageMarkdown
83
+ };