@techninja/clearstack 0.4.16 → 0.4.20
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/docs/OG_METADATA_SPEC.md
CHANGED
|
@@ -24,14 +24,14 @@ Both are driven by the same `clearstack.routes.json` config and data sources.
|
|
|
24
24
|
"/trait/:slug": {
|
|
25
25
|
"title": "{slug.name}",
|
|
26
26
|
"description": "{slug.description}",
|
|
27
|
-
"image": "{slug.
|
|
28
|
-
"data": "
|
|
27
|
+
"image": "{slug.image}",
|
|
28
|
+
"data": "data/traits-og.json",
|
|
29
29
|
"ogTemplate": "trait"
|
|
30
30
|
},
|
|
31
31
|
"/shop/product/:sku": {
|
|
32
32
|
"title": "{sku.name} | {store.name}",
|
|
33
33
|
"description": "{sku.description}",
|
|
34
|
-
"image": "{
|
|
34
|
+
"image": "{sku.images.0}",
|
|
35
35
|
"data": "src/data/products.json",
|
|
36
36
|
"ogTemplate": "product"
|
|
37
37
|
}
|
|
@@ -111,6 +111,53 @@ clearstack build all --url=https://mysite.com --site=MySite --out=dist
|
|
|
111
111
|
| `--logo` | Logo path or URL |
|
|
112
112
|
| `--slug` | Single item slug (fast iteration) |
|
|
113
113
|
|
|
114
|
+
## Cloudflare Pages Routing
|
|
115
|
+
|
|
116
|
+
Cloudflare Pages evaluates `_redirects` **before** checking the filesystem.
|
|
117
|
+
The standard SPA catch-all `/* /index.html 200` will intercept all pre-rendered
|
|
118
|
+
pages before they can be served. Since CF Pages serves `.html` files natively
|
|
119
|
+
for extensionless URLs when a matching file exists on the filesystem, no extra
|
|
120
|
+
rewrite rules are needed — just ensure the catch-all is the **only** rule and
|
|
121
|
+
that it comes after any explicit redirects:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
/old-path /new-path 301
|
|
125
|
+
/* /index.html 200
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The pre-rendered files (`shop/product/SM-TEE.html`) will be served directly by
|
|
129
|
+
CF Pages' static file layer before the `_redirects` rules are evaluated.
|
|
130
|
+
|
|
131
|
+
## Canonical Tags
|
|
132
|
+
|
|
133
|
+
Inject `<link rel="canonical">` into every pre-rendered page so Google
|
|
134
|
+
deduplicates the static HTML shell from the SPA view at the same URL. Do this
|
|
135
|
+
as a post-processing step in `build.js` after `buildOG` runs:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
139
|
+
import { resolve } from 'node:path';
|
|
140
|
+
|
|
141
|
+
function injectCanonicals(distDir, baseUrl) {
|
|
142
|
+
const prefixes = ['shop/product', 'trait', 'gene'];
|
|
143
|
+
for (const prefix of prefixes) {
|
|
144
|
+
const dir = resolve(distDir, prefix);
|
|
145
|
+
if (!existsSync(dir)) continue;
|
|
146
|
+
for (const file of readdirSync(dir)) {
|
|
147
|
+
if (!file.endsWith('.html')) continue;
|
|
148
|
+
const slug = file.slice(0, -5);
|
|
149
|
+
const url = `${baseUrl}/${prefix}/${slug}`;
|
|
150
|
+
const filePath = resolve(dir, file);
|
|
151
|
+
let html = readFileSync(filePath, 'utf-8');
|
|
152
|
+
if (!html.includes('rel="canonical"')) {
|
|
153
|
+
html = html.replace('</head>', ` <link rel="canonical" href="${url}" />\n </head>`);
|
|
154
|
+
writeFileSync(filePath, html);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
114
161
|
## Requirements
|
|
115
162
|
|
|
116
163
|
- Zero config for basic cases (route title + description → OG tags)
|
package/lib/watch-runner.js
CHANGED
|
@@ -42,6 +42,7 @@ export function runKeys(specRows, keys, ctx) {
|
|
|
42
42
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1) + 's';
|
|
43
43
|
row.result = typeof raw === 'boolean' ? { pass: raw } : raw;
|
|
44
44
|
row.pass = row.result.pass;
|
|
45
|
+
if (row.pass) row.ranAt = Date.now();
|
|
45
46
|
row.detail = row.pass
|
|
46
47
|
? `${row.result.detail ?? (row.result.files ? `${row.result.files} files` : '')} (${elapsed})`
|
|
47
48
|
: (row.result.violations?.length
|
package/lib/watch-ui.js
CHANGED
|
@@ -1,79 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Spec watch UI — rendering
|
|
2
|
+
* Spec watch UI — rendering and violation display.
|
|
3
|
+
* Split candidate detection and violation extraction live in watch-violations.js.
|
|
3
4
|
* Widget construction lives in watch-widgets.js.
|
|
4
5
|
* @module lib/watch-ui
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
-
import { resolve } from 'node:path';
|
|
9
8
|
import { screen, statusBox, divider, logBox, getSpin, getCopyNote } from './watch-widgets.js';
|
|
10
9
|
|
|
11
10
|
export { setQuit } from './watch-widgets.js';
|
|
11
|
+
export { splitCandidates, extractViolations } from './watch-violations.js';
|
|
12
12
|
|
|
13
|
-
// ──
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Suggest split seams for a file that exceeds the line limit.
|
|
17
|
-
* Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
|
|
18
|
-
* @param {string} filePath absolute path
|
|
19
|
-
* @returns {string[]}
|
|
20
|
-
*/
|
|
21
|
-
export function splitCandidates(filePath) {
|
|
22
|
-
if (!existsSync(filePath)) return [];
|
|
23
|
-
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
|
24
|
-
const explicit = [];
|
|
25
|
-
lines.forEach((l, i) => {
|
|
26
|
-
const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
|
|
27
|
-
if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
|
|
28
|
-
});
|
|
29
|
-
if (explicit.length) return explicit;
|
|
30
|
-
|
|
31
|
-
const seams = [];
|
|
32
|
-
lines.forEach((l, i) => {
|
|
33
|
-
// Only exported functions/classes are meaningful split boundaries
|
|
34
|
-
if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
|
|
35
|
-
});
|
|
36
|
-
if (seams.length < 2) return [];
|
|
37
|
-
const suggestions = [];
|
|
38
|
-
for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
|
|
39
|
-
const start = seams[i], end = seams[i + 1] - 1;
|
|
40
|
-
if (end - start < 10) continue;
|
|
41
|
-
// Peek at the function name for a more useful suggestion
|
|
42
|
-
const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
|
|
43
|
-
const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
|
|
44
|
-
suggestions.push(` L${start}-${end}: ${hint}`);
|
|
45
|
-
}
|
|
46
|
-
return suggestions;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Violation extraction ──────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Process a failed check row into violation tuples for the log panel.
|
|
53
|
-
* @param {object} row
|
|
54
|
-
* @param {string} projectDir
|
|
55
|
-
* @returns {string[][]}
|
|
56
|
-
*/
|
|
57
|
-
export function extractViolations(row, projectDir) {
|
|
58
|
-
if (row.pass) return [];
|
|
59
|
-
if (row.result?.violations?.length) {
|
|
60
|
-
return row.result.violations.map((v) => {
|
|
61
|
-
if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
|
|
62
|
-
const candidates = splitCandidates(resolve(projectDir, v.file));
|
|
63
|
-
return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
if (row.result?.errors?.length) {
|
|
67
|
-
const errors = row.result.errors;
|
|
68
|
-
// Prettier: [warn] src/file.js — expand each into its own entry
|
|
69
|
-
const prettierFiles = errors.map((l) => l.match(/^\[warn\]\s+(.+\.\w+)$/)?.[1]).filter(Boolean);
|
|
70
|
-
if (prettierFiles.length) return prettierFiles.map((f) => [f, 'formatting — run npm run format']);
|
|
71
|
-
return [[row.label ?? row.name ?? row.key, errors.slice(0, 5).join('\n')]];
|
|
72
|
-
}
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Render ────────────────────────────────────────────────────────────────────
|
|
13
|
+
// ── Row rendering ─────────────────────────────────────────────────────────────
|
|
77
14
|
|
|
78
15
|
/** @param {object} r */
|
|
79
16
|
function rowIcon(r) {
|
|
@@ -85,11 +22,40 @@ function rowIcon(r) {
|
|
|
85
22
|
}
|
|
86
23
|
if (r.pass === null) return '{grey-fg}' + getSpin() + '{/}';
|
|
87
24
|
if (r.pass) return '{green-fg}ok{/}';
|
|
25
|
+
if (r.keyBinding) return '{grey-fg}–{/} ';
|
|
88
26
|
return '{red-fg}!{/} ';
|
|
89
27
|
}
|
|
90
28
|
|
|
29
|
+
/** @param {number} ms @returns {string} */
|
|
30
|
+
function timeAgo(ms) {
|
|
31
|
+
const s = Math.floor((Date.now() - ms) / 1000);
|
|
32
|
+
if (s < 60) return `${s}s ago`;
|
|
33
|
+
const m = Math.floor(s / 60);
|
|
34
|
+
if (m < 60) return `${m}m ago`;
|
|
35
|
+
return `${Math.floor(m / 60)}h ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
91
38
|
/** @param {object} r @param {number} w */
|
|
92
|
-
const rowLine = (r, w) => {
|
|
39
|
+
const rowLine = (r, w) => {
|
|
40
|
+
const n = (r.label ?? r.name ?? r.key).padEnd(w);
|
|
41
|
+
const isIdle = r.pass === false && r.keyBinding;
|
|
42
|
+
const name = (!isIdle && r.pass === false) ? `{red-fg}${n}{/}` : n;
|
|
43
|
+
const detail = isIdle
|
|
44
|
+
? `{grey-fg}${r.ranAt ? timeAgo(r.ranAt) : `press ${r.keyBinding} to run`}{/}`
|
|
45
|
+
: (r.detail ? `{grey-fg}${r.detail}{/}` : '');
|
|
46
|
+
return ` ${rowIcon(r)} ${name} ${detail}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ── Status box helpers ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** @param {object[]} rows @param {string} lastCheck @param {string[]} watchDirs */
|
|
52
|
+
function statusContent(rows, lastCheck, watchDirs) {
|
|
53
|
+
const w = Math.max(...rows.map((r) => (r.label ?? r.name ?? r.key).length));
|
|
54
|
+
return [...rows.map((r) => rowLine(r, w)), '',
|
|
55
|
+
`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
93
59
|
|
|
94
60
|
/**
|
|
95
61
|
* Render the dashboard.
|
|
@@ -99,14 +65,11 @@ const rowLine = (r, w) => { const n = (r.label ?? r.name ?? r.key).padEnd(w); re
|
|
|
99
65
|
* @param {string[][]} violations
|
|
100
66
|
*/
|
|
101
67
|
export function render(rows, lastCheck, watchDirs, violations) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
statusLines.push(`{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`);
|
|
106
|
-
statusBox.setContent(statusLines.join('\n'));
|
|
107
|
-
statusBox.height = statusLines.length;
|
|
68
|
+
const lines = statusContent(rows, lastCheck, watchDirs);
|
|
69
|
+
statusBox.setContent(lines.join('\n'));
|
|
70
|
+
statusBox.height = lines.length;
|
|
108
71
|
|
|
109
|
-
const statusHeight =
|
|
72
|
+
const statusHeight = lines.length + 1;
|
|
110
73
|
divider.top = statusHeight;
|
|
111
74
|
logBox.top = statusHeight + 1;
|
|
112
75
|
|
|
@@ -139,7 +102,6 @@ export function destroyScreen() {
|
|
|
139
102
|
|
|
140
103
|
/** Refresh just the status box — used for transient notes like copy confirmation. */
|
|
141
104
|
export function renderNote(rows, lastCheck, watchDirs) {
|
|
142
|
-
|
|
143
|
-
statusBox.setContent([...rows.map((r) => rowLine(r, w)), '', `{grey-fg} watching ${watchDirs.join(', ')} last check: ${lastCheck}{/}${getCopyNote()}`].join('\n'));
|
|
105
|
+
statusBox.setContent(statusContent(rows, lastCheck, watchDirs).join('\n'));
|
|
144
106
|
screen.render();
|
|
145
107
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Violation extraction and split candidate detection for the spec watch dashboard.
|
|
3
|
+
* @module lib/watch-violations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Suggest split seams for a file that exceeds the line limit.
|
|
11
|
+
* Prefers `// SPLIT CANDIDATE:` comments, falls back to heuristics.
|
|
12
|
+
* @param {string} filePath absolute path
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function splitCandidates(filePath) {
|
|
16
|
+
if (!existsSync(filePath)) return [];
|
|
17
|
+
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
|
18
|
+
const explicit = [];
|
|
19
|
+
lines.forEach((l, i) => {
|
|
20
|
+
const m = l.match(/\/\/\s*SPLIT CANDIDATE:\s*(.+)/i);
|
|
21
|
+
if (m) explicit.push(` L${i + 1}: ${m[1].trim()}`);
|
|
22
|
+
});
|
|
23
|
+
if (explicit.length) return explicit;
|
|
24
|
+
|
|
25
|
+
const seams = [];
|
|
26
|
+
lines.forEach((l, i) => {
|
|
27
|
+
// Only exported functions/classes are meaningful split boundaries
|
|
28
|
+
if (/^export (async function|function|class)/.test(l)) seams.push(i + 1);
|
|
29
|
+
});
|
|
30
|
+
if (seams.length < 2) return [];
|
|
31
|
+
const suggestions = [];
|
|
32
|
+
for (let i = 0; i < seams.length - 1 && suggestions.length < 3; i++) {
|
|
33
|
+
const start = seams[i], end = seams[i + 1] - 1;
|
|
34
|
+
if (end - start < 10) continue;
|
|
35
|
+
// Peek at the function name for a more useful suggestion
|
|
36
|
+
const nameMatch = lines[start - 1]?.match(/^export (?:async )?function (\w+)/);
|
|
37
|
+
const hint = nameMatch ? `→ ${nameMatch[1]}()` : 'consider extracting';
|
|
38
|
+
suggestions.push(` L${start}-${end}: ${hint}`);
|
|
39
|
+
}
|
|
40
|
+
return suggestions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Process a failed check row into violation tuples for the log panel.
|
|
45
|
+
* @param {object} row
|
|
46
|
+
* @param {string} projectDir
|
|
47
|
+
* @returns {string[][]}
|
|
48
|
+
*/
|
|
49
|
+
export function extractViolations(row, projectDir) {
|
|
50
|
+
if (row.pass) return [];
|
|
51
|
+
if (row.result?.violations?.length) {
|
|
52
|
+
return row.result.violations.map((v) => {
|
|
53
|
+
if (v.spec !== undefined) return [v.file, `import '${v.spec}' → use #prefix/ alias`];
|
|
54
|
+
const candidates = splitCandidates(resolve(projectDir, v.file));
|
|
55
|
+
return [v.file, `${v.lines} lines (max ${v.max}, +${v.lines - v.max} over)`, ...candidates];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (row.result?.errors?.length) {
|
|
59
|
+
const errors = row.result.errors;
|
|
60
|
+
// Prettier: [warn] src/file.js — expand each into its own entry
|
|
61
|
+
const prettierFiles = errors.map((l) => l.match(/^\[warn\]\s+(.+\.\w+)$/)?.[1]).filter(Boolean);
|
|
62
|
+
if (prettierFiles.length) return prettierFiles.map((f) => [f, 'formatting — run npm run format']);
|
|
63
|
+
return [[row.label ?? row.name ?? row.key, errors.slice(0, 5).join('\n')]];
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
package/lib/watch.js
CHANGED
|
@@ -17,7 +17,7 @@ import { screen, outer } from './watch-widgets.js';
|
|
|
17
17
|
/**
|
|
18
18
|
*
|
|
19
19
|
*/
|
|
20
|
-
function toRow(check) { return { ...check, pass: null, detail: '', result: null }; }
|
|
20
|
+
function toRow(check) { return { ...check, pass: check.keyBinding ? false : null, detail: '', result: null }; }
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
*
|
|
@@ -78,6 +78,13 @@ export async function startWatch(projectDir) {
|
|
|
78
78
|
render(rows, lastCheck.value, watchDirs, currentViolations.value);
|
|
79
79
|
run(new Set(specRows.filter((r) => !r.keyBinding).map((r) => r.key)));
|
|
80
80
|
|
|
81
|
+
// Refresh idle key-bound rows so "X ago" detail stays current.
|
|
82
|
+
const idleTimer = setInterval(() => {
|
|
83
|
+
if (specRows.some((r) => r.keyBinding && r.pass === false && r.ranAt))
|
|
84
|
+
render(rows, lastCheck.value, watchDirs, currentViolations.value);
|
|
85
|
+
}, 30_000);
|
|
86
|
+
idleTimer.unref();
|
|
87
|
+
|
|
81
88
|
setupFix(specRows, cmds, ctx, pending, timers, run);
|
|
82
89
|
|
|
83
90
|
setupLifecycle({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techninja/clearstack",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"start": "node src/server.js",
|
|
25
25
|
"dev": "node --watch --env-file=.env --env-file=.env.local src/server.js",
|
|
26
|
+
"docs": "node scripts/sync-docs.js",
|
|
26
27
|
"setup": "node scripts/vendor-deps.js && node scripts/build-icons.js",
|
|
27
28
|
"test": "node scripts/test.js",
|
|
28
29
|
"spec": "node --env-file=.env scripts/spec.js",
|
|
@@ -24,14 +24,14 @@ Both are driven by the same `clearstack.routes.json` config and data sources.
|
|
|
24
24
|
"/trait/:slug": {
|
|
25
25
|
"title": "{slug.name}",
|
|
26
26
|
"description": "{slug.description}",
|
|
27
|
-
"image": "{slug.
|
|
28
|
-
"data": "
|
|
27
|
+
"image": "{slug.image}",
|
|
28
|
+
"data": "data/traits-og.json",
|
|
29
29
|
"ogTemplate": "trait"
|
|
30
30
|
},
|
|
31
31
|
"/shop/product/:sku": {
|
|
32
32
|
"title": "{sku.name} | {store.name}",
|
|
33
33
|
"description": "{sku.description}",
|
|
34
|
-
"image": "{
|
|
34
|
+
"image": "{sku.images.0}",
|
|
35
35
|
"data": "src/data/products.json",
|
|
36
36
|
"ogTemplate": "product"
|
|
37
37
|
}
|
|
@@ -111,6 +111,53 @@ clearstack build all --url=https://mysite.com --site=MySite --out=dist
|
|
|
111
111
|
| `--logo` | Logo path or URL |
|
|
112
112
|
| `--slug` | Single item slug (fast iteration) |
|
|
113
113
|
|
|
114
|
+
## Cloudflare Pages Routing
|
|
115
|
+
|
|
116
|
+
Cloudflare Pages evaluates `_redirects` **before** checking the filesystem.
|
|
117
|
+
The standard SPA catch-all `/* /index.html 200` will intercept all pre-rendered
|
|
118
|
+
pages before they can be served. Since CF Pages serves `.html` files natively
|
|
119
|
+
for extensionless URLs when a matching file exists on the filesystem, no extra
|
|
120
|
+
rewrite rules are needed — just ensure the catch-all is the **only** rule and
|
|
121
|
+
that it comes after any explicit redirects:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
/old-path /new-path 301
|
|
125
|
+
/* /index.html 200
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The pre-rendered files (`shop/product/SM-TEE.html`) will be served directly by
|
|
129
|
+
CF Pages' static file layer before the `_redirects` rules are evaluated.
|
|
130
|
+
|
|
131
|
+
## Canonical Tags
|
|
132
|
+
|
|
133
|
+
Inject `<link rel="canonical">` into every pre-rendered page so Google
|
|
134
|
+
deduplicates the static HTML shell from the SPA view at the same URL. Do this
|
|
135
|
+
as a post-processing step in `build.js` after `buildOG` runs:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
139
|
+
import { resolve } from 'node:path';
|
|
140
|
+
|
|
141
|
+
function injectCanonicals(distDir, baseUrl) {
|
|
142
|
+
const prefixes = ['shop/product', 'trait', 'gene'];
|
|
143
|
+
for (const prefix of prefixes) {
|
|
144
|
+
const dir = resolve(distDir, prefix);
|
|
145
|
+
if (!existsSync(dir)) continue;
|
|
146
|
+
for (const file of readdirSync(dir)) {
|
|
147
|
+
if (!file.endsWith('.html')) continue;
|
|
148
|
+
const slug = file.slice(0, -5);
|
|
149
|
+
const url = `${baseUrl}/${prefix}/${slug}`;
|
|
150
|
+
const filePath = resolve(dir, file);
|
|
151
|
+
let html = readFileSync(filePath, 'utf-8');
|
|
152
|
+
if (!html.includes('rel="canonical"')) {
|
|
153
|
+
html = html.replace('</head>', ` <link rel="canonical" href="${url}" />\n </head>`);
|
|
154
|
+
writeFileSync(filePath, html);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
114
161
|
## Requirements
|
|
115
162
|
|
|
116
163
|
- Zero config for basic cases (route title + description → OG tags)
|