stylegallery 0.1.8 → 0.1.10
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/AGENTS.md +4 -2
- package/DOMAINS.md +1 -1
- package/README.md +12 -0
- package/consumer-reference/agent-native/v2/material-registry.json +1 -1
- package/expression/brand-studies.md +2 -2
- package/expression/direction-brief.md +17 -5
- package/expression/directions/nocturne-editorial.md +6 -5
- package/expression/directions/warm-print.md +5 -5
- package/expression/index.md +12 -0
- package/expression/techniques/webgl-hero.md +1 -1
- package/log.md +40 -0
- package/motion/index.md +69 -0
- package/package.json +12 -2
- package/patterns/media-fit/icon-frame.md +1 -1
- package/patterns/viewport-shell/holy-grail.md +7 -0
- package/quality/claim-records/samples.md +1494 -0
- package/quality/claims.md +31 -1
- package/scripts/compiler/cli.mjs +20 -0
- package/scripts/conservator-exhibits.mjs +768 -0
- package/scripts/conservator.mjs +326 -0
- package/scripts/markdown-structure.mjs +45 -0
- package/scripts/pattern-data.mjs +325 -0
- package/scripts/registrar.mjs +323 -0
- package/showcase/README.md +15 -1
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Conservator inspects the gallery's Layout patterns in a real browser. For every exhibit in
|
|
3
|
+
// scripts/conservator-exhibits.mjs it renders the pattern doc's CSS block verbatim around stress
|
|
4
|
+
// content at 320, 768, and 1440 CSS pixels and measures the spatial behavior the doc claims, plus
|
|
5
|
+
// root inline overflow, keyboard focus order, axe-core serious or critical rules, and running
|
|
6
|
+
// infinite animations under reduced motion. Negative controls (--controls) remove a declaration the
|
|
7
|
+
// doc says the layout depends on and require the named behavior to fail.
|
|
8
|
+
// A pass supports the rendered layout contract on one Chromium engine; it does not prove usability
|
|
9
|
+
// or complete accessibility.
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { exhibits, notMeasured, sharedMeasures } from "./conservator-exhibits.mjs";
|
|
17
|
+
|
|
18
|
+
const galleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
export const conservatorWidths = [320, 768, 1440];
|
|
20
|
+
const viewportHeight = 720;
|
|
21
|
+
const blockingImpacts = new Set(["serious", "critical"]);
|
|
22
|
+
const maxTabStops = 40;
|
|
23
|
+
|
|
24
|
+
// Harness normalization only: a readable default face and no UA margins on text and figures, so
|
|
25
|
+
// measured gaps come from the pattern CSS. The pattern CSS follows and wins on its own selectors.
|
|
26
|
+
const harnessCss = `body { background: #fff; color: #111; font: 16px/1.5 system-ui, sans-serif; margin: 0; }
|
|
27
|
+
h1, h2, p, figure { margin: 0; }
|
|
28
|
+
a { color: #0a3a8a; }`;
|
|
29
|
+
|
|
30
|
+
export class ConservatorError extends Error {
|
|
31
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function patternDocPath(root, slug) {
|
|
35
|
+
const patternsDir = path.join(root, "patterns");
|
|
36
|
+
for (const category of fs.existsSync(patternsDir) ? fs.readdirSync(patternsDir).sort() : []) {
|
|
37
|
+
const candidate = path.join(patternsDir, category, `${slug}.md`);
|
|
38
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function patternCss(root, slug) {
|
|
44
|
+
const doc = patternDocPath(root, slug);
|
|
45
|
+
if (!doc) throw new ConservatorError("pattern_missing", `no pattern doc patterns/*/${slug}.md`);
|
|
46
|
+
const css = fs.readFileSync(doc, "utf8").split(/^## CSS\s*$/m)[1]?.match(/^```css\n([\s\S]*?)^```/m)?.[1];
|
|
47
|
+
if (!css) throw new ConservatorError("pattern_css_missing", `${path.relative(root, doc)} has no css block under ## CSS`);
|
|
48
|
+
return css;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function withoutDeclaration(css, selector, property) {
|
|
52
|
+
const start = css.indexOf(`${selector} {\n`);
|
|
53
|
+
const end = start === -1 ? -1 : css.indexOf("\n}", start);
|
|
54
|
+
if (end === -1) throw new ConservatorError("control_target_missing", `css has no rule for ${selector}`);
|
|
55
|
+
const block = css.slice(start, end);
|
|
56
|
+
const pattern = new RegExp(`\\n {4}${property.replace(/[-]/g, "\\-")}: [^;]+;`);
|
|
57
|
+
if (!pattern.test(block)) throw new ConservatorError("control_target_missing", `${selector} declares no ${property}`);
|
|
58
|
+
return css.slice(0, start) + block.replace(pattern, "") + css.slice(end);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function exhibitPage(exhibit, css) {
|
|
62
|
+
return `<!doctype html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8">
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
67
|
+
<title>${exhibit.slug} Conservator exhibit</title>
|
|
68
|
+
<style data-conservator="harness">
|
|
69
|
+
${harnessCss}
|
|
70
|
+
${exhibit.harness ?? ""}
|
|
71
|
+
</style>
|
|
72
|
+
<style data-conservator="pattern">
|
|
73
|
+
${css}</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
${exhibit.html}
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Runs inside the page. Builds the helper object, loads the shared measures, and runs one measure.
|
|
83
|
+
function measureInPage({ measure, shared, width }) {
|
|
84
|
+
const failures = [];
|
|
85
|
+
const root = document.documentElement;
|
|
86
|
+
const c = {
|
|
87
|
+
W: root.clientWidth,
|
|
88
|
+
H: innerHeight,
|
|
89
|
+
root,
|
|
90
|
+
q(selector) {
|
|
91
|
+
const element = document.querySelector(selector);
|
|
92
|
+
if (!element) throw new Error(`exhibit has no ${selector}`);
|
|
93
|
+
return element;
|
|
94
|
+
},
|
|
95
|
+
r(selector) { return c.q(selector).getBoundingClientRect(); },
|
|
96
|
+
rs(selector) { return [...document.querySelectorAll(selector)].map((element) => element.getBoundingClientRect()); },
|
|
97
|
+
near(a, b, tolerance = 1.5) { return Math.abs(a - b) <= tolerance; },
|
|
98
|
+
rowsOf(rects) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const rect of rects) {
|
|
101
|
+
const row = rows.at(-1);
|
|
102
|
+
if (row && Math.abs(row[0].top - rect.top) < 2) row.push(rect);
|
|
103
|
+
else rows.push([rect]);
|
|
104
|
+
}
|
|
105
|
+
return rows;
|
|
106
|
+
},
|
|
107
|
+
length(value, selector) {
|
|
108
|
+
const probe = document.createElement("div");
|
|
109
|
+
probe.style.cssText = `display:block;inline-size:${value};block-size:0;padding:0;border:0`;
|
|
110
|
+
c.q(selector).append(probe);
|
|
111
|
+
const size = probe.getBoundingClientRect().width;
|
|
112
|
+
probe.remove();
|
|
113
|
+
return size;
|
|
114
|
+
},
|
|
115
|
+
expect(condition, behavior, message) { if (!condition) failures.push(`${behavior}: ${message}`); },
|
|
116
|
+
};
|
|
117
|
+
for (const [name, source] of Object.entries(shared)) c[name] = (0, eval)(`(${source})`);
|
|
118
|
+
try { (0, eval)(`(${measure})`)(c, width); } catch (error) { failures.push(`measure-error: ${error.message}`); }
|
|
119
|
+
return failures;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function focusFailures(page) {
|
|
123
|
+
const failures = [];
|
|
124
|
+
const count = await page.evaluate(() => [...document.querySelectorAll("a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex='-1'])")]
|
|
125
|
+
.filter((element) => !element.disabled && element.getClientRects().length > 0).length);
|
|
126
|
+
await page.evaluate(() => { window.scrollTo(0, 0); document.activeElement?.blur(); });
|
|
127
|
+
let previous = -1;
|
|
128
|
+
for (let step = 0; step < Math.min(count, maxTabStops); step += 1) {
|
|
129
|
+
await page.keyboard.press("Tab");
|
|
130
|
+
const state = await page.evaluate(() => {
|
|
131
|
+
const element = document.activeElement;
|
|
132
|
+
if (!element || element === document.body) return null;
|
|
133
|
+
const rect = element.getBoundingClientRect();
|
|
134
|
+
return {
|
|
135
|
+
index: [...document.querySelectorAll("*")].indexOf(element),
|
|
136
|
+
label: `${element.tagName.toLowerCase()} "${(element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 24)}"`,
|
|
137
|
+
visible: rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < innerHeight && rect.right > 0 && rect.left < innerWidth,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
if (!state) { failures.push(`focus-order: tab stop ${step + 1} left the document`); break; }
|
|
141
|
+
if (!state.visible) failures.push(`focus-order: focused ${state.label} is not visible in the viewport`);
|
|
142
|
+
if (state.index <= previous) failures.push(`focus-order: focused ${state.label} out of DOM order`);
|
|
143
|
+
previous = state.index;
|
|
144
|
+
}
|
|
145
|
+
return failures;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function inspectAt(page, exhibit, css, width, { full, AxeBuilder }) {
|
|
149
|
+
await page.setContent(exhibitPage(exhibit, css), { waitUntil: "load" });
|
|
150
|
+
await page.evaluate(() => document.fonts.ready);
|
|
151
|
+
const shared = Object.fromEntries(Object.entries(sharedMeasures).map(([name, fn]) => [name, fn.toString()]));
|
|
152
|
+
const failures = await page.evaluate(measureInPage, { measure: exhibit.measure.toString(), shared, width });
|
|
153
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
154
|
+
const { scrollWidth, clientWidth } = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }));
|
|
155
|
+
if (scrollWidth > clientWidth + 1) failures.push(`overflow: document root overflows inline ${scrollWidth}px > ${clientWidth}px`);
|
|
156
|
+
if (full) {
|
|
157
|
+
failures.push(...await focusFailures(page));
|
|
158
|
+
const axe = await new AxeBuilder({ page }).analyze();
|
|
159
|
+
failures.push(...axe.violations.filter((violation) => blockingImpacts.has(violation.impact))
|
|
160
|
+
.map((violation) => `axe: ${violation.impact} ${violation.id} (${violation.nodes.length} nodes)`));
|
|
161
|
+
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
162
|
+
failures.push(...(await page.evaluate(() => document.getAnimations()
|
|
163
|
+
.filter((animation) => animation.playState === "running" && animation.effect?.getComputedTiming().iterations === Infinity)
|
|
164
|
+
.map((animation) => `reduced-motion: infinite animation ${animation.animationName ?? "unnamed"} is running`))));
|
|
165
|
+
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
166
|
+
}
|
|
167
|
+
return failures;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const behaviorOf = (failure) => failure.split(": ")[0];
|
|
171
|
+
|
|
172
|
+
async function loadBrowser({ channel, installBrowser }) {
|
|
173
|
+
let playwright;
|
|
174
|
+
let AxeBuilder;
|
|
175
|
+
try {
|
|
176
|
+
playwright = await import("playwright");
|
|
177
|
+
AxeBuilder = (await import("@axe-core/playwright")).default;
|
|
178
|
+
} catch {
|
|
179
|
+
throw new ConservatorError("playwright_missing", "The Conservator needs the playwright and @axe-core/playwright packages. Reinstall stylegallery, or run npm install playwright @axe-core/playwright in a checkout.");
|
|
180
|
+
}
|
|
181
|
+
if (installBrowser && !channel) await installChromium();
|
|
182
|
+
try {
|
|
183
|
+
return { browser: await playwright.chromium.launch(channel ? { channel } : {}), AxeBuilder };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const message = String(error.message ?? error);
|
|
186
|
+
if (channel) throw new ConservatorError("browser_unavailable", `Could not launch the installed ${channel} browser: ${message.split("\n")[0]}`);
|
|
187
|
+
throw new ConservatorError("browser_missing", "Chromium for Playwright is not installed. Run `npx playwright install chromium`, rerun with --install-browser, or use an installed Chrome with --channel chrome.");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function installChromium() {
|
|
192
|
+
const require = createRequire(import.meta.url);
|
|
193
|
+
const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const child = spawn(process.execPath, [cli, "install", "chromium"], { stdio: ["ignore", process.stderr, process.stderr] });
|
|
196
|
+
child.once("error", reject);
|
|
197
|
+
child.once("exit", (code) => (code === 0 ? resolve() : reject(new ConservatorError("browser_install_failed", `playwright install chromium exited with ${code}`))));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runConservator({
|
|
202
|
+
root = galleryRoot, only = null, controls = false, channel = null, installBrowser = false, out = null,
|
|
203
|
+
} = {}) {
|
|
204
|
+
const started = Date.now();
|
|
205
|
+
const selected = only ? exhibits.filter(({ slug }) => only.includes(slug)) : exhibits;
|
|
206
|
+
const unknown = only ? only.filter((slug) => !exhibits.some((exhibit) => exhibit.slug === slug)) : [];
|
|
207
|
+
if (unknown.length > 0) throw new ConservatorError("exhibit_unknown", `no Conservator exhibit named ${unknown.join(", ")}`);
|
|
208
|
+
const { browser, AxeBuilder } = await loadBrowser({ channel, installBrowser });
|
|
209
|
+
const results = [];
|
|
210
|
+
try {
|
|
211
|
+
const jobs = selected.flatMap((exhibit) => (exhibit.widths ?? conservatorWidths).map((width) => ({ exhibit, width })));
|
|
212
|
+
const byWidth = new Map();
|
|
213
|
+
for (const job of jobs) byWidth.set(job.width, [...(byWidth.get(job.width) ?? []), job]);
|
|
214
|
+
const outcomes = [];
|
|
215
|
+
// One page per width runs in parallel; exhibits run in sequence on each page.
|
|
216
|
+
await Promise.all([...byWidth.entries()].map(async ([width, widthJobs]) => {
|
|
217
|
+
const context = await browser.newContext({ viewport: { width, height: viewportHeight } });
|
|
218
|
+
const page = await context.newPage();
|
|
219
|
+
const pageErrors = [];
|
|
220
|
+
page.on("pageerror", (error) => pageErrors.push(`page-error: ${error.message}`));
|
|
221
|
+
try {
|
|
222
|
+
for (const { exhibit } of widthJobs) {
|
|
223
|
+
let css;
|
|
224
|
+
try { css = patternCss(root, exhibit.slug); } catch (error) { outcomes.push({ slug: exhibit.slug, width, kind: "inspect", failures: [`pattern-css: ${error.message}`] }); continue; }
|
|
225
|
+
pageErrors.length = 0;
|
|
226
|
+
const failures = await inspectAt(page, exhibit, css, width, { full: true, AxeBuilder });
|
|
227
|
+
outcomes.push({ slug: exhibit.slug, width, kind: "inspect", failures: [...failures, ...pageErrors] });
|
|
228
|
+
if (controls) {
|
|
229
|
+
const control = exhibit.negative;
|
|
230
|
+
let mutated;
|
|
231
|
+
try { mutated = withoutDeclaration(css, control.selector, control.property); } catch (error) { outcomes.push({ slug: exhibit.slug, width, kind: "control", failures: [`control-target: ${error.message}`] }); continue; }
|
|
232
|
+
outcomes.push({ slug: exhibit.slug, width, kind: "control", failures: await inspectAt(page, exhibit, mutated, width, { full: false, AxeBuilder }) });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} finally { await context.close(); }
|
|
236
|
+
}));
|
|
237
|
+
for (const exhibit of selected) {
|
|
238
|
+
const widths = exhibit.widths ?? conservatorWidths;
|
|
239
|
+
const inspections = widths.map((width) => outcomes.find((outcome) => outcome.slug === exhibit.slug && outcome.width === width && outcome.kind === "inspect"));
|
|
240
|
+
const entry = {
|
|
241
|
+
slug: exhibit.slug, claim: exhibit.claim, widths,
|
|
242
|
+
ok: inspections.every((outcome) => outcome.failures.length === 0),
|
|
243
|
+
failures: inspections.flatMap((outcome) => outcome.failures.map((failure) => `w${outcome.width}: ${failure}`)),
|
|
244
|
+
};
|
|
245
|
+
if (controls) {
|
|
246
|
+
const control = exhibit.negative;
|
|
247
|
+
const runs = widths.map((width) => outcomes.find((outcome) => outcome.slug === exhibit.slug && outcome.width === width && outcome.kind === "control"));
|
|
248
|
+
const allowed = new Set([control.behavior, ...(control.allow ?? [])]);
|
|
249
|
+
const observed = runs.flatMap((outcome) => outcome.failures);
|
|
250
|
+
const caught = observed.some((failure) => behaviorOf(failure) === control.behavior);
|
|
251
|
+
const stray = observed.filter((failure) => !allowed.has(behaviorOf(failure)));
|
|
252
|
+
entry.control = {
|
|
253
|
+
removed: `${control.selector} { ${control.property} }`, behavior: control.behavior,
|
|
254
|
+
ok: caught && stray.length === 0,
|
|
255
|
+
caught_at: runs.filter((outcome) => outcome.failures.some((failure) => behaviorOf(failure) === control.behavior)).map((outcome) => outcome.width),
|
|
256
|
+
stray,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
results.push(entry);
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
await browser.close();
|
|
263
|
+
}
|
|
264
|
+
const failures = results.flatMap((entry) => [
|
|
265
|
+
...entry.failures.map((failure) => `${entry.slug} ${failure}`),
|
|
266
|
+
...(entry.control && !entry.control.ok ? [`${entry.slug} control ${entry.control.removed}: ${entry.control.caught_at.length === 0 ? `did not fail ${entry.control.behavior}` : `failed outside ${entry.control.behavior}: ${entry.control.stray.join("; ")}`}`] : []),
|
|
267
|
+
]);
|
|
268
|
+
const result = {
|
|
269
|
+
ok: failures.length === 0,
|
|
270
|
+
exhibits: results.length,
|
|
271
|
+
widths: conservatorWidths,
|
|
272
|
+
controls,
|
|
273
|
+
failures,
|
|
274
|
+
results,
|
|
275
|
+
not_measured: notMeasured,
|
|
276
|
+
durationMs: Date.now() - started,
|
|
277
|
+
};
|
|
278
|
+
if (out) {
|
|
279
|
+
fs.mkdirSync(out, { recursive: true });
|
|
280
|
+
fs.writeFileSync(path.join(out, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
|
281
|
+
for (const exhibit of selected) {
|
|
282
|
+
try {
|
|
283
|
+
fs.mkdirSync(path.join(out, exhibit.slug), { recursive: true });
|
|
284
|
+
fs.writeFileSync(path.join(out, exhibit.slug, "index.html"), exhibitPage(exhibit, patternCss(root, exhibit.slug)));
|
|
285
|
+
} catch { /* a missing pattern is already reported as a failure */ }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function parseConservatorArgs(argv) {
|
|
292
|
+
const options = { only: null, controls: false, channel: null, installBrowser: false, out: null, root: undefined };
|
|
293
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
294
|
+
const flag = argv[index];
|
|
295
|
+
const value = () => {
|
|
296
|
+
const next = argv[index + 1];
|
|
297
|
+
if (next === undefined || next.startsWith("--")) throw new ConservatorError("argument_value_required", `${flag} requires a value`);
|
|
298
|
+
index += 1;
|
|
299
|
+
return next;
|
|
300
|
+
};
|
|
301
|
+
if (flag === "--controls") options.controls = true;
|
|
302
|
+
else if (flag === "--install-browser") options.installBrowser = true;
|
|
303
|
+
else if (flag === "--json") continue;
|
|
304
|
+
else if (flag === "--pattern") options.only = [...(options.only ?? []), ...value().split(",")];
|
|
305
|
+
else if (flag === "--channel") options.channel = value();
|
|
306
|
+
else if (flag === "--out") options.out = path.resolve(value());
|
|
307
|
+
else if (flag === "--root") options.root = path.resolve(value());
|
|
308
|
+
else throw new ConservatorError("argument_invalid", `Unknown conservator argument: ${flag}`);
|
|
309
|
+
}
|
|
310
|
+
return options;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
314
|
+
if (isMain) {
|
|
315
|
+
const argv = process.argv.slice(2);
|
|
316
|
+
try {
|
|
317
|
+
const result = await runConservator(parseConservatorArgs(argv));
|
|
318
|
+
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
319
|
+
else if (result.ok) console.log(`ok: ${result.exhibits} exhibits at ${result.widths.join("/")}${result.controls ? " with negative controls" : ""} in ${result.durationMs}ms`);
|
|
320
|
+
else console.error(result.failures.join("\n"));
|
|
321
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error(`${error.code ?? "conservator_failed"}: ${error.message}`);
|
|
324
|
+
process.exitCode = 2;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function stripFencedCodeBlocks(content) {
|
|
2
|
+
const kept = [];
|
|
3
|
+
let fence = null;
|
|
4
|
+
|
|
5
|
+
for (const line of content.split("\n")) {
|
|
6
|
+
const marker = line.match(/^ {0,3}(`{3,})[^`]*$/)?.[1] ?? line.match(/^ {0,3}(~{3,}).*$/)?.[1];
|
|
7
|
+
if (!fence && marker) {
|
|
8
|
+
fence = { character: marker[0], length: marker.length };
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
if (fence) {
|
|
12
|
+
const closing = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/)?.[1];
|
|
13
|
+
if (closing && closing[0] === fence.character && closing.length >= fence.length) fence = null;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
kept.push(line);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return kept.join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stripHtmlComments(content) {
|
|
23
|
+
return content.replace(/<!--[\s\S]*?(?:-->|$)/g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function structuralMarkdown(content) {
|
|
27
|
+
return stripFencedCodeBlocks(stripHtmlComments(content));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function markdownLinkDestinations(content) {
|
|
31
|
+
const body = structuralMarkdown(content);
|
|
32
|
+
const destinations = [];
|
|
33
|
+
const inline = /!?\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+[^)]*)?\)/g;
|
|
34
|
+
const reference = /^ {0,3}\[[^\]]+\]:\s*<?([^\s>]+)>?(?:\s+.*)?$/gm;
|
|
35
|
+
for (const pattern of [inline, reference]) {
|
|
36
|
+
for (const match of body.matchAll(pattern)) destinations.push(match[1]);
|
|
37
|
+
}
|
|
38
|
+
return destinations;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isOmoDependency(destination) {
|
|
42
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(destination) || destination.startsWith("#")) return false;
|
|
43
|
+
const pathOnly = destination.split(/[?#]/, 1)[0];
|
|
44
|
+
return pathOnly.split(/[\\/]/).includes(".omo");
|
|
45
|
+
}
|