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,164 @@
1
+ "use client";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import { Fragment, useState } from "react";
4
+ import { Badge } from "@/components/ui/badge";
5
+
6
+ type FmValue =
7
+ | string
8
+ | number
9
+ | boolean
10
+ | string[]
11
+ | Record<string, unknown>
12
+ | null;
13
+
14
+ interface Props {
15
+ data: Record<string, FmValue>;
16
+ }
17
+
18
+ const KNOWN_KEYS = new Set([
19
+ "title",
20
+ "type",
21
+ "status",
22
+ "created",
23
+ "updated",
24
+ "date",
25
+ "tags",
26
+ "sources",
27
+ "private",
28
+ "supersedes",
29
+ "superseded_by",
30
+ ]);
31
+
32
+ function fmt(v: unknown): string {
33
+ if (v === null || v === undefined) return "";
34
+ if (typeof v === "string") return v;
35
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
36
+ if (Array.isArray(v)) return v.map(fmt).join(", ");
37
+ try {
38
+ return JSON.stringify(v);
39
+ } catch {
40
+ return String(v);
41
+ }
42
+ }
43
+
44
+ export function FrontmatterHeader({ data }: Props) {
45
+ const [showRaw, setShowRaw] = useState(false);
46
+ const keys = Object.keys(data);
47
+ if (keys.length === 0) return null;
48
+
49
+ const title = typeof data.title === "string" ? data.title : null;
50
+ const type = typeof data.type === "string" ? data.type : null;
51
+ const status = typeof data.status === "string" ? data.status : null;
52
+ const created = typeof data.created === "string" ? data.created : null;
53
+ const updated = typeof data.updated === "string" ? data.updated : null;
54
+ const isPrivate = data.private === true;
55
+ const tags = Array.isArray(data.tags) ? (data.tags as string[]) : [];
56
+ const sources = Array.isArray(data.sources) ? (data.sources as string[]) : [];
57
+ const supersededBy =
58
+ typeof data.superseded_by === "string" ? data.superseded_by : null;
59
+
60
+ const otherKeys = keys.filter((k) => !KNOWN_KEYS.has(k));
61
+
62
+ return (
63
+ <div className="mb-6 rounded-sm border bg-card/40 px-4 py-3 text-xs">
64
+ {title && (
65
+ <div className="mb-2 text-base font-normal text-foreground">
66
+ {title}
67
+ </div>
68
+ )}
69
+
70
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-muted-foreground">
71
+ {type && (
72
+ <Badge variant="outline" className="font-normal">
73
+ {type}
74
+ </Badge>
75
+ )}
76
+ {status && (
77
+ <Badge variant="outline" className="font-normal">
78
+ {status}
79
+ </Badge>
80
+ )}
81
+ {isPrivate && (
82
+ <Badge
83
+ variant="outline"
84
+ className="border-warning-ink/40 font-normal text-warning-ink"
85
+ >
86
+ private
87
+ </Badge>
88
+ )}
89
+ {supersededBy && (
90
+ <Badge
91
+ variant="outline"
92
+ className="border-destructive/40 font-normal text-destructive"
93
+ >
94
+ superseded → {supersededBy}
95
+ </Badge>
96
+ )}
97
+ {created && (
98
+ <span>
99
+ <span className="opacity-60">created</span>{" "}
100
+ <span className="text-foreground/80">{created}</span>
101
+ </span>
102
+ )}
103
+ {updated && updated !== created && (
104
+ <span>
105
+ <span className="opacity-60">updated</span>{" "}
106
+ <span className="text-foreground/80">{updated}</span>
107
+ </span>
108
+ )}
109
+ </div>
110
+
111
+ {tags.length > 0 && (
112
+ <div className="mt-2 flex flex-wrap gap-1">
113
+ {tags.map((t) => (
114
+ <Badge
115
+ key={t}
116
+ variant="secondary"
117
+ className="font-normal text-[11px]"
118
+ >
119
+ #{t}
120
+ </Badge>
121
+ ))}
122
+ </div>
123
+ )}
124
+
125
+ {sources.length > 0 && (
126
+ <div className="mt-2">
127
+ <span className="opacity-60">sources:</span>{" "}
128
+ <span className="text-foreground/80">{sources.join(", ")}</span>
129
+ </div>
130
+ )}
131
+
132
+ {otherKeys.length > 0 && (
133
+ <div className="mt-2 border-t pt-2">
134
+ <button
135
+ type="button"
136
+ onClick={() => setShowRaw((v) => !v)}
137
+ className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
138
+ >
139
+ {showRaw ? (
140
+ <ChevronDown className="h-3 w-3" />
141
+ ) : (
142
+ <ChevronRight className="h-3 w-3" />
143
+ )}
144
+ <span>
145
+ {otherKeys.length} more field{otherKeys.length === 1 ? "" : "s"}
146
+ </span>
147
+ </button>
148
+ {showRaw && (
149
+ <dl className="mt-1.5 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5">
150
+ {otherKeys.map((k) => (
151
+ <Fragment key={k}>
152
+ <dt className="opacity-60">{k}</dt>
153
+ <dd className="text-foreground/80 break-words">
154
+ {fmt(data[k])}
155
+ </dd>
156
+ </Fragment>
157
+ ))}
158
+ </dl>
159
+ )}
160
+ </div>
161
+ )}
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Server-side singleton that manages child processes for node-app directories.
3
+ * Lives as a module-level Map so it persists across requests in both dev and
4
+ * the Next.js standalone production server.
5
+ */
6
+ import { spawn, type ChildProcess, execSync } from "node:child_process";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { createConnection, createServer } from "node:net";
9
+ import path from "node:path";
10
+
11
+ export type AppStatus = "stopped" | "installing" | "starting" | "running" | "error";
12
+
13
+ interface RunningApp {
14
+ port: number;
15
+ process: ChildProcess | null;
16
+ status: AppStatus;
17
+ error?: string;
18
+ logs: string[];
19
+ }
20
+
21
+ // ── singleton ────────────────────────────────────────────────────────────────
22
+ const apps = new Map<string, RunningApp>();
23
+
24
+ // ── helpers ──────────────────────────────────────────────────────────────────
25
+
26
+ function findFreePort(): Promise<number> {
27
+ return new Promise((resolve, reject) => {
28
+ const s = createServer();
29
+ s.listen(0, "127.0.0.1", () => {
30
+ const addr = s.address();
31
+ const port = typeof addr === "object" && addr ? addr.port : 0;
32
+ s.close(() => resolve(port));
33
+ });
34
+ s.on("error", reject);
35
+ });
36
+ }
37
+
38
+ function canConnect(port: number, host: string): Promise<boolean> {
39
+ return new Promise((resolve) => {
40
+ const sock = createConnection(port, host);
41
+ sock.setTimeout(800);
42
+ sock.on("connect", () => { sock.destroy(); resolve(true); });
43
+ sock.on("error", () => { sock.destroy(); resolve(false); });
44
+ sock.on("timeout", () => { sock.destroy(); resolve(false); });
45
+ });
46
+ }
47
+
48
+ // Probe both IPv4 (127.0.0.1) and IPv6 (::1) — Vite binds to ::1 by default.
49
+ async function waitForPort(port: number, timeoutMs = 30_000): Promise<boolean> {
50
+ const deadline = Date.now() + timeoutMs;
51
+ while (Date.now() < deadline) {
52
+ const [v4, v6] = await Promise.all([
53
+ canConnect(port, "127.0.0.1"),
54
+ canConnect(port, "::1"),
55
+ ]);
56
+ if (v4 || v6) return true;
57
+ await new Promise((r) => setTimeout(r, 400));
58
+ }
59
+ return false;
60
+ }
61
+
62
+ type PM = "npm" | "pnpm" | "yarn";
63
+
64
+ function detectPM(dir: string): PM {
65
+ if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
66
+ if (existsSync(path.join(dir, "yarn.lock"))) return "yarn";
67
+ return "npm";
68
+ }
69
+
70
+ interface Cmd {
71
+ bin: string;
72
+ args: string[];
73
+ isVite: boolean;
74
+ }
75
+
76
+ function detectCmd(dir: string, pm: PM): Cmd | null {
77
+ const pkgPath = path.join(dir, "package.json");
78
+ if (!existsSync(pkgPath)) return null;
79
+
80
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
81
+ scripts?: Record<string, string>;
82
+ main?: string;
83
+ dependencies?: Record<string, string>;
84
+ devDependencies?: Record<string, string>;
85
+ };
86
+ const scripts = pkg.scripts ?? {};
87
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
88
+ const hasVite = Object.keys(allDeps).some((k) => k === "vite" || k.includes("vite"));
89
+
90
+ const run = (script: string, extraArgs: string[] = []): Cmd => ({
91
+ bin: pm,
92
+ args: ["run", script, ...(extraArgs.length ? ["--", ...extraArgs] : [])],
93
+ isVite: hasVite,
94
+ });
95
+
96
+ // Priority: start > preview (built) > dev > node main
97
+ if (scripts.start) return run("start");
98
+ if (scripts.preview && existsSync(path.join(dir, "dist"))) return run("preview");
99
+ if (scripts.dev) return run("dev");
100
+ if (pkg.main) return { bin: "node", args: [pkg.main], isVite: false };
101
+ return null;
102
+ }
103
+
104
+ function needsInstall(dir: string): boolean {
105
+ return !existsSync(path.join(dir, "node_modules"));
106
+ }
107
+
108
+ function runInstall(dir: string, pm: PM): Promise<void> {
109
+ return new Promise((resolve, reject) => {
110
+ const child = spawn(pm, ["install"], { cwd: dir, stdio: "pipe" });
111
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${pm} install failed (exit ${code})`))));
112
+ child.on("error", reject);
113
+ });
114
+ }
115
+
116
+ // ── public API ───────────────────────────────────────────────────────────────
117
+
118
+ export function getStatus(relPath: string): { status: AppStatus; port?: number; error?: string; logs: string[] } {
119
+ const app = apps.get(relPath);
120
+ if (!app) return { status: "stopped", logs: [] };
121
+ return { status: app.status, port: app.port || undefined, error: app.error, logs: app.logs };
122
+ }
123
+
124
+ export async function startApp(relPath: string, absPath: string): Promise<{ port: number }> {
125
+ const existing = apps.get(relPath);
126
+ if (existing && existing.status !== "stopped" && existing.status !== "error") {
127
+ return { port: existing.port };
128
+ }
129
+
130
+ const port = await findFreePort();
131
+ const pm = detectPM(absPath);
132
+ const cmd = detectCmd(absPath, pm);
133
+ if (!cmd) throw new Error("No runnable script found in package.json (need start, preview, or dev)");
134
+
135
+ const entry: RunningApp = { port, process: null, status: "installing", logs: [] };
136
+ apps.set(relPath, entry);
137
+
138
+ const pushLog = (line: string) => {
139
+ entry.logs.push(line);
140
+ if (entry.logs.length > 200) entry.logs.shift();
141
+ };
142
+
143
+ // Install if needed
144
+ if (needsInstall(absPath)) {
145
+ try {
146
+ pushLog(`[wiki-viewer] Running ${pm} install…`);
147
+ await runInstall(absPath, pm);
148
+ } catch (e) {
149
+ entry.status = "error";
150
+ entry.error = String(e);
151
+ return { port };
152
+ }
153
+ }
154
+
155
+ entry.status = "starting";
156
+ pushLog(`[wiki-viewer] Starting on port ${port}: ${cmd.bin} ${cmd.args.join(" ")}`);
157
+
158
+ const portArgs = cmd.isVite ? ["--port", String(port)] : [];
159
+ const child = spawn(cmd.bin, [...cmd.args, ...portArgs], {
160
+ cwd: absPath,
161
+ stdio: "pipe",
162
+ env: {
163
+ ...process.env,
164
+ PORT: String(port),
165
+ VITE_PORT: String(port),
166
+ },
167
+ });
168
+ entry.process = child;
169
+
170
+ const handleOutput = (data: Buffer) => {
171
+ for (const line of data.toString().split("\n")) {
172
+ if (line.trim()) pushLog(line);
173
+ }
174
+ };
175
+ child.stdout?.on("data", handleOutput);
176
+ child.stderr?.on("data", handleOutput);
177
+
178
+ child.on("exit", (code) => {
179
+ const a = apps.get(relPath);
180
+ if (a?.process === child) {
181
+ a.status = code === 0 || code === null ? "stopped" : "error";
182
+ a.error = code ? `Process exited with code ${code}` : undefined;
183
+ }
184
+ });
185
+
186
+ // Wait for port in background
187
+ waitForPort(port).then((ok) => {
188
+ const a = apps.get(relPath);
189
+ if (a?.process === child) {
190
+ a.status = ok ? "running" : "error";
191
+ if (!ok) a.error = "Port never became reachable (30 s timeout)";
192
+ }
193
+ });
194
+
195
+ return { port };
196
+ }
197
+
198
+ export function stopApp(relPath: string): void {
199
+ const app = apps.get(relPath);
200
+ if (!app?.process) return;
201
+ try {
202
+ app.process.kill("SIGTERM");
203
+ } catch {}
204
+ app.status = "stopped";
205
+ }
206
+
207
+ export function listApps(): Array<{ relPath: string; status: AppStatus; port?: number }> {
208
+ return [...apps.entries()].map(([relPath, a]) => ({
209
+ relPath,
210
+ status: a.status,
211
+ port: a.port || undefined,
212
+ }));
213
+ }
214
+
215
+ /**
216
+ * Given URL path segments, find the longest prefix that matches a running app.
217
+ * e.g. ["apps", "roadmap-server", "api", "specs"] → { relPath: "apps/roadmap-server", port, rest: "/api/specs" }
218
+ */
219
+ export function resolveByPrefix(
220
+ segments: string[],
221
+ ): { relPath: string; port: number; rest: string } | null {
222
+ for (let i = segments.length; i > 0; i--) {
223
+ const relPath = segments.slice(0, i).join("/");
224
+ const app = apps.get(relPath);
225
+ if (app && app.status === "running" && app.port) {
226
+ const rest = "/" + segments.slice(i).join("/");
227
+ return { relPath, port: app.port, rest };
228
+ }
229
+ }
230
+ return null;
231
+ }
@@ -0,0 +1,75 @@
1
+ import type { TreeNode } from "@/types";
2
+
3
+ function pathMatchesNode(targetPath: string, nodePath: string): boolean {
4
+ return targetPath === nodePath || targetPath.startsWith(`${nodePath}/`);
5
+ }
6
+
7
+ export function findRootCabinetNode(nodes: TreeNode[]): TreeNode | null {
8
+ const root = nodes[0];
9
+ return root?.type === "cabinet" && root.path === "." ? root : null;
10
+ }
11
+
12
+ export function findNodeByPath(
13
+ nodes: TreeNode[],
14
+ path: string,
15
+ ): TreeNode | null {
16
+ for (const node of nodes) {
17
+ if (node.path === path) return node;
18
+ if (node.children) {
19
+ const found = findNodeByPath(node.children, path);
20
+ if (found) return found;
21
+ }
22
+ }
23
+
24
+ return null;
25
+ }
26
+
27
+ export function findDeepestCabinetNode(
28
+ nodes: TreeNode[],
29
+ targetPath: string,
30
+ ): TreeNode | null {
31
+ let match: TreeNode | null = null;
32
+
33
+ for (const node of nodes) {
34
+ if (!pathMatchesNode(targetPath, node.path)) continue;
35
+
36
+ if (node.type === "cabinet") {
37
+ match = node;
38
+ }
39
+
40
+ if (node.children) {
41
+ const childMatch = findDeepestCabinetNode(node.children, targetPath);
42
+ if (childMatch) {
43
+ match = childMatch;
44
+ }
45
+ }
46
+ }
47
+
48
+ return match;
49
+ }
50
+
51
+ export function findParentCabinetNode(
52
+ nodes: TreeNode[],
53
+ cabinetPath: string,
54
+ cabinetAncestor: TreeNode | null = null,
55
+ ): TreeNode | null {
56
+ for (const node of nodes) {
57
+ const nextCabinetAncestor =
58
+ node.type === "cabinet" ? node : cabinetAncestor;
59
+
60
+ if (node.path === cabinetPath) {
61
+ return node.type === "cabinet" ? cabinetAncestor : null;
62
+ }
63
+
64
+ if (node.children) {
65
+ const found = findParentCabinetNode(
66
+ node.children,
67
+ cabinetPath,
68
+ nextCabinetAncestor,
69
+ );
70
+ if (found) return found;
71
+ }
72
+ }
73
+
74
+ return null;
75
+ }
@@ -0,0 +1,32 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ interface WikiViewerConfig {
6
+ pinnedPaths?: string[];
7
+ lastOpenedPath?: string;
8
+ }
9
+
10
+ function configPath() {
11
+ return path.join(os.homedir(), ".wiki-viewer", "config.json");
12
+ }
13
+
14
+ async function ensureDir() {
15
+ await mkdir(path.join(os.homedir(), ".wiki-viewer"), { recursive: true });
16
+ }
17
+
18
+ export async function readConfig(): Promise<WikiViewerConfig> {
19
+ try {
20
+ const raw = await readFile(configPath(), "utf8");
21
+ return JSON.parse(raw) as WikiViewerConfig;
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ export async function writeConfig(patch: Partial<WikiViewerConfig>): Promise<void> {
28
+ await ensureDir();
29
+ const existing = await readConfig();
30
+ const next = { ...existing, ...patch };
31
+ await writeFile(configPath(), JSON.stringify(next, null, 2), "utf8");
32
+ }
@@ -0,0 +1,142 @@
1
+ export type EmbedProvider =
2
+ | "youtube"
3
+ | "vimeo"
4
+ | "loom"
5
+ | "twitter"
6
+ | "facebook"
7
+ | "instagram"
8
+ | "tiktok"
9
+ | "spotify"
10
+ | "video"
11
+ | "iframe";
12
+
13
+ export interface DetectedEmbed {
14
+ provider: EmbedProvider;
15
+ embedUrl: string;
16
+ originalUrl: string;
17
+ /** Aspect ratio as width/height, e.g. 16/9 = 1.777… */
18
+ aspectRatio?: number;
19
+ }
20
+
21
+ const YT_ID =
22
+ /(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/;
23
+ const VIMEO_ID = /vimeo\.com\/(?:video\/)?(\d+)/;
24
+ const LOOM_ID = /loom\.com\/share\/([A-Za-z0-9]+)/;
25
+ const TWITTER = /^https?:\/\/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/;
26
+ const FACEBOOK = /^https?:\/\/(?:www\.)?facebook\.com\/.+/;
27
+ const INSTAGRAM =
28
+ /^https?:\/\/(?:www\.)?instagram\.com\/(?:p|reel|tv)\/([^/?#]+)/;
29
+ const TIKTOK = /^https?:\/\/(?:www\.)?tiktok\.com\/@[^/]+\/video\/(\d+)/;
30
+ const SPOTIFY =
31
+ /^https?:\/\/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/;
32
+ const VIDEO_FILE = /\.(mp4|webm|ogg|mov|m4v)(\?|$)/i;
33
+
34
+ export function detectEmbed(raw: string): DetectedEmbed | null {
35
+ const url = raw.trim();
36
+ if (!url) return null;
37
+
38
+ const yt = url.match(YT_ID);
39
+ if (yt) {
40
+ return {
41
+ provider: "youtube",
42
+ embedUrl: `https://www.youtube.com/embed/${yt[1]}`,
43
+ originalUrl: url,
44
+ aspectRatio: 16 / 9,
45
+ };
46
+ }
47
+
48
+ const vi = url.match(VIMEO_ID);
49
+ if (vi) {
50
+ return {
51
+ provider: "vimeo",
52
+ embedUrl: `https://player.vimeo.com/video/${vi[1]}`,
53
+ originalUrl: url,
54
+ aspectRatio: 16 / 9,
55
+ };
56
+ }
57
+
58
+ const lo = url.match(LOOM_ID);
59
+ if (lo) {
60
+ return {
61
+ provider: "loom",
62
+ embedUrl: `https://www.loom.com/embed/${lo[1]}`,
63
+ originalUrl: url,
64
+ aspectRatio: 16 / 9,
65
+ };
66
+ }
67
+
68
+ if (TWITTER.test(url)) {
69
+ return { provider: "twitter", embedUrl: url, originalUrl: url };
70
+ }
71
+
72
+ if (INSTAGRAM.test(url)) {
73
+ return {
74
+ provider: "instagram",
75
+ embedUrl: url.replace(/\/?(\?.*)?$/, "/embed"),
76
+ originalUrl: url,
77
+ };
78
+ }
79
+
80
+ const tt = url.match(TIKTOK);
81
+ if (tt) {
82
+ return {
83
+ provider: "tiktok",
84
+ embedUrl: `https://www.tiktok.com/embed/v2/${tt[1]}`,
85
+ originalUrl: url,
86
+ aspectRatio: 9 / 16,
87
+ };
88
+ }
89
+
90
+ const sp = url.match(SPOTIFY);
91
+ if (sp) {
92
+ return {
93
+ provider: "spotify",
94
+ embedUrl: `https://open.spotify.com/embed/${sp[1]}/${sp[2]}`,
95
+ originalUrl: url,
96
+ };
97
+ }
98
+
99
+ if (FACEBOOK.test(url)) {
100
+ return {
101
+ provider: "facebook",
102
+ embedUrl: `https://www.facebook.com/plugins/post.php?href=${encodeURIComponent(url)}&show_text=true`,
103
+ originalUrl: url,
104
+ };
105
+ }
106
+
107
+ if (VIDEO_FILE.test(url) || url.startsWith("/api/assets/")) {
108
+ return { provider: "video", embedUrl: url, originalUrl: url };
109
+ }
110
+
111
+ // Fallback: any http(s) URL can be embedded as a generic iframe.
112
+ if (/^https?:\/\//.test(url)) {
113
+ return { provider: "iframe", embedUrl: url, originalUrl: url };
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ export function providerLabel(p: EmbedProvider): string {
120
+ switch (p) {
121
+ case "youtube":
122
+ return "YouTube";
123
+ case "vimeo":
124
+ return "Vimeo";
125
+ case "loom":
126
+ return "Loom";
127
+ case "twitter":
128
+ return "X / Twitter";
129
+ case "facebook":
130
+ return "Facebook";
131
+ case "instagram":
132
+ return "Instagram";
133
+ case "tiktok":
134
+ return "TikTok";
135
+ case "spotify":
136
+ return "Spotify";
137
+ case "video":
138
+ return "Video file";
139
+ case "iframe":
140
+ return "Web page";
141
+ }
142
+ }