@geml/geml 1.0.0 → 1.3.2
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 +21 -21
- package/README.md +109 -72
- package/codemap/adapters/crg.mjs +120 -0
- package/codemap/adapters/joern.mjs +131 -0
- package/codemap/adapters/scip.mjs +658 -0
- package/codemap/browser-stub.mjs +29 -0
- package/codemap/build.mjs +579 -0
- package/codemap/detect.mjs +399 -0
- package/codemap/emit.mjs +432 -0
- package/codemap/entries.mjs +129 -0
- package/codemap/exclude.mjs +52 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/joern-export.sc +83 -0
- package/codemap/mcp-server.mjs +172 -0
- package/codemap/normalize.mjs +272 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +310 -0
- package/codemap/render-all.mjs +64 -0
- package/codemap/serve.mjs +578 -0
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +143 -0
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +7 -1
- package/dist/geml.js +700 -52
- package/dist/history.d.ts +30 -0
- package/dist/history.js +212 -32
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +91 -2
- package/dist/render.js +1916 -50
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +5 -3
- package/package.json +63 -54
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// geml-code-graph SFC virtualizer — Vue/Svelte single-file components become
|
|
3
|
+
// indexable TypeScript. scip-typescript cannot read .vue/.svelte; this script
|
|
4
|
+
// projects each SFC to a shadow TS file (script AND template — an
|
|
5
|
+
// @click="save" in a Vue template becomes a real reference to save), plus a
|
|
6
|
+
// line-mapping sidecar the scip adapter uses to attribute every symbol back
|
|
7
|
+
// to the original .vue/.svelte source. Env-driven like joern-export.sc:
|
|
8
|
+
//
|
|
9
|
+
// GEML_SRC project (sub)root to walk for .vue/.svelte and real TS/JS
|
|
10
|
+
// GEML_OUT output dir (the "virtual dir"): shadows + sidecars + tsconfig
|
|
11
|
+
//
|
|
12
|
+
// The build invokes it hermetically — @geml/geml stays zero-dependency:
|
|
13
|
+
// npx -y -p @vue/language-core -p svelte2tsx -p svelte -p typescript@5 \
|
|
14
|
+
// node <abs path to this script>
|
|
15
|
+
// npx does NOT put the -p packages on NODE_PATH; it prepends
|
|
16
|
+
// <npm-cache>/_npx/<hash>/node_modules/.bin to PATH. We derive the
|
|
17
|
+
// node_modules dir from that PATH entry and createRequire() out of it
|
|
18
|
+
// (proven on Windows); the target project's own node_modules and this
|
|
19
|
+
// script's context are fallbacks, in that order. typescript is pinned @5:
|
|
20
|
+
// typescript@latest is 7.x, which @vue/language-core does not accept.
|
|
21
|
+
//
|
|
22
|
+
// Vue — @vue/language-core (Volar): createVueLanguagePlugin().createVirtualCode()
|
|
23
|
+
// and the `script_ts|js|tsx|jsx` embedded code, whose text contains the
|
|
24
|
+
// <script> verbatim plus the template projection; its offset mappings
|
|
25
|
+
// [sourceOffsets, generatedOffsets, lengths] become the line map.
|
|
26
|
+
// Svelte — svelte2tsx {code, map}: everything lands inside a generated
|
|
27
|
+
// $$render(); the source-map v3 `mappings` VLQ string is decoded here
|
|
28
|
+
// (no dependency) into the same line-map shape.
|
|
29
|
+
//
|
|
30
|
+
// Per SFC <rel>.vue|.svelte the virtual dir receives:
|
|
31
|
+
// <rel>.<vue|svelte>.<ts|js|tsx|jsx> the shadow (".vue.ts" naming makes
|
|
32
|
+
// `import x from './App.vue'` resolve
|
|
33
|
+
// to the shadow by TS extension probing)
|
|
34
|
+
// <rel>.….map.json the mapping sidecar:
|
|
35
|
+
// { "version": 1,
|
|
36
|
+
// "original": "src/App.vue", // posix, relative to GEML_SRC
|
|
37
|
+
// "framework": "vue" | "svelte",
|
|
38
|
+
// "component": "App", // basename minus extension
|
|
39
|
+
// "lines": [[generatedLine, originalLine], …], // 1-based, sorted by
|
|
40
|
+
// // generatedLine, first mapping wins;
|
|
41
|
+
// // a generated line absent here is
|
|
42
|
+
// // PURE GENERATED (never attribute it)
|
|
43
|
+
// "regions": [{ "name": "template", "start": 8, "end": 11 }] }
|
|
44
|
+
// // 1-based ORIGINAL line spans of
|
|
45
|
+
// // template/markup — a reference whose
|
|
46
|
+
// // mapped line falls here but whose
|
|
47
|
+
// // caller is generated-only belongs to
|
|
48
|
+
// // the synthetic <Component>.template
|
|
49
|
+
// plus one sfc-manifest.json ({ src, files:[{shadow, original, map}] }) the
|
|
50
|
+
// scip adapter loads, and one synthetic tsconfig.json.
|
|
51
|
+
//
|
|
52
|
+
// tsconfig include strategy: explicit "files" only (shadows + the project's
|
|
53
|
+
// real .ts/.tsx/.js/.jsx via relative paths) — no include globs, so sibling
|
|
54
|
+
// virtual dirs, node_modules and build output can never leak in.
|
|
55
|
+
// "rootDirs": [".", <rel GEML_SRC>] merges the two trees for RELATIVE import
|
|
56
|
+
// resolution: a shadow's `./helper` finds the real <src>/helper.ts, a real
|
|
57
|
+
// main.ts's `./App.vue` finds the shadow App.vue.ts. Bare imports (vue,
|
|
58
|
+
// svelte) resolve through "paths" → every node_modules dir walking up from
|
|
59
|
+
// GEML_SRC, so hoisted monorepo layouts work.
|
|
60
|
+
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
|
61
|
+
import { join, dirname, relative, resolve, basename, delimiter } from "node:path";
|
|
62
|
+
import { createRequire } from "node:module";
|
|
63
|
+
import { SKIP_DIRS } from "./detect.mjs";
|
|
64
|
+
|
|
65
|
+
const posix = (p) => p.replace(/\\/g, "/");
|
|
66
|
+
|
|
67
|
+
// ---- library resolution (npx -p → project → this script) -------------------
|
|
68
|
+
function makeResolver(srcAbs) {
|
|
69
|
+
const requires = [];
|
|
70
|
+
const npxBin = (process.env.PATH ?? "")
|
|
71
|
+
.split(delimiter)
|
|
72
|
+
.find((p) => /[\\/]_npx[\\/]/.test(p) && /[\\/]\.bin[\\/]?$/.test(p));
|
|
73
|
+
if (npxBin) {
|
|
74
|
+
try { requires.push(createRequire(join(npxBin.replace(/[\\/]\.bin[\\/]?$/, ""), "x.js"))); } catch { /* malformed PATH entry */ }
|
|
75
|
+
}
|
|
76
|
+
for (let d = srcAbs; ; ) {
|
|
77
|
+
if (existsSync(join(d, "node_modules"))) {
|
|
78
|
+
try { requires.push(createRequire(join(d, "node_modules", "x.js"))); } catch { /* keep walking */ }
|
|
79
|
+
}
|
|
80
|
+
const up = dirname(d);
|
|
81
|
+
if (up === d) break;
|
|
82
|
+
d = up;
|
|
83
|
+
}
|
|
84
|
+
try { requires.push(createRequire(import.meta.url)); } catch { /* no local context */ }
|
|
85
|
+
const lib = (name) => {
|
|
86
|
+
for (const r of requires) { try { return r(name); } catch { /* next origin */ } }
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
lib.path = (name) => {
|
|
90
|
+
for (const r of requires) { try { return r.resolve(name); } catch { /* next origin */ } }
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
return lib;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- shared line helpers ----------------------------------------------------
|
|
97
|
+
// offset -> 0-based line, O(log n) over precomputed line starts.
|
|
98
|
+
function lineIndex(text) {
|
|
99
|
+
const starts = [0];
|
|
100
|
+
for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
|
|
101
|
+
return (off) => {
|
|
102
|
+
let lo = 0, hi = starts.length - 1;
|
|
103
|
+
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; }
|
|
104
|
+
return lo;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Volar offset mappings -> [[genLine, origLine], …] (1-based). A multi-line
|
|
109
|
+
// mapping segment is a verbatim copy, so lines advance in lockstep.
|
|
110
|
+
function linePairsFromVolar(srcText, genText, mappings) {
|
|
111
|
+
const srcLine = lineIndex(srcText), genLine = lineIndex(genText);
|
|
112
|
+
const triples = [];
|
|
113
|
+
for (const m of mappings) {
|
|
114
|
+
for (let i = 0; i < m.sourceOffsets.length; i++) {
|
|
115
|
+
triples.push([m.sourceOffsets[i], m.generatedOffsets[i], m.lengths?.[i] ?? 0]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
triples.sort((a, b) => a[1] - b[1]);
|
|
119
|
+
const pairs = new Map(); // genLine0 -> origLine0, first mapping wins
|
|
120
|
+
for (const [s, g, l] of triples) {
|
|
121
|
+
const g0 = genLine(g), s0 = srcLine(s);
|
|
122
|
+
const span = genLine(g + Math.max(l - 1, 0)) - g0;
|
|
123
|
+
for (let k = 0; k <= span; k++) if (!pairs.has(g0 + k)) pairs.set(g0 + k, s0 + k);
|
|
124
|
+
}
|
|
125
|
+
return [...pairs.entries()].sort((a, b) => a[0] - b[0]).map(([g, s]) => [g + 1, s + 1]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Source-map v3 `mappings` VLQ -> [[genLine, origLine], …] (1-based). Tiny
|
|
129
|
+
// hand-rolled base64-VLQ decoder — per generated line the FIRST segment that
|
|
130
|
+
// names a source line wins. Fields per segment: [genCol, srcIdx, srcLine,
|
|
131
|
+
// srcCol, name]; all deltas except genCol carry across lines.
|
|
132
|
+
function linePairsFromV3(mappings) {
|
|
133
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
134
|
+
const val = new Map([...B64].map((c, i) => [c, i]));
|
|
135
|
+
const pairs = [];
|
|
136
|
+
let srcIdx = 0, srcLine = 0, srcCol = 0, name = 0;
|
|
137
|
+
const rows = String(mappings).split(";");
|
|
138
|
+
for (let g = 0; g < rows.length; g++) {
|
|
139
|
+
let taken = false;
|
|
140
|
+
for (const seg of rows[g].split(",")) {
|
|
141
|
+
if (!seg) continue;
|
|
142
|
+
const fields = [];
|
|
143
|
+
let shift = 0, cur = 0;
|
|
144
|
+
for (const ch of seg) {
|
|
145
|
+
const d = val.get(ch);
|
|
146
|
+
cur |= (d & 31) << shift;
|
|
147
|
+
if (d & 32) { shift += 5; continue; }
|
|
148
|
+
fields.push(cur & 1 ? -(cur >>> 1) : cur >>> 1);
|
|
149
|
+
shift = 0; cur = 0;
|
|
150
|
+
}
|
|
151
|
+
if (fields.length >= 4) {
|
|
152
|
+
srcIdx += fields[1]; srcLine += fields[2]; srcCol += fields[3];
|
|
153
|
+
if (fields.length >= 5) name += fields[4];
|
|
154
|
+
if (!taken) { pairs.push([g + 1, srcLine + 1]); taken = true; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return pairs;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- walk --------------------------------------------------------------------
|
|
162
|
+
function walk(rootAbs) {
|
|
163
|
+
const sfc = [], real = [];
|
|
164
|
+
const rec = (dir) => {
|
|
165
|
+
let ents;
|
|
166
|
+
try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
167
|
+
for (const e of ents) {
|
|
168
|
+
if (e.isDirectory()) {
|
|
169
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) rec(join(dir, e.name));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!e.isFile()) continue;
|
|
173
|
+
const rel = posix(relative(rootAbs, join(dir, e.name)));
|
|
174
|
+
if (/\.(vue|svelte)$/i.test(e.name)) sfc.push(rel);
|
|
175
|
+
else if (/\.(ts|tsx|js|jsx)$/i.test(e.name) && !e.name.endsWith(".d.ts")) real.push(rel);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
rec(rootAbs);
|
|
179
|
+
return { sfc: sfc.sort(), real: real.sort() };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- vue ---------------------------------------------------------------------
|
|
183
|
+
function makeVue(lib) {
|
|
184
|
+
const ts = lib("typescript");
|
|
185
|
+
const core = lib("@vue/language-core");
|
|
186
|
+
if (!ts || !core) return null;
|
|
187
|
+
const vueOptions = core.resolveVueCompilerOptions
|
|
188
|
+
? core.resolveVueCompilerOptions({}) // language-core 2.x
|
|
189
|
+
: core.getDefaultCompilerOptions(); // language-core 3.x
|
|
190
|
+
const plugin = core.createVueLanguagePlugin(ts, { allowJs: true }, vueOptions, (id) => id);
|
|
191
|
+
return (absPosix, text) => {
|
|
192
|
+
const code = plugin.createVirtualCode(absPosix, "vue", ts.ScriptSnapshot.fromString(text));
|
|
193
|
+
if (!code) throw new Error("not a valid vue file for this plugin");
|
|
194
|
+
let embedded;
|
|
195
|
+
for (const ec of core.forEachEmbeddedCode(code)) {
|
|
196
|
+
if (/^script_(ts|tsx|js|jsx)$/.test(ec.id)) { embedded = ec; break; }
|
|
197
|
+
}
|
|
198
|
+
if (!embedded) throw new Error("no script embedded code produced");
|
|
199
|
+
const gen = embedded.snapshot.getText(0, embedded.snapshot.getLength());
|
|
200
|
+
const regions = [];
|
|
201
|
+
const tpl = code.vueSfc?.descriptor?.template?.loc;
|
|
202
|
+
if (tpl) regions.push({ name: "template", start: tpl.start.line, end: tpl.end.line });
|
|
203
|
+
else {
|
|
204
|
+
// parse-less fallback: the top-level <template> block by text position
|
|
205
|
+
const idx = lineIndex(text);
|
|
206
|
+
const open = text.search(/<template[\s>]/);
|
|
207
|
+
const close = text.lastIndexOf("</template>");
|
|
208
|
+
if (open >= 0 && close > open) regions.push({ name: "template", start: idx(open) + 1, end: idx(close) + 1 });
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
ext: embedded.id.slice("script_".length),
|
|
212
|
+
gen,
|
|
213
|
+
lines: linePairsFromVolar(text, gen, embedded.mappings),
|
|
214
|
+
regions,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- svelte ------------------------------------------------------------------
|
|
220
|
+
function makeSvelte(lib) {
|
|
221
|
+
const mod = lib("svelte2tsx");
|
|
222
|
+
if (!mod) return null;
|
|
223
|
+
const svelte2tsx = mod.svelte2tsx ?? mod;
|
|
224
|
+
return (absPosix, text) => {
|
|
225
|
+
const isTsFile = /<script[^>]*\blang\s*=\s*["']?ts/.test(text);
|
|
226
|
+
const out = svelte2tsx(text, { filename: absPosix, isTsFile, mode: "ts" });
|
|
227
|
+
// markup = every line outside top-level <script>/<style> blocks, trimmed
|
|
228
|
+
// to its non-blank extent; a component may hold several disjoint spans.
|
|
229
|
+
const idx = lineIndex(text);
|
|
230
|
+
const total = idx(text.length) + 1;
|
|
231
|
+
const blocked = new Array(total).fill(false);
|
|
232
|
+
for (const m of text.matchAll(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/g)) {
|
|
233
|
+
const a = idx(m.index), b = idx(m.index + m[0].length - 1);
|
|
234
|
+
for (let i = a; i <= b; i++) blocked[i] = true;
|
|
235
|
+
}
|
|
236
|
+
const srcLines = text.split("\n");
|
|
237
|
+
const regions = [];
|
|
238
|
+
for (let i = 0; i < total; i++) {
|
|
239
|
+
if (blocked[i] || !srcLines[i]?.trim()) continue;
|
|
240
|
+
let j = i;
|
|
241
|
+
while (j + 1 < total && !blocked[j + 1]) j++;
|
|
242
|
+
while (j > i && !srcLines[j]?.trim()) j--;
|
|
243
|
+
regions.push({ name: "template", start: i + 1, end: j + 1 });
|
|
244
|
+
i = j;
|
|
245
|
+
}
|
|
246
|
+
return { ext: "ts", gen: out.code, lines: linePairsFromV3(out.map.mappings), regions };
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- main --------------------------------------------------------------------
|
|
251
|
+
const srcAbs = resolve(process.env.GEML_SRC ?? ".");
|
|
252
|
+
const outAbs = resolve(process.env.GEML_OUT ?? "");
|
|
253
|
+
if (!process.env.GEML_OUT) {
|
|
254
|
+
console.error("sfc-virtualize: GEML_OUT (output dir) is required; GEML_SRC defaults to cwd");
|
|
255
|
+
process.exit(2);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const lib = makeResolver(srcAbs);
|
|
259
|
+
const { sfc, real } = walk(srcAbs);
|
|
260
|
+
if (!sfc.length) {
|
|
261
|
+
console.error(`sfc-virtualize: no .vue/.svelte files under ${srcAbs} — nothing to do`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let vue = null, svelte = null;
|
|
266
|
+
const missing = [];
|
|
267
|
+
if (sfc.some((f) => /\.vue$/i.test(f))) {
|
|
268
|
+
vue = makeVue(lib);
|
|
269
|
+
if (!vue) missing.push("@vue/language-core (+ typescript@5)");
|
|
270
|
+
}
|
|
271
|
+
if (sfc.some((f) => /\.svelte$/i.test(f))) {
|
|
272
|
+
svelte = makeSvelte(lib);
|
|
273
|
+
if (!svelte) missing.push("svelte2tsx (+ svelte)");
|
|
274
|
+
}
|
|
275
|
+
if (missing.length) {
|
|
276
|
+
console.error(
|
|
277
|
+
`sfc-virtualize: cannot resolve ${missing.join(" and ")} — run via\n`
|
|
278
|
+
+ " npx -y -p @vue/language-core -p svelte2tsx -p svelte -p typescript@5 node <this script>\n"
|
|
279
|
+
+ "or install them in the target project.",
|
|
280
|
+
);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
mkdirSync(outAbs, { recursive: true });
|
|
285
|
+
const manifest = [];
|
|
286
|
+
let nVue = 0, nSvelte = 0, failed = 0;
|
|
287
|
+
for (const rel of sfc) {
|
|
288
|
+
const absPosix = posix(join(srcAbs, rel));
|
|
289
|
+
try {
|
|
290
|
+
const text = readFileSync(join(srcAbs, rel), "utf8");
|
|
291
|
+
const isVue = /\.vue$/i.test(rel);
|
|
292
|
+
const r = (isVue ? vue : svelte)(absPosix, text);
|
|
293
|
+
const shadowRel = `${rel}.${r.ext}`;
|
|
294
|
+
const shadowAbs = join(outAbs, shadowRel);
|
|
295
|
+
mkdirSync(dirname(shadowAbs), { recursive: true });
|
|
296
|
+
writeFileSync(shadowAbs, r.gen);
|
|
297
|
+
const mapRel = `${shadowRel}.map.json`;
|
|
298
|
+
writeFileSync(join(outAbs, mapRel), JSON.stringify({
|
|
299
|
+
version: 1,
|
|
300
|
+
original: rel,
|
|
301
|
+
framework: isVue ? "vue" : "svelte",
|
|
302
|
+
component: basename(rel).replace(/\.(vue|svelte)$/i, ""),
|
|
303
|
+
lines: r.lines,
|
|
304
|
+
regions: r.regions,
|
|
305
|
+
}));
|
|
306
|
+
manifest.push({ shadow: shadowRel, original: rel, map: mapRel });
|
|
307
|
+
isVue ? nVue++ : nSvelte++;
|
|
308
|
+
} catch (e) {
|
|
309
|
+
failed++;
|
|
310
|
+
console.error(`sfc-virtualize: FAILED ${rel}: ${e.message}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!manifest.length) {
|
|
315
|
+
console.error("sfc-virtualize: every SFC failed to virtualize — nothing to index");
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// svelte2tsx global shims (svelteHTML etc) — cosmetic for indexing (user
|
|
320
|
+
// symbols resolve without them) but cheap to include when findable.
|
|
321
|
+
const extraFiles = [];
|
|
322
|
+
if (nSvelte) {
|
|
323
|
+
for (const shim of ["svelte2tsx/svelte-shims-v4.d.ts", "svelte2tsx/svelte-shims.d.ts"]) {
|
|
324
|
+
const p = lib.path(shim);
|
|
325
|
+
if (p) {
|
|
326
|
+
try {
|
|
327
|
+
copyFileSync(p, join(outAbs, "svelte-shims.d.ts"));
|
|
328
|
+
extraFiles.push("svelte-shims.d.ts");
|
|
329
|
+
} catch { /* optional */ }
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const relSrc = posix(relative(outAbs, srcAbs)) || ".";
|
|
336
|
+
const nmPaths = [];
|
|
337
|
+
for (let d = srcAbs; ; ) {
|
|
338
|
+
if (existsSync(join(d, "node_modules"))) nmPaths.push(`${posix(relative(outAbs, join(d, "node_modules")))}/*`);
|
|
339
|
+
const up = dirname(d);
|
|
340
|
+
if (up === d) break;
|
|
341
|
+
d = up;
|
|
342
|
+
}
|
|
343
|
+
writeFileSync(join(outAbs, "tsconfig.json"), JSON.stringify({
|
|
344
|
+
compilerOptions: {
|
|
345
|
+
allowJs: true, checkJs: false, noEmit: true, skipLibCheck: true,
|
|
346
|
+
module: "esnext", target: "esnext", moduleResolution: "node",
|
|
347
|
+
jsx: "preserve", baseUrl: ".",
|
|
348
|
+
rootDirs: [".", relSrc],
|
|
349
|
+
...(nmPaths.length ? { paths: { "*": nmPaths } } : {}),
|
|
350
|
+
},
|
|
351
|
+
files: [
|
|
352
|
+
...manifest.map((m) => m.shadow),
|
|
353
|
+
...extraFiles,
|
|
354
|
+
...real.map((f) => `${relSrc}/${f}`),
|
|
355
|
+
],
|
|
356
|
+
}, null, 2));
|
|
357
|
+
|
|
358
|
+
writeFileSync(join(outAbs, "sfc-manifest.json"), JSON.stringify({
|
|
359
|
+
version: 1,
|
|
360
|
+
src: posix(srcAbs),
|
|
361
|
+
files: manifest,
|
|
362
|
+
}, null, 2));
|
|
363
|
+
|
|
364
|
+
console.error(
|
|
365
|
+
`sfc-virtualize: ${manifest.length} shadow(s) (${nVue} vue, ${nSvelte} svelte)`
|
|
366
|
+
+ `${failed ? `, ${failed} FAILED` : ""}, ${real.length} real source file(s) -> ${outAbs}`,
|
|
367
|
+
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// geml-code-graph verify — the codemap's correctness oracle, two passes:
|
|
3
|
+
//
|
|
4
|
+
// 1. `geml check` over every .geml (document structure, id uniqueness,
|
|
5
|
+
// native references).
|
|
6
|
+
// 2. The codemap-profile pass (docs/codemap-profile.md): CSV cells and meta
|
|
7
|
+
// values are opaque to the GEML standard BY DESIGN (the standard stays
|
|
8
|
+
// untouched), so edge integrity is checked here — the from/to columns of
|
|
9
|
+
// #calls / #called-by / #ref-by tables and every meta `entry` value must
|
|
10
|
+
// resolve (`#id` in the same document, `doc.geml#id` in a sibling).
|
|
11
|
+
// A renamed or deleted method therefore fails the build, not the reader.
|
|
12
|
+
//
|
|
13
|
+
// geml codemap verify [dir] [--geml <path-to-geml.js|geml>]
|
|
14
|
+
import { readdirSync, existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve, dirname, relative } from "node:path";
|
|
16
|
+
import { posix } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const flagI = args.indexOf("--geml");
|
|
22
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
23
|
+
console.error("usage: geml codemap verify [dir] [--geml <path>] (dir defaults to ./.geml-code-graph)");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
const dir = args.find((a, i) => !a.startsWith("-") && (flagI < 0 || i !== flagI + 1)) || ".geml-code-graph";
|
|
27
|
+
const rootDir = resolve(dir);
|
|
28
|
+
|
|
29
|
+
// Resolve the geml CLI (pass 1) and the parser API (pass 2).
|
|
30
|
+
const localParser = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
|
|
31
|
+
let cli = flagI >= 0 ? args[flagI + 1] : undefined;
|
|
32
|
+
if (!cli) cli = existsSync(localParser) ? localParser : "geml";
|
|
33
|
+
// win32 cmd.exe quoting (same rationale as build.mjs). q: space-aware quote for
|
|
34
|
+
// the PROGRAM token — a bare launcher name (geml resolved via PATH) whose
|
|
35
|
+
// .cmd/.bat shim uses %~dp0 breaks if the name is blanket-quoted. shq: ALWAYS
|
|
36
|
+
// double-quote ARGUMENTS — Node does not escape args under shell:true, so a
|
|
37
|
+
// `.geml` filename containing & | ( ) would otherwise break out and inject.
|
|
38
|
+
// cmd.exe treats those metacharacters and whitespace as literal inside quotes;
|
|
39
|
+
// CRT rules for embedded " / trailing \.
|
|
40
|
+
const q = (s) => (/[\s"]/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
|
|
41
|
+
const shq = (s) => `"${String(s).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
|
|
42
|
+
const runCheck = (file) => {
|
|
43
|
+
// The built-parser path: run node on geml.js directly (array args, no shell).
|
|
44
|
+
if (cli.endsWith(".js")) return spawnSync(process.execPath, [cli, "check", file], { encoding: "utf8" });
|
|
45
|
+
// A non-.js cli may be a .cmd/.bat launcher (e.g. geml.cmd on PATH), which
|
|
46
|
+
// Node can only spawn through the shell. Hand cmd.exe ONE pre-escaped command
|
|
47
|
+
// string (never an args array — that is the unescaped, injection-prone path).
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
return spawnSync([q(cli), ...["check", file].map(shq)].join(" "), { encoding: "utf8", shell: true });
|
|
50
|
+
}
|
|
51
|
+
return spawnSync(cli, ["check", file], { encoding: "utf8" });
|
|
52
|
+
};
|
|
53
|
+
if (!existsSync(localParser)) {
|
|
54
|
+
console.error("verify: the profile pass needs the built parser (cd geml-parser && npm install && npm run build)");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const { parse } = await import(`file://${localParser.replace(/\\/g, "/")}`);
|
|
58
|
+
|
|
59
|
+
const files = [];
|
|
60
|
+
const walk = (d) => {
|
|
61
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
62
|
+
const p = join(d, e.name);
|
|
63
|
+
if (e.isDirectory()) walk(p);
|
|
64
|
+
else if (e.name.endsWith(".geml")) files.push(p);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(rootDir);
|
|
68
|
+
files.sort();
|
|
69
|
+
|
|
70
|
+
// ---- pass 1: geml check ----
|
|
71
|
+
let failed = 0;
|
|
72
|
+
for (const f of files) {
|
|
73
|
+
const r = runCheck(f);
|
|
74
|
+
if (r.status !== 0) {
|
|
75
|
+
failed++;
|
|
76
|
+
console.error(`FAIL ${f}`);
|
|
77
|
+
console.error((r.stderr || r.stdout || "").split("\n").slice(0, 4).map((l) => ` ${l}`).join("\n"));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- pass 2: codemap profile references ----
|
|
82
|
+
const REF_TABLES = new Set(["calls", "called-by", "ref-by"]);
|
|
83
|
+
const relDoc = (f) => relative(rootDir, f).replace(/\\/g, "/");
|
|
84
|
+
const docs = new Map(); // relPath -> { ids:Set, blocks }
|
|
85
|
+
const collectIds = (blocks, ids) => {
|
|
86
|
+
for (const b of blocks) {
|
|
87
|
+
if (b.id) ids.add(b.id);
|
|
88
|
+
if (b.children) collectIds(b.children, ids);
|
|
89
|
+
if (b.items) for (const it of b.items) if (it.children) collectIds(it.children, ids);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
for (const f of files) {
|
|
93
|
+
const doc = parse(readFileSync(f, "utf8"));
|
|
94
|
+
const ids = new Set();
|
|
95
|
+
collectIds(doc.children, ids);
|
|
96
|
+
docs.set(relDoc(f), { ids, blocks: doc.children });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let refErrors = 0;
|
|
100
|
+
const err = (doc, where, msg) => {
|
|
101
|
+
refErrors++;
|
|
102
|
+
console.error(`REF ${doc} ${where}: ${msg}`);
|
|
103
|
+
};
|
|
104
|
+
const checkRef = (fromDoc, where, ref) => {
|
|
105
|
+
ref = String(ref).trim();
|
|
106
|
+
if (!ref) return err(fromDoc, where, "empty reference cell");
|
|
107
|
+
const h = ref.indexOf("#");
|
|
108
|
+
if (h < 0) return err(fromDoc, where, `not a reference: \`${ref}\``);
|
|
109
|
+
let targetDoc = fromDoc;
|
|
110
|
+
if (h > 0) targetDoc = posix.normalize(posix.join(posix.dirname(fromDoc), ref.slice(0, h)));
|
|
111
|
+
const id = ref.slice(h + 1);
|
|
112
|
+
const target = docs.get(targetDoc);
|
|
113
|
+
if (!target) return err(fromDoc, where, `cannot resolve document \`${ref.slice(0, h)}\``);
|
|
114
|
+
// reads/writes values may carry a plain-text `.member` suffix (ids never contain '.')
|
|
115
|
+
const bare = id.split(".")[0];
|
|
116
|
+
if (!target.ids.has(bare)) return err(fromDoc, where, `unresolved reference \`${ref}\``);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const [docPath, { blocks }] of docs) {
|
|
120
|
+
for (const b of blocks) {
|
|
121
|
+
if (b.kind !== "block") continue;
|
|
122
|
+
if (b.type === "table" && REF_TABLES.has(b.id) && b.table) {
|
|
123
|
+
const fromCol = b.table.columns.indexOf("from");
|
|
124
|
+
const toCol = b.table.columns.indexOf("to");
|
|
125
|
+
if (fromCol < 0 || toCol < 0) { err(docPath, `#${b.id}`, "missing from/to columns"); continue; }
|
|
126
|
+
b.table.rows.forEach((row, i) => {
|
|
127
|
+
checkRef(docPath, `#${b.id} row ${i + 1} from`, row[fromCol]?.text ?? "");
|
|
128
|
+
checkRef(docPath, `#${b.id} row ${i + 1} to`, row[toCol]?.text ?? "");
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (b.type === "meta" && b.data?.entry) {
|
|
132
|
+
for (const ref of String(b.data.entry).split(/\s+/).filter(Boolean)) {
|
|
133
|
+
checkRef(docPath, "meta entry", ref);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.error(
|
|
140
|
+
`verify: ${files.length - failed}/${files.length} documents pass geml check; `
|
|
141
|
+
+ `profile references: ${refErrors === 0 ? "all resolve" : `${refErrors} dangling`}`,
|
|
142
|
+
);
|
|
143
|
+
process.exit(failed || refErrors ? 1 : 0);
|
package/dist/from-md.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// thematic break (---/***) -> dropped (§1: not part of GEML)
|
|
15
15
|
//
|
|
16
16
|
// Anything else (ATX headings, lists, paragraphs) is already valid GEML.
|
|
17
|
+
import { META_REF_SRC } from "./inline.js";
|
|
17
18
|
// Pick a fence length longer than any run of `=` that appears alone on a body
|
|
18
19
|
// line (leading indentation included), so the close fence stays unambiguous
|
|
19
20
|
// (§3 equal-length close rule) and the body's own `=` fences nest safely.
|
|
@@ -68,6 +69,63 @@ function autolinks(s) {
|
|
|
68
69
|
.replace(/<mailto:([^>\s]+)>/g, "[$1](mailto:$1)"))
|
|
69
70
|
.join("");
|
|
70
71
|
}
|
|
72
|
+
// A literal `{{name}}` in Markdown prose is plain text (Markdown has no
|
|
73
|
+
// metadata interpolation), but converted GEML flow text would read it as a §4
|
|
74
|
+
// reference — an unknown key fails `geml check`, and a key that happens to
|
|
75
|
+
// exist in the generated `=== meta` block substitutes silently. Escape it to
|
|
76
|
+
// `\{{name}}`, skipping the spans GEML interpolation itself leaves verbatim
|
|
77
|
+
// (inline code, inline math) and already-escaped characters.
|
|
78
|
+
const META_REF_Y = new RegExp(META_REF_SRC, "y");
|
|
79
|
+
function escMetaRefs(s) {
|
|
80
|
+
if (!s.includes("{{"))
|
|
81
|
+
return s;
|
|
82
|
+
let out = "";
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < s.length) {
|
|
85
|
+
const c = s[i];
|
|
86
|
+
if (c === "\\" && i + 1 < s.length) {
|
|
87
|
+
out += s.slice(i, i + 2);
|
|
88
|
+
i += 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (c === "`") {
|
|
92
|
+
let n = 0;
|
|
93
|
+
while (s[i + n] === "`")
|
|
94
|
+
n++;
|
|
95
|
+
const close = s.indexOf("`".repeat(n), i + n);
|
|
96
|
+
if (close >= 0) {
|
|
97
|
+
out += s.slice(i, close + n);
|
|
98
|
+
i = close + n;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out += s.slice(i, i + n);
|
|
102
|
+
i += n;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === "$") {
|
|
106
|
+
const close = s.indexOf("$", i + 1);
|
|
107
|
+
if (close > i + 1) {
|
|
108
|
+
out += s.slice(i, close + 1);
|
|
109
|
+
i = close + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
out += c;
|
|
113
|
+
i++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === "{" && s[i + 1] === "{") {
|
|
117
|
+
META_REF_Y.lastIndex = i;
|
|
118
|
+
if (META_REF_Y.test(s)) {
|
|
119
|
+
out += "\\{";
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
out += c;
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
71
129
|
// GitHub-style heading anchor: drop code backticks (keep content), lowercase,
|
|
72
130
|
// strip punctuation except `-`/`_`, collapse whitespace to hyphens. Used to keep
|
|
73
131
|
// converted headings' ids in sync with Markdown TOC links.
|
|
@@ -151,12 +209,12 @@ export function mdToGeml(source) {
|
|
|
151
209
|
if (line.trim() !== "" && !THEMATIC.test(line) && i + 1 < lines.length) {
|
|
152
210
|
const nxt = lines[i + 1];
|
|
153
211
|
if (SETEXT_UL.test(nxt)) {
|
|
154
|
-
out.push(`# ${line.trim()}`);
|
|
212
|
+
out.push(`# ${escMetaRefs(line.trim())}`);
|
|
155
213
|
i += 2;
|
|
156
214
|
continue;
|
|
157
215
|
}
|
|
158
216
|
if (SETEXT_DASH.test(nxt)) {
|
|
159
|
-
out.push(`## ${line.trim()}`);
|
|
217
|
+
out.push(`## ${escMetaRefs(line.trim())}`);
|
|
160
218
|
i += 2;
|
|
161
219
|
continue;
|
|
162
220
|
}
|
|
@@ -183,7 +241,7 @@ export function mdToGeml(source) {
|
|
|
183
241
|
else
|
|
184
242
|
break;
|
|
185
243
|
}
|
|
186
|
-
emitBlock(out, "note", `{#${fn[1].trim()}}`, body.map(autolinks), ids);
|
|
244
|
+
emitBlock(out, "note", `{#${fn[1].trim()}}`, body.map((l) => escMetaRefs(autolinks(l))), ids);
|
|
187
245
|
i = j;
|
|
188
246
|
continue;
|
|
189
247
|
}
|
|
@@ -191,7 +249,7 @@ export function mdToGeml(source) {
|
|
|
191
249
|
if (/^\s*>/.test(line)) {
|
|
192
250
|
const body = [];
|
|
193
251
|
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
|
194
|
-
body.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
252
|
+
body.push(escMetaRefs(lines[i].replace(/^\s*>\s?/, "")));
|
|
195
253
|
i++;
|
|
196
254
|
}
|
|
197
255
|
emitBlock(out, "note", "", body, ids);
|
|
@@ -220,13 +278,14 @@ export function mdToGeml(source) {
|
|
|
220
278
|
if (atx && atx[2].includes("`") && !/\{[^}]*\}\s*$/.test(atx[2])) {
|
|
221
279
|
const id = githubSlug(atx[2]);
|
|
222
280
|
if (id) {
|
|
223
|
-
out.push(`${atx[1]} ${atx[2]} {#${id}}`);
|
|
281
|
+
out.push(`${atx[1]} ${escMetaRefs(atx[2])} {#${id}}`);
|
|
224
282
|
i++;
|
|
225
283
|
continue;
|
|
226
284
|
}
|
|
227
285
|
}
|
|
228
|
-
// Inline pass: rewrite autolinks to GEML links
|
|
229
|
-
|
|
286
|
+
// Inline pass: rewrite autolinks to GEML links, escape literal `{{name}}`
|
|
287
|
+
// (both outside code spans).
|
|
288
|
+
const text = escMetaRefs(autolinks(line));
|
|
230
289
|
// Raw HTML note — ignore `<…>` that sits inside an inline code span.
|
|
231
290
|
if (/<[a-zA-Z/]/.test(text.replace(/`[^`]*`/g, ""))) {
|
|
232
291
|
notes.push(`raw HTML kept as text at line ${i + 1}: ${line.trim().slice(0, 40)}`);
|
package/dist/geml.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export { type Value } from "./attrs.js";
|
|
|
7
7
|
export { type Inline } from "./inline.js";
|
|
8
8
|
export { type TableModel } from "./table.js";
|
|
9
9
|
export { mdToGeml, type ConvertResult } from "./from-md.js";
|
|
10
|
-
export { renderHtml
|
|
10
|
+
export { renderHtml } from "./render-html.js";
|
|
11
|
+
export { type RenderOptions } from "./render.js";
|
|
11
12
|
export { serialize } from "./serialize.js";
|
|
12
13
|
export { gemlToMd } from "./to-md.js";
|
|
13
14
|
export type BodyMode = "raw" | "flow" | "data";
|
|
@@ -68,3 +69,8 @@ export interface ParseOptions {
|
|
|
68
69
|
resolveDoc?: (doc: string) => string | null;
|
|
69
70
|
}
|
|
70
71
|
export declare function parse(source: string, opts?: ParseOptions): Document;
|
|
72
|
+
export interface Span {
|
|
73
|
+
start: number;
|
|
74
|
+
end: number;
|
|
75
|
+
}
|
|
76
|
+
export declare function blockSpans(source: string): Map<string, Span>;
|