mosage 0.1.0 → 0.8.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.
- package/LICENSE +1 -0
- package/README.md +16 -217
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/init-Bbtj2pxF.js +262 -0
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +84 -64
- package/skills/apply-comments/SKILL.md +43 -56
- package/skills/create-doc/SKILL.md +106 -0
- package/skills/create-theme/SKILL.md +184 -0
- package/skills/current-doc/SKILL.md +120 -0
- package/skills/doc-authoring/SKILL.md +434 -0
- package/skills/doc-authoring/references/assets.md +47 -0
- package/skills/doc-authoring/references/design-system.md +81 -0
- package/skills/doc-authoring/references/long-form.md +131 -0
- package/skills/doc-authoring/references/pagination.md +118 -0
- package/skills/doc-authoring/references/tables-and-charts.md +161 -0
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +27 -0
- package/template/README.md +39 -0
- package/template/docs/getting-started/index.tsx +230 -0
- package/template/mosage.config.ts +5 -0
- package/template/package.json +24 -0
- package/template/tsconfig.json +17 -0
- package/README.en.md +0 -57
- package/dist/cli.js +0 -4545
- package/dist/web/assets/index-Czg2WeHe.js +0 -182
- package/dist/web/assets/index-DKAyt92W.css +0 -1
- package/dist/web/index.html +0 -15
- package/skills/current-position/SKILL.md +0 -65
- package/skills/kickoff/SKILL.md +0 -105
- package/skills/mosage-reference/SKILL.md +0 -164
- package/skills/outline/SKILL.md +0 -79
- package/skills/review/SKILL.md +0 -64
- package/skills/write-chapter/SKILL.md +0 -58
- package/template/book/STYLE.md +0 -6
- package/template/book/assets/.gitkeep +0 -0
- package/template/book/book.yaml +0 -42
- package/template/book/brief.md +0 -6
- package/template/book/chapters/.gitkeep +0 -0
- package/template/book/notes/README.md +0 -7
- package/template/project/AGENTS.md +0 -82
- package/template/project/CLAUDE.md +0 -1
- package/template/project/README.md +0 -48
- package/template/project/books/.gitkeep +0 -0
- package/template/project/gitignore +0 -5
- package/template/project/mosage.yaml +0 -23
- package/template/project/notes/README.md +0 -8
- package/template/project/package.json +0 -14
|
@@ -0,0 +1,2619 @@
|
|
|
1
|
+
import { a as defaultDesign, n as DiagramSyntaxError, r as parseDelimited, t as compileDiagram } from "./diagram-xlVDekYk.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
7
|
+
import react from "@vitejs/plugin-react";
|
|
8
|
+
import fg from "fast-glob";
|
|
9
|
+
import { loadConfigFromFile, normalizePath } from "vite";
|
|
10
|
+
import { parse } from "@babel/parser";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
//#region src/vite/mosage-plugin.ts
|
|
13
|
+
const DOC_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
14
|
+
const CONFIG_FILE = "mosage.config.ts";
|
|
15
|
+
const DOCS_VMOD = "virtual:mosage/docs";
|
|
16
|
+
const CONFIG_VMOD = "virtual:mosage/config";
|
|
17
|
+
const FOLDERS_VMOD = "virtual:mosage/folders";
|
|
18
|
+
async function readFoldersManifest(file) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
21
|
+
return {
|
|
22
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
23
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
24
|
+
};
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.code === "ENOENT") return {
|
|
27
|
+
folders: [],
|
|
28
|
+
assignments: {}
|
|
29
|
+
};
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function resolved$1(id) {
|
|
34
|
+
return `\0${id}`;
|
|
35
|
+
}
|
|
36
|
+
async function findDocs(userCwd, docsDir) {
|
|
37
|
+
const abs = path.resolve(userCwd, docsDir);
|
|
38
|
+
if (!existsSync(abs)) return [];
|
|
39
|
+
return (await fg("*/index.{tsx,jsx,ts,js}", {
|
|
40
|
+
cwd: abs,
|
|
41
|
+
absolute: true,
|
|
42
|
+
onlyFiles: true
|
|
43
|
+
})).sort();
|
|
44
|
+
}
|
|
45
|
+
function toId(absFile, docsRoot) {
|
|
46
|
+
return path.relative(docsRoot, absFile).split(path.sep)[0];
|
|
47
|
+
}
|
|
48
|
+
const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
|
|
49
|
+
const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
|
|
50
|
+
/**
|
|
51
|
+
* Reads `meta` with a brace-matched regex instead of parsing: the plugin runs on
|
|
52
|
+
* every discovery pass, and the two fields it needs (sort order, theme back-link)
|
|
53
|
+
* are contractually string literals.
|
|
54
|
+
*/
|
|
55
|
+
function extractMeta(src) {
|
|
56
|
+
const empty = {
|
|
57
|
+
theme: null,
|
|
58
|
+
createdAt: null
|
|
59
|
+
};
|
|
60
|
+
const metaStart = src.search(/export\s+const\s+meta\b/);
|
|
61
|
+
if (metaStart === -1) return empty;
|
|
62
|
+
const openBrace = src.indexOf("{", src.indexOf("=", metaStart));
|
|
63
|
+
if (openBrace === -1) return empty;
|
|
64
|
+
let depth = 0;
|
|
65
|
+
let closeBrace = -1;
|
|
66
|
+
for (let i = openBrace; i < src.length; i++) {
|
|
67
|
+
const ch = src[i];
|
|
68
|
+
if (ch === "{") depth++;
|
|
69
|
+
else if (ch === "}") {
|
|
70
|
+
depth--;
|
|
71
|
+
if (depth === 0) {
|
|
72
|
+
closeBrace = i;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (closeBrace === -1) return empty;
|
|
78
|
+
const body = src.slice(openBrace + 1, closeBrace);
|
|
79
|
+
return {
|
|
80
|
+
theme: body.match(META_THEME_RE)?.[1] ?? null,
|
|
81
|
+
createdAt: body.match(META_CREATED_AT_RE)?.[1] ?? null
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function parseCreatedAtMs(iso) {
|
|
85
|
+
if (!iso) return null;
|
|
86
|
+
const ms = Date.parse(iso);
|
|
87
|
+
return Number.isFinite(ms) ? ms : null;
|
|
88
|
+
}
|
|
89
|
+
async function readMeta(abs) {
|
|
90
|
+
try {
|
|
91
|
+
return extractMeta(await fs.readFile(abs, "utf8"));
|
|
92
|
+
} catch {
|
|
93
|
+
return {
|
|
94
|
+
theme: null,
|
|
95
|
+
createdAt: null
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const warnedInvalidDocIds = /* @__PURE__ */ new Set();
|
|
100
|
+
async function generateDocsModule(files, docsRoot, isDev) {
|
|
101
|
+
const scanned = await Promise.all(files.map(async (abs) => {
|
|
102
|
+
const meta = await readMeta(abs);
|
|
103
|
+
return {
|
|
104
|
+
id: toId(abs, docsRoot),
|
|
105
|
+
importPath: isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs,
|
|
106
|
+
theme: meta.theme,
|
|
107
|
+
createdAt: parseCreatedAtMs(meta.createdAt)
|
|
108
|
+
};
|
|
109
|
+
}));
|
|
110
|
+
const entries = scanned.filter((e) => DOC_ID_RE.test(e.id));
|
|
111
|
+
const ignored = scanned.filter((e) => !DOC_ID_RE.test(e.id)).map((e) => e.id);
|
|
112
|
+
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
113
|
+
const createdAtMap = {};
|
|
114
|
+
const themesMap = {};
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
|
|
117
|
+
if (e.theme) themesMap[e.id] = e.theme;
|
|
118
|
+
}
|
|
119
|
+
const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
|
|
120
|
+
const devRuntime = isDev ? `
|
|
121
|
+
const docImportTokens = ${importTokens};
|
|
122
|
+
if (import.meta.hot) {
|
|
123
|
+
import.meta.hot.on('mosage:doc-changed', (data) => {
|
|
124
|
+
const ids = Array.isArray(data?.docIds) ? data.docIds : data?.docId ? [data.docId] : [];
|
|
125
|
+
const token = Date.now();
|
|
126
|
+
for (const id of ids) {
|
|
127
|
+
if (Object.prototype.hasOwnProperty.call(docImportTokens, id)) docImportTokens[id] = token;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
` : "";
|
|
132
|
+
const cases = entries.map((e) => {
|
|
133
|
+
const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(`${e.importPath}?t=`)} + docImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
|
|
134
|
+
return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
|
|
135
|
+
}).join("\n");
|
|
136
|
+
return {
|
|
137
|
+
code: `// virtual:mosage/docs — generated
|
|
138
|
+
export const docIds = ${ids};
|
|
139
|
+
export const docCreatedAt = ${JSON.stringify(createdAtMap)};
|
|
140
|
+
export const docThemes = ${JSON.stringify(themesMap)};
|
|
141
|
+
${devRuntime}
|
|
142
|
+
|
|
143
|
+
export async function loadDoc(id) {
|
|
144
|
+
switch (id) {
|
|
145
|
+
${cases}
|
|
146
|
+
default: throw new Error('Document not found: ' + id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
`,
|
|
150
|
+
ignored
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function mosagePlugin(opts) {
|
|
154
|
+
const { userCwd, config, coreVersion } = opts;
|
|
155
|
+
const docsDir = config.docsDir ?? "docs";
|
|
156
|
+
const docsRoot = path.resolve(userCwd, docsDir);
|
|
157
|
+
const foldersManifestPath = path.join(docsRoot, ".folders.json");
|
|
158
|
+
let isDev = false;
|
|
159
|
+
const docIdForEntry = (p) => {
|
|
160
|
+
const rel = path.relative(docsRoot, p);
|
|
161
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
162
|
+
const parts = rel.split(path.sep);
|
|
163
|
+
if (parts.length !== 2) return null;
|
|
164
|
+
if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
|
|
165
|
+
return parts[0];
|
|
166
|
+
};
|
|
167
|
+
let docChangeTimer = null;
|
|
168
|
+
const pendingDocChanges = /* @__PURE__ */ new Set();
|
|
169
|
+
const queueDocChanged = (server, id) => {
|
|
170
|
+
pendingDocChanges.add(id);
|
|
171
|
+
if (docChangeTimer) clearTimeout(docChangeTimer);
|
|
172
|
+
docChangeTimer = setTimeout(() => {
|
|
173
|
+
docChangeTimer = null;
|
|
174
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(DOCS_VMOD));
|
|
175
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
176
|
+
const docIds = Array.from(pendingDocChanges);
|
|
177
|
+
pendingDocChanges.clear();
|
|
178
|
+
server.ws.send({
|
|
179
|
+
type: "custom",
|
|
180
|
+
event: "mosage:doc-changed",
|
|
181
|
+
data: { docIds }
|
|
182
|
+
});
|
|
183
|
+
}, 100);
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
name: "mosage",
|
|
187
|
+
config(_c, env) {
|
|
188
|
+
isDev = env.command === "serve";
|
|
189
|
+
return { server: { fs: { allow: [userCwd] } } };
|
|
190
|
+
},
|
|
191
|
+
resolveId(id) {
|
|
192
|
+
if (id === DOCS_VMOD) return resolved$1(DOCS_VMOD);
|
|
193
|
+
if (id === CONFIG_VMOD) return resolved$1(CONFIG_VMOD);
|
|
194
|
+
if (id === FOLDERS_VMOD) return resolved$1(FOLDERS_VMOD);
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
async load(id) {
|
|
198
|
+
if (id === resolved$1(DOCS_VMOD)) {
|
|
199
|
+
const { code, ignored } = await generateDocsModule(await findDocs(userCwd, docsDir), docsRoot, isDev);
|
|
200
|
+
for (const docId of ignored) {
|
|
201
|
+
if (warnedInvalidDocIds.has(docId)) continue;
|
|
202
|
+
warnedInvalidDocIds.add(docId);
|
|
203
|
+
this.warn(`Ignoring document folder "${docId}": ids must match ${DOC_ID_RE} (letters, digits, "-", "_"). Rename it under "${docsDir}/" to a kebab-case id so it shows up.`);
|
|
204
|
+
}
|
|
205
|
+
return code;
|
|
206
|
+
}
|
|
207
|
+
if (id === resolved$1(CONFIG_VMOD)) {
|
|
208
|
+
const userBuild = config.build ?? {};
|
|
209
|
+
const build = isDev ? {
|
|
210
|
+
showDocBrowser: true,
|
|
211
|
+
allowHtmlExport: true
|
|
212
|
+
} : {
|
|
213
|
+
showDocBrowser: userBuild.showDocBrowser ?? true,
|
|
214
|
+
allowHtmlExport: userBuild.allowHtmlExport ?? true
|
|
215
|
+
};
|
|
216
|
+
return `export default ${JSON.stringify({
|
|
217
|
+
...config,
|
|
218
|
+
build,
|
|
219
|
+
version: coreVersion
|
|
220
|
+
})};\n`;
|
|
221
|
+
}
|
|
222
|
+
if (id === resolved$1(FOLDERS_VMOD)) {
|
|
223
|
+
const manifest = await readFoldersManifest(foldersManifestPath);
|
|
224
|
+
return `export default ${JSON.stringify(manifest)};\n`;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
},
|
|
228
|
+
handleHotUpdate(ctx) {
|
|
229
|
+
const docId = docIdForEntry(ctx.file);
|
|
230
|
+
if (!docId) return;
|
|
231
|
+
queueDocChanged(ctx.server, docId);
|
|
232
|
+
return [];
|
|
233
|
+
},
|
|
234
|
+
configureServer(server) {
|
|
235
|
+
const isDocEntry = (p) => docIdForEntry(p) !== null;
|
|
236
|
+
let reloadTimer = null;
|
|
237
|
+
const reload = () => {
|
|
238
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
239
|
+
reloadTimer = setTimeout(() => {
|
|
240
|
+
reloadTimer = null;
|
|
241
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(DOCS_VMOD));
|
|
242
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
243
|
+
server.ws.send({ type: "full-reload" });
|
|
244
|
+
}, 150);
|
|
245
|
+
};
|
|
246
|
+
server.watcher.add(docsRoot);
|
|
247
|
+
server.watcher.on("add", (p) => {
|
|
248
|
+
if (isDocEntry(p)) reload();
|
|
249
|
+
});
|
|
250
|
+
server.watcher.on("unlink", (p) => {
|
|
251
|
+
if (isDocEntry(p)) reload();
|
|
252
|
+
});
|
|
253
|
+
server.watcher.on("unlinkDir", (p) => {
|
|
254
|
+
if (path.dirname(p) === docsRoot) reload();
|
|
255
|
+
});
|
|
256
|
+
let foldersTimer = null;
|
|
257
|
+
const foldersChanged = () => {
|
|
258
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
259
|
+
foldersTimer = setTimeout(() => {
|
|
260
|
+
foldersTimer = null;
|
|
261
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(FOLDERS_VMOD));
|
|
262
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
263
|
+
server.ws.send({
|
|
264
|
+
type: "custom",
|
|
265
|
+
event: "mosage:files-changed",
|
|
266
|
+
data: {}
|
|
267
|
+
});
|
|
268
|
+
}, 100);
|
|
269
|
+
};
|
|
270
|
+
server.watcher.add(foldersManifestPath);
|
|
271
|
+
for (const event of [
|
|
272
|
+
"add",
|
|
273
|
+
"change",
|
|
274
|
+
"unlink"
|
|
275
|
+
]) server.watcher.on(event, (p) => {
|
|
276
|
+
if (p === foldersManifestPath) foldersChanged();
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async function loadUserConfig(userCwd) {
|
|
282
|
+
const file = path.join(userCwd, CONFIG_FILE);
|
|
283
|
+
if (!existsSync(file)) return {};
|
|
284
|
+
return (await loadConfigFromFile({
|
|
285
|
+
command: "serve",
|
|
286
|
+
mode: "development"
|
|
287
|
+
}, file, userCwd, "silent"))?.config ?? {};
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/files/assets.ts
|
|
291
|
+
const GLOBAL_SCOPE = "@global";
|
|
292
|
+
const ASSET_FORBIDDEN_RE = /[\x00-\x1F\x7F/\\:*?"<>|]/;
|
|
293
|
+
const MIME_BY_EXT = {
|
|
294
|
+
png: "image/png",
|
|
295
|
+
jpg: "image/jpeg",
|
|
296
|
+
jpeg: "image/jpeg",
|
|
297
|
+
gif: "image/gif",
|
|
298
|
+
svg: "image/svg+xml",
|
|
299
|
+
webp: "image/webp",
|
|
300
|
+
avif: "image/avif",
|
|
301
|
+
ico: "image/x-icon",
|
|
302
|
+
pdf: "application/pdf",
|
|
303
|
+
woff: "font/woff",
|
|
304
|
+
woff2: "font/woff2",
|
|
305
|
+
ttf: "font/ttf",
|
|
306
|
+
otf: "font/otf",
|
|
307
|
+
csv: "text/csv; charset=utf-8",
|
|
308
|
+
json: "application/json",
|
|
309
|
+
txt: "text/plain; charset=utf-8",
|
|
310
|
+
md: "text/markdown; charset=utf-8"
|
|
311
|
+
};
|
|
312
|
+
function mimeForFilename(name) {
|
|
313
|
+
const dot = name.lastIndexOf(".");
|
|
314
|
+
if (dot < 0) return "application/octet-stream";
|
|
315
|
+
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
|
|
316
|
+
}
|
|
317
|
+
function assetCreatedAt(birthtimeMs, mtimeMs) {
|
|
318
|
+
return Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : mtimeMs;
|
|
319
|
+
}
|
|
320
|
+
function validateAssetName(v) {
|
|
321
|
+
if (typeof v !== "string") return null;
|
|
322
|
+
const trimmed = v.trim();
|
|
323
|
+
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
324
|
+
if (ASSET_FORBIDDEN_RE.test(trimmed)) return null;
|
|
325
|
+
if (trimmed.startsWith(".") || trimmed.startsWith("~")) return null;
|
|
326
|
+
if (trimmed === ".." || trimmed.split(/[/\\]/).includes("..")) return null;
|
|
327
|
+
const dot = trimmed.lastIndexOf(".");
|
|
328
|
+
if (dot <= 0 || dot === trimmed.length - 1) return null;
|
|
329
|
+
return trimmed;
|
|
330
|
+
}
|
|
331
|
+
function resolveAssetsDir(docsRoot, docId) {
|
|
332
|
+
if (!DOC_ID_RE.test(docId)) return null;
|
|
333
|
+
const docDir = path.resolve(docsRoot, docId);
|
|
334
|
+
if (!docDir.startsWith(docsRoot + path.sep)) return null;
|
|
335
|
+
const assetsDir = path.resolve(docDir, "assets");
|
|
336
|
+
if (assetsDir !== path.join(docDir, "assets")) return null;
|
|
337
|
+
return assetsDir;
|
|
338
|
+
}
|
|
339
|
+
function resolveScopedAssetsDir(docsRoot, globalAssetsRoot, scope) {
|
|
340
|
+
if (scope === "@global") return globalAssetsRoot;
|
|
341
|
+
return resolveAssetsDir(docsRoot, scope);
|
|
342
|
+
}
|
|
343
|
+
function resolveScopedAssetFile(docsRoot, globalAssetsRoot, scope, filename) {
|
|
344
|
+
if (!validateAssetName(filename)) return null;
|
|
345
|
+
const dir = resolveScopedAssetsDir(docsRoot, globalAssetsRoot, scope);
|
|
346
|
+
if (!dir) return null;
|
|
347
|
+
const file = path.resolve(dir, filename);
|
|
348
|
+
if (!file.startsWith(dir + path.sep)) return null;
|
|
349
|
+
return file;
|
|
350
|
+
}
|
|
351
|
+
/** How a document imports an asset — the string we look for when counting usages. */
|
|
352
|
+
function assetImportPath(scope, filename) {
|
|
353
|
+
return scope === "@global" ? `@assets/${filename}` : `./assets/${filename}`;
|
|
354
|
+
}
|
|
355
|
+
function escapeRegExp(value) {
|
|
356
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Counts references to an asset in a document's source. Matches the quoted path
|
|
360
|
+
* in both import statements and `new URL('./assets/x.png', import.meta.url)`,
|
|
361
|
+
* which is every form the authoring skill sanctions.
|
|
362
|
+
*/
|
|
363
|
+
function countAssetUsages(source, importPath) {
|
|
364
|
+
const quoted = new RegExp(`(['"\`])${escapeRegExp(importPath)}\\1`, "g");
|
|
365
|
+
return source.match(quoted)?.length ?? 0;
|
|
366
|
+
}
|
|
367
|
+
function findReferencedAssets(source, importPaths) {
|
|
368
|
+
return importPaths.filter((p) => countAssetUsages(source, p) > 0);
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/http/request-guard.ts
|
|
372
|
+
function firstHeaderValue(value) {
|
|
373
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
374
|
+
return value ?? null;
|
|
375
|
+
}
|
|
376
|
+
function headerValue(req, name) {
|
|
377
|
+
return firstHeaderValue(req.headers[name.toLowerCase()])?.trim() ?? null;
|
|
378
|
+
}
|
|
379
|
+
function firstCommaToken(value) {
|
|
380
|
+
if (!value) return null;
|
|
381
|
+
const [first] = value.split(",", 1);
|
|
382
|
+
return first?.trim() || null;
|
|
383
|
+
}
|
|
384
|
+
function requestProto(req) {
|
|
385
|
+
const forwarded = firstCommaToken(headerValue(req, "x-forwarded-proto"))?.toLowerCase();
|
|
386
|
+
if (forwarded === "http" || forwarded === "https") return forwarded;
|
|
387
|
+
return "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
|
|
388
|
+
}
|
|
389
|
+
function normalizedOrigin(origin) {
|
|
390
|
+
try {
|
|
391
|
+
const url = new URL(origin);
|
|
392
|
+
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The dev server writes to the user's filesystem, so every mutating endpoint is
|
|
399
|
+
* guarded against a page on another origin driving it through the browser.
|
|
400
|
+
*/
|
|
401
|
+
function validateMutationRequest(req, opts = {}) {
|
|
402
|
+
if (opts.requireJsonBody) {
|
|
403
|
+
if (!(headerValue(req, "content-type")?.toLowerCase())?.startsWith("application/json")) return {
|
|
404
|
+
ok: false,
|
|
405
|
+
status: 415,
|
|
406
|
+
error: "content-type must be application/json"
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (firstCommaToken(headerValue(req, "sec-fetch-site"))?.toLowerCase() === "cross-site") return {
|
|
410
|
+
ok: false,
|
|
411
|
+
status: 403,
|
|
412
|
+
error: "cross-site request blocked"
|
|
413
|
+
};
|
|
414
|
+
const originRaw = headerValue(req, "origin");
|
|
415
|
+
if (!originRaw) return { ok: true };
|
|
416
|
+
if (originRaw.toLowerCase() === "null") return {
|
|
417
|
+
ok: false,
|
|
418
|
+
status: 403,
|
|
419
|
+
error: "opaque origin is not allowed"
|
|
420
|
+
};
|
|
421
|
+
const actualOrigin = normalizedOrigin(originRaw);
|
|
422
|
+
if (!actualOrigin) return {
|
|
423
|
+
ok: false,
|
|
424
|
+
status: 403,
|
|
425
|
+
error: "invalid origin header"
|
|
426
|
+
};
|
|
427
|
+
const host = firstCommaToken(headerValue(req, "x-forwarded-host")) ?? headerValue(req, "host");
|
|
428
|
+
if (!host) return {
|
|
429
|
+
ok: false,
|
|
430
|
+
status: 400,
|
|
431
|
+
error: "missing host header"
|
|
432
|
+
};
|
|
433
|
+
if (actualOrigin !== `${requestProto(req)}://${host}`.toLowerCase()) return {
|
|
434
|
+
ok: false,
|
|
435
|
+
status: 403,
|
|
436
|
+
error: "origin mismatch"
|
|
437
|
+
};
|
|
438
|
+
return { ok: true };
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/vite/routes/context.ts
|
|
442
|
+
function makeContext(opts) {
|
|
443
|
+
const userCwd = opts.userCwd;
|
|
444
|
+
const docsDir = opts.docsDir ?? "docs";
|
|
445
|
+
const assetsDir = opts.assetsDir ?? "assets";
|
|
446
|
+
const docsRoot = path.resolve(userCwd, docsDir);
|
|
447
|
+
return {
|
|
448
|
+
userCwd,
|
|
449
|
+
docsDir,
|
|
450
|
+
docsRoot,
|
|
451
|
+
globalAssetsRoot: path.resolve(userCwd, assetsDir),
|
|
452
|
+
manifestPath: path.join(docsRoot, ".folders.json"),
|
|
453
|
+
coreVersion: opts.coreVersion,
|
|
454
|
+
...opts.serverOrigin !== void 0 ? { serverOrigin: opts.serverOrigin } : {}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async function readBody(req) {
|
|
458
|
+
return await new Promise((resolve, reject) => {
|
|
459
|
+
const chunks = [];
|
|
460
|
+
req.on("data", (c) => chunks.push(c));
|
|
461
|
+
req.on("end", () => {
|
|
462
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
463
|
+
if (!raw) return resolve({});
|
|
464
|
+
try {
|
|
465
|
+
resolve(JSON.parse(raw));
|
|
466
|
+
} catch (e) {
|
|
467
|
+
reject(e);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
req.on("error", reject);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function json(res, status, body) {
|
|
474
|
+
res.statusCode = status;
|
|
475
|
+
res.setHeader("content-type", "application/json");
|
|
476
|
+
res.end(JSON.stringify(body));
|
|
477
|
+
}
|
|
478
|
+
const ENTRY_NAMES$1 = [
|
|
479
|
+
"index.tsx",
|
|
480
|
+
"index.jsx",
|
|
481
|
+
"index.ts",
|
|
482
|
+
"index.js"
|
|
483
|
+
];
|
|
484
|
+
/** Absolute path of a document's entry file, or null when the id is unusable. */
|
|
485
|
+
function resolveDocPath(userCwd, docsDir, docId) {
|
|
486
|
+
if (!DOC_ID_RE.test(docId)) return null;
|
|
487
|
+
const docsRoot = path.resolve(userCwd, docsDir);
|
|
488
|
+
const full = path.resolve(docsRoot, docId, ENTRY_NAMES$1[0]);
|
|
489
|
+
if (!full.startsWith(docsRoot + path.sep)) return null;
|
|
490
|
+
return full;
|
|
491
|
+
}
|
|
492
|
+
/** The entry file that actually exists on disk for a document, or null. */
|
|
493
|
+
function resolveDocEntry(docsRoot, docId) {
|
|
494
|
+
if (!DOC_ID_RE.test(docId)) return null;
|
|
495
|
+
const docDir = path.resolve(docsRoot, docId);
|
|
496
|
+
if (!docDir.startsWith(docsRoot + path.sep)) return null;
|
|
497
|
+
for (const name of ENTRY_NAMES$1) {
|
|
498
|
+
const candidate = path.join(docDir, name);
|
|
499
|
+
if (existsSync(candidate)) return candidate;
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/vite/routes/assets.ts
|
|
505
|
+
async function listDocIds$1(docsRoot) {
|
|
506
|
+
try {
|
|
507
|
+
return (await fs.readdir(docsRoot, { withFileTypes: true })).filter((e) => e.isDirectory() && DOC_ID_RE.test(e.name)).map((e) => e.name);
|
|
508
|
+
} catch {
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function readDocSource(docsRoot, docId) {
|
|
513
|
+
const entry = resolveDocEntry(docsRoot, docId);
|
|
514
|
+
if (!entry) return null;
|
|
515
|
+
try {
|
|
516
|
+
return await fs.readFile(entry, "utf8");
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function registerAssetRoutes(server, ctx) {
|
|
522
|
+
server.middlewares.use("/__assets", async (req, res, next) => {
|
|
523
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
524
|
+
const method = req.method ?? "GET";
|
|
525
|
+
try {
|
|
526
|
+
const listMatch = url.pathname.match(/^\/([^/]+)\/?$/);
|
|
527
|
+
const fileMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
|
|
528
|
+
const usagesMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/usages$/);
|
|
529
|
+
if (usagesMatch && method === "GET") {
|
|
530
|
+
const scope = decodeURIComponent(usagesMatch[1]);
|
|
531
|
+
const filename = decodeURIComponent(usagesMatch[2]);
|
|
532
|
+
if (!validateAssetName(filename)) return json(res, 400, { error: "invalid path" });
|
|
533
|
+
const isGlobal = scope === GLOBAL_SCOPE;
|
|
534
|
+
if (!isGlobal && !DOC_ID_RE.test(scope)) return json(res, 400, { error: "invalid scope" });
|
|
535
|
+
const importPath = assetImportPath(scope, filename);
|
|
536
|
+
const docIds = isGlobal ? await listDocIds$1(ctx.docsRoot) : [scope];
|
|
537
|
+
const usages = [];
|
|
538
|
+
let totalCount = 0;
|
|
539
|
+
for (const docId of docIds) {
|
|
540
|
+
const source = await readDocSource(ctx.docsRoot, docId);
|
|
541
|
+
if (source === null) continue;
|
|
542
|
+
const count = countAssetUsages(source, importPath);
|
|
543
|
+
if (count > 0) {
|
|
544
|
+
usages.push({
|
|
545
|
+
docId,
|
|
546
|
+
count
|
|
547
|
+
});
|
|
548
|
+
totalCount += count;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return json(res, 200, {
|
|
552
|
+
usages,
|
|
553
|
+
totalCount
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (listMatch && method === "GET") {
|
|
557
|
+
const scope = decodeURIComponent(listMatch[1]);
|
|
558
|
+
const scopedDir = resolveScopedAssetsDir(ctx.docsRoot, ctx.globalAssetsRoot, scope);
|
|
559
|
+
if (!scopedDir) return json(res, 400, { error: "invalid scope" });
|
|
560
|
+
let entries;
|
|
561
|
+
try {
|
|
562
|
+
entries = await fs.readdir(scopedDir);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (err.code === "ENOENT") return json(res, 200, { assets: [] });
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
const assets = [];
|
|
568
|
+
for (const name of entries) {
|
|
569
|
+
if (!validateAssetName(name)) continue;
|
|
570
|
+
const stat = await fs.stat(path.join(scopedDir, name));
|
|
571
|
+
if (!stat.isFile()) continue;
|
|
572
|
+
assets.push({
|
|
573
|
+
name,
|
|
574
|
+
size: stat.size,
|
|
575
|
+
createdAt: assetCreatedAt(stat.birthtimeMs, stat.mtimeMs),
|
|
576
|
+
mtime: stat.mtimeMs,
|
|
577
|
+
mime: mimeForFilename(name),
|
|
578
|
+
url: `/__assets/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`,
|
|
579
|
+
importPath: assetImportPath(scope, name),
|
|
580
|
+
unused: true
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
584
|
+
if (assets.length > 0) {
|
|
585
|
+
const scanIds = scope === "@global" ? await listDocIds$1(ctx.docsRoot) : [scope];
|
|
586
|
+
const pathToAsset = new Map(assets.map((a) => [a.importPath, a]));
|
|
587
|
+
const paths = assets.map((a) => a.importPath);
|
|
588
|
+
for (const docId of scanIds) {
|
|
589
|
+
const source = await readDocSource(ctx.docsRoot, docId);
|
|
590
|
+
if (source === null) continue;
|
|
591
|
+
for (const p of findReferencedAssets(source, paths)) {
|
|
592
|
+
const asset = pathToAsset.get(p);
|
|
593
|
+
if (asset) asset.unused = false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return json(res, 200, { assets });
|
|
598
|
+
}
|
|
599
|
+
if (fileMatch) {
|
|
600
|
+
const scope = decodeURIComponent(fileMatch[1]);
|
|
601
|
+
const filename = decodeURIComponent(fileMatch[2]);
|
|
602
|
+
const file = resolveScopedAssetFile(ctx.docsRoot, ctx.globalAssetsRoot, scope, filename);
|
|
603
|
+
if (!file) return json(res, 400, { error: "invalid path" });
|
|
604
|
+
if (method === "GET") try {
|
|
605
|
+
const buf = await fs.readFile(file);
|
|
606
|
+
res.statusCode = 200;
|
|
607
|
+
res.setHeader("content-type", mimeForFilename(filename));
|
|
608
|
+
res.setHeader("cache-control", "no-store");
|
|
609
|
+
res.end(buf);
|
|
610
|
+
return;
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
613
|
+
throw err;
|
|
614
|
+
}
|
|
615
|
+
if (method === "POST") {
|
|
616
|
+
const requestCheck = validateMutationRequest(req);
|
|
617
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
618
|
+
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
619
|
+
const lenHeader = req.headers["content-length"];
|
|
620
|
+
const len = typeof lenHeader === "string" ? Number(lenHeader) : NaN;
|
|
621
|
+
if (Number.isFinite(len) && len > 26214400) return json(res, 413, { error: "file too large" });
|
|
622
|
+
if (!overwrite) try {
|
|
623
|
+
await fs.access(file);
|
|
624
|
+
return json(res, 409, { error: "asset exists" });
|
|
625
|
+
} catch {}
|
|
626
|
+
const scopedDir = resolveScopedAssetsDir(ctx.docsRoot, ctx.globalAssetsRoot, scope);
|
|
627
|
+
if (!scopedDir) return json(res, 400, { error: "invalid scope" });
|
|
628
|
+
await fs.mkdir(scopedDir, { recursive: true });
|
|
629
|
+
const chunks = [];
|
|
630
|
+
let total = 0;
|
|
631
|
+
let oversized = false;
|
|
632
|
+
await new Promise((resolve, reject) => {
|
|
633
|
+
req.on("data", (c) => {
|
|
634
|
+
total += c.length;
|
|
635
|
+
if (total > 26214400) {
|
|
636
|
+
oversized = true;
|
|
637
|
+
req.destroy();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
chunks.push(c);
|
|
641
|
+
});
|
|
642
|
+
req.on("end", () => resolve());
|
|
643
|
+
req.on("error", reject);
|
|
644
|
+
});
|
|
645
|
+
if (oversized) return json(res, 413, { error: "file too large" });
|
|
646
|
+
await fs.writeFile(file, Buffer.concat(chunks));
|
|
647
|
+
const stat = await fs.stat(file);
|
|
648
|
+
return json(res, 200, {
|
|
649
|
+
ok: true,
|
|
650
|
+
name: filename,
|
|
651
|
+
size: stat.size,
|
|
652
|
+
createdAt: assetCreatedAt(stat.birthtimeMs, stat.mtimeMs),
|
|
653
|
+
mtime: stat.mtimeMs,
|
|
654
|
+
mime: mimeForFilename(filename),
|
|
655
|
+
url: `/__assets/${encodeURIComponent(scope)}/${encodeURIComponent(filename)}`,
|
|
656
|
+
importPath: assetImportPath(scope, filename)
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (method === "PATCH") {
|
|
660
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
661
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
662
|
+
const target = validateAssetName((await readBody(req)).name);
|
|
663
|
+
if (!target) return json(res, 400, { error: "invalid name" });
|
|
664
|
+
if (target === filename) return json(res, 200, {
|
|
665
|
+
ok: true,
|
|
666
|
+
name: filename
|
|
667
|
+
});
|
|
668
|
+
const dest = resolveScopedAssetFile(ctx.docsRoot, ctx.globalAssetsRoot, scope, target);
|
|
669
|
+
if (!dest) return json(res, 400, { error: "invalid name" });
|
|
670
|
+
try {
|
|
671
|
+
await fs.access(dest);
|
|
672
|
+
return json(res, 409, { error: "target exists" });
|
|
673
|
+
} catch {}
|
|
674
|
+
try {
|
|
675
|
+
await fs.rename(file, dest);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
678
|
+
throw err;
|
|
679
|
+
}
|
|
680
|
+
return json(res, 200, {
|
|
681
|
+
ok: true,
|
|
682
|
+
name: target
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (method === "DELETE") {
|
|
686
|
+
const requestCheck = validateMutationRequest(req);
|
|
687
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
688
|
+
try {
|
|
689
|
+
await fs.unlink(file);
|
|
690
|
+
} catch (err) {
|
|
691
|
+
if (err.code === "ENOENT") return json(res, 404, { error: "asset not found" });
|
|
692
|
+
throw err;
|
|
693
|
+
}
|
|
694
|
+
return json(res, 200, { ok: true });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return next();
|
|
698
|
+
} catch (err) {
|
|
699
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/editing/doc-ops.ts
|
|
705
|
+
function parseSource$2(source) {
|
|
706
|
+
try {
|
|
707
|
+
return parse(source, {
|
|
708
|
+
sourceType: "module",
|
|
709
|
+
plugins: ["typescript", "jsx"],
|
|
710
|
+
errorRecovery: true
|
|
711
|
+
});
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function programBody$1(ast) {
|
|
717
|
+
return ast.program?.body ?? [];
|
|
718
|
+
}
|
|
719
|
+
function metaObjectOf(node) {
|
|
720
|
+
if (node.type !== "ExportNamedDeclaration") return null;
|
|
721
|
+
const decl = node.declaration;
|
|
722
|
+
if (decl?.type !== "VariableDeclaration") return null;
|
|
723
|
+
const declarations = decl.declarations ?? [];
|
|
724
|
+
for (const d of declarations) {
|
|
725
|
+
const id = d.id;
|
|
726
|
+
if (id?.type !== "Identifier" || id.name !== "meta") continue;
|
|
727
|
+
let init = d.init;
|
|
728
|
+
if (!init) return null;
|
|
729
|
+
if (init.type === "TSSatisfiesExpression" || init.type === "TSAsExpression") {
|
|
730
|
+
const expr = init.expression;
|
|
731
|
+
if (expr) init = expr;
|
|
732
|
+
}
|
|
733
|
+
return init.type === "ObjectExpression" ? init : null;
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
function findMetaObject(ast) {
|
|
738
|
+
for (const node of programBody$1(ast)) {
|
|
739
|
+
const object = metaObjectOf(node);
|
|
740
|
+
if (object) return object;
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
function jsString$1(value) {
|
|
745
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, " ")}'`;
|
|
746
|
+
}
|
|
747
|
+
function validateDocTitle(v) {
|
|
748
|
+
if (typeof v !== "string") return null;
|
|
749
|
+
const trimmed = v.trim();
|
|
750
|
+
if (trimmed.length < 1 || trimmed.length > 120) return null;
|
|
751
|
+
return trimmed;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Rewrites `meta.title` in place, adding the property when the meta object
|
|
755
|
+
* exists without one. Returns null when there is no meta object to edit —
|
|
756
|
+
* the caller reports that rather than inventing an export.
|
|
757
|
+
*/
|
|
758
|
+
function setMetaTitle(source, title) {
|
|
759
|
+
const ast = parseSource$2(source);
|
|
760
|
+
if (!ast) return null;
|
|
761
|
+
const object = findMetaObject(ast);
|
|
762
|
+
if (!object) return null;
|
|
763
|
+
const properties = object.properties ?? [];
|
|
764
|
+
for (const prop of properties) {
|
|
765
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
766
|
+
const p = prop;
|
|
767
|
+
if (p.computed) continue;
|
|
768
|
+
if ((p.key.type === "Identifier" ? p.key.name : p.key.value) !== "title") continue;
|
|
769
|
+
return source.slice(0, p.value.start) + jsString$1(title) + source.slice(p.value.end);
|
|
770
|
+
}
|
|
771
|
+
const open = object.start + 1;
|
|
772
|
+
const inserted = source.slice(open, object.end - 1).trim() === "" ? `\n title: ${jsString$1(title)},\n` : `\n title: ${jsString$1(title)},`;
|
|
773
|
+
return source.slice(0, open) + inserted + source.slice(open);
|
|
774
|
+
}
|
|
775
|
+
function readMetaTitle(source) {
|
|
776
|
+
const ast = parseSource$2(source);
|
|
777
|
+
if (!ast) return null;
|
|
778
|
+
const object = findMetaObject(ast);
|
|
779
|
+
if (!object) return null;
|
|
780
|
+
const properties = object.properties ?? [];
|
|
781
|
+
for (const prop of properties) {
|
|
782
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
783
|
+
const p = prop;
|
|
784
|
+
if (p.computed) continue;
|
|
785
|
+
if ((p.key.type === "Identifier" ? p.key.name : p.key.value) !== "title") continue;
|
|
786
|
+
if (p.value.type !== "StringLiteral") return null;
|
|
787
|
+
return typeof p.value.value === "string" ? p.value.value : null;
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
/** `q3-review` → `q3-review-copy`, `q3-review-copy` → `q3-review-copy-2`, … */
|
|
792
|
+
function nextCopyId(baseId, taken) {
|
|
793
|
+
const stem = baseId.replace(/-copy(-\d+)?$/, "");
|
|
794
|
+
let candidate = `${stem}-copy`;
|
|
795
|
+
let n = 2;
|
|
796
|
+
while (taken.has(candidate)) {
|
|
797
|
+
candidate = `${stem}-copy-${n}`;
|
|
798
|
+
n++;
|
|
799
|
+
}
|
|
800
|
+
return candidate;
|
|
801
|
+
}
|
|
802
|
+
function copyTitle(title) {
|
|
803
|
+
return /\(copy( \d+)?\)$/.test(title) ? title : `${title} (copy)`;
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/files/folders.ts
|
|
807
|
+
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
808
|
+
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
809
|
+
function emptyManifest() {
|
|
810
|
+
return {
|
|
811
|
+
folders: [],
|
|
812
|
+
assignments: {}
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
async function readManifest(file) {
|
|
816
|
+
try {
|
|
817
|
+
const raw = await fs.readFile(file, "utf8");
|
|
818
|
+
const parsed = JSON.parse(raw);
|
|
819
|
+
return {
|
|
820
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
821
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
822
|
+
};
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (err.code === "ENOENT") return emptyManifest();
|
|
825
|
+
throw err;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function writeManifest(file, manifest) {
|
|
829
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
830
|
+
await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
831
|
+
}
|
|
832
|
+
function newFolderId() {
|
|
833
|
+
return `f-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
834
|
+
}
|
|
835
|
+
function validateName(v) {
|
|
836
|
+
if (typeof v !== "string") return null;
|
|
837
|
+
const trimmed = v.trim();
|
|
838
|
+
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
839
|
+
return trimmed;
|
|
840
|
+
}
|
|
841
|
+
function validateReorder(v, current) {
|
|
842
|
+
if (!Array.isArray(v) || v.length !== current.length) return null;
|
|
843
|
+
const known = new Set(current.map((f) => f.id));
|
|
844
|
+
const seen = /* @__PURE__ */ new Set();
|
|
845
|
+
const out = [];
|
|
846
|
+
for (const id of v) {
|
|
847
|
+
if (typeof id !== "string" || !FOLDER_ID_RE.test(id)) return null;
|
|
848
|
+
if (!known.has(id) || seen.has(id)) return null;
|
|
849
|
+
seen.add(id);
|
|
850
|
+
out.push(id);
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
function validateIcon(v) {
|
|
855
|
+
if (!v || typeof v !== "object") return null;
|
|
856
|
+
const icon = v;
|
|
857
|
+
if (icon.type === "emoji") {
|
|
858
|
+
if (typeof icon.value !== "string") return null;
|
|
859
|
+
if (icon.value.length < 1 || icon.value.length > 8) return null;
|
|
860
|
+
return {
|
|
861
|
+
type: "emoji",
|
|
862
|
+
value: icon.value
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
if (icon.type === "color") {
|
|
866
|
+
if (typeof icon.value !== "string" || !COLOR_RE.test(icon.value)) return null;
|
|
867
|
+
return {
|
|
868
|
+
type: "color",
|
|
869
|
+
value: icon.value
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/ops/documents.ts
|
|
876
|
+
/**
|
|
877
|
+
* Operations shared by the dev API and the CLI. Everything here takes an
|
|
878
|
+
* `ApiContext` and touches disk directly — no HTTP, so a command and a browser
|
|
879
|
+
* calling `/__docs` end up in exactly the same code.
|
|
880
|
+
*/
|
|
881
|
+
var OpsError = class extends Error {
|
|
882
|
+
status;
|
|
883
|
+
constructor(status, message) {
|
|
884
|
+
super(message);
|
|
885
|
+
this.status = status;
|
|
886
|
+
this.name = "OpsError";
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
const ENTRY_NAMES = [
|
|
890
|
+
"index.tsx",
|
|
891
|
+
"index.jsx",
|
|
892
|
+
"index.ts",
|
|
893
|
+
"index.js"
|
|
894
|
+
];
|
|
895
|
+
function docDir(ctx, docId) {
|
|
896
|
+
if (!DOC_ID_RE.test(docId)) throw new OpsError(400, `invalid document id: ${docId}`);
|
|
897
|
+
const dir = path.resolve(ctx.docsRoot, docId);
|
|
898
|
+
if (!dir.startsWith(ctx.docsRoot + path.sep)) throw new OpsError(400, `invalid document id: ${docId}`);
|
|
899
|
+
return dir;
|
|
900
|
+
}
|
|
901
|
+
function resolveEntry(ctx, docId) {
|
|
902
|
+
const dir = docDir(ctx, docId);
|
|
903
|
+
for (const name of ENTRY_NAMES) {
|
|
904
|
+
const file = path.join(dir, name);
|
|
905
|
+
if (existsSync(file)) return file;
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
async function listDocIds(ctx) {
|
|
910
|
+
try {
|
|
911
|
+
return (await fs.readdir(ctx.docsRoot, { withFileTypes: true })).filter((e) => e.isDirectory() && DOC_ID_RE.test(e.name)).map((e) => e.name).sort();
|
|
912
|
+
} catch {
|
|
913
|
+
return [];
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async function createDocument(ctx, docId, source) {
|
|
917
|
+
const dir = docDir(ctx, docId);
|
|
918
|
+
if (existsSync(dir)) throw new OpsError(409, `document already exists: ${docId}`);
|
|
919
|
+
await fs.mkdir(dir, { recursive: true });
|
|
920
|
+
const entry = path.join(dir, "index.tsx");
|
|
921
|
+
await fs.writeFile(entry, source, "utf8");
|
|
922
|
+
return {
|
|
923
|
+
id: docId,
|
|
924
|
+
entry: path.relative(ctx.userCwd, entry)
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
async function renameDocument(ctx, docId, rawTitle) {
|
|
928
|
+
const title = validateDocTitle(rawTitle);
|
|
929
|
+
if (!title) throw new OpsError(400, "invalid title");
|
|
930
|
+
const entry = resolveEntry(ctx, docId);
|
|
931
|
+
if (!entry) throw new OpsError(404, `document not found: ${docId}`);
|
|
932
|
+
const source = await fs.readFile(entry, "utf8");
|
|
933
|
+
const next = setMetaTitle(source, title);
|
|
934
|
+
if (next === null) throw new OpsError(422, "document has no `export const meta` object to rename");
|
|
935
|
+
if (next !== source) await fs.writeFile(entry, next, "utf8");
|
|
936
|
+
return {
|
|
937
|
+
id: docId,
|
|
938
|
+
title
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
async function duplicateDocument(ctx, docId, newId) {
|
|
942
|
+
const dir = docDir(ctx, docId);
|
|
943
|
+
if (!existsSync(dir)) throw new OpsError(404, `document not found: ${docId}`);
|
|
944
|
+
const taken = new Set(await listDocIds(ctx));
|
|
945
|
+
let targetId;
|
|
946
|
+
if (newId) {
|
|
947
|
+
if (!DOC_ID_RE.test(newId)) throw new OpsError(400, `invalid newId: ${newId}`);
|
|
948
|
+
if (taken.has(newId)) throw new OpsError(409, `document already exists: ${newId}`);
|
|
949
|
+
targetId = newId;
|
|
950
|
+
} else targetId = nextCopyId(docId, taken);
|
|
951
|
+
await fs.cp(dir, docDir(ctx, targetId), { recursive: true });
|
|
952
|
+
const entry = resolveEntry(ctx, targetId);
|
|
953
|
+
if (entry) {
|
|
954
|
+
const source = await fs.readFile(entry, "utf8");
|
|
955
|
+
const renamed = setMetaTitle(source, copyTitle(readMetaTitle(source) ?? docId));
|
|
956
|
+
if (renamed && renamed !== source) await fs.writeFile(entry, renamed, "utf8");
|
|
957
|
+
}
|
|
958
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
959
|
+
const folderId = manifest.assignments[docId];
|
|
960
|
+
if (folderId) {
|
|
961
|
+
manifest.assignments[targetId] = folderId;
|
|
962
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
963
|
+
}
|
|
964
|
+
return { id: targetId };
|
|
965
|
+
}
|
|
966
|
+
async function deleteDocument(ctx, docId) {
|
|
967
|
+
const dir = docDir(ctx, docId);
|
|
968
|
+
if (!existsSync(dir)) throw new OpsError(404, `document not found: ${docId}`);
|
|
969
|
+
await fs.rm(dir, {
|
|
970
|
+
recursive: true,
|
|
971
|
+
force: true
|
|
972
|
+
});
|
|
973
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
974
|
+
if (manifest.assignments[docId]) {
|
|
975
|
+
delete manifest.assignments[docId];
|
|
976
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/vite/routes/docs.ts
|
|
981
|
+
function registerDocRoutes(server, ctx) {
|
|
982
|
+
server.middlewares.use("/__docs", async (req, res, next) => {
|
|
983
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
984
|
+
const method = req.method ?? "GET";
|
|
985
|
+
try {
|
|
986
|
+
const duplicateMatch = url.pathname.match(/^\/([^/]+)\/duplicate$/);
|
|
987
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
988
|
+
if (duplicateMatch && method === "POST") {
|
|
989
|
+
const requestCheck = validateMutationRequest(req);
|
|
990
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
991
|
+
const docId = decodeURIComponent(duplicateMatch[1]);
|
|
992
|
+
const body = await readBody(req).catch(() => ({}));
|
|
993
|
+
return json(res, 200, {
|
|
994
|
+
ok: true,
|
|
995
|
+
docId: (await duplicateDocument(ctx, docId, typeof body.newId === "string" && body.newId !== "" ? body.newId : void 0)).id
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
if (idMatch) {
|
|
999
|
+
const docId = decodeURIComponent(idMatch[1]);
|
|
1000
|
+
if (method === "PATCH") {
|
|
1001
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
1002
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1003
|
+
return json(res, 200, {
|
|
1004
|
+
ok: true,
|
|
1005
|
+
title: (await renameDocument(ctx, docId, (await readBody(req)).title)).title
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
if (method === "DELETE") {
|
|
1009
|
+
const requestCheck = validateMutationRequest(req);
|
|
1010
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1011
|
+
await deleteDocument(ctx, docId);
|
|
1012
|
+
return json(res, 200, { ok: true });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
next();
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
if (err instanceof OpsError) return json(res, err.status, { error: err.message });
|
|
1018
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
//#endregion
|
|
1023
|
+
//#region src/editing/babel-walk.ts
|
|
1024
|
+
function parseSource$1(code) {
|
|
1025
|
+
try {
|
|
1026
|
+
return parse(code, {
|
|
1027
|
+
sourceType: "module",
|
|
1028
|
+
plugins: ["typescript", "jsx"],
|
|
1029
|
+
errorRecovery: true
|
|
1030
|
+
});
|
|
1031
|
+
} catch {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
function isNode(value) {
|
|
1036
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
1037
|
+
}
|
|
1038
|
+
/** Depth-first walk over every node; the visitor decides what it cares about. */
|
|
1039
|
+
function walkAst(node, visit) {
|
|
1040
|
+
if (Array.isArray(node)) {
|
|
1041
|
+
for (const child of node) walkAst(child, visit);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (!isNode(node)) return;
|
|
1045
|
+
visit(node);
|
|
1046
|
+
for (const key of Object.keys(node)) {
|
|
1047
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue;
|
|
1048
|
+
walkAst(node[key], visit);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
function walkJsx(ast, visit) {
|
|
1052
|
+
walkAst(ast, (node) => {
|
|
1053
|
+
if (node.type === "JSXElement") visit(node);
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* The JSX element whose opening tag starts at this source position.
|
|
1058
|
+
*
|
|
1059
|
+
* The match is exact on both line and column. Anything looser can resolve to a
|
|
1060
|
+
* different element on the same line, and this location decides where an edit
|
|
1061
|
+
* gets written — a near miss silently rewrites the wrong text.
|
|
1062
|
+
*/
|
|
1063
|
+
function findJsxAt(ast, line, column) {
|
|
1064
|
+
let found = null;
|
|
1065
|
+
walkJsx(ast, (node) => {
|
|
1066
|
+
if (found) return;
|
|
1067
|
+
const start = node.loc?.start;
|
|
1068
|
+
if (start && start.line === line && start.column === column) found = node;
|
|
1069
|
+
});
|
|
1070
|
+
return found;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Every JSX element that starts on a line, nearest column first.
|
|
1074
|
+
*
|
|
1075
|
+
* Columns cannot be trusted for locations derived from React's `_debugSource`:
|
|
1076
|
+
* injecting the loc attribute shifts the columns of anything later on the same
|
|
1077
|
+
* line, so the offset differs per element. The line survives that, so callers
|
|
1078
|
+
* scan the line and pick by content instead.
|
|
1079
|
+
*/
|
|
1080
|
+
function findJsxOnLine(ast, line, column) {
|
|
1081
|
+
const hits = [];
|
|
1082
|
+
walkJsx(ast, (node) => {
|
|
1083
|
+
const start = node.loc?.start;
|
|
1084
|
+
if (!start || start.line !== line) return;
|
|
1085
|
+
hits.push({
|
|
1086
|
+
node,
|
|
1087
|
+
distance: Math.abs(start.column - column)
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
return hits.sort((a, b) => a.distance - b.distance).map((hit) => hit.node);
|
|
1091
|
+
}
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/editing/comments.ts
|
|
1094
|
+
const MARKER_RE = /\{\/\*\s*@doc-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/;
|
|
1095
|
+
function b64urlEncode(value) {
|
|
1096
|
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1097
|
+
}
|
|
1098
|
+
function b64urlDecode(value) {
|
|
1099
|
+
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - value.length % 4);
|
|
1100
|
+
return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
1101
|
+
}
|
|
1102
|
+
function parseMarkers(source) {
|
|
1103
|
+
const comments = [];
|
|
1104
|
+
source.split("\n").forEach((line, index) => {
|
|
1105
|
+
const match = MARKER_RE.exec(line);
|
|
1106
|
+
if (!match) return;
|
|
1107
|
+
const [, id, ts, payload] = match;
|
|
1108
|
+
try {
|
|
1109
|
+
const decoded = JSON.parse(b64urlDecode(payload));
|
|
1110
|
+
comments.push({
|
|
1111
|
+
id,
|
|
1112
|
+
line: index + 1,
|
|
1113
|
+
ts,
|
|
1114
|
+
note: decoded.note,
|
|
1115
|
+
hint: decoded.hint
|
|
1116
|
+
});
|
|
1117
|
+
} catch {}
|
|
1118
|
+
});
|
|
1119
|
+
return comments;
|
|
1120
|
+
}
|
|
1121
|
+
function newCommentId() {
|
|
1122
|
+
return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
1123
|
+
}
|
|
1124
|
+
function markerFor(id, ts, note, hint) {
|
|
1125
|
+
return `{/* @doc-comment id="${id}" ts="${ts}" text="${b64urlEncode(JSON.stringify(hint ? {
|
|
1126
|
+
note,
|
|
1127
|
+
hint
|
|
1128
|
+
} : { note }))}" */}`;
|
|
1129
|
+
}
|
|
1130
|
+
function markerPattern(id) {
|
|
1131
|
+
return `\\{\\/\\*\\s*@doc-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text="[A-Za-z0-9_\\-]+={0,2}"\\s*\\*\\/\\}`;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Removes a marker and the whitespace the insertion added — whichever side it
|
|
1135
|
+
* landed on — so deleting a comment restores the file byte for byte.
|
|
1136
|
+
*/
|
|
1137
|
+
function removeMarker(source, id) {
|
|
1138
|
+
const pattern = markerPattern(id);
|
|
1139
|
+
for (const re of [
|
|
1140
|
+
new RegExp(`[ \\t]*${pattern}\\n`),
|
|
1141
|
+
new RegExp(`\\n[ \\t]*${pattern}`),
|
|
1142
|
+
new RegExp(pattern)
|
|
1143
|
+
]) if (re.test(source)) return source.replace(re, "");
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
function indentOfLine(source, offset) {
|
|
1147
|
+
const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
|
|
1148
|
+
return /^[ \t]*/.exec(source.slice(lineStart, offset))?.[0] ?? "";
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Splices the marker in as the **first child** of the target element. A
|
|
1152
|
+
* JSX-comment token outside a JSX child position parses as an empty object
|
|
1153
|
+
* literal and breaks the surrounding expression, so this never puts one before
|
|
1154
|
+
* an element.
|
|
1155
|
+
*/
|
|
1156
|
+
function insertMarker(source, target, note, hint) {
|
|
1157
|
+
const ast = parseSource$1(source);
|
|
1158
|
+
if (!ast) return null;
|
|
1159
|
+
const element = findJsxAt(ast, target.line, target.column);
|
|
1160
|
+
if (!element) return null;
|
|
1161
|
+
const opening = element.openingElement;
|
|
1162
|
+
if (!opening) return null;
|
|
1163
|
+
if (opening.selfClosing === true) return null;
|
|
1164
|
+
const offset = opening.end;
|
|
1165
|
+
const id = newCommentId();
|
|
1166
|
+
const marker = markerFor(id, (/* @__PURE__ */ new Date()).toISOString(), note, hint);
|
|
1167
|
+
const indent = indentOfLine(source, element.start ?? offset);
|
|
1168
|
+
return {
|
|
1169
|
+
source: `${source.slice(0, offset)}\n${indent} ${marker}${source.slice(offset)}`,
|
|
1170
|
+
id
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
//#endregion
|
|
1174
|
+
//#region src/editing/edit-ops.ts
|
|
1175
|
+
function escapeJsxText(text) {
|
|
1176
|
+
return text.replace(/[{}<>]/g, (char) => `{'${char}'}`);
|
|
1177
|
+
}
|
|
1178
|
+
function normalizeText(value) {
|
|
1179
|
+
return value.replace(/\s+/g, " ").trim();
|
|
1180
|
+
}
|
|
1181
|
+
function isAstNode(value) {
|
|
1182
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
1183
|
+
}
|
|
1184
|
+
function jsxChildren(element) {
|
|
1185
|
+
return element.children ?? [];
|
|
1186
|
+
}
|
|
1187
|
+
function labelOf(node) {
|
|
1188
|
+
if (node.type === "JSXExpressionContainer") return "{…}";
|
|
1189
|
+
if (node.type === "JSXElement") return `<${(node.openingElement?.name)?.name ?? "element"}>`;
|
|
1190
|
+
return "…";
|
|
1191
|
+
}
|
|
1192
|
+
/** Text written straight into the JSX. Its surrounding whitespace is indentation, so the slot excludes it. */
|
|
1193
|
+
function literalSlot(node) {
|
|
1194
|
+
const raw = node.value;
|
|
1195
|
+
const leading = (raw.match(/^\s*/)?.[0] ?? "").length;
|
|
1196
|
+
const trailing = (raw.match(/\s*$/)?.[0] ?? "").length;
|
|
1197
|
+
return {
|
|
1198
|
+
value: raw.trim(),
|
|
1199
|
+
start: node.start + leading,
|
|
1200
|
+
end: node.end - trailing,
|
|
1201
|
+
escape: escapeJsxText
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
/** `name="…"` at a call site. Only the quote in use has to be escaped. */
|
|
1205
|
+
function attributeSlot(node, source) {
|
|
1206
|
+
const quote = source[node.start] ?? "\"";
|
|
1207
|
+
const entity = quote === "\"" ? """ : "'";
|
|
1208
|
+
return {
|
|
1209
|
+
value: node.value,
|
|
1210
|
+
start: node.start + 1,
|
|
1211
|
+
end: node.end - 1,
|
|
1212
|
+
escape: (text) => text.split(quote).join(entity)
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** A plain string in an array or object literal — a JS string, not JSX. */
|
|
1216
|
+
function stringSlot(node, source) {
|
|
1217
|
+
const quote = source[node.start] ?? "'";
|
|
1218
|
+
return {
|
|
1219
|
+
value: node.value,
|
|
1220
|
+
start: node.start + 1,
|
|
1221
|
+
end: node.end - 1,
|
|
1222
|
+
escape: (text) => text.split("\\").join("\\\\").split(quote).join(`\\${quote}`)
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* A code block is written `<Code>{`docs/…`}</Code>` — the words are a template
|
|
1227
|
+
* literal, not JSX text. Nothing has to be traced to reach them, so they are a
|
|
1228
|
+
* slot wherever they appear: as a child of the element, or as the children one
|
|
1229
|
+
* call site handed a helper.
|
|
1230
|
+
*/
|
|
1231
|
+
function expressionSlot(child, source) {
|
|
1232
|
+
if (child.type !== "JSXExpressionContainer") return null;
|
|
1233
|
+
const expression = child.expression;
|
|
1234
|
+
if (expression?.type === "StringLiteral") return stringSlot(expression, source);
|
|
1235
|
+
if (expression?.type !== "TemplateLiteral") return null;
|
|
1236
|
+
if ((expression.expressions ?? []).length > 0) return null;
|
|
1237
|
+
const quasis = expression.quasis ?? [];
|
|
1238
|
+
const raw = (quasis[0]?.value)?.raw ?? "";
|
|
1239
|
+
if (quasis.length !== 1) return null;
|
|
1240
|
+
return {
|
|
1241
|
+
value: raw,
|
|
1242
|
+
start: expression.start + 1,
|
|
1243
|
+
end: expression.end - 1,
|
|
1244
|
+
escape: (text) => text.split("\\").join("\\\\").split("`").join("\\`").split("${").join("\\${")
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
function identifierName(child) {
|
|
1248
|
+
if (child.type !== "JSXExpressionContainer") return null;
|
|
1249
|
+
const expression = child.expression;
|
|
1250
|
+
if (expression?.type !== "Identifier") return null;
|
|
1251
|
+
return expression.name ?? null;
|
|
1252
|
+
}
|
|
1253
|
+
/** The chain of nodes from the program down to this element — its scopes, in order. */
|
|
1254
|
+
function pathTo(ast, element) {
|
|
1255
|
+
let found = null;
|
|
1256
|
+
const visit = (node, trail) => {
|
|
1257
|
+
if (found) return;
|
|
1258
|
+
const here = [...trail, node];
|
|
1259
|
+
if (node === element) {
|
|
1260
|
+
found = here;
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
for (const key of Object.keys(node)) {
|
|
1264
|
+
if (key === "loc") continue;
|
|
1265
|
+
const value = node[key];
|
|
1266
|
+
if (Array.isArray(value)) {
|
|
1267
|
+
for (const child of value) if (isAstNode(child)) visit(child, here);
|
|
1268
|
+
} else if (isAstNode(value)) visit(value, here);
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
visit(ast, []);
|
|
1272
|
+
return found;
|
|
1273
|
+
}
|
|
1274
|
+
function propNames(declarator) {
|
|
1275
|
+
const pattern = (declarator.init?.params)?.[0];
|
|
1276
|
+
if (pattern?.type !== "ObjectPattern") return [];
|
|
1277
|
+
return (pattern.properties ?? []).map((property) => property.key?.name ?? "").filter((name) => name !== "");
|
|
1278
|
+
}
|
|
1279
|
+
/** The component this element belongs to, and the props it declares. */
|
|
1280
|
+
function componentScope(path) {
|
|
1281
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
1282
|
+
const node = path[index];
|
|
1283
|
+
if (node?.type !== "VariableDeclarator") continue;
|
|
1284
|
+
const name = node.id?.name;
|
|
1285
|
+
const props = propNames(node);
|
|
1286
|
+
if (name !== void 0 && props.length > 0) return {
|
|
1287
|
+
name,
|
|
1288
|
+
props
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
/** The `xs.map(entry => …)` this element is rendered inside, if any. */
|
|
1294
|
+
function mapScope(path) {
|
|
1295
|
+
for (let index = path.length - 1; index >= 1; index--) {
|
|
1296
|
+
const arrow = path[index];
|
|
1297
|
+
const call = path[index - 1];
|
|
1298
|
+
if (arrow?.type !== "ArrowFunctionExpression" || call?.type !== "CallExpression") continue;
|
|
1299
|
+
const callee = call.callee;
|
|
1300
|
+
if (callee?.type !== "MemberExpression") continue;
|
|
1301
|
+
if (callee.property?.name !== "map") continue;
|
|
1302
|
+
const object = callee.object;
|
|
1303
|
+
const pattern = (arrow.params ?? [])[0];
|
|
1304
|
+
if (object?.type !== "Identifier" || !pattern) continue;
|
|
1305
|
+
return {
|
|
1306
|
+
pattern,
|
|
1307
|
+
array: object.name
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
function patternNames(pattern) {
|
|
1313
|
+
if (pattern.type === "Identifier") return [pattern.name];
|
|
1314
|
+
if (pattern.type === "ArrayPattern") return (pattern.elements ?? []).filter((element) => element?.type === "Identifier").map((element) => element.name);
|
|
1315
|
+
if (pattern.type === "ObjectPattern") return (pattern.properties ?? []).map((property) => property.value?.name ?? "").filter((name) => name !== "");
|
|
1316
|
+
return [];
|
|
1317
|
+
}
|
|
1318
|
+
/** Destructure one array entry the way the map callback does. */
|
|
1319
|
+
function entryBindings(pattern, entry, source) {
|
|
1320
|
+
if (pattern.type === "Identifier") {
|
|
1321
|
+
if (entry.type !== "StringLiteral") return null;
|
|
1322
|
+
return /* @__PURE__ */ new Map([[pattern.name, stringSlot(entry, source)]]);
|
|
1323
|
+
}
|
|
1324
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1325
|
+
if (pattern.type === "ArrayPattern") {
|
|
1326
|
+
if (entry.type !== "ArrayExpression") return null;
|
|
1327
|
+
const values = entry.elements ?? [];
|
|
1328
|
+
const targets = pattern.elements ?? [];
|
|
1329
|
+
for (let index = 0; index < targets.length; index++) {
|
|
1330
|
+
const target = targets[index];
|
|
1331
|
+
if (!target) continue;
|
|
1332
|
+
const value = values[index];
|
|
1333
|
+
if (target.type !== "Identifier" || value?.type !== "StringLiteral") return null;
|
|
1334
|
+
bindings.set(target.name, stringSlot(value, source));
|
|
1335
|
+
}
|
|
1336
|
+
return bindings.size > 0 ? bindings : null;
|
|
1337
|
+
}
|
|
1338
|
+
if (pattern.type === "ObjectPattern") {
|
|
1339
|
+
if (entry.type !== "ObjectExpression") return null;
|
|
1340
|
+
for (const property of pattern.properties ?? []) {
|
|
1341
|
+
const key = property.key?.name ?? "";
|
|
1342
|
+
const local = property.value?.name ?? "";
|
|
1343
|
+
if (key === "" || local === "") return null;
|
|
1344
|
+
const value = (entry.properties ?? []).find((candidate) => candidate.key?.name === key || candidate.key?.value === key)?.value;
|
|
1345
|
+
if (value?.type !== "StringLiteral") return null;
|
|
1346
|
+
bindings.set(local, stringSlot(value, source));
|
|
1347
|
+
}
|
|
1348
|
+
return bindings.size > 0 ? bindings : null;
|
|
1349
|
+
}
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Two call sites of the same component render different words, and only what
|
|
1354
|
+
* is on screen can say which one was clicked — without it a save rewrites
|
|
1355
|
+
* whichever came first in the file. An entry that cannot be told apart from
|
|
1356
|
+
* its neighbours is therefore not editable at all.
|
|
1357
|
+
*/
|
|
1358
|
+
function fits(bindings, shown) {
|
|
1359
|
+
if (shown === void 0) return true;
|
|
1360
|
+
const visible = normalizeText(shown);
|
|
1361
|
+
const values = [...bindings.values()].map((slot) => normalizeText(slot.value));
|
|
1362
|
+
if (values.join("") === "") return false;
|
|
1363
|
+
return values.every((value) => visible.includes(value));
|
|
1364
|
+
}
|
|
1365
|
+
function tagName(node) {
|
|
1366
|
+
return (node.openingElement?.name)?.name;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* `<Section name="主旨">…</Section>` puts its words between the tags rather
|
|
1370
|
+
* than in an attribute. Only a lone run of text qualifies: anything nested
|
|
1371
|
+
* would be flattened into a string and lost on the first save.
|
|
1372
|
+
*/
|
|
1373
|
+
function childrenSlot(node, source) {
|
|
1374
|
+
const children = jsxChildren(node).filter((child) => child.type !== "JSXText" || child.value.trim() !== "");
|
|
1375
|
+
const only = children[0];
|
|
1376
|
+
if (children.length !== 1 || !only) return null;
|
|
1377
|
+
if (only.type === "JSXText") return literalSlot(only);
|
|
1378
|
+
return expressionSlot(only, source);
|
|
1379
|
+
}
|
|
1380
|
+
function callSiteBindings(node, wanted, source) {
|
|
1381
|
+
const attributes = node.openingElement?.attributes ?? [];
|
|
1382
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1383
|
+
for (const name of wanted) {
|
|
1384
|
+
if (name === "children") {
|
|
1385
|
+
const slot = childrenSlot(node, source);
|
|
1386
|
+
if (!slot) return null;
|
|
1387
|
+
bindings.set(name, slot);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
const value = attributes.find((candidate) => candidate.name?.name === name)?.value;
|
|
1391
|
+
if (value?.type !== "StringLiteral") return null;
|
|
1392
|
+
bindings.set(name, attributeSlot(value, source));
|
|
1393
|
+
}
|
|
1394
|
+
return bindings;
|
|
1395
|
+
}
|
|
1396
|
+
function propBindings(ctx, component, wanted) {
|
|
1397
|
+
const matches = [];
|
|
1398
|
+
walkJsx(ctx.ast, (node) => {
|
|
1399
|
+
if (tagName(node) !== component) return;
|
|
1400
|
+
const bindings = callSiteBindings(node, wanted, ctx.source);
|
|
1401
|
+
if (bindings && fits(bindings, ctx.shown)) matches.push(bindings);
|
|
1402
|
+
});
|
|
1403
|
+
return matches.length === 1 ? matches[0] ?? null : null;
|
|
1404
|
+
}
|
|
1405
|
+
/** Every array literal that could be the one being mapped over. */
|
|
1406
|
+
function arrayCandidates(ctx, name, component) {
|
|
1407
|
+
const found = [];
|
|
1408
|
+
walkAst(ctx.ast, (node) => {
|
|
1409
|
+
if (node.type === "VariableDeclarator") {
|
|
1410
|
+
const id = node.id;
|
|
1411
|
+
const init = node.init;
|
|
1412
|
+
if (id?.type === "Identifier" && id.name === name && init?.type === "ArrayExpression") found.push(init);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (node.type !== "JSXElement" || component === null || tagName(node) !== component) return;
|
|
1416
|
+
const attributes = node.openingElement.attributes ?? [];
|
|
1417
|
+
for (const attribute of attributes) {
|
|
1418
|
+
if (attribute.name?.name !== name) continue;
|
|
1419
|
+
const value = attribute.value;
|
|
1420
|
+
if (value?.type !== "JSXExpressionContainer") continue;
|
|
1421
|
+
const expression = value.expression;
|
|
1422
|
+
if (expression?.type === "ArrayExpression") found.push(expression);
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
return found;
|
|
1426
|
+
}
|
|
1427
|
+
function mapBindings(ctx, scope, component, wanted) {
|
|
1428
|
+
const matches = [];
|
|
1429
|
+
for (const array of arrayCandidates(ctx, scope.array, component)) for (const entry of array.elements ?? []) {
|
|
1430
|
+
if (!entry) continue;
|
|
1431
|
+
const bindings = entryBindings(scope.pattern, entry, ctx.source);
|
|
1432
|
+
if (!bindings || !wanted.every((name) => bindings.has(name))) continue;
|
|
1433
|
+
if (fits(bindings, ctx.shown)) matches.push(bindings);
|
|
1434
|
+
}
|
|
1435
|
+
return matches.length === 1 ? matches[0] ?? null : null;
|
|
1436
|
+
}
|
|
1437
|
+
function bindingsFor(ctx, element, names) {
|
|
1438
|
+
const path = pathTo(ctx.ast, element);
|
|
1439
|
+
if (!path) return null;
|
|
1440
|
+
const map = mapScope(path);
|
|
1441
|
+
const component = componentScope(path);
|
|
1442
|
+
const mapped = map ? patternNames(map.pattern) : [];
|
|
1443
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1444
|
+
const fromMap = names.filter((name) => mapped.includes(name));
|
|
1445
|
+
if (map && fromMap.length > 0) {
|
|
1446
|
+
const entry = mapBindings(ctx, map, component?.name ?? null, fromMap);
|
|
1447
|
+
if (entry) for (const name of fromMap) {
|
|
1448
|
+
const slot = entry.get(name);
|
|
1449
|
+
if (slot) bindings.set(name, slot);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const fromProps = names.filter((name) => !mapped.includes(name) && (component?.props.includes(name) ?? false));
|
|
1453
|
+
if (component && fromProps.length > 0) {
|
|
1454
|
+
const call = propBindings(ctx, component.name, fromProps);
|
|
1455
|
+
if (call) for (const [name, slot] of call) bindings.set(name, slot);
|
|
1456
|
+
}
|
|
1457
|
+
return bindings.size > 0 ? bindings : null;
|
|
1458
|
+
}
|
|
1459
|
+
/** The element's children, each resolved to a writable slot or left as markup. */
|
|
1460
|
+
function resolve$1(element, ctx) {
|
|
1461
|
+
const children = jsxChildren(element);
|
|
1462
|
+
const names = children.map((child) => identifierName(child)).filter((name) => name !== null);
|
|
1463
|
+
const bindings = ctx && names.length > 0 ? bindingsFor(ctx, element, names) : null;
|
|
1464
|
+
const parts = [];
|
|
1465
|
+
const slots = [];
|
|
1466
|
+
const take = (slot) => {
|
|
1467
|
+
parts.push({
|
|
1468
|
+
kind: "text",
|
|
1469
|
+
index: slots.length,
|
|
1470
|
+
value: slot.value
|
|
1471
|
+
});
|
|
1472
|
+
slots.push(slot);
|
|
1473
|
+
};
|
|
1474
|
+
for (const child of children) {
|
|
1475
|
+
if (child.type === "JSXText") {
|
|
1476
|
+
if (child.value.trim() !== "") take(literalSlot(child));
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
const name = identifierName(child);
|
|
1480
|
+
const slot = (name === null ? void 0 : bindings?.get(name)) ?? (ctx ? expressionSlot(child, ctx.source) ?? void 0 : void 0);
|
|
1481
|
+
if (slot) take(slot);
|
|
1482
|
+
else parts.push({
|
|
1483
|
+
kind: "markup",
|
|
1484
|
+
label: labelOf(child)
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
parts,
|
|
1489
|
+
slots
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
function describe(element, ctx) {
|
|
1493
|
+
const { parts } = resolve$1(element, ctx);
|
|
1494
|
+
const texts = parts.filter((part) => part.kind === "text");
|
|
1495
|
+
if (texts.length === 0) return {
|
|
1496
|
+
editable: false,
|
|
1497
|
+
text: "",
|
|
1498
|
+
parts,
|
|
1499
|
+
reason: parts.some((part) => part.kind === "markup" && part.label === "{…}") ? "text is produced by code — edit whatever feeds it" : "element has no text of its own"
|
|
1500
|
+
};
|
|
1501
|
+
return {
|
|
1502
|
+
editable: true,
|
|
1503
|
+
text: texts.map((part) => part.value).join(" "),
|
|
1504
|
+
parts
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Picks the element an inspector click meant.
|
|
1509
|
+
*
|
|
1510
|
+
* Coordinates alone are not enough: fallback candidates come from React's
|
|
1511
|
+
* `_debugSource`, whose columns drift once the loc-tag transform has widened
|
|
1512
|
+
* the line. The rendered text the user is looking at is the tiebreaker — an
|
|
1513
|
+
* element only wins if its source text is part of what is on screen.
|
|
1514
|
+
*/
|
|
1515
|
+
function resolveTextTarget(source, candidates, expected) {
|
|
1516
|
+
const ast = parseSource$1(source);
|
|
1517
|
+
if (!ast) return null;
|
|
1518
|
+
const ctx = {
|
|
1519
|
+
ast,
|
|
1520
|
+
source,
|
|
1521
|
+
shown: expected
|
|
1522
|
+
};
|
|
1523
|
+
const shown = expected ? normalizeText(expected) : null;
|
|
1524
|
+
const matches = (info) => {
|
|
1525
|
+
if (!shown) return true;
|
|
1526
|
+
const runs = info.parts.filter((part) => part.kind === "text");
|
|
1527
|
+
if (runs.length === 0) return false;
|
|
1528
|
+
return runs.every((part) => part.kind === "text" && shown.includes(normalizeText(part.value)));
|
|
1529
|
+
};
|
|
1530
|
+
for (const candidate of candidates) {
|
|
1531
|
+
const exact = findJsxAt(ast, candidate.line, candidate.column);
|
|
1532
|
+
if (exact) {
|
|
1533
|
+
const info = describe(exact, ctx);
|
|
1534
|
+
if (info.editable && matches(info)) return {
|
|
1535
|
+
...info,
|
|
1536
|
+
...candidate
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
if (!shown) continue;
|
|
1540
|
+
for (const node of findJsxOnLine(ast, candidate.line, candidate.column)) {
|
|
1541
|
+
const info = describe(node, ctx);
|
|
1542
|
+
const start = node.loc?.start;
|
|
1543
|
+
if (!info.editable || !matches(info) || !start) continue;
|
|
1544
|
+
return {
|
|
1545
|
+
...info,
|
|
1546
|
+
line: start.line,
|
|
1547
|
+
column: start.column
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const first = candidates[0];
|
|
1552
|
+
if (!first) return null;
|
|
1553
|
+
const clicked = findJsxAt(ast, first.line, first.column);
|
|
1554
|
+
return clicked ? {
|
|
1555
|
+
...describe(clicked, ctx),
|
|
1556
|
+
...first
|
|
1557
|
+
} : null;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Replaces one text run of an element, leaving its markup and every other run
|
|
1561
|
+
* untouched. `expected` is the text the caller believes is there; a mismatch
|
|
1562
|
+
* means the source moved under us and the write is refused.
|
|
1563
|
+
*/
|
|
1564
|
+
function replaceTextAt(source, target, text, opts = {}) {
|
|
1565
|
+
const ast = parseSource$1(source);
|
|
1566
|
+
if (!ast) return {
|
|
1567
|
+
ok: false,
|
|
1568
|
+
status: 422,
|
|
1569
|
+
error: "could not parse document source"
|
|
1570
|
+
};
|
|
1571
|
+
const element = findJsxAt(ast, target.line, target.column);
|
|
1572
|
+
if (!element) return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
status: 404,
|
|
1575
|
+
error: "no element at that source location"
|
|
1576
|
+
};
|
|
1577
|
+
const { slots } = resolve$1(element, {
|
|
1578
|
+
ast,
|
|
1579
|
+
source,
|
|
1580
|
+
shown: opts.shown
|
|
1581
|
+
});
|
|
1582
|
+
if (slots.length === 0) return {
|
|
1583
|
+
ok: false,
|
|
1584
|
+
status: 422,
|
|
1585
|
+
error: "element has no text to replace"
|
|
1586
|
+
};
|
|
1587
|
+
const slot = slots[opts.index ?? 0];
|
|
1588
|
+
if (!slot) return {
|
|
1589
|
+
ok: false,
|
|
1590
|
+
status: 404,
|
|
1591
|
+
error: "no such text run in this element"
|
|
1592
|
+
};
|
|
1593
|
+
if (opts.expected !== void 0 && normalizeText(slot.value) !== normalizeText(opts.expected)) return {
|
|
1594
|
+
ok: false,
|
|
1595
|
+
status: 409,
|
|
1596
|
+
error: "source changed since this was opened — reselect it"
|
|
1597
|
+
};
|
|
1598
|
+
return {
|
|
1599
|
+
ok: true,
|
|
1600
|
+
source: source.slice(0, slot.start) + slot.escape(text) + source.slice(slot.end)
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
//#endregion
|
|
1604
|
+
//#region src/vite/routes/edit.ts
|
|
1605
|
+
function readLoc(body) {
|
|
1606
|
+
const { docId, line, column } = body;
|
|
1607
|
+
if (typeof docId !== "string") return null;
|
|
1608
|
+
if (typeof line !== "number" || typeof column !== "number") return null;
|
|
1609
|
+
return {
|
|
1610
|
+
docId,
|
|
1611
|
+
line,
|
|
1612
|
+
column
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
function registerEditRoutes(server, ctx) {
|
|
1616
|
+
const entryFor = (docId) => resolveDocEntry(ctx.docsRoot, docId);
|
|
1617
|
+
server.middlewares.use("/__edit", async (req, res, next) => {
|
|
1618
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1619
|
+
const method = req.method ?? "GET";
|
|
1620
|
+
try {
|
|
1621
|
+
if (method === "GET" && url.pathname === "/text") {
|
|
1622
|
+
const docId = url.searchParams.get("docId") ?? "";
|
|
1623
|
+
const entry = entryFor(docId);
|
|
1624
|
+
const locs = (url.searchParams.get("locs") ?? "").split(",").map((pair) => pair.split(":").map(Number)).filter(([line, column]) => Number.isFinite(line) && Number.isFinite(column)).map(([line, column]) => ({
|
|
1625
|
+
line,
|
|
1626
|
+
column
|
|
1627
|
+
}));
|
|
1628
|
+
if (!entry || locs.length === 0) return json(res, 400, { error: "invalid target" });
|
|
1629
|
+
const resolved = resolveTextTarget(await fs.readFile(entry, "utf8"), locs, url.searchParams.get("shown") ?? void 0);
|
|
1630
|
+
if (!resolved) return json(res, 404, { error: "element not found" });
|
|
1631
|
+
return json(res, 200, resolved);
|
|
1632
|
+
}
|
|
1633
|
+
if (method === "PUT" && url.pathname === "/text") {
|
|
1634
|
+
const check = validateMutationRequest(req, { requireJsonBody: true });
|
|
1635
|
+
if (!check.ok) return json(res, check.status, { error: check.error });
|
|
1636
|
+
const body = await readBody(req);
|
|
1637
|
+
const loc = readLoc(body);
|
|
1638
|
+
const text = body.text;
|
|
1639
|
+
if (!loc || typeof text !== "string") return json(res, 400, { error: "invalid payload" });
|
|
1640
|
+
const entry = entryFor(loc.docId);
|
|
1641
|
+
if (!entry) return json(res, 404, { error: "document not found" });
|
|
1642
|
+
const source = await fs.readFile(entry, "utf8");
|
|
1643
|
+
const result = replaceTextAt(source, loc, text, {
|
|
1644
|
+
index: typeof body.index === "number" ? body.index : void 0,
|
|
1645
|
+
expected: typeof body.expected === "string" ? body.expected : void 0,
|
|
1646
|
+
shown: typeof body.shown === "string" ? body.shown : void 0
|
|
1647
|
+
});
|
|
1648
|
+
if (!result.ok) return json(res, result.status, { error: result.error });
|
|
1649
|
+
if (result.source !== source) await fs.writeFile(entry, result.source, "utf8");
|
|
1650
|
+
return json(res, 200, { ok: true });
|
|
1651
|
+
}
|
|
1652
|
+
if (method === "POST" && url.pathname === "/comment") {
|
|
1653
|
+
const check = validateMutationRequest(req, { requireJsonBody: true });
|
|
1654
|
+
if (!check.ok) return json(res, check.status, { error: check.error });
|
|
1655
|
+
const body = await readBody(req);
|
|
1656
|
+
const loc = readLoc(body);
|
|
1657
|
+
const note = body.note;
|
|
1658
|
+
if (!loc || typeof note !== "string" || note.trim() === "") return json(res, 400, { error: "invalid payload" });
|
|
1659
|
+
const entry = entryFor(loc.docId);
|
|
1660
|
+
if (!entry) return json(res, 404, { error: "document not found" });
|
|
1661
|
+
const inserted = insertMarker(await fs.readFile(entry, "utf8"), loc, note.trim(), typeof body.hint === "string" ? body.hint : void 0);
|
|
1662
|
+
if (!inserted) return json(res, 422, { error: "cannot anchor a comment here — pick the surrounding element" });
|
|
1663
|
+
await fs.writeFile(entry, inserted.source, "utf8");
|
|
1664
|
+
return json(res, 200, {
|
|
1665
|
+
ok: true,
|
|
1666
|
+
id: inserted.id
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
return next();
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
1674
|
+
server.middlewares.use("/__comments", async (req, res, next) => {
|
|
1675
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1676
|
+
const method = req.method ?? "GET";
|
|
1677
|
+
const docId = url.searchParams.get("docId") ?? "";
|
|
1678
|
+
const entry = entryFor(docId);
|
|
1679
|
+
try {
|
|
1680
|
+
if (method === "GET") {
|
|
1681
|
+
if (!entry) return json(res, 200, { comments: [] });
|
|
1682
|
+
return json(res, 200, { comments: parseMarkers(await fs.readFile(entry, "utf8")) });
|
|
1683
|
+
}
|
|
1684
|
+
if (method === "DELETE") {
|
|
1685
|
+
const check = validateMutationRequest(req);
|
|
1686
|
+
if (!check.ok) return json(res, check.status, { error: check.error });
|
|
1687
|
+
const id = url.searchParams.get("id") ?? "";
|
|
1688
|
+
if (!entry || !/^c-[a-f0-9]+$/.test(id)) return json(res, 400, { error: "invalid id" });
|
|
1689
|
+
const next = removeMarker(await fs.readFile(entry, "utf8"), id);
|
|
1690
|
+
if (next === null) return json(res, 404, { error: "comment not found" });
|
|
1691
|
+
await fs.writeFile(entry, next, "utf8");
|
|
1692
|
+
return json(res, 200, { ok: true });
|
|
1693
|
+
}
|
|
1694
|
+
return next();
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
//#endregion
|
|
1701
|
+
//#region src/vite/routes/folders.ts
|
|
1702
|
+
function registerFolderRoutes(server, ctx) {
|
|
1703
|
+
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
1704
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1705
|
+
const method = req.method ?? "GET";
|
|
1706
|
+
try {
|
|
1707
|
+
if (method === "GET" && url.pathname === "/") return json(res, 200, await readManifest(ctx.manifestPath));
|
|
1708
|
+
if (method === "POST" && url.pathname === "/") {
|
|
1709
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
1710
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1711
|
+
const body = await readBody(req);
|
|
1712
|
+
const name = validateName(body.name);
|
|
1713
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
1714
|
+
const icon = validateIcon(body.icon);
|
|
1715
|
+
if (!icon) return json(res, 400, { error: "invalid icon" });
|
|
1716
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
1717
|
+
const folder = {
|
|
1718
|
+
id: newFolderId(),
|
|
1719
|
+
name,
|
|
1720
|
+
icon
|
|
1721
|
+
};
|
|
1722
|
+
manifest.folders.push(folder);
|
|
1723
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
1724
|
+
return json(res, 200, folder);
|
|
1725
|
+
}
|
|
1726
|
+
if (method === "PUT" && url.pathname === "/assign") {
|
|
1727
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
1728
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1729
|
+
const body = await readBody(req);
|
|
1730
|
+
if (typeof body.docId !== "string" || !DOC_ID_RE.test(body.docId)) return json(res, 400, { error: "invalid docId" });
|
|
1731
|
+
const docId = body.docId;
|
|
1732
|
+
let folderId;
|
|
1733
|
+
if (body.folderId === null) folderId = null;
|
|
1734
|
+
else if (typeof body.folderId === "string" && FOLDER_ID_RE.test(body.folderId)) folderId = body.folderId;
|
|
1735
|
+
else return json(res, 400, { error: "invalid folderId" });
|
|
1736
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
1737
|
+
if (folderId && !manifest.folders.some((f) => f.id === folderId)) return json(res, 404, { error: "folder not found" });
|
|
1738
|
+
if (folderId === null) delete manifest.assignments[docId];
|
|
1739
|
+
else manifest.assignments[docId] = folderId;
|
|
1740
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
1741
|
+
return json(res, 200, { ok: true });
|
|
1742
|
+
}
|
|
1743
|
+
if (method === "PUT" && url.pathname === "/reorder") {
|
|
1744
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
1745
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1746
|
+
const body = await readBody(req);
|
|
1747
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
1748
|
+
const ids = validateReorder(body.ids, manifest.folders);
|
|
1749
|
+
if (!ids) return json(res, 400, { error: "invalid ids" });
|
|
1750
|
+
const byId = new Map(manifest.folders.map((f) => [f.id, f]));
|
|
1751
|
+
manifest.folders = ids.map((id) => byId.get(id));
|
|
1752
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
1753
|
+
return json(res, 200, { ok: true });
|
|
1754
|
+
}
|
|
1755
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
1756
|
+
if (idMatch) {
|
|
1757
|
+
const id = idMatch[1];
|
|
1758
|
+
if (!FOLDER_ID_RE.test(id)) return json(res, 400, { error: "invalid id" });
|
|
1759
|
+
if (method === "PATCH") {
|
|
1760
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
1761
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1762
|
+
const body = await readBody(req);
|
|
1763
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
1764
|
+
const folder = manifest.folders.find((f) => f.id === id);
|
|
1765
|
+
if (!folder) return json(res, 404, { error: "folder not found" });
|
|
1766
|
+
if (body.name !== void 0) {
|
|
1767
|
+
const name = validateName(body.name);
|
|
1768
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
1769
|
+
folder.name = name;
|
|
1770
|
+
}
|
|
1771
|
+
if (body.icon !== void 0) {
|
|
1772
|
+
const icon = validateIcon(body.icon);
|
|
1773
|
+
if (!icon) return json(res, 400, { error: "invalid icon" });
|
|
1774
|
+
folder.icon = icon;
|
|
1775
|
+
}
|
|
1776
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
1777
|
+
return json(res, 200, folder);
|
|
1778
|
+
}
|
|
1779
|
+
if (method === "DELETE") {
|
|
1780
|
+
const requestCheck = validateMutationRequest(req);
|
|
1781
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
1782
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
1783
|
+
const before = manifest.folders.length;
|
|
1784
|
+
manifest.folders = manifest.folders.filter((f) => f.id !== id);
|
|
1785
|
+
if (manifest.folders.length === before) return json(res, 404, { error: "folder not found" });
|
|
1786
|
+
for (const [docId, folderId] of Object.entries(manifest.assignments)) if (folderId === id) delete manifest.assignments[docId];
|
|
1787
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
1788
|
+
return json(res, 200, { ok: true });
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
next();
|
|
1792
|
+
} catch (err) {
|
|
1793
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
//#endregion
|
|
1798
|
+
//#region src/vite/api-plugin.ts
|
|
1799
|
+
function apiPlugin(opts) {
|
|
1800
|
+
return {
|
|
1801
|
+
name: "mosage:api",
|
|
1802
|
+
apply: "serve",
|
|
1803
|
+
configureServer(server) {
|
|
1804
|
+
const ctx = makeContext(opts);
|
|
1805
|
+
registerAssetRoutes(server, ctx);
|
|
1806
|
+
registerFolderRoutes(server, ctx);
|
|
1807
|
+
registerDocRoutes(server, ctx);
|
|
1808
|
+
registerEditRoutes(server, ctx);
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
//#endregion
|
|
1813
|
+
//#region src/vite/current-plugin.ts
|
|
1814
|
+
const TEXT_SNIPPET_MAX = 120;
|
|
1815
|
+
function parseSelection(raw) {
|
|
1816
|
+
if (raw == null || typeof raw !== "object") return null;
|
|
1817
|
+
const sel = raw;
|
|
1818
|
+
if (typeof sel.line !== "number" || !Number.isFinite(sel.line)) return null;
|
|
1819
|
+
if (typeof sel.column !== "number" || !Number.isFinite(sel.column)) return null;
|
|
1820
|
+
const tagName = typeof sel.tagName === "string" ? sel.tagName.toLowerCase().slice(0, 32) : "unknown";
|
|
1821
|
+
const text = typeof sel.text === "string" ? sel.text.replace(/\s+/g, " ").trim().slice(0, TEXT_SNIPPET_MAX) : "";
|
|
1822
|
+
return {
|
|
1823
|
+
line: Math.max(1, Math.floor(sel.line)),
|
|
1824
|
+
column: Math.max(0, Math.floor(sel.column)),
|
|
1825
|
+
tagName,
|
|
1826
|
+
text
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Writes `node_modules/.mosage/current.json` whenever the viewer navigates or
|
|
1831
|
+
* the inspector picks an element, so an agent can resolve "this page" without
|
|
1832
|
+
* asking. Dev only — a static build has no cursor to report.
|
|
1833
|
+
*/
|
|
1834
|
+
function currentPlugin(opts) {
|
|
1835
|
+
const userCwd = opts.userCwd;
|
|
1836
|
+
const docsDir = opts.docsDir ?? "docs";
|
|
1837
|
+
const outDir = path.join(userCwd, "node_modules", ".mosage");
|
|
1838
|
+
const outFile = path.join(outDir, "current.json");
|
|
1839
|
+
const tmpFile = `${outFile}.tmp`;
|
|
1840
|
+
let cached = null;
|
|
1841
|
+
return {
|
|
1842
|
+
name: "mosage:current",
|
|
1843
|
+
apply: "serve",
|
|
1844
|
+
configureServer(server) {
|
|
1845
|
+
server.ws.on("mosage:current", async (raw) => {
|
|
1846
|
+
const next = cached ? { ...cached } : {
|
|
1847
|
+
docId: "",
|
|
1848
|
+
pageIndex: 0,
|
|
1849
|
+
pageNumber: 1,
|
|
1850
|
+
totalPages: 1,
|
|
1851
|
+
docTitle: "",
|
|
1852
|
+
pagePath: "",
|
|
1853
|
+
selection: null
|
|
1854
|
+
};
|
|
1855
|
+
if (typeof raw?.docId === "string") {
|
|
1856
|
+
if (!DOC_ID_RE.test(raw.docId)) return;
|
|
1857
|
+
const totalPages = typeof raw.totalPages === "number" && Number.isFinite(raw.totalPages) && raw.totalPages > 0 ? Math.floor(raw.totalPages) : 1;
|
|
1858
|
+
const rawIndex = typeof raw.pageIndex === "number" && Number.isFinite(raw.pageIndex) ? Math.floor(raw.pageIndex) : 0;
|
|
1859
|
+
const pageIndex = Math.max(0, Math.min(totalPages - 1, rawIndex));
|
|
1860
|
+
const docTitle = typeof raw.docTitle === "string" ? raw.docTitle : raw.docId;
|
|
1861
|
+
const pagePath = path.join(docsDir, raw.docId, "index.tsx").split(path.sep).join("/");
|
|
1862
|
+
if (cached?.docId !== raw.docId || cached?.pageIndex !== pageIndex) next.selection = null;
|
|
1863
|
+
next.docId = raw.docId;
|
|
1864
|
+
next.pageIndex = pageIndex;
|
|
1865
|
+
next.pageNumber = pageIndex + 1;
|
|
1866
|
+
next.totalPages = totalPages;
|
|
1867
|
+
next.docTitle = docTitle;
|
|
1868
|
+
next.pagePath = pagePath;
|
|
1869
|
+
}
|
|
1870
|
+
if ("selection" in raw) next.selection = parseSelection(raw.selection);
|
|
1871
|
+
if (!next.docId) return;
|
|
1872
|
+
cached = next;
|
|
1873
|
+
const body = {
|
|
1874
|
+
...next,
|
|
1875
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1876
|
+
};
|
|
1877
|
+
try {
|
|
1878
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
1879
|
+
await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
1880
|
+
await fs.rename(tmpFile, outFile);
|
|
1881
|
+
} catch {}
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
//#endregion
|
|
1887
|
+
//#region src/vite/data-plugin.ts
|
|
1888
|
+
const DATA_RE = /\.(csv|tsv)$/i;
|
|
1889
|
+
/**
|
|
1890
|
+
* Makes `import rows from './data/q3.csv'` an array of objects at build time.
|
|
1891
|
+
*
|
|
1892
|
+
* Fetching data at render time is not an option here: the flow packer measures
|
|
1893
|
+
* the real DOM to decide where pages break, and both exporters serialize what
|
|
1894
|
+
* is on screen. Data that arrives a tick later arrives after the layout is
|
|
1895
|
+
* already decided. Resolving it as a module keeps a table's numbers as
|
|
1896
|
+
* synchronous as the prose around them, in the dev server and in a static build
|
|
1897
|
+
* alike.
|
|
1898
|
+
*
|
|
1899
|
+
* `?raw` and `?url` are left to Vite.
|
|
1900
|
+
*/
|
|
1901
|
+
function dataPlugin() {
|
|
1902
|
+
return {
|
|
1903
|
+
name: "mosage:data",
|
|
1904
|
+
enforce: "pre",
|
|
1905
|
+
async load(id) {
|
|
1906
|
+
const [file, query] = id.split("?");
|
|
1907
|
+
if (query !== void 0) return null;
|
|
1908
|
+
if (!DATA_RE.test(file)) return null;
|
|
1909
|
+
let text;
|
|
1910
|
+
try {
|
|
1911
|
+
text = await fs.readFile(file, "utf8");
|
|
1912
|
+
} catch {
|
|
1913
|
+
return null;
|
|
1914
|
+
}
|
|
1915
|
+
const table = parseDelimited(text, { delimiter: /\.tsv$/i.test(file) ? " " : "," });
|
|
1916
|
+
return [
|
|
1917
|
+
`export const columns = ${JSON.stringify(table.columns)};`,
|
|
1918
|
+
`export const rows = ${JSON.stringify(table.rows)};`,
|
|
1919
|
+
"export default rows;"
|
|
1920
|
+
].join("\n");
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
//#endregion
|
|
1925
|
+
//#region src/vite/design-plugin.ts
|
|
1926
|
+
function parseSource(source) {
|
|
1927
|
+
try {
|
|
1928
|
+
return parse(source, {
|
|
1929
|
+
sourceType: "module",
|
|
1930
|
+
plugins: ["typescript", "jsx"],
|
|
1931
|
+
errorRecovery: true
|
|
1932
|
+
});
|
|
1933
|
+
} catch {
|
|
1934
|
+
return null;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
function programBody(ast) {
|
|
1938
|
+
return ast.program?.body ?? [];
|
|
1939
|
+
}
|
|
1940
|
+
function designObjectOf(node) {
|
|
1941
|
+
let varDecl = null;
|
|
1942
|
+
if (node.type === "VariableDeclaration") varDecl = node;
|
|
1943
|
+
else if (node.type === "ExportNamedDeclaration") {
|
|
1944
|
+
const decl = node.declaration;
|
|
1945
|
+
if (decl?.type === "VariableDeclaration") varDecl = decl;
|
|
1946
|
+
}
|
|
1947
|
+
if (!varDecl) return null;
|
|
1948
|
+
const declarations = varDecl.declarations ?? [];
|
|
1949
|
+
for (const d of declarations) {
|
|
1950
|
+
const id = d.id;
|
|
1951
|
+
if (id?.type !== "Identifier" || id.name !== "design") continue;
|
|
1952
|
+
const init = d.init;
|
|
1953
|
+
if (!init) return "unsupported";
|
|
1954
|
+
let inner = init;
|
|
1955
|
+
if (inner.type === "TSSatisfiesExpression" || inner.type === "TSAsExpression") {
|
|
1956
|
+
const expr = inner.expression;
|
|
1957
|
+
if (expr) inner = expr;
|
|
1958
|
+
}
|
|
1959
|
+
if (inner.type !== "ObjectExpression") return "unsupported";
|
|
1960
|
+
return {
|
|
1961
|
+
decl: node,
|
|
1962
|
+
object: inner
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
function findDesign(ast) {
|
|
1968
|
+
for (const node of programBody(ast)) {
|
|
1969
|
+
const hit = designObjectOf(node);
|
|
1970
|
+
if (hit === null) continue;
|
|
1971
|
+
if (hit === "unsupported") return null;
|
|
1972
|
+
return {
|
|
1973
|
+
loc: {
|
|
1974
|
+
declStart: hit.decl.start,
|
|
1975
|
+
declEnd: hit.decl.end,
|
|
1976
|
+
objectStart: hit.object.start,
|
|
1977
|
+
objectEnd: hit.object.end
|
|
1978
|
+
},
|
|
1979
|
+
object: hit.object
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
return null;
|
|
1983
|
+
}
|
|
1984
|
+
function literalToValue(node) {
|
|
1985
|
+
switch (node.type) {
|
|
1986
|
+
case "StringLiteral": return node.value;
|
|
1987
|
+
case "NumericLiteral": return node.value;
|
|
1988
|
+
case "BooleanLiteral": return node.value;
|
|
1989
|
+
case "NullLiteral": return null;
|
|
1990
|
+
case "UnaryExpression": {
|
|
1991
|
+
const op = node.operator;
|
|
1992
|
+
const arg = node.argument;
|
|
1993
|
+
const v = literalToValue(arg);
|
|
1994
|
+
if (op === "-" && typeof v === "number") return -v;
|
|
1995
|
+
if (op === "+" && typeof v === "number") return v;
|
|
1996
|
+
throw new Error(`unsupported unary operator ${op}`);
|
|
1997
|
+
}
|
|
1998
|
+
case "TemplateLiteral": {
|
|
1999
|
+
const quasis = node.quasis;
|
|
2000
|
+
if (node.expressions.length > 0) throw new Error("template literal has expressions");
|
|
2001
|
+
const value = quasis[0].value;
|
|
2002
|
+
return value.cooked ?? value.raw;
|
|
2003
|
+
}
|
|
2004
|
+
case "ObjectExpression": {
|
|
2005
|
+
const properties = node.properties;
|
|
2006
|
+
const out = {};
|
|
2007
|
+
for (const prop of properties) {
|
|
2008
|
+
if (prop.type !== "ObjectProperty") throw new Error("object has spread or method");
|
|
2009
|
+
const p = prop;
|
|
2010
|
+
if (p.computed) throw new Error("object has computed key");
|
|
2011
|
+
let key;
|
|
2012
|
+
if (p.key.type === "Identifier" && typeof p.key.name === "string") key = p.key.name;
|
|
2013
|
+
else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") key = p.key.value;
|
|
2014
|
+
else throw new Error("unsupported object key");
|
|
2015
|
+
out[key] = literalToValue(p.value);
|
|
2016
|
+
}
|
|
2017
|
+
return out;
|
|
2018
|
+
}
|
|
2019
|
+
default: throw new Error(`unsupported node type ${node.type}`);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
function isPlainObject(v) {
|
|
2023
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2024
|
+
}
|
|
2025
|
+
function mergeDesign(base, patch) {
|
|
2026
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
2027
|
+
const apply = (target, src) => {
|
|
2028
|
+
for (const [k, v] of Object.entries(src)) if (isPlainObject(v) && isPlainObject(target[k])) apply(target[k], v);
|
|
2029
|
+
else target[k] = v;
|
|
2030
|
+
};
|
|
2031
|
+
if (isPlainObject(patch)) apply(out, patch);
|
|
2032
|
+
return out;
|
|
2033
|
+
}
|
|
2034
|
+
function indent(level) {
|
|
2035
|
+
return " ".repeat(level);
|
|
2036
|
+
}
|
|
2037
|
+
function jsString(s) {
|
|
2038
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
|
|
2039
|
+
}
|
|
2040
|
+
function isValidIdentifier(name) {
|
|
2041
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
2042
|
+
}
|
|
2043
|
+
function serializeValue(value, level) {
|
|
2044
|
+
if (value === null) return "null";
|
|
2045
|
+
if (typeof value === "string") return jsString(value);
|
|
2046
|
+
if (typeof value === "number") {
|
|
2047
|
+
if (!Number.isFinite(value)) throw new Error("non-finite number");
|
|
2048
|
+
return String(value);
|
|
2049
|
+
}
|
|
2050
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
2051
|
+
if (isPlainObject(value)) {
|
|
2052
|
+
const entries = Object.entries(value);
|
|
2053
|
+
if (entries.length === 0) return "{}";
|
|
2054
|
+
const childIndent = indent(level + 1);
|
|
2055
|
+
return `{\n${entries.map(([k, v]) => {
|
|
2056
|
+
const key = isValidIdentifier(k) ? k : jsString(k);
|
|
2057
|
+
return `${childIndent}${key}: ${serializeValue(v, level + 1)},`;
|
|
2058
|
+
}).join("\n")}\n${indent(level)}}`;
|
|
2059
|
+
}
|
|
2060
|
+
throw new Error(`unsupported value type ${typeof value}`);
|
|
2061
|
+
}
|
|
2062
|
+
function serializeDesign(design) {
|
|
2063
|
+
return serializeValue(design, 0);
|
|
2064
|
+
}
|
|
2065
|
+
function parseDocDesign(source) {
|
|
2066
|
+
const ast = parseSource(source);
|
|
2067
|
+
if (!ast) return {
|
|
2068
|
+
ok: false,
|
|
2069
|
+
exists: true,
|
|
2070
|
+
error: "could not parse document source"
|
|
2071
|
+
};
|
|
2072
|
+
const found = findDesign(ast);
|
|
2073
|
+
if (!found) {
|
|
2074
|
+
if (programBody(ast).some((node) => designObjectOf(node) !== null)) return {
|
|
2075
|
+
ok: false,
|
|
2076
|
+
exists: true,
|
|
2077
|
+
error: "design has an unsupported initializer"
|
|
2078
|
+
};
|
|
2079
|
+
return {
|
|
2080
|
+
ok: false,
|
|
2081
|
+
exists: false
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
let value;
|
|
2085
|
+
try {
|
|
2086
|
+
value = literalToValue(found.object);
|
|
2087
|
+
} catch (err) {
|
|
2088
|
+
return {
|
|
2089
|
+
ok: false,
|
|
2090
|
+
exists: true,
|
|
2091
|
+
error: err.message
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
return {
|
|
2095
|
+
ok: true,
|
|
2096
|
+
design: mergeDesign(defaultDesign, value),
|
|
2097
|
+
loc: found.loc
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
function findImports(ast) {
|
|
2101
|
+
const out = [];
|
|
2102
|
+
for (const node of programBody(ast)) {
|
|
2103
|
+
if (node.type !== "ImportDeclaration") continue;
|
|
2104
|
+
const src = node.source?.value;
|
|
2105
|
+
if (typeof src !== "string") continue;
|
|
2106
|
+
out.push({
|
|
2107
|
+
node,
|
|
2108
|
+
source: src,
|
|
2109
|
+
specifiers: node.specifiers ?? []
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
return out;
|
|
2113
|
+
}
|
|
2114
|
+
function ensureDesignSystemImport(source, ast) {
|
|
2115
|
+
const imports = findImports(ast);
|
|
2116
|
+
const coreImport = imports.find((imp) => imp.source === "mosage");
|
|
2117
|
+
if (coreImport) {
|
|
2118
|
+
if (coreImport.specifiers.some((spec) => {
|
|
2119
|
+
if (spec.type !== "ImportSpecifier") return false;
|
|
2120
|
+
return spec.imported?.name === "DesignSystem";
|
|
2121
|
+
})) return source;
|
|
2122
|
+
const node = coreImport.node;
|
|
2123
|
+
const braceClose = source.slice(node.start, node.end).lastIndexOf("}");
|
|
2124
|
+
if (braceClose === -1) return source;
|
|
2125
|
+
const absoluteBrace = node.start + braceClose;
|
|
2126
|
+
const insertText = coreImport.specifiers.length > 0 ? ", type DesignSystem" : "type DesignSystem";
|
|
2127
|
+
return source.slice(0, absoluteBrace) + insertText + source.slice(absoluteBrace);
|
|
2128
|
+
}
|
|
2129
|
+
const stmt = `import type { DesignSystem } from 'mosage';\n`;
|
|
2130
|
+
if (imports.length > 0) {
|
|
2131
|
+
const insertAt = imports[imports.length - 1].node.end;
|
|
2132
|
+
const trail = source[insertAt] === "\n" ? "" : "\n";
|
|
2133
|
+
return `${source.slice(0, insertAt)}\n${stmt.slice(0, -1)}${trail}${source.slice(insertAt)}`;
|
|
2134
|
+
}
|
|
2135
|
+
return `${stmt}\n${source}`;
|
|
2136
|
+
}
|
|
2137
|
+
function findInsertionPoint(source, ast) {
|
|
2138
|
+
const imports = findImports(ast);
|
|
2139
|
+
if (imports.length === 0) return 0;
|
|
2140
|
+
let off = imports[imports.length - 1].node.end;
|
|
2141
|
+
while (off < source.length && source[off] !== "\n") off++;
|
|
2142
|
+
if (off < source.length) off++;
|
|
2143
|
+
return off;
|
|
2144
|
+
}
|
|
2145
|
+
function applyDesignWrite(source, next) {
|
|
2146
|
+
let body;
|
|
2147
|
+
try {
|
|
2148
|
+
body = serializeDesign(next);
|
|
2149
|
+
} catch (err) {
|
|
2150
|
+
return {
|
|
2151
|
+
ok: false,
|
|
2152
|
+
status: 422,
|
|
2153
|
+
error: `serialize failed: ${err.message}`
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
const ast = parseSource(source);
|
|
2157
|
+
if (!ast) return {
|
|
2158
|
+
ok: false,
|
|
2159
|
+
status: 422,
|
|
2160
|
+
error: "could not parse document source"
|
|
2161
|
+
};
|
|
2162
|
+
const found = findDesign(ast);
|
|
2163
|
+
if (found) return {
|
|
2164
|
+
ok: true,
|
|
2165
|
+
source: source.slice(0, found.loc.objectStart) + body + source.slice(found.loc.objectEnd),
|
|
2166
|
+
created: false
|
|
2167
|
+
};
|
|
2168
|
+
const withImport = ensureDesignSystemImport(source, ast);
|
|
2169
|
+
const ast2 = parseSource(withImport);
|
|
2170
|
+
if (!ast2) return {
|
|
2171
|
+
ok: false,
|
|
2172
|
+
status: 422,
|
|
2173
|
+
error: "failed to re-parse after adding import"
|
|
2174
|
+
};
|
|
2175
|
+
const insertAt = findInsertionPoint(withImport, ast2);
|
|
2176
|
+
const block = `\nexport const design: DesignSystem = ${body};\n`;
|
|
2177
|
+
return {
|
|
2178
|
+
ok: true,
|
|
2179
|
+
source: withImport.slice(0, insertAt) + block + withImport.slice(insertAt),
|
|
2180
|
+
created: true
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
function designPlugin(opts) {
|
|
2184
|
+
const userCwd = opts.userCwd;
|
|
2185
|
+
const docsDir = opts.docsDir ?? "docs";
|
|
2186
|
+
return {
|
|
2187
|
+
name: "mosage:design",
|
|
2188
|
+
apply: "serve",
|
|
2189
|
+
configureServer(server) {
|
|
2190
|
+
server.middlewares.use("/__design", async (req, res, next) => {
|
|
2191
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
2192
|
+
const method = req.method ?? "GET";
|
|
2193
|
+
const docId = url.searchParams.get("docId") ?? "";
|
|
2194
|
+
const file = resolveDocPath(userCwd, docsDir, docId);
|
|
2195
|
+
if (!file) return json(res, 400, { error: "invalid docId" });
|
|
2196
|
+
try {
|
|
2197
|
+
if (method === "GET" && url.pathname === "/") {
|
|
2198
|
+
let source;
|
|
2199
|
+
try {
|
|
2200
|
+
source = await fs.readFile(file, "utf8");
|
|
2201
|
+
} catch {
|
|
2202
|
+
return json(res, 404, { error: "document not found" });
|
|
2203
|
+
}
|
|
2204
|
+
const parsed = parseDocDesign(source);
|
|
2205
|
+
if (parsed.ok) return json(res, 200, {
|
|
2206
|
+
design: parsed.design,
|
|
2207
|
+
exists: true,
|
|
2208
|
+
warning: null
|
|
2209
|
+
});
|
|
2210
|
+
if (parsed.exists === false) return json(res, 200, {
|
|
2211
|
+
design: defaultDesign,
|
|
2212
|
+
exists: false,
|
|
2213
|
+
warning: null
|
|
2214
|
+
});
|
|
2215
|
+
return json(res, 200, {
|
|
2216
|
+
design: defaultDesign,
|
|
2217
|
+
exists: true,
|
|
2218
|
+
warning: parsed.error
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
if (method === "PUT" && url.pathname === "/") {
|
|
2222
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2223
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2224
|
+
const patch = (await readBody(req)).patch;
|
|
2225
|
+
if (!patch || typeof patch !== "object") return json(res, 400, { error: "missing patch object" });
|
|
2226
|
+
let source;
|
|
2227
|
+
try {
|
|
2228
|
+
source = await fs.readFile(file, "utf8");
|
|
2229
|
+
} catch {
|
|
2230
|
+
return json(res, 404, { error: "document not found" });
|
|
2231
|
+
}
|
|
2232
|
+
const parsed = parseDocDesign(source);
|
|
2233
|
+
if (!parsed.ok && parsed.exists) return json(res, 422, { error: parsed.error });
|
|
2234
|
+
const merged = mergeDesign(parsed.ok ? parsed.design : defaultDesign, patch);
|
|
2235
|
+
const written = applyDesignWrite(source, merged);
|
|
2236
|
+
if (!written.ok) return json(res, written.status, { error: written.error });
|
|
2237
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
2238
|
+
return json(res, 200, {
|
|
2239
|
+
ok: true,
|
|
2240
|
+
design: merged,
|
|
2241
|
+
created: written.created
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
if (method === "POST" && url.pathname === "/reset") {
|
|
2245
|
+
const requestCheck = validateMutationRequest(req);
|
|
2246
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2247
|
+
let source;
|
|
2248
|
+
try {
|
|
2249
|
+
source = await fs.readFile(file, "utf8");
|
|
2250
|
+
} catch {
|
|
2251
|
+
return json(res, 404, { error: "document not found" });
|
|
2252
|
+
}
|
|
2253
|
+
const written = applyDesignWrite(source, defaultDesign);
|
|
2254
|
+
if (!written.ok) return json(res, written.status, { error: written.error });
|
|
2255
|
+
if (written.source !== source) await fs.writeFile(file, written.source, "utf8");
|
|
2256
|
+
return json(res, 200, {
|
|
2257
|
+
ok: true,
|
|
2258
|
+
design: defaultDesign,
|
|
2259
|
+
created: written.created
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
return next();
|
|
2263
|
+
} catch (err) {
|
|
2264
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
//#endregion
|
|
2271
|
+
//#region src/vite/diagram-plugin.ts
|
|
2272
|
+
const DIAGRAM_RE = /\.(mmd|mermaid)$/i;
|
|
2273
|
+
/**
|
|
2274
|
+
* Makes `import chart from './architecture.mmd'` a themed SVG at build time.
|
|
2275
|
+
*
|
|
2276
|
+
* The same rule as `data-plugin` applies, and for the same reason: the flow
|
|
2277
|
+
* packer measures the real DOM to decide where pages break, so a drawing that
|
|
2278
|
+
* renders a tick later renders after the layout is decided. Compiling in the
|
|
2279
|
+
* plugin also means no diagram library reaches the browser bundle — the SVG
|
|
2280
|
+
* arrives as text, already sized.
|
|
2281
|
+
*
|
|
2282
|
+
* `?raw` and `?url` are left to Vite.
|
|
2283
|
+
*/
|
|
2284
|
+
function diagramPlugin() {
|
|
2285
|
+
return {
|
|
2286
|
+
name: "mosage:diagram",
|
|
2287
|
+
enforce: "pre",
|
|
2288
|
+
async load(id) {
|
|
2289
|
+
const [file, query] = id.split("?");
|
|
2290
|
+
if (query !== void 0) return null;
|
|
2291
|
+
if (!DIAGRAM_RE.test(file)) return null;
|
|
2292
|
+
let text;
|
|
2293
|
+
try {
|
|
2294
|
+
text = await fs.readFile(file, "utf8");
|
|
2295
|
+
} catch {
|
|
2296
|
+
return null;
|
|
2297
|
+
}
|
|
2298
|
+
try {
|
|
2299
|
+
const suffix = path.basename(file).replace(/[^a-zA-Z0-9]/g, "");
|
|
2300
|
+
const compiled = compileDiagram(text, { idSuffix: suffix });
|
|
2301
|
+
return [
|
|
2302
|
+
`export const svg = ${JSON.stringify(compiled.svg)};`,
|
|
2303
|
+
`export const width = ${compiled.width};`,
|
|
2304
|
+
`export const height = ${compiled.height};`,
|
|
2305
|
+
"export default { svg, width, height };"
|
|
2306
|
+
].join("\n");
|
|
2307
|
+
} catch (err) {
|
|
2308
|
+
if (err instanceof DiagramSyntaxError) this.error(`${path.relative(process.cwd(), file)}: ${err.message}`);
|
|
2309
|
+
throw err;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
//#endregion
|
|
2315
|
+
//#region src/vite/loc-tags-plugin.ts
|
|
2316
|
+
const FORWARDING_COMPONENTS = /* @__PURE__ */ new Set(["ImagePlaceholder"]);
|
|
2317
|
+
function taggableName(opening) {
|
|
2318
|
+
const name = opening.name;
|
|
2319
|
+
if (name?.type !== "JSXIdentifier" || typeof name.name !== "string") return null;
|
|
2320
|
+
if (/^[a-z]/.test(name.name) || FORWARDING_COMPONENTS.has(name.name)) return name.name;
|
|
2321
|
+
return null;
|
|
2322
|
+
}
|
|
2323
|
+
function alreadyTagged(opening) {
|
|
2324
|
+
return (opening.attributes ?? []).some((attr) => {
|
|
2325
|
+
if (attr.type !== "JSXAttribute") return false;
|
|
2326
|
+
return attr.name?.name === "data-od-loc";
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
function injectLocTags(code) {
|
|
2330
|
+
const ast = parseSource$1(code);
|
|
2331
|
+
if (!ast) return null;
|
|
2332
|
+
const insertions = [];
|
|
2333
|
+
walkJsx(ast, (node) => {
|
|
2334
|
+
const opening = node.openingElement;
|
|
2335
|
+
if (!opening || !node.loc) return;
|
|
2336
|
+
if (!taggableName(opening) || alreadyTagged(opening)) return;
|
|
2337
|
+
const nameNode = opening.name;
|
|
2338
|
+
insertions.push({
|
|
2339
|
+
offset: nameNode.end,
|
|
2340
|
+
text: ` data-od-loc="${node.loc.start.line}:${node.loc.start.column}"`
|
|
2341
|
+
});
|
|
2342
|
+
});
|
|
2343
|
+
if (insertions.length === 0) return null;
|
|
2344
|
+
insertions.sort((a, b) => b.offset - a.offset);
|
|
2345
|
+
let next = code;
|
|
2346
|
+
for (const insertion of insertions) next = next.slice(0, insertion.offset) + insertion.text + next.slice(insertion.offset);
|
|
2347
|
+
return next;
|
|
2348
|
+
}
|
|
2349
|
+
function isDocSourceFile(id, docsRootPosix) {
|
|
2350
|
+
const filePath = id.split(/[?#]/)[0].replace(/\\/g, "/");
|
|
2351
|
+
if (!filePath.startsWith(`${docsRootPosix}/`)) return false;
|
|
2352
|
+
if (!filePath.endsWith(".tsx")) return false;
|
|
2353
|
+
if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return false;
|
|
2354
|
+
return filePath.slice(docsRootPosix.length + 1).includes("/");
|
|
2355
|
+
}
|
|
2356
|
+
function locTagsPlugin(opts) {
|
|
2357
|
+
const docsRoot = path.resolve(opts.userCwd, opts.docsDir ?? "docs").replace(/\\/g, "/");
|
|
2358
|
+
return {
|
|
2359
|
+
name: "mosage:loc-tags",
|
|
2360
|
+
apply: "serve",
|
|
2361
|
+
enforce: "pre",
|
|
2362
|
+
transform(code, id) {
|
|
2363
|
+
if (!isDocSourceFile(id, docsRoot)) return null;
|
|
2364
|
+
const next = injectLocTags(code);
|
|
2365
|
+
return next === null ? null : {
|
|
2366
|
+
code: next,
|
|
2367
|
+
map: null
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
//#endregion
|
|
2373
|
+
//#region src/vite/themes-plugin.ts
|
|
2374
|
+
const THEMES_VMOD = "virtual:mosage/themes";
|
|
2375
|
+
function resolved(id) {
|
|
2376
|
+
return `\0${id}`;
|
|
2377
|
+
}
|
|
2378
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
2379
|
+
function parseFrontmatter(raw, themeId) {
|
|
2380
|
+
const match = raw.match(FM_RE);
|
|
2381
|
+
const fmText = match ? match[1] : "";
|
|
2382
|
+
const body = match ? match[2] : raw;
|
|
2383
|
+
const data = {};
|
|
2384
|
+
for (const line of fmText.split(/\r?\n/)) {
|
|
2385
|
+
const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
|
|
2386
|
+
if (!m) continue;
|
|
2387
|
+
let value = m[2].trim();
|
|
2388
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
2389
|
+
data[m[1]] = value;
|
|
2390
|
+
}
|
|
2391
|
+
return {
|
|
2392
|
+
fm: {
|
|
2393
|
+
name: data.name || themeId,
|
|
2394
|
+
description: data.description || "",
|
|
2395
|
+
pageSize: data.pageSize || "",
|
|
2396
|
+
mode: data.mode || ""
|
|
2397
|
+
},
|
|
2398
|
+
body: body.trim()
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
async function findThemes(userCwd, themesDir) {
|
|
2402
|
+
const abs = path.resolve(userCwd, themesDir);
|
|
2403
|
+
if (!existsSync(abs)) return [];
|
|
2404
|
+
return (await fg("*.md", {
|
|
2405
|
+
cwd: abs,
|
|
2406
|
+
absolute: true,
|
|
2407
|
+
onlyFiles: true
|
|
2408
|
+
})).sort();
|
|
2409
|
+
}
|
|
2410
|
+
async function readTheme(mdAbs, themesRoot) {
|
|
2411
|
+
const id = path.basename(mdAbs, ".md");
|
|
2412
|
+
const { fm, body } = parseFrontmatter(await fs.readFile(mdAbs, "utf8"), id);
|
|
2413
|
+
let demoAbs = null;
|
|
2414
|
+
for (const cand of [
|
|
2415
|
+
`${id}.demo.tsx`,
|
|
2416
|
+
`${id}.demo.jsx`,
|
|
2417
|
+
`${id}.demo.ts`,
|
|
2418
|
+
`${id}.demo.js`
|
|
2419
|
+
]) {
|
|
2420
|
+
const p = path.join(themesRoot, cand);
|
|
2421
|
+
if (existsSync(p)) {
|
|
2422
|
+
demoAbs = p;
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
return {
|
|
2427
|
+
id,
|
|
2428
|
+
frontmatter: fm,
|
|
2429
|
+
body,
|
|
2430
|
+
demoAbs
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
function generateThemesModule(themes, isDev) {
|
|
2434
|
+
const meta = themes.map((t) => ({
|
|
2435
|
+
id: t.id,
|
|
2436
|
+
name: t.frontmatter.name,
|
|
2437
|
+
description: t.frontmatter.description,
|
|
2438
|
+
pageSize: t.frontmatter.pageSize,
|
|
2439
|
+
mode: t.frontmatter.mode,
|
|
2440
|
+
body: t.body,
|
|
2441
|
+
hasDemo: t.demoAbs !== null
|
|
2442
|
+
}));
|
|
2443
|
+
const cases = themes.flatMap((t) => {
|
|
2444
|
+
const abs = t.demoAbs;
|
|
2445
|
+
if (!abs) return [];
|
|
2446
|
+
const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
2447
|
+
const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(importPath)})` : `import(${JSON.stringify(importPath)})`;
|
|
2448
|
+
return [` case ${JSON.stringify(t.id)}: return ${importExpr};`];
|
|
2449
|
+
}).join("\n");
|
|
2450
|
+
return `// virtual:mosage/themes — generated
|
|
2451
|
+
export const themes = ${JSON.stringify(meta)};
|
|
2452
|
+
|
|
2453
|
+
export async function loadThemeDemo(id) {
|
|
2454
|
+
switch (id) {
|
|
2455
|
+
${cases}
|
|
2456
|
+
default: throw new Error('Theme demo not found: ' + id);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
`;
|
|
2460
|
+
}
|
|
2461
|
+
function themesPlugin(opts) {
|
|
2462
|
+
const { userCwd, config } = opts;
|
|
2463
|
+
const themesDir = config.themesDir ?? "themes";
|
|
2464
|
+
const themesRoot = path.resolve(userCwd, themesDir);
|
|
2465
|
+
let isDev = false;
|
|
2466
|
+
return {
|
|
2467
|
+
name: "mosage:themes",
|
|
2468
|
+
config(_c, env) {
|
|
2469
|
+
isDev = env.command === "serve";
|
|
2470
|
+
},
|
|
2471
|
+
resolveId(id) {
|
|
2472
|
+
if (id === THEMES_VMOD) return resolved(THEMES_VMOD);
|
|
2473
|
+
return null;
|
|
2474
|
+
},
|
|
2475
|
+
async load(id) {
|
|
2476
|
+
if (id !== resolved(THEMES_VMOD)) return null;
|
|
2477
|
+
const files = await findThemes(userCwd, themesDir);
|
|
2478
|
+
return generateThemesModule(await Promise.all(files.map((f) => readTheme(f, themesRoot))), isDev);
|
|
2479
|
+
},
|
|
2480
|
+
configureServer(server) {
|
|
2481
|
+
const isThemeFile = (p) => {
|
|
2482
|
+
const rel = path.relative(themesRoot, p);
|
|
2483
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
2484
|
+
if (rel.includes(path.sep)) return false;
|
|
2485
|
+
return /\.(md|demo\.(tsx|jsx|ts|js))$/.test(rel);
|
|
2486
|
+
};
|
|
2487
|
+
let reloadTimer = null;
|
|
2488
|
+
const reload = () => {
|
|
2489
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
2490
|
+
reloadTimer = setTimeout(() => {
|
|
2491
|
+
reloadTimer = null;
|
|
2492
|
+
const mod = server.moduleGraph.getModuleById(resolved(THEMES_VMOD));
|
|
2493
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
2494
|
+
server.ws.send({ type: "full-reload" });
|
|
2495
|
+
}, 150);
|
|
2496
|
+
};
|
|
2497
|
+
server.watcher.add(themesRoot);
|
|
2498
|
+
for (const event of [
|
|
2499
|
+
"add",
|
|
2500
|
+
"unlink",
|
|
2501
|
+
"change"
|
|
2502
|
+
]) server.watcher.on(event, (p) => {
|
|
2503
|
+
if (isThemeFile(p)) reload();
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
//#endregion
|
|
2509
|
+
//#region src/vite/config.ts
|
|
2510
|
+
function findPackageRoot(fromFile) {
|
|
2511
|
+
let dir = path.dirname(fromFile);
|
|
2512
|
+
while (dir !== path.dirname(dir)) {
|
|
2513
|
+
if (existsSync(path.join(dir, "package.json"))) return dir;
|
|
2514
|
+
dir = path.dirname(dir);
|
|
2515
|
+
}
|
|
2516
|
+
throw new Error(`Could not find package.json walking up from ${fromFile}`);
|
|
2517
|
+
}
|
|
2518
|
+
const PKG_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
|
|
2519
|
+
const APP_ROOT = path.join(PKG_ROOT, "src", "app");
|
|
2520
|
+
function readCoreVersion() {
|
|
2521
|
+
try {
|
|
2522
|
+
const raw = readFileSync(path.join(PKG_ROOT, "package.json"), "utf8");
|
|
2523
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
2524
|
+
} catch {
|
|
2525
|
+
return "0.0.0";
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
const CORE_VERSION = readCoreVersion();
|
|
2529
|
+
async function createViteConfig(opts) {
|
|
2530
|
+
const userCwd = path.resolve(opts.userCwd);
|
|
2531
|
+
const config = opts.config ?? await loadUserConfig(userCwd);
|
|
2532
|
+
const docsDir = config.docsDir ?? "docs";
|
|
2533
|
+
const assetsDir = config.assetsDir ?? "assets";
|
|
2534
|
+
const docsAbs = path.resolve(userCwd, docsDir);
|
|
2535
|
+
const themesAbs = path.resolve(userCwd, config.themesDir ?? "themes");
|
|
2536
|
+
const assetsAbs = path.resolve(userCwd, assetsDir);
|
|
2537
|
+
return {
|
|
2538
|
+
base: config.base ?? "/",
|
|
2539
|
+
root: APP_ROOT,
|
|
2540
|
+
configFile: false,
|
|
2541
|
+
envDir: userCwd,
|
|
2542
|
+
plugins: [
|
|
2543
|
+
dataPlugin(),
|
|
2544
|
+
diagramPlugin(),
|
|
2545
|
+
locTagsPlugin({
|
|
2546
|
+
userCwd,
|
|
2547
|
+
docsDir
|
|
2548
|
+
}),
|
|
2549
|
+
react(),
|
|
2550
|
+
tailwindcss(),
|
|
2551
|
+
mosagePlugin({
|
|
2552
|
+
userCwd,
|
|
2553
|
+
config,
|
|
2554
|
+
coreVersion: CORE_VERSION
|
|
2555
|
+
}),
|
|
2556
|
+
themesPlugin({
|
|
2557
|
+
userCwd,
|
|
2558
|
+
config
|
|
2559
|
+
}),
|
|
2560
|
+
designPlugin({
|
|
2561
|
+
userCwd,
|
|
2562
|
+
docsDir
|
|
2563
|
+
}),
|
|
2564
|
+
apiPlugin({
|
|
2565
|
+
userCwd,
|
|
2566
|
+
docsDir,
|
|
2567
|
+
assetsDir,
|
|
2568
|
+
coreVersion: CORE_VERSION
|
|
2569
|
+
}),
|
|
2570
|
+
...opts.headless ? [] : [currentPlugin({
|
|
2571
|
+
userCwd,
|
|
2572
|
+
docsDir
|
|
2573
|
+
})]
|
|
2574
|
+
],
|
|
2575
|
+
resolve: { alias: {
|
|
2576
|
+
"@": APP_ROOT,
|
|
2577
|
+
"@assets": assetsAbs
|
|
2578
|
+
} },
|
|
2579
|
+
optimizeDeps: {
|
|
2580
|
+
entries: [path.join(APP_ROOT, "main.tsx")],
|
|
2581
|
+
include: [
|
|
2582
|
+
"react",
|
|
2583
|
+
"react-dom",
|
|
2584
|
+
"react-dom/client",
|
|
2585
|
+
"react-router-dom",
|
|
2586
|
+
"next-themes",
|
|
2587
|
+
"lucide-react",
|
|
2588
|
+
"clsx",
|
|
2589
|
+
"tailwind-merge"
|
|
2590
|
+
],
|
|
2591
|
+
esbuildOptions: { plugins: [{
|
|
2592
|
+
name: "mosage:virtual-externals",
|
|
2593
|
+
setup(build) {
|
|
2594
|
+
build.onResolve({ filter: /^virtual:mosage\// }, (args) => ({
|
|
2595
|
+
path: args.path,
|
|
2596
|
+
external: true
|
|
2597
|
+
}));
|
|
2598
|
+
}
|
|
2599
|
+
}] }
|
|
2600
|
+
},
|
|
2601
|
+
server: {
|
|
2602
|
+
port: config.port ?? 5273,
|
|
2603
|
+
...config.allowedHosts !== void 0 ? { allowedHosts: config.allowedHosts } : {},
|
|
2604
|
+
fs: { allow: [
|
|
2605
|
+
APP_ROOT,
|
|
2606
|
+
userCwd,
|
|
2607
|
+
docsAbs,
|
|
2608
|
+
themesAbs,
|
|
2609
|
+
assetsAbs
|
|
2610
|
+
] }
|
|
2611
|
+
},
|
|
2612
|
+
build: {
|
|
2613
|
+
outDir: path.resolve(userCwd, "dist"),
|
|
2614
|
+
emptyOutDir: true
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
//#endregion
|
|
2619
|
+
export { resolveEntry as a, DOC_ID_RE as c, listDocIds as i, loadUserConfig as l, OpsError as n, makeContext as o, createDocument as r, validateAssetName as s, createViteConfig as t, mosagePlugin as u };
|