wiki-viewer 1.0.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 (119) hide show
  1. package/.next/standalone/.github/workflows/publish.yml +37 -0
  2. package/.next/standalone/DESIGN.md +521 -0
  3. package/.next/standalone/README.md +283 -0
  4. package/.next/standalone/bin/wiki-viewer.js +158 -0
  5. package/.next/standalone/next.config.ts +11 -0
  6. package/.next/standalone/package.json +120 -0
  7. package/.next/standalone/pnpm-lock.yaml +5654 -0
  8. package/.next/standalone/pnpm-workspace.yaml +2 -0
  9. package/.next/standalone/postcss.config.mjs +2 -0
  10. package/.next/standalone/public/logo.svg +11 -0
  11. package/.next/standalone/public/public/logo.svg +11 -0
  12. package/.next/standalone/server.js +44 -0
  13. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
  14. package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
  15. package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
  16. package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
  17. package/.next/standalone/src/app/api/system/config/route.ts +10 -0
  18. package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
  19. package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
  20. package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
  21. package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
  22. package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
  23. package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
  24. package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
  25. package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
  26. package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
  27. package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
  28. package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
  29. package/.next/standalone/src/app/api/wiki/route.ts +98 -0
  30. package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
  31. package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
  32. package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
  33. package/.next/standalone/src/app/globals.css +716 -0
  34. package/.next/standalone/src/app/icon.svg +11 -0
  35. package/.next/standalone/src/app/layout.tsx +35 -0
  36. package/.next/standalone/src/app/page.tsx +1509 -0
  37. package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
  38. package/.next/standalone/src/components/dir-picker.tsx +393 -0
  39. package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
  40. package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
  41. package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
  42. package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
  43. package/.next/standalone/src/components/editor/editor.tsx +633 -0
  44. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
  45. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
  46. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
  47. package/.next/standalone/src/components/editor/extensions.ts +148 -0
  48. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
  49. package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
  50. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
  51. package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
  52. package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
  53. package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
  54. package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
  55. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
  56. package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
  57. package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
  58. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
  59. package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
  60. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
  61. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
  62. package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
  63. package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
  64. package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
  65. package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
  66. package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
  67. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
  68. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
  69. package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
  70. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
  71. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
  72. package/.next/standalone/src/components/theme-provider.tsx +16 -0
  73. package/.next/standalone/src/components/theme-toggle.tsx +24 -0
  74. package/.next/standalone/src/components/ui/badge.tsx +35 -0
  75. package/.next/standalone/src/components/ui/button.tsx +56 -0
  76. package/.next/standalone/src/components/ui/card.tsx +63 -0
  77. package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
  78. package/.next/standalone/src/components/ui/command.tsx +150 -0
  79. package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
  80. package/.next/standalone/src/components/ui/dialog.tsx +119 -0
  81. package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
  82. package/.next/standalone/src/components/ui/input.tsx +22 -0
  83. package/.next/standalone/src/components/ui/label.tsx +25 -0
  84. package/.next/standalone/src/components/ui/popover.tsx +33 -0
  85. package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
  86. package/.next/standalone/src/components/ui/select.tsx +156 -0
  87. package/.next/standalone/src/components/ui/separator.tsx +30 -0
  88. package/.next/standalone/src/components/ui/sheet.tsx +140 -0
  89. package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
  90. package/.next/standalone/src/components/ui/switch.tsx +28 -0
  91. package/.next/standalone/src/components/ui/tabs.tsx +54 -0
  92. package/.next/standalone/src/components/ui/textarea.tsx +21 -0
  93. package/.next/standalone/src/components/ui/tip.tsx +23 -0
  94. package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
  95. package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
  96. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
  97. package/.next/standalone/src/lib/app-runner.ts +231 -0
  98. package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
  99. package/.next/standalone/src/lib/config.ts +32 -0
  100. package/.next/standalone/src/lib/embeds/detect.ts +142 -0
  101. package/.next/standalone/src/lib/google/detect.ts +107 -0
  102. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
  103. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
  104. package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
  105. package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
  106. package/.next/standalone/src/lib/root-dir.ts +35 -0
  107. package/.next/standalone/src/lib/toast.ts +9 -0
  108. package/.next/standalone/src/lib/utils.ts +6 -0
  109. package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
  110. package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
  111. package/.next/standalone/src/stores/editor-store.ts +244 -0
  112. package/.next/standalone/src/stores/tree-store.ts +52 -0
  113. package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
  114. package/.next/standalone/src/types/index.ts +47 -0
  115. package/.next/standalone/tailwind.config.ts +201 -0
  116. package/.next/standalone/tsconfig.json +41 -0
  117. package/README.md +283 -0
  118. package/bin/wiki-viewer.js +158 -0
  119. package/package.json +120 -0
@@ -0,0 +1,107 @@
1
+ export type GoogleKind = "sheets" | "slides" | "docs" | "forms" | "drive";
2
+
3
+ export interface GoogleLink {
4
+ kind: GoogleKind;
5
+ id: string;
6
+ /** URL suitable for embedding in an iframe. May require "Publish to Web". */
7
+ embedUrl: string;
8
+ /** URL to open in a new tab to view/edit in Google's own UI. */
9
+ openUrl: string;
10
+ /** Raw input URL. */
11
+ rawUrl: string;
12
+ }
13
+
14
+ const PATTERNS: Array<{ re: RegExp; kind: GoogleKind }> = [
15
+ {
16
+ re: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/(?:e\/)?([A-Za-z0-9_-]+)/,
17
+ kind: "sheets",
18
+ },
19
+ {
20
+ re: /^https?:\/\/docs\.google\.com\/presentation\/d\/(?:e\/)?([A-Za-z0-9_-]+)/,
21
+ kind: "slides",
22
+ },
23
+ {
24
+ re: /^https?:\/\/docs\.google\.com\/document\/d\/(?:e\/)?([A-Za-z0-9_-]+)/,
25
+ kind: "docs",
26
+ },
27
+ {
28
+ re: /^https?:\/\/docs\.google\.com\/forms\/d\/(?:e\/)?([A-Za-z0-9_-]+)/,
29
+ kind: "forms",
30
+ },
31
+ {
32
+ re: /^https?:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)/,
33
+ kind: "drive",
34
+ },
35
+ ];
36
+
37
+ /**
38
+ * Inspect a URL and resolve it to a Google document we know how to embed.
39
+ * Returns null for any URL that isn't a Google Workspace link.
40
+ */
41
+ export function detectGoogle(rawUrl: string): GoogleLink | null {
42
+ const url = rawUrl.trim();
43
+ if (!url) return null;
44
+ for (const { re, kind } of PATTERNS) {
45
+ const m = url.match(re);
46
+ if (!m) continue;
47
+ const id = m[1];
48
+ return {
49
+ kind,
50
+ id,
51
+ embedUrl: computeEmbedUrl(kind, id, url),
52
+ openUrl: computeOpenUrl(kind, id),
53
+ rawUrl,
54
+ };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function computeEmbedUrl(kind: GoogleKind, id: string, rawUrl: string): string {
60
+ // Preserve any `?e=` / share-link variant Google already generated
61
+ const hasE = /\/d\/e\//.test(rawUrl);
62
+ const idSeg = hasE ? `e/${id}` : id;
63
+ switch (kind) {
64
+ case "sheets":
65
+ // `pubhtml` renders as a scrollable table; `edit?embed=true` keeps full UI
66
+ // when the doc is published. Prefer `pubhtml` for minimal chrome.
67
+ return `https://docs.google.com/spreadsheets/d/${idSeg}/pubhtml?widget=true&headers=false`;
68
+ case "slides":
69
+ return `https://docs.google.com/presentation/d/${idSeg}/embed?start=false&loop=false&delayms=3000`;
70
+ case "docs":
71
+ return `https://docs.google.com/document/d/${idSeg}/pub?embedded=true`;
72
+ case "forms":
73
+ return `https://docs.google.com/forms/d/${idSeg}/viewform?embedded=true`;
74
+ case "drive":
75
+ return `https://drive.google.com/file/d/${id}/preview`;
76
+ }
77
+ }
78
+
79
+ function computeOpenUrl(kind: GoogleKind, id: string): string {
80
+ switch (kind) {
81
+ case "sheets":
82
+ return `https://docs.google.com/spreadsheets/d/${id}/edit`;
83
+ case "slides":
84
+ return `https://docs.google.com/presentation/d/${id}/edit`;
85
+ case "docs":
86
+ return `https://docs.google.com/document/d/${id}/edit`;
87
+ case "forms":
88
+ return `https://docs.google.com/forms/d/${id}/edit`;
89
+ case "drive":
90
+ return `https://drive.google.com/file/d/${id}/view`;
91
+ }
92
+ }
93
+
94
+ export function googleKindLabel(kind: GoogleKind): string {
95
+ switch (kind) {
96
+ case "sheets":
97
+ return "Google Sheets";
98
+ case "slides":
99
+ return "Google Slides";
100
+ case "docs":
101
+ return "Google Docs";
102
+ case "forms":
103
+ return "Google Forms";
104
+ case "drive":
105
+ return "Google Drive";
106
+ }
107
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tiny client-safe frontmatter parser for wiki YAML.
3
+ * Handles the subset we use: scalars, inline arrays, and block arrays.
4
+ * Not a full YAML parser. For complex structures, fall back to server.
5
+ */
6
+
7
+ export interface ParsedFrontmatter {
8
+ data: Record<string, unknown>;
9
+ body: string;
10
+ }
11
+
12
+ const BLOCK_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
13
+
14
+ function parseScalar(raw: string): unknown {
15
+ const v = raw.trim();
16
+ if (v === "") return "";
17
+ if (v === "true") return true;
18
+ if (v === "false") return false;
19
+ if (v === "null" || v === "~") return null;
20
+ if (/^-?\d+$/.test(v)) return Number(v);
21
+ if (/^-?\d*\.\d+$/.test(v)) return Number(v);
22
+ // Quoted string
23
+ if (
24
+ (v.startsWith('"') && v.endsWith('"')) ||
25
+ (v.startsWith("'") && v.endsWith("'"))
26
+ ) {
27
+ return v.slice(1, -1);
28
+ }
29
+ // Inline array: [a, b, c]
30
+ if (v.startsWith("[") && v.endsWith("]")) {
31
+ const inner = v.slice(1, -1).trim();
32
+ if (inner === "") return [];
33
+ return inner.split(",").map((s) => parseScalar(s));
34
+ }
35
+ return v;
36
+ }
37
+
38
+ export function parseFrontmatter(text: string): ParsedFrontmatter {
39
+ const match = BLOCK_RE.exec(text);
40
+ if (!match) return { data: {}, body: text };
41
+
42
+ const block = match[1];
43
+ const body = text.slice(match[0].length);
44
+ const data: Record<string, unknown> = {};
45
+
46
+ const lines = block.split(/\r?\n/);
47
+ let i = 0;
48
+ while (i < lines.length) {
49
+ const line = lines[i];
50
+ if (!line || /^\s*#/.test(line)) {
51
+ i += 1;
52
+ continue;
53
+ }
54
+ const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
55
+ if (!m) {
56
+ i += 1;
57
+ continue;
58
+ }
59
+ const key = m[1];
60
+ const rest = m[2];
61
+ if (rest.trim() === "") {
62
+ // Possibly a block array on following indented lines
63
+ const items: unknown[] = [];
64
+ let j = i + 1;
65
+ while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
66
+ const itemMatch = /^\s+-\s+(.*)$/.exec(lines[j]);
67
+ if (itemMatch) items.push(parseScalar(itemMatch[1]));
68
+ j += 1;
69
+ }
70
+ if (items.length > 0) {
71
+ data[key] = items;
72
+ i = j;
73
+ continue;
74
+ }
75
+ data[key] = "";
76
+ i += 1;
77
+ continue;
78
+ }
79
+ data[key] = parseScalar(rest);
80
+ i += 1;
81
+ }
82
+
83
+ return { data, body };
84
+ }
@@ -0,0 +1,100 @@
1
+ import type { Link, Parent, PhrasingContent, Root, Text } from "mdast";
2
+ import type { Plugin } from "unified";
3
+
4
+ const WIKILINK_RE = /\[\[([a-z0-9-]+)(?:\|([^\]#|]+)|#([a-z0-9-]+))?\]\]/g;
5
+
6
+ /**
7
+ * Remark plugin that converts [[slug]], [[slug|alias]], and [[slug#anchor]]
8
+ * occurrences in text nodes into link nodes targeting `#wiki:slug` (with
9
+ * `#anchor` appended when present). The link's data attributes carry the
10
+ * slug, optional alias, and optional anchor so renderers can branch on
11
+ * wiki-links specifically.
12
+ */
13
+ const remarkWikilinks: Plugin<[], Root> = () => {
14
+ return (tree) => {
15
+ visit(tree, (node, index, parent) => {
16
+ if (!parent || index === undefined) return;
17
+ if (node.type !== "text") return;
18
+ const text = (node as Text).value;
19
+ if (!text?.includes("[[")) return;
20
+
21
+ const replacements: PhrasingContent[] = [];
22
+ let lastIndex = 0;
23
+ WIKILINK_RE.lastIndex = 0;
24
+ let match: RegExpExecArray | null = WIKILINK_RE.exec(text);
25
+ while (match !== null) {
26
+ const [full, slug, alias, anchor] = match;
27
+ if (match.index > lastIndex) {
28
+ replacements.push({
29
+ type: "text",
30
+ value: text.slice(lastIndex, match.index),
31
+ } as Text);
32
+ }
33
+ const visible = alias ?? (anchor ? `${slug}#${anchor}` : slug);
34
+ const url = anchor ? `#wiki:${slug}#${anchor}` : `#wiki:${slug}`;
35
+ const link: Link = {
36
+ type: "link",
37
+ url,
38
+ title: null,
39
+ children: [{ type: "text", value: visible } as Text],
40
+ data: {
41
+ hProperties: {
42
+ "data-wiki-link": "true",
43
+ "data-slug": slug,
44
+ ...(alias ? { "data-alias": alias } : {}),
45
+ ...(anchor ? { "data-anchor": anchor } : {}),
46
+ className: ["wiki-link"],
47
+ },
48
+ },
49
+ };
50
+ replacements.push(link);
51
+ lastIndex = match.index + full.length;
52
+ match = WIKILINK_RE.exec(text);
53
+ }
54
+
55
+ if (replacements.length === 0) return;
56
+ if (lastIndex < text.length) {
57
+ replacements.push({
58
+ type: "text",
59
+ value: text.slice(lastIndex),
60
+ } as Text);
61
+ }
62
+
63
+ (parent as Parent).children.splice(index, 1, ...replacements);
64
+ return index + replacements.length;
65
+ });
66
+ };
67
+ };
68
+
69
+ type Visitor = (
70
+ node: Root | PhrasingContent | Parent,
71
+ index: number | undefined,
72
+ parent: Parent | null,
73
+ ) => number | undefined;
74
+
75
+ function visit(
76
+ node: Root | Parent,
77
+ fn: Visitor,
78
+ parent: Parent | null = null,
79
+ ): void {
80
+ if ("children" in node && Array.isArray(node.children)) {
81
+ let i = 0;
82
+ while (i < node.children.length) {
83
+ const child = node.children[i];
84
+ const next = fn(child as PhrasingContent, i, node);
85
+ if (typeof next === "number") {
86
+ i = next;
87
+ continue;
88
+ }
89
+ if (child && typeof child === "object" && "children" in child) {
90
+ visit(child as Parent, fn, node);
91
+ }
92
+ i += 1;
93
+ }
94
+ }
95
+ if (parent === null) {
96
+ fn(node, undefined, null);
97
+ }
98
+ }
99
+
100
+ export default remarkWikilinks;
@@ -0,0 +1,168 @@
1
+ import rehypeStringify from "rehype-stringify";
2
+ import remarkGfm from "remark-gfm";
3
+ import remarkParse from "remark-parse";
4
+ import remarkRehype from "remark-rehype";
5
+ import { unified } from "unified";
6
+ import { detectEmbed } from "@/lib/embeds/detect";
7
+
8
+ // Canonical wiki-link regex (llm-wiki-pm ground truth).
9
+ // Groups: 1=slug 2=alias 3=anchor
10
+ const WIKI_LINK_RE = /\[\[([^\]|#]+)(?:\|([^\]]*)|#([^\]]*))?\]\]/g;
11
+ const SLUG_VALID_RE = /^[a-z0-9-]+$/;
12
+
13
+ /**
14
+ * Pre-process markdown to convert wiki-links to HTML anchors
15
+ * before the remark pipeline (which treats [[ as plain text).
16
+ *
17
+ * Handles three forms:
18
+ * [[slug]] - bare
19
+ * [[slug|alias]] - aliased
20
+ * [[slug#anchor]] - anchored
21
+ *
22
+ * Slugs that fail ^[a-z0-9-]+$ validation are left as plain text.
23
+ */
24
+ function convertWikiLinks(markdown: string): string {
25
+ return markdown.replace(
26
+ WIKI_LINK_RE,
27
+ (
28
+ _match: string,
29
+ rawSlug: string,
30
+ alias: string | undefined,
31
+ anchor: string | undefined,
32
+ ): string => {
33
+ const slug = rawSlug.trim();
34
+ if (!SLUG_VALID_RE.test(slug)) return _match;
35
+ const visibleText = alias ?? (anchor ? `${slug}#${anchor}` : slug);
36
+ const href = anchor ? `#wiki:${slug}#${anchor}` : `#wiki:${slug}`;
37
+ const aliasAttr = alias !== undefined ? ` data-alias="${alias}"` : "";
38
+ const anchorAttr = anchor !== undefined ? ` data-anchor="${anchor}"` : "";
39
+ return `<a data-wiki-link="true" data-slug="${slug}"${aliasAttr}${anchorAttr} href="${href}" class="wiki-link">${visibleText}</a>`;
40
+ },
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Post-process HTML to fix task list structure for Tiptap compatibility.
46
+ * remark-gfm outputs: <li><input type="checkbox" ...> text</li>
47
+ * Tiptap expects: <li data-type="taskItem" data-checked="..."><label><input ...></label><div><p>text</p></div></li>
48
+ * And the parent <ul> needs class="task-list" and data-type="taskList".
49
+ */
50
+ function fixTaskListHtml(html: string): string {
51
+ // Convert task list <ul> with contains-task-list class
52
+ html = html.replace(
53
+ /<ul class="contains-task-list">/g,
54
+ '<ul data-type="taskList" class="task-list">',
55
+ );
56
+
57
+ // Convert each task list item to Tiptap's expected structure
58
+ html = html.replace(
59
+ /<li class="task-list-item">\s*<input type="checkbox"([^>]*)>\s*([\s\S]*?)(?=<\/li>)/g,
60
+ (_match, attrs: string, content: string) => {
61
+ const checked = attrs.includes("checked");
62
+ const cleanContent = content.trim();
63
+ return `<li data-type="taskItem" data-checked="${checked}"><label><input type="checkbox"${checked ? " checked" : ""}></label><div><p>${cleanContent}</p></div>`;
64
+ },
65
+ );
66
+
67
+ return html;
68
+ }
69
+
70
+ /**
71
+ * Upgrade broken `<video src="https://youtu.be/...">` (or any non-file video URL
72
+ * that points at a known embed provider) into a real iframe embed block.
73
+ *
74
+ * This heals content written before we had proper embed support, and also any
75
+ * time the TipTap schema round-trip collapsed an iframe into a video tag.
76
+ */
77
+ function upgradeProviderVideos(html: string): string {
78
+ return html.replace(
79
+ /<video\b([^>]*)\bsrc="([^"]+)"([^>]*)><\/video>/gi,
80
+ (match, _before: string, src: string, _after: string) => {
81
+ const detected = detectEmbed(src);
82
+ if (!detected || detected.provider === "video") return match;
83
+
84
+ const aspect = detected.aspectRatio
85
+ ? ` data-aspect-ratio="${detected.aspectRatio}"`
86
+ : "";
87
+ return (
88
+ `<div data-embed="true" data-provider="${detected.provider}"` +
89
+ ` data-src="${detected.embedUrl}"` +
90
+ ` data-original-url="${detected.originalUrl}"${aspect}>` +
91
+ `<iframe src="${detected.embedUrl}"` +
92
+ ` data-embed-provider="${detected.provider}"` +
93
+ ` allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"` +
94
+ ` allowfullscreen loading="lazy" frameborder="0"></iframe>` +
95
+ `</div>`
96
+ );
97
+ },
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Rewrite relative URLs (./file.pdf, ./image.png) to /api/assets/{pagePath}/file
103
+ * and convert PDF links to inline embedded viewers.
104
+ * Applies to href, src, and data-src attributes (the last is used by embed blocks).
105
+ */
106
+ function resolveRelativeUrls(html: string, pagePath: string): string {
107
+ const dirPath = pagePath;
108
+
109
+ html = html.replace(
110
+ /href="\.\/([^"]+)"/g,
111
+ (_match, file: string) => `href="/api/assets/${dirPath}/${file}"`,
112
+ );
113
+
114
+ html = html.replace(
115
+ /src="\.\/([^"]+)"/g,
116
+ (_match, file: string) => `src="/api/assets/${dirPath}/${file}"`,
117
+ );
118
+
119
+ html = html.replace(
120
+ /data-src="\.\/([^"]+)"/g,
121
+ (_match, file: string) => `data-src="/api/assets/${dirPath}/${file}"`,
122
+ );
123
+
124
+ // Mark PDF links with a data attribute so the editor can handle them
125
+ html = html.replace(
126
+ /<a([^>]*?)href="(\/api\/assets\/[^"]+\.pdf)"([^>]*?)>/gi,
127
+ (_match, before: string, url: string, after: string) => {
128
+ return `<a${before}href="${url}"${after} data-pdf-link="true">`;
129
+ },
130
+ );
131
+
132
+ return html;
133
+ }
134
+
135
+ // Unified's plugin resolution + processor freeze runs on every `unified()`
136
+ // call. Reuse a single frozen pipeline across every page render so
137
+ // navigation doesn't pay that cost on the hot path.
138
+ const processor = unified()
139
+ .use(remarkParse)
140
+ .use(remarkGfm)
141
+ .use(remarkRehype, { allowDangerousHtml: true })
142
+ .use(rehypeStringify, { allowDangerousHtml: true })
143
+ .freeze();
144
+
145
+ export async function markdownToHtml(
146
+ markdown: string,
147
+ pagePath?: string,
148
+ ): Promise<string> {
149
+ // Pre-process wiki-links before remark (which would treat [[ as text)
150
+ const preprocessed = convertWikiLinks(markdown);
151
+
152
+ const result = await processor.process(preprocessed);
153
+
154
+ let html = String(result);
155
+
156
+ // Post-process task lists for Tiptap compatibility
157
+ html = fixTaskListHtml(html);
158
+
159
+ // Heal <video src="youtube-url"> into real iframe embeds
160
+ html = upgradeProviderVideos(html);
161
+
162
+ // Resolve relative URLs if page path is provided
163
+ if (pagePath) {
164
+ html = resolveRelativeUrls(html, pagePath);
165
+ }
166
+
167
+ return html;
168
+ }
@@ -0,0 +1,238 @@
1
+ import TurndownService from "turndown";
2
+ // @ts-expect-error — no types available for this package
3
+ import { gfm } from "turndown-plugin-gfm";
4
+ import { detectEmbed } from "@/lib/embeds/detect";
5
+
6
+ const turndown = new TurndownService({
7
+ headingStyle: "atx",
8
+ hr: "---",
9
+ bulletListMarker: "-",
10
+ codeBlockStyle: "fenced",
11
+ fence: "```",
12
+ emDelimiter: "*",
13
+ strongDelimiter: "**",
14
+ });
15
+
16
+ // Add GFM support (tables, strikethrough, task lists)
17
+ turndown.use(gfm);
18
+
19
+ // Preserve line breaks in code blocks
20
+ turndown.addRule("codeBlock", {
21
+ filter: (node) => {
22
+ return (
23
+ node.nodeName === "PRE" &&
24
+ node.firstChild !== null &&
25
+ node.firstChild.nodeName === "CODE"
26
+ );
27
+ },
28
+ replacement: (_content, node) => {
29
+ const code = node.firstChild as HTMLElement;
30
+ const lang = code.getAttribute("class")?.replace("language-", "") || "";
31
+ const text = code.textContent || "";
32
+ return `\n\`\`\`${lang}\n${text}\n\`\`\`\n`;
33
+ },
34
+ });
35
+
36
+ // Serialize wiki-links back to llm-wiki-pm syntax.
37
+ // Round-trip is lossless: slug/alias/anchor attrs map 1-to-1 to each form.
38
+ turndown.addRule("wikiLink", {
39
+ filter: (node) =>
40
+ node.nodeName === "A" && node.getAttribute("data-wiki-link") === "true",
41
+ replacement: (_content, node) => {
42
+ const el = node as HTMLElement;
43
+ const slug = el.getAttribute("data-slug") ?? "";
44
+ const alias = el.getAttribute("data-alias");
45
+ const anchor = el.getAttribute("data-anchor");
46
+ if (alias) return `[[${slug}|${alias}]]`;
47
+ if (anchor) return `[[${slug}#${anchor}]]`;
48
+ return `[[${slug}]]`;
49
+ },
50
+ });
51
+
52
+ // Preserve inline styled spans (text color, background color, font weight, etc.)
53
+ // so colors and highlights survive markdown roundtrip.
54
+ turndown.addRule("styledSpan", {
55
+ filter: (node) =>
56
+ node.nodeName === "SPAN" && !!(node as HTMLElement).getAttribute("style"),
57
+ replacement: (content, node) => {
58
+ const style = (node as HTMLElement).getAttribute("style") ?? "";
59
+ return `<span style="${style}">${content}</span>`;
60
+ },
61
+ });
62
+
63
+ // Lucide icon node — serialize as a clean `<span data-lucide="…" data-color="…">`
64
+ // stub. The editor's IconExtension rebuilds the colored SVG on load.
65
+ turndown.addRule("lucideIcon", {
66
+ filter: (node) =>
67
+ node.nodeName === "SPAN" &&
68
+ (node as HTMLElement).hasAttribute("data-lucide"),
69
+ replacement: (_content, node) => {
70
+ const el = node as HTMLElement;
71
+ const name = el.getAttribute("data-lucide") ?? "file";
72
+ const color = el.getAttribute("data-color") ?? "gray";
73
+ return `<span data-lucide="${name}" data-color="${color}">&nbsp;</span>`;
74
+ },
75
+ });
76
+
77
+ // Preserve <mark> with any attributes (highlight extension writes data-color + style).
78
+ turndown.addRule("mark", {
79
+ filter: "mark" as never,
80
+ replacement: (content, node) => {
81
+ const el = node as HTMLElement;
82
+ const attrs: string[] = [];
83
+ for (const attr of Array.from(el.attributes)) {
84
+ attrs.push(`${attr.name}="${attr.value.replace(/"/g, "&quot;")}"`);
85
+ }
86
+ return `<mark${attrs.length ? ` ${attrs.join(" ")}` : ""}>${content}</mark>`;
87
+ },
88
+ });
89
+
90
+ // Preserve <u>, <sub>, <sup> (underline, subscript, superscript).
91
+ for (const tag of ["u", "sub", "sup"] as const) {
92
+ turndown.addRule(tag, {
93
+ filter: tag as never,
94
+ replacement: (content) => `<${tag}>${content}</${tag}>`,
95
+ });
96
+ }
97
+
98
+ // Preserve <video> tags with all attrs (file-uploaded videos).
99
+ // If the src points at a known embed provider (YouTube, Vimeo, Loom, …),
100
+ // upgrade it to a proper embed block instead of preserving a tag that
101
+ // browsers can't render.
102
+ turndown.addRule("video", {
103
+ filter: "video" as never,
104
+ replacement: (_content, node) => {
105
+ const el = node as HTMLElement;
106
+ const src = el.getAttribute("src") ?? "";
107
+ const detected = src ? detectEmbed(src) : null;
108
+ if (detected && detected.provider !== "video") {
109
+ const aspect = detected.aspectRatio
110
+ ? ` data-aspect-ratio="${detected.aspectRatio}"`
111
+ : "";
112
+ return (
113
+ `\n<div data-embed="true" data-provider="${detected.provider}"` +
114
+ ` data-src="${detected.embedUrl}"` +
115
+ ` data-original-url="${detected.originalUrl}"${aspect}>` +
116
+ `<iframe src="${detected.embedUrl}"` +
117
+ ` data-embed-provider="${detected.provider}"` +
118
+ ` allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"` +
119
+ ` allowfullscreen loading="lazy" frameborder="0"></iframe>` +
120
+ `</div>\n`
121
+ );
122
+ }
123
+
124
+ const attrs: string[] = [];
125
+ for (const attr of Array.from(el.attributes)) {
126
+ attrs.push(`${attr.name}="${attr.value.replace(/"/g, "&quot;")}"`);
127
+ }
128
+ return `<video${attrs.length ? ` ${attrs.join(" ")}` : ""}></video>`;
129
+ },
130
+ });
131
+
132
+ // Preserve embed blocks (YouTube/Vimeo/Loom/X/Facebook/Instagram/etc.) as HTML.
133
+ turndown.addRule("embedBlock", {
134
+ filter: (node) =>
135
+ node.nodeName === "DIV" &&
136
+ (node as HTMLElement).getAttribute("data-embed") === "true",
137
+ replacement: (_content, node) => {
138
+ const el = node as HTMLElement;
139
+ const provider = el.getAttribute("data-provider") ?? "iframe";
140
+ const src = el.getAttribute("data-src") ?? "";
141
+ const originalUrl = el.getAttribute("data-original-url") ?? "";
142
+ const aspect = el.getAttribute("data-aspect-ratio") ?? "";
143
+
144
+ if (provider === "video") {
145
+ return `\n<video src="${src}" controls></video>\n`;
146
+ }
147
+
148
+ const attrs = [
149
+ `data-embed="true"`,
150
+ `data-provider="${provider}"`,
151
+ `data-src="${src}"`,
152
+ originalUrl ? `data-original-url="${originalUrl}"` : "",
153
+ aspect ? `data-aspect-ratio="${aspect}"` : "",
154
+ ]
155
+ .filter(Boolean)
156
+ .join(" ");
157
+
158
+ return `\n<div ${attrs}><iframe src="${src}" data-embed-provider="${provider}" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" allowfullscreen loading="lazy" frameborder="0"></iframe></div>\n`;
159
+ },
160
+ });
161
+
162
+ // Preserve Twitter/X embeds as <blockquote class="twitter-tweet">…
163
+ turndown.addRule("twitterEmbed", {
164
+ filter: (node) =>
165
+ node.nodeName === "BLOCKQUOTE" &&
166
+ (node as HTMLElement).classList.contains("twitter-tweet"),
167
+ replacement: (_content, node) => {
168
+ const el = node as HTMLElement;
169
+ const a = el.querySelector("a");
170
+ const href = a?.getAttribute("href") ?? "";
171
+ return `\n<blockquote class="twitter-tweet" data-theme="auto"><a href="${href}">${href}</a></blockquote>\n`;
172
+ },
173
+ });
174
+
175
+ // Preserve images that have inline width/align data. Turndown's default image
176
+ // rule emits `![]()`, losing size info — so when the <img> has width, align, or
177
+ // wraps in a resizable-image div we emit the raw HTML instead.
178
+ turndown.addRule("sizedImage", {
179
+ filter: (node) => {
180
+ if (node.nodeName !== "IMG") return false;
181
+ const el = node as HTMLElement;
182
+ const width = el.getAttribute("width") ?? el.style.width;
183
+ const align = el.getAttribute("data-align");
184
+ return !!(width || align);
185
+ },
186
+ replacement: (_content, node) => {
187
+ const el = node as HTMLElement;
188
+ const attrs: string[] = [];
189
+ for (const attr of Array.from(el.attributes)) {
190
+ attrs.push(`${attr.name}="${attr.value.replace(/"/g, "&quot;")}"`);
191
+ }
192
+ return `<img ${attrs.join(" ")} />`;
193
+ },
194
+ });
195
+
196
+ // Preserve the resizable-image wrapper so align info survives.
197
+ turndown.addRule("resizableImageWrapper", {
198
+ filter: (node) =>
199
+ node.nodeName === "DIV" &&
200
+ (node as HTMLElement).classList.contains("resizable-image"),
201
+ replacement: (content) => content,
202
+ });
203
+
204
+ // Math extension renders <span data-type="inlineMath" data-latex="..."> etc.
205
+ turndown.addRule("inlineMath", {
206
+ filter: (node) => {
207
+ const el = node as HTMLElement;
208
+ if (el.nodeName !== "SPAN") return false;
209
+ const dataType = el.getAttribute("data-type");
210
+ return dataType === "inline-math" || dataType === "inlineMath";
211
+ },
212
+ replacement: (_content, node) => {
213
+ const latex = (node as HTMLElement).getAttribute("data-latex") ?? "";
214
+ return `$${latex}$`;
215
+ },
216
+ });
217
+
218
+ // Preserve aligned blocks (paragraphs/headings with inline text-align style).
219
+ // Default turndown serializers lose the style attr; we emit raw HTML when a
220
+ // block has non-default alignment.
221
+ turndown.addRule("alignedBlock", {
222
+ filter: (node) => {
223
+ if (!["P", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.nodeName))
224
+ return false;
225
+ const style = (node as HTMLElement).getAttribute("style") ?? "";
226
+ return /text-align:\s*(center|right|justify)/.test(style);
227
+ },
228
+ replacement: (content, node) => {
229
+ const el = node as HTMLElement;
230
+ const tag = el.nodeName.toLowerCase();
231
+ const style = el.getAttribute("style") ?? "";
232
+ return `\n<${tag} style="${style}">${content}</${tag}>\n`;
233
+ },
234
+ });
235
+
236
+ export function htmlToMarkdown(html: string): string {
237
+ return turndown.turndown(html);
238
+ }