@unhead/cli 3.0.5
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 -0
- package/README.md +123 -0
- package/bin/unhead.mjs +7 -0
- package/dist/index.d.mts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.mjs +1356 -0
- package/package.json +64 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
import { defineCommand, runMain } from 'citty';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { relative, extname, resolve } from 'pathe';
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import MagicString from 'magic-string';
|
|
6
|
+
import { parseSync } from 'oxc-parser';
|
|
7
|
+
import { glob } from 'tinyglobby';
|
|
8
|
+
import { tagPredicates, headInputPredicates, migrationTagPredicates } from 'unhead/validate';
|
|
9
|
+
import { walk } from 'oxc-walker';
|
|
10
|
+
import { parseHtmlForUnheadExtraction } from 'unhead/parser';
|
|
11
|
+
import { ValidatePlugin } from 'unhead/plugins';
|
|
12
|
+
import { createHead, renderSSRHead } from 'unhead/server';
|
|
13
|
+
|
|
14
|
+
const SEPARATORS = [" | ", " - ", " \u2013 ", " \u2014 ", " \xB7 ", " :: ", " / "];
|
|
15
|
+
const TEMPLATE_PARAM_RE = /%[a-z][a-z0-9]*/i;
|
|
16
|
+
function flatten(results) {
|
|
17
|
+
const titles = [];
|
|
18
|
+
const templates = [];
|
|
19
|
+
for (const r of results) {
|
|
20
|
+
for (const t of r.titles) titles.push({ ...t, filePath: r.filePath });
|
|
21
|
+
for (const t of r.titleTemplates) templates.push({ ...t, filePath: r.filePath });
|
|
22
|
+
}
|
|
23
|
+
return { titles, templates };
|
|
24
|
+
}
|
|
25
|
+
function detectSeparator(value) {
|
|
26
|
+
let best;
|
|
27
|
+
for (const sep of SEPARATORS) {
|
|
28
|
+
const count = value.split(sep).length - 1;
|
|
29
|
+
if (count > 0 && (!best || count > best.count))
|
|
30
|
+
best = { sep, count };
|
|
31
|
+
}
|
|
32
|
+
return best?.sep;
|
|
33
|
+
}
|
|
34
|
+
function suffixAfterSeparator(value, sep) {
|
|
35
|
+
const parts = value.split(sep);
|
|
36
|
+
return parts[parts.length - 1].trim();
|
|
37
|
+
}
|
|
38
|
+
function analyzeTitleConsistency(results) {
|
|
39
|
+
const { titles, templates } = flatten(results);
|
|
40
|
+
if (titles.length < 2)
|
|
41
|
+
return [];
|
|
42
|
+
const findings = [];
|
|
43
|
+
const sepBuckets = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const t of titles) {
|
|
45
|
+
const sep = detectSeparator(t.value);
|
|
46
|
+
if (!sep)
|
|
47
|
+
continue;
|
|
48
|
+
const arr = sepBuckets.get(sep) ?? [];
|
|
49
|
+
arr.push(t);
|
|
50
|
+
sepBuckets.set(sep, arr);
|
|
51
|
+
}
|
|
52
|
+
if (sepBuckets.size > 1) {
|
|
53
|
+
const breakdown = Array.from(sepBuckets.entries()).sort((a, b) => b[1].length - a[1].length).map(([sep, occ]) => `"${sep.trim()}" (${occ.length})`).join(", ");
|
|
54
|
+
const occurrences = [];
|
|
55
|
+
for (const [, occ] of sepBuckets) {
|
|
56
|
+
for (const o of occ.slice(0, 3))
|
|
57
|
+
occurrences.push({ filePath: o.filePath, line: o.line, column: o.column, value: o.value });
|
|
58
|
+
}
|
|
59
|
+
findings.push({
|
|
60
|
+
kind: "separator",
|
|
61
|
+
message: `${sepBuckets.size} different title separators in use across pages: ${breakdown}.`,
|
|
62
|
+
hint: 'Set `templateParams: { separator: "\xB7" }` and a single `titleTemplate: "%s %separator %siteName"` in `nuxt.config.app.head` so every page renders with the same separator without repeating it.',
|
|
63
|
+
occurrences
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const trailingCounts = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const t of titles) {
|
|
68
|
+
const sep = detectSeparator(t.value);
|
|
69
|
+
if (!sep)
|
|
70
|
+
continue;
|
|
71
|
+
const tail = suffixAfterSeparator(t.value, sep);
|
|
72
|
+
if (!tail || TEMPLATE_PARAM_RE.test(tail))
|
|
73
|
+
continue;
|
|
74
|
+
const arr = trailingCounts.get(tail) ?? [];
|
|
75
|
+
arr.push(t);
|
|
76
|
+
trailingCounts.set(tail, arr);
|
|
77
|
+
}
|
|
78
|
+
let bestSuffix;
|
|
79
|
+
for (const [tail, occ] of trailingCounts) {
|
|
80
|
+
if (occ.length < 3)
|
|
81
|
+
continue;
|
|
82
|
+
if (!bestSuffix || occ.length > bestSuffix.occ.length)
|
|
83
|
+
bestSuffix = { tail, occ };
|
|
84
|
+
}
|
|
85
|
+
if (bestSuffix && bestSuffix.occ.length >= Math.max(3, Math.ceil(titles.length * 0.5))) {
|
|
86
|
+
const templateSet = templates.length > 0;
|
|
87
|
+
const suffix = bestSuffix.tail;
|
|
88
|
+
const hint = templateSet ? `\`titleTemplate\` is already set in this project \u2014 these pages duplicate "${suffix}" and will likely render it twice. Drop the suffix from each \`title:\` value.` : `Set \`titleTemplate: "%s %separator %siteName"\` and \`templateParams: { siteName: "${suffix}" }\` in \`nuxt.config.app.head\`, then drop the trailing " \u2026 ${suffix}" from every page \`title\`.`;
|
|
89
|
+
findings.push({
|
|
90
|
+
kind: templateSet ? "redundant-suffix" : "common-suffix",
|
|
91
|
+
message: `${bestSuffix.occ.length} of ${titles.length} page titles end with " \u2026 ${suffix}".`,
|
|
92
|
+
hint,
|
|
93
|
+
occurrences: bestSuffix.occ.slice(0, 5).map((o) => ({ filePath: o.filePath, line: o.line, column: o.column, value: o.value }))
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const usingParams = titles.filter((t) => TEMPLATE_PARAM_RE.test(t.value));
|
|
97
|
+
if (usingParams.length > 0 && usingParams.length < titles.length) {
|
|
98
|
+
const literal = titles.filter((t) => !TEMPLATE_PARAM_RE.test(t.value));
|
|
99
|
+
findings.push({
|
|
100
|
+
kind: "literal-mixed-with-template",
|
|
101
|
+
message: `${usingParams.length} of ${titles.length} page titles use template params (e.g. %siteName) and ${literal.length} use literal strings.`,
|
|
102
|
+
hint: "Pick one approach: either move shared parts into `titleTemplate` + `templateParams` and have pages set just their unique segment, or drop template params everywhere. Mixing them makes the rendered output unpredictable.",
|
|
103
|
+
occurrences: literal.slice(0, 5).map((o) => ({ filePath: o.filePath, line: o.line, column: o.column, value: o.value }))
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RED = "\x1B[31m";
|
|
110
|
+
const YELLOW = "\x1B[33m";
|
|
111
|
+
const GREEN = "\x1B[32m";
|
|
112
|
+
const CYAN = "\x1B[36m";
|
|
113
|
+
const DIM = "\x1B[90m";
|
|
114
|
+
const RESET = "\x1B[0m";
|
|
115
|
+
const BOLD = "\x1B[1m";
|
|
116
|
+
function formatStylish(results, cwd, color) {
|
|
117
|
+
const titleFindings = analyzeTitleConsistency(results);
|
|
118
|
+
if (results.length === 0 && titleFindings.length === 0)
|
|
119
|
+
return "";
|
|
120
|
+
const c = color ? { red: RED, yellow: YELLOW, green: GREEN, cyan: CYAN, dim: DIM, reset: RESET, bold: BOLD } : { red: "", yellow: "", green: "", cyan: "", dim: "", reset: "", bold: "" };
|
|
121
|
+
const lines = [];
|
|
122
|
+
let total = 0;
|
|
123
|
+
let errors = 0;
|
|
124
|
+
let warnings = 0;
|
|
125
|
+
let infos = 0;
|
|
126
|
+
const sevColor = { error: c.red, warning: c.yellow, info: c.cyan };
|
|
127
|
+
for (const r of results) {
|
|
128
|
+
if (r.diagnostics.length === 0)
|
|
129
|
+
continue;
|
|
130
|
+
lines.push("");
|
|
131
|
+
lines.push(`${c.bold}${relative(cwd, r.filePath)}${c.reset}`);
|
|
132
|
+
const padLineCol = r.diagnostics.reduce((n, d) => Math.max(n, `${d.line}:${d.column}`.length), 0);
|
|
133
|
+
const padSeverity = r.diagnostics.reduce((n, d) => Math.max(n, d.severity.length), 0);
|
|
134
|
+
for (const d of r.diagnostics) {
|
|
135
|
+
total++;
|
|
136
|
+
if (d.severity === "error")
|
|
137
|
+
errors++;
|
|
138
|
+
else if (d.severity === "warning")
|
|
139
|
+
warnings++;
|
|
140
|
+
else
|
|
141
|
+
infos++;
|
|
142
|
+
const lc = `${d.line}:${d.column}`.padEnd(padLineCol);
|
|
143
|
+
const sev = d.severity.padEnd(padSeverity);
|
|
144
|
+
lines.push(` ${c.dim}${lc}${c.reset} ${sevColor[d.severity]}${sev}${c.reset} ${d.message} ${c.dim}${d.ruleId}${c.reset}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const covered = results.filter((r) => r.diagnostics.length === 0 && r.headCalls.length > 0);
|
|
148
|
+
if (covered.length > 0) {
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(`${c.dim}Scanned ${covered.length} file${covered.length === 1 ? "" : "s"} with head usage and no issues:${c.reset}`);
|
|
151
|
+
const padPath = covered.reduce((n, r) => Math.max(n, relative(cwd, r.filePath).length), 0);
|
|
152
|
+
for (const r of covered) {
|
|
153
|
+
const path = relative(cwd, r.filePath).padEnd(padPath);
|
|
154
|
+
const counts = summariseCalls(r.headCalls);
|
|
155
|
+
lines.push(` ${c.green}\u2713${c.reset} ${path} ${c.dim}${counts}${c.reset}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (titleFindings.length > 0) {
|
|
159
|
+
lines.push("");
|
|
160
|
+
lines.push(`${c.bold}Title consistency${c.reset}`);
|
|
161
|
+
for (const f of titleFindings) {
|
|
162
|
+
lines.push(` ${c.yellow}\u26A0${c.reset} ${f.message}`);
|
|
163
|
+
lines.push(` ${c.dim}\u2192 ${f.hint}${c.reset}`);
|
|
164
|
+
for (const o of f.occurrences) {
|
|
165
|
+
const path = relative(cwd, o.filePath);
|
|
166
|
+
lines.push(` ${c.dim}${path}:${o.line}${c.reset} ${truncate(o.value, 80)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (total === 0 && covered.length === 0 && titleFindings.length === 0)
|
|
171
|
+
return "";
|
|
172
|
+
if (total > 0) {
|
|
173
|
+
lines.push("");
|
|
174
|
+
const parts = [`${errors} error${errors === 1 ? "" : "s"}`, `${warnings} warning${warnings === 1 ? "" : "s"}`];
|
|
175
|
+
if (infos > 0)
|
|
176
|
+
parts.push(`${infos} info`);
|
|
177
|
+
const summary = `${total} problem${total === 1 ? "" : "s"} (${parts.join(", ")})`;
|
|
178
|
+
const summaryColor = errors > 0 ? c.red : warnings > 0 ? c.yellow : c.cyan;
|
|
179
|
+
const glyph = errors > 0 || warnings > 0 ? "\u2716" : "\u2139";
|
|
180
|
+
lines.push(`${summaryColor}${c.bold}${glyph} ${summary}${c.reset}`);
|
|
181
|
+
}
|
|
182
|
+
return `${lines.join("\n")}
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
function truncate(s, n) {
|
|
186
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}\u2026`;
|
|
187
|
+
}
|
|
188
|
+
function summariseCalls(calls) {
|
|
189
|
+
const counts = /* @__PURE__ */ new Map();
|
|
190
|
+
for (const call of calls)
|
|
191
|
+
counts.set(call.name, (counts.get(call.name) ?? 0) + 1);
|
|
192
|
+
return Array.from(counts.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([name, n]) => n === 1 ? name : `${name} \xD7${n}`).join(", ");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const TS_WRAPPERS$2 = /* @__PURE__ */ new Set(["TSAsExpression", "TSSatisfiesExpression", "TSNonNullExpression", "TSTypeAssertion", "TSInstantiationExpression"]);
|
|
196
|
+
function unwrapTS$2(node) {
|
|
197
|
+
let cur = node;
|
|
198
|
+
while (cur && TS_WRAPPERS$2.has(cur.type))
|
|
199
|
+
cur = cur.expression;
|
|
200
|
+
return cur;
|
|
201
|
+
}
|
|
202
|
+
function getStringValue(node) {
|
|
203
|
+
const inner = unwrapTS$2(node);
|
|
204
|
+
if (!inner)
|
|
205
|
+
return void 0;
|
|
206
|
+
if (inner.type === "Literal" && typeof inner.value === "string")
|
|
207
|
+
return inner.value;
|
|
208
|
+
if (inner.type === "TemplateLiteral" && inner.expressions.length === 0 && inner.quasis.length === 1)
|
|
209
|
+
return inner.quasis[0].value.cooked ?? void 0;
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
function getKeyName$2(prop) {
|
|
213
|
+
if (prop.type !== "Property" || prop.computed)
|
|
214
|
+
return void 0;
|
|
215
|
+
const k = prop.key;
|
|
216
|
+
if (k.type === "Identifier")
|
|
217
|
+
return k.name;
|
|
218
|
+
if (k.type === "Literal" && typeof k.value === "string")
|
|
219
|
+
return k.value;
|
|
220
|
+
return void 0;
|
|
221
|
+
}
|
|
222
|
+
function findProperty(obj, key) {
|
|
223
|
+
for (let i = obj.properties.length - 1; i >= 0; i--) {
|
|
224
|
+
const prop = obj.properties[i];
|
|
225
|
+
if (getKeyName$2(prop) === key)
|
|
226
|
+
return prop;
|
|
227
|
+
}
|
|
228
|
+
return void 0;
|
|
229
|
+
}
|
|
230
|
+
function materializeTag(obj, tagType, inArray) {
|
|
231
|
+
const props = {};
|
|
232
|
+
const keys = /* @__PURE__ */ new Set();
|
|
233
|
+
const propLocs = {};
|
|
234
|
+
for (const p of obj.properties) {
|
|
235
|
+
const name = getKeyName$2(p);
|
|
236
|
+
if (!name)
|
|
237
|
+
continue;
|
|
238
|
+
keys.add(name);
|
|
239
|
+
propLocs[name] = { start: p.start, end: p.end };
|
|
240
|
+
const value = unwrapTS$2(p.value);
|
|
241
|
+
if (!value)
|
|
242
|
+
continue;
|
|
243
|
+
if (value.type === "Literal") {
|
|
244
|
+
const v = value.value;
|
|
245
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
246
|
+
props[name] = v;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const str = getStringValue(value);
|
|
250
|
+
if (str !== void 0)
|
|
251
|
+
props[name] = str;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
tagType,
|
|
255
|
+
props,
|
|
256
|
+
keys,
|
|
257
|
+
loc: { start: obj.start, end: obj.end },
|
|
258
|
+
propLocs,
|
|
259
|
+
inArray
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function materializeHeadInput(obj, callee) {
|
|
263
|
+
const props = {};
|
|
264
|
+
const keys = /* @__PURE__ */ new Set();
|
|
265
|
+
const propLocs = {};
|
|
266
|
+
for (const p of obj.properties) {
|
|
267
|
+
const name = getKeyName$2(p);
|
|
268
|
+
if (!name)
|
|
269
|
+
continue;
|
|
270
|
+
keys.add(name);
|
|
271
|
+
propLocs[name] = { start: p.start, end: p.end };
|
|
272
|
+
if (name === "title" || name === "titleTemplate") {
|
|
273
|
+
const str = getStringValue(p.value);
|
|
274
|
+
if (str !== void 0)
|
|
275
|
+
props[name] = str;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { callee, props, keys, loc: { start: obj.start, end: obj.end }, propLocs };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function applyFix(magic, obj, fix, pieceOffset, pieceCode) {
|
|
282
|
+
const off = pieceOffset;
|
|
283
|
+
switch (fix.type) {
|
|
284
|
+
case "rename-prop": {
|
|
285
|
+
const prop = findProperty(obj, fix.key);
|
|
286
|
+
if (!prop)
|
|
287
|
+
return false;
|
|
288
|
+
magic.overwrite(prop.key.start + off, prop.key.end + off, fix.newKey);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
case "replace-prop-value": {
|
|
292
|
+
const prop = findProperty(obj, fix.key);
|
|
293
|
+
if (!prop)
|
|
294
|
+
return false;
|
|
295
|
+
magic.overwrite(prop.value.start + off, prop.value.end + off, fix.newSource);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
case "replace-prop": {
|
|
299
|
+
const prop = findProperty(obj, fix.key);
|
|
300
|
+
if (!prop)
|
|
301
|
+
return false;
|
|
302
|
+
magic.overwrite(prop.start + off, prop.end + off, fix.newSource);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
case "insert-after-prop": {
|
|
306
|
+
const prop = findProperty(obj, fix.afterKey);
|
|
307
|
+
if (!prop)
|
|
308
|
+
return false;
|
|
309
|
+
magic.appendRight(prop.end + off, fix.insert);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
case "remove-prop": {
|
|
313
|
+
const prop = findProperty(obj, fix.key);
|
|
314
|
+
if (!prop)
|
|
315
|
+
return false;
|
|
316
|
+
let from = prop.start;
|
|
317
|
+
let to = prop.end;
|
|
318
|
+
let f = to;
|
|
319
|
+
while (f < pieceCode.length && /\s/.test(pieceCode[f])) f++;
|
|
320
|
+
if (pieceCode[f] === ",") {
|
|
321
|
+
to = f + 1;
|
|
322
|
+
} else {
|
|
323
|
+
let b = from - 1;
|
|
324
|
+
while (b >= 0 && /\s/.test(pieceCode[b])) b--;
|
|
325
|
+
if (pieceCode[b] === ",")
|
|
326
|
+
from = b;
|
|
327
|
+
}
|
|
328
|
+
magic.remove(from + off, to + off);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
case "wrap-tag": {
|
|
332
|
+
magic.appendLeft(obj.start + off, `${fix.wrapWith}(`);
|
|
333
|
+
magic.appendRight(obj.end + off, `)`);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const TS_WRAPPERS$1 = /* @__PURE__ */ new Set([
|
|
340
|
+
"TSAsExpression",
|
|
341
|
+
"TSSatisfiesExpression",
|
|
342
|
+
"TSNonNullExpression",
|
|
343
|
+
"TSTypeAssertion",
|
|
344
|
+
"TSInstantiationExpression"
|
|
345
|
+
]);
|
|
346
|
+
function unwrapTS$1(node) {
|
|
347
|
+
let cur = node;
|
|
348
|
+
while (cur && TS_WRAPPERS$1.has(cur.type))
|
|
349
|
+
cur = cur.expression;
|
|
350
|
+
return cur;
|
|
351
|
+
}
|
|
352
|
+
function getKeyName$1(prop) {
|
|
353
|
+
if (prop.type !== "Property" || prop.computed)
|
|
354
|
+
return void 0;
|
|
355
|
+
const k = prop.key;
|
|
356
|
+
if (k.type === "Identifier")
|
|
357
|
+
return k.name;
|
|
358
|
+
if (k.type === "Literal" && typeof k.value === "string")
|
|
359
|
+
return k.value;
|
|
360
|
+
return void 0;
|
|
361
|
+
}
|
|
362
|
+
function metaTagToFlatKey(value) {
|
|
363
|
+
const parts = value.split(/[:\-_]/).filter(Boolean);
|
|
364
|
+
if (parts.length === 0)
|
|
365
|
+
return void 0;
|
|
366
|
+
const head = parts[0].toLowerCase();
|
|
367
|
+
const tail = parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1).toLowerCase()).join("");
|
|
368
|
+
return head + tail;
|
|
369
|
+
}
|
|
370
|
+
const PASSTHROUGH_KEYS = /* @__PURE__ */ new Set(["title", "titleTemplate"]);
|
|
371
|
+
const META_ATTR_KEYS = /* @__PURE__ */ new Set(["name", "property", "http-equiv", "httpEquiv"]);
|
|
372
|
+
function analyzeUseHeadForUseSeoMeta(inputNode, pieceCode) {
|
|
373
|
+
if (inputNode.type !== "ObjectExpression")
|
|
374
|
+
return void 0;
|
|
375
|
+
const props = [];
|
|
376
|
+
const seenFlatKeys = /* @__PURE__ */ new Set();
|
|
377
|
+
let metaCount = 0;
|
|
378
|
+
for (const prop of inputNode.properties) {
|
|
379
|
+
if (prop.type !== "Property" || prop.computed || prop.shorthand)
|
|
380
|
+
return void 0;
|
|
381
|
+
const key = getKeyName$1(prop);
|
|
382
|
+
if (!key)
|
|
383
|
+
return void 0;
|
|
384
|
+
if (PASSTHROUGH_KEYS.has(key)) {
|
|
385
|
+
const valueSource = pieceCode.slice(prop.value.start, prop.value.end);
|
|
386
|
+
if (seenFlatKeys.has(key))
|
|
387
|
+
return void 0;
|
|
388
|
+
seenFlatKeys.add(key);
|
|
389
|
+
props.push({ key, valueSource });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (key !== "meta")
|
|
393
|
+
return void 0;
|
|
394
|
+
const metaArr = unwrapTS$1(prop.value);
|
|
395
|
+
if (metaArr?.type !== "ArrayExpression")
|
|
396
|
+
return void 0;
|
|
397
|
+
for (const el of metaArr.elements) {
|
|
398
|
+
const obj = unwrapTS$1(el);
|
|
399
|
+
if (obj?.type !== "ObjectExpression")
|
|
400
|
+
return void 0;
|
|
401
|
+
let flatKey;
|
|
402
|
+
let contentSource;
|
|
403
|
+
let charsetSource;
|
|
404
|
+
for (const mp of obj.properties) {
|
|
405
|
+
if (mp.type !== "Property" || mp.computed || mp.shorthand)
|
|
406
|
+
return void 0;
|
|
407
|
+
const k = getKeyName$1(mp);
|
|
408
|
+
if (!k)
|
|
409
|
+
return void 0;
|
|
410
|
+
if (k === "charset") {
|
|
411
|
+
flatKey = "charset";
|
|
412
|
+
charsetSource = pieceCode.slice(mp.value.start, mp.value.end);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (META_ATTR_KEYS.has(k)) {
|
|
416
|
+
const v = unwrapTS$1(mp.value);
|
|
417
|
+
if (v?.type !== "Literal" || typeof v.value !== "string")
|
|
418
|
+
return void 0;
|
|
419
|
+
const candidate = metaTagToFlatKey(v.value);
|
|
420
|
+
if (!candidate)
|
|
421
|
+
return void 0;
|
|
422
|
+
if (flatKey && flatKey !== candidate)
|
|
423
|
+
return void 0;
|
|
424
|
+
flatKey = candidate;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (k === "content") {
|
|
428
|
+
contentSource = pieceCode.slice(mp.value.start, mp.value.end);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
return void 0;
|
|
432
|
+
}
|
|
433
|
+
if (!flatKey)
|
|
434
|
+
return void 0;
|
|
435
|
+
const valueSource = charsetSource ?? contentSource;
|
|
436
|
+
if (!valueSource)
|
|
437
|
+
return void 0;
|
|
438
|
+
if (seenFlatKeys.has(flatKey))
|
|
439
|
+
return void 0;
|
|
440
|
+
seenFlatKeys.add(flatKey);
|
|
441
|
+
props.push({ key: flatKey, valueSource });
|
|
442
|
+
metaCount++;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (metaCount === 0)
|
|
446
|
+
return void 0;
|
|
447
|
+
return { props };
|
|
448
|
+
}
|
|
449
|
+
function renderUseSeoMetaArg(conversion, originalSource) {
|
|
450
|
+
const indent = detectIndent(originalSource);
|
|
451
|
+
const lines = conversion.props.map((p) => `${indent}${p.key}: ${p.valueSource},`);
|
|
452
|
+
return `{
|
|
453
|
+
${lines.join("\n")}
|
|
454
|
+
}`;
|
|
455
|
+
}
|
|
456
|
+
function detectIndent(source) {
|
|
457
|
+
const m = source.match(/\n([ \t]+)\S/);
|
|
458
|
+
return m ? m[1] : " ";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const SCRIPT_OPEN_RE = /<script\b((?:"[^"]*"|'[^']*'|[^>"'])*)>/gi;
|
|
462
|
+
const SCRIPT_CLOSE_RE = /<\/script\s*>/gi;
|
|
463
|
+
const LANG_ATTR_RE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'>]+))/i;
|
|
464
|
+
function extractScriptBlocks(source) {
|
|
465
|
+
const out = [];
|
|
466
|
+
SCRIPT_OPEN_RE.lastIndex = 0;
|
|
467
|
+
for (let m = SCRIPT_OPEN_RE.exec(source); m; m = SCRIPT_OPEN_RE.exec(source)) {
|
|
468
|
+
const attrs = m[1];
|
|
469
|
+
const openEnd = m.index + m[0].length;
|
|
470
|
+
SCRIPT_CLOSE_RE.lastIndex = openEnd;
|
|
471
|
+
const closeMatch = SCRIPT_CLOSE_RE.exec(source);
|
|
472
|
+
if (!closeMatch)
|
|
473
|
+
continue;
|
|
474
|
+
const code = source.slice(openEnd, closeMatch.index);
|
|
475
|
+
const langMatch = attrs.match(LANG_ATTR_RE);
|
|
476
|
+
const declared = (langMatch?.[1] ?? langMatch?.[2] ?? langMatch?.[3])?.toLowerCase();
|
|
477
|
+
const lang = declared === "ts" ? "ts" : declared === "tsx" ? "tsx" : declared === "jsx" ? "jsx" : "js";
|
|
478
|
+
out.push({ code, offset: openEnd, lang });
|
|
479
|
+
SCRIPT_OPEN_RE.lastIndex = closeMatch.index + closeMatch[0].length;
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
function langForExt(ext) {
|
|
484
|
+
switch (ext) {
|
|
485
|
+
case ".ts":
|
|
486
|
+
case ".mts":
|
|
487
|
+
case ".cts":
|
|
488
|
+
return "ts";
|
|
489
|
+
case ".tsx":
|
|
490
|
+
return "tsx";
|
|
491
|
+
case ".jsx":
|
|
492
|
+
return "jsx";
|
|
493
|
+
case ".js":
|
|
494
|
+
case ".mjs":
|
|
495
|
+
case ".cjs":
|
|
496
|
+
return "js";
|
|
497
|
+
default:
|
|
498
|
+
return void 0;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const HEAD_INPUT_CALLEES = /* @__PURE__ */ new Set([
|
|
503
|
+
"useHead",
|
|
504
|
+
"useHeadSafe",
|
|
505
|
+
"useServerHead",
|
|
506
|
+
"useServerHeadSafe",
|
|
507
|
+
"useSeoMeta",
|
|
508
|
+
"useServerSeoMeta"
|
|
509
|
+
]);
|
|
510
|
+
const NUXT_CONFIG_CALLEES = /* @__PURE__ */ new Set([
|
|
511
|
+
"defineNuxtConfig"
|
|
512
|
+
]);
|
|
513
|
+
const TAG_HELPER_CALLEES = /* @__PURE__ */ new Set([
|
|
514
|
+
"defineLink",
|
|
515
|
+
"defineScript"
|
|
516
|
+
]);
|
|
517
|
+
const HEAD_INPUT_TAG_KEYS = /* @__PURE__ */ new Set([
|
|
518
|
+
"meta",
|
|
519
|
+
"link",
|
|
520
|
+
"script",
|
|
521
|
+
"noscript",
|
|
522
|
+
"style"
|
|
523
|
+
]);
|
|
524
|
+
const TS_WRAPPERS = /* @__PURE__ */ new Set(["TSAsExpression", "TSSatisfiesExpression", "TSNonNullExpression", "TSTypeAssertion", "TSInstantiationExpression"]);
|
|
525
|
+
function unwrapTS(node) {
|
|
526
|
+
let cur = node;
|
|
527
|
+
while (cur && TS_WRAPPERS.has(cur.type))
|
|
528
|
+
cur = cur.expression;
|
|
529
|
+
return cur;
|
|
530
|
+
}
|
|
531
|
+
function getCalleeName(node) {
|
|
532
|
+
const callee = unwrapTS(node.callee);
|
|
533
|
+
if (!callee)
|
|
534
|
+
return void 0;
|
|
535
|
+
if (callee.type === "Identifier")
|
|
536
|
+
return callee.name;
|
|
537
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier")
|
|
538
|
+
return callee.property.name;
|
|
539
|
+
return void 0;
|
|
540
|
+
}
|
|
541
|
+
function getKeyName(prop) {
|
|
542
|
+
if (prop.type !== "Property" || prop.computed)
|
|
543
|
+
return void 0;
|
|
544
|
+
const k = prop.key;
|
|
545
|
+
if (k.type === "Identifier")
|
|
546
|
+
return k.name;
|
|
547
|
+
if (k.type === "Literal" && typeof k.value === "string")
|
|
548
|
+
return k.value;
|
|
549
|
+
return void 0;
|
|
550
|
+
}
|
|
551
|
+
const HELPER_NAMES = /* @__PURE__ */ new Set(["defineLink", "defineScript"]);
|
|
552
|
+
const HELPER_SOURCES = /* @__PURE__ */ new Set([
|
|
553
|
+
"unhead",
|
|
554
|
+
"@unhead/vue",
|
|
555
|
+
"@unhead/react",
|
|
556
|
+
"@unhead/svelte",
|
|
557
|
+
"@unhead/solid-js",
|
|
558
|
+
"@unhead/angular"
|
|
559
|
+
]);
|
|
560
|
+
function isHelperSource(source) {
|
|
561
|
+
if (HELPER_SOURCES.has(source))
|
|
562
|
+
return true;
|
|
563
|
+
return source.startsWith("@unhead/");
|
|
564
|
+
}
|
|
565
|
+
function collectImportedHelpers(program) {
|
|
566
|
+
const out = /* @__PURE__ */ new Map();
|
|
567
|
+
for (const node of program.body) {
|
|
568
|
+
if (node.type !== "ImportDeclaration")
|
|
569
|
+
continue;
|
|
570
|
+
const source = typeof node.source?.value === "string" ? node.source.value : "";
|
|
571
|
+
if (!isHelperSource(source))
|
|
572
|
+
continue;
|
|
573
|
+
for (const spec of node.specifiers) {
|
|
574
|
+
if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier" && HELPER_NAMES.has(spec.imported.name))
|
|
575
|
+
out.set(spec.imported.name, spec.local?.name ?? spec.imported.name);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
function collectCallees(root) {
|
|
581
|
+
const calls = /* @__PURE__ */ new Set();
|
|
582
|
+
walk(root, {
|
|
583
|
+
enter(n) {
|
|
584
|
+
if (n.type === "CallExpression") {
|
|
585
|
+
const name = getCalleeName(n);
|
|
586
|
+
if (name)
|
|
587
|
+
calls.add(name);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
return calls;
|
|
592
|
+
}
|
|
593
|
+
function visitDecl(decl, fns) {
|
|
594
|
+
if (decl.type === "FunctionDeclaration" && decl.id?.name) {
|
|
595
|
+
fns.set(decl.id.name, collectCallees(decl.body));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (decl.type === "VariableDeclaration") {
|
|
599
|
+
for (const d of decl.declarations) {
|
|
600
|
+
if (d.id?.type !== "Identifier" || !d.init)
|
|
601
|
+
continue;
|
|
602
|
+
const init = unwrapTS(d.init);
|
|
603
|
+
if (init && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression"))
|
|
604
|
+
fns.set(d.id.name, collectCallees(init.body));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function extractCallGraph(program) {
|
|
609
|
+
const functions = /* @__PURE__ */ new Map();
|
|
610
|
+
for (const stmt of program.body) {
|
|
611
|
+
if (stmt.type === "ExportNamedDeclaration" && stmt.declaration) {
|
|
612
|
+
visitDecl(stmt.declaration, functions);
|
|
613
|
+
} else if (stmt.type === "ExportDefaultDeclaration") {
|
|
614
|
+
const d = stmt.declaration;
|
|
615
|
+
if (d?.type === "FunctionDeclaration" && d.id?.name)
|
|
616
|
+
functions.set(d.id.name, collectCallees(d.body));
|
|
617
|
+
} else {
|
|
618
|
+
visitDecl(stmt, functions);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { functions, allCalls: collectCallees(program) };
|
|
622
|
+
}
|
|
623
|
+
const TITLE_PROP_KEY = "title";
|
|
624
|
+
const TITLE_TEMPLATE_PROP_KEY = "titleTemplate";
|
|
625
|
+
function readStringLiteral(value) {
|
|
626
|
+
const v = unwrapTS(value);
|
|
627
|
+
if (!v)
|
|
628
|
+
return void 0;
|
|
629
|
+
if (v.type === "Literal" && typeof v.value === "string")
|
|
630
|
+
return { value: v.value, dynamic: false };
|
|
631
|
+
if (v.type === "TemplateLiteral") {
|
|
632
|
+
let out = "";
|
|
633
|
+
for (let i = 0; i < v.quasis.length; i++) {
|
|
634
|
+
out += v.quasis[i].value.cooked ?? v.quasis[i].value.raw ?? "";
|
|
635
|
+
if (i < v.expressions.length)
|
|
636
|
+
out += "{\u2026}";
|
|
637
|
+
}
|
|
638
|
+
return { value: out, dynamic: v.expressions.length > 0 };
|
|
639
|
+
}
|
|
640
|
+
return void 0;
|
|
641
|
+
}
|
|
642
|
+
function extractCandidateTitles(program) {
|
|
643
|
+
const titles = [];
|
|
644
|
+
const templates = [];
|
|
645
|
+
walk(program, {
|
|
646
|
+
enter(node) {
|
|
647
|
+
if (node.type !== "CallExpression")
|
|
648
|
+
return;
|
|
649
|
+
const callee = getCalleeName(node);
|
|
650
|
+
if (!callee)
|
|
651
|
+
return;
|
|
652
|
+
const arg = unwrapTS(node.arguments[0]);
|
|
653
|
+
if (arg?.type !== "ObjectExpression")
|
|
654
|
+
return;
|
|
655
|
+
for (const prop of arg.properties) {
|
|
656
|
+
const key = getKeyName(prop);
|
|
657
|
+
if (key !== TITLE_PROP_KEY && key !== TITLE_TEMPLATE_PROP_KEY)
|
|
658
|
+
continue;
|
|
659
|
+
const lit = readStringLiteral(prop.value);
|
|
660
|
+
if (!lit)
|
|
661
|
+
continue;
|
|
662
|
+
const entry = { callee, value: lit.value, dynamic: lit.dynamic, start: prop.start };
|
|
663
|
+
if (key === TITLE_PROP_KEY)
|
|
664
|
+
titles.push(entry);
|
|
665
|
+
else
|
|
666
|
+
templates.push(entry);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
return { titles, templates };
|
|
671
|
+
}
|
|
672
|
+
function walkHeadCalls(program, visitor) {
|
|
673
|
+
walk(program, {
|
|
674
|
+
enter(node) {
|
|
675
|
+
if (node.type !== "CallExpression")
|
|
676
|
+
return;
|
|
677
|
+
const name = getCalleeName(node);
|
|
678
|
+
if (!name)
|
|
679
|
+
return;
|
|
680
|
+
if (TAG_HELPER_CALLEES.has(name)) {
|
|
681
|
+
visitor.onCall?.(node, name);
|
|
682
|
+
const arg2 = unwrapTS(node.arguments[0]);
|
|
683
|
+
if (arg2?.type === "ObjectExpression") {
|
|
684
|
+
const tagType = name.slice("define".length).toLowerCase();
|
|
685
|
+
visitor.onTag?.(arg2, tagType, { inArray: false });
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (NUXT_CONFIG_CALLEES.has(name)) {
|
|
690
|
+
const arg2 = unwrapTS(node.arguments[0]);
|
|
691
|
+
if (arg2?.type !== "ObjectExpression")
|
|
692
|
+
return;
|
|
693
|
+
const head = findNuxtAppHead(arg2);
|
|
694
|
+
if (!head)
|
|
695
|
+
return;
|
|
696
|
+
visitor.onCall?.(node, name);
|
|
697
|
+
visitor.onHeadInput?.(head, name, node);
|
|
698
|
+
descendHeadInput(head, name, visitor);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (!HEAD_INPUT_CALLEES.has(name))
|
|
702
|
+
return;
|
|
703
|
+
visitor.onCall?.(node, name);
|
|
704
|
+
const arg = unwrapTS(node.arguments[0]);
|
|
705
|
+
if (arg?.type !== "ObjectExpression")
|
|
706
|
+
return;
|
|
707
|
+
visitor.onHeadInput?.(arg, name, node);
|
|
708
|
+
descendHeadInput(arg, name, visitor);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
function descendHeadInput(arg, name, visitor) {
|
|
713
|
+
if (name === "useSeoMeta" || name === "useServerSeoMeta")
|
|
714
|
+
return;
|
|
715
|
+
for (const prop of arg.properties) {
|
|
716
|
+
const key = getKeyName(prop);
|
|
717
|
+
if (!key || !HEAD_INPUT_TAG_KEYS.has(key))
|
|
718
|
+
continue;
|
|
719
|
+
const value = unwrapTS(prop.value);
|
|
720
|
+
if (value?.type === "ArrayExpression") {
|
|
721
|
+
for (const el of value.elements) {
|
|
722
|
+
const inner = unwrapTS(el);
|
|
723
|
+
if (inner?.type === "ObjectExpression")
|
|
724
|
+
visitor.onTag?.(inner, key, { inArray: true });
|
|
725
|
+
}
|
|
726
|
+
} else if (value?.type === "ObjectExpression") {
|
|
727
|
+
visitor.onTag?.(value, key, { inArray: false });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function findNuxtAppHead(config) {
|
|
732
|
+
for (const prop of config.properties) {
|
|
733
|
+
if (getKeyName(prop) !== "app")
|
|
734
|
+
continue;
|
|
735
|
+
const app = unwrapTS(prop.value);
|
|
736
|
+
if (app?.type !== "ObjectExpression")
|
|
737
|
+
return void 0;
|
|
738
|
+
for (const inner of app.properties) {
|
|
739
|
+
if (getKeyName(inner) !== "head")
|
|
740
|
+
continue;
|
|
741
|
+
const head = unwrapTS(inner.value);
|
|
742
|
+
if (head?.type === "ObjectExpression")
|
|
743
|
+
return head;
|
|
744
|
+
return void 0;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return void 0;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const RECOMMENDED_SEVERITY = {
|
|
751
|
+
"defer-on-module-script": "warning",
|
|
752
|
+
"empty-meta-content": "warning",
|
|
753
|
+
"deprecated-prop-children": "error",
|
|
754
|
+
"deprecated-prop-hid-vmid": "error",
|
|
755
|
+
"deprecated-prop-body": "error",
|
|
756
|
+
"html-in-title": "warning",
|
|
757
|
+
"possible-typo": "warning",
|
|
758
|
+
"non-absolute-canonical": "warning",
|
|
759
|
+
"numeric-tag-priority": "warning",
|
|
760
|
+
"preload-font-crossorigin": "error",
|
|
761
|
+
"preload-missing-as": "error",
|
|
762
|
+
"robots-conflict": "error",
|
|
763
|
+
"script-src-with-content": "error",
|
|
764
|
+
"twitter-handle-missing-at": "warning",
|
|
765
|
+
"viewport-user-scalable": "warning",
|
|
766
|
+
"prefer-define-helpers": "warning",
|
|
767
|
+
"prefer-use-seo-meta": "warning",
|
|
768
|
+
"parse-error": "warning",
|
|
769
|
+
"page-missing-head": "info"
|
|
770
|
+
};
|
|
771
|
+
function lineCol(source, offset) {
|
|
772
|
+
let line = 1;
|
|
773
|
+
let lastNL = -1;
|
|
774
|
+
for (let i = 0; i < offset; i++) {
|
|
775
|
+
if (source.charCodeAt(i) === 10) {
|
|
776
|
+
line++;
|
|
777
|
+
lastNL = i;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return { line, column: offset - lastNL };
|
|
781
|
+
}
|
|
782
|
+
function anchorOffset(node, diag) {
|
|
783
|
+
if (!diag.at || diag.at.kind === "tag")
|
|
784
|
+
return node.start;
|
|
785
|
+
for (let i = node.properties.length - 1; i >= 0; i--) {
|
|
786
|
+
const p = node.properties[i];
|
|
787
|
+
const k = p.key;
|
|
788
|
+
const name = k?.type === "Identifier" ? k.name : k?.type === "Literal" && typeof k.value === "string" ? k.value : void 0;
|
|
789
|
+
if (name !== diag.at.key)
|
|
790
|
+
continue;
|
|
791
|
+
if (diag.at.kind === "prop")
|
|
792
|
+
return p.start;
|
|
793
|
+
if (diag.at.kind === "prop-key")
|
|
794
|
+
return p.key.start;
|
|
795
|
+
return p.value.start;
|
|
796
|
+
}
|
|
797
|
+
return node.start;
|
|
798
|
+
}
|
|
799
|
+
async function auditFile(filePath, source, pieces, predicateNames, shouldFix) {
|
|
800
|
+
const diagnostics = [];
|
|
801
|
+
const headCalls = [];
|
|
802
|
+
const titles = [];
|
|
803
|
+
const titleTemplates = [];
|
|
804
|
+
const candidateTitles = [];
|
|
805
|
+
const candidateTemplates = [];
|
|
806
|
+
const callGraph = { functions: /* @__PURE__ */ new Map(), allCalls: /* @__PURE__ */ new Set() };
|
|
807
|
+
const magic = shouldFix ? new MagicString(source) : void 0;
|
|
808
|
+
let edited = false;
|
|
809
|
+
for (const piece of pieces) {
|
|
810
|
+
let emit = function(diag, node) {
|
|
811
|
+
const absOffset = piece.offset + anchorOffset(node, diag);
|
|
812
|
+
const { line, column } = lineCol(source, absOffset);
|
|
813
|
+
diagnostics.push({
|
|
814
|
+
ruleId: diag.ruleId,
|
|
815
|
+
message: diag.message,
|
|
816
|
+
line,
|
|
817
|
+
column,
|
|
818
|
+
severity: RECOMMENDED_SEVERITY[diag.ruleId] ?? "warning"
|
|
819
|
+
});
|
|
820
|
+
if (shouldFix && magic && diag.fix) {
|
|
821
|
+
if (applyFix(magic, node, diag.fix, piece.offset, piece.code))
|
|
822
|
+
edited = true;
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
let parsed;
|
|
826
|
+
try {
|
|
827
|
+
parsed = parseSync(filePath, piece.code, { sourceType: "module", lang: piece.lang });
|
|
828
|
+
} catch (err) {
|
|
829
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
830
|
+
diagnostics.push({
|
|
831
|
+
ruleId: "parse-error",
|
|
832
|
+
message: `skipped ${piece.lang} block: ${message}`,
|
|
833
|
+
line: lineCol(source, piece.offset).line,
|
|
834
|
+
column: 1,
|
|
835
|
+
severity: "warning"
|
|
836
|
+
});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (parsed.errors.length > 0) {
|
|
840
|
+
const first = parsed.errors[0];
|
|
841
|
+
diagnostics.push({
|
|
842
|
+
ruleId: "parse-error",
|
|
843
|
+
message: `skipped ${piece.lang} block: ${first.message ?? "parse error"}`,
|
|
844
|
+
line: lineCol(source, piece.offset).line,
|
|
845
|
+
column: 1,
|
|
846
|
+
severity: "warning"
|
|
847
|
+
});
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const program = parsed.program;
|
|
851
|
+
let importedHelpers;
|
|
852
|
+
const pieceGraph = extractCallGraph(program);
|
|
853
|
+
for (const c of pieceGraph.allCalls) callGraph.allCalls.add(c);
|
|
854
|
+
for (const [n, calls] of pieceGraph.functions) {
|
|
855
|
+
const existing = callGraph.functions.get(n);
|
|
856
|
+
if (existing) {
|
|
857
|
+
for (const c of calls) existing.add(c);
|
|
858
|
+
} else {
|
|
859
|
+
callGraph.functions.set(n, new Set(calls));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const cands = extractCandidateTitles(program);
|
|
863
|
+
for (const t of cands.titles) {
|
|
864
|
+
const { line, column } = lineCol(source, piece.offset + t.start);
|
|
865
|
+
candidateTitles.push({ value: t.value, line, column, callee: t.callee, dynamic: t.dynamic });
|
|
866
|
+
}
|
|
867
|
+
for (const t of cands.templates) {
|
|
868
|
+
const { line, column } = lineCol(source, piece.offset + t.start);
|
|
869
|
+
candidateTemplates.push({ value: t.value, line, column, callee: t.callee, dynamic: t.dynamic });
|
|
870
|
+
}
|
|
871
|
+
walkHeadCalls(program, {
|
|
872
|
+
onCall(call, callee) {
|
|
873
|
+
const { line, column } = lineCol(source, piece.offset + call.start);
|
|
874
|
+
headCalls.push({ name: callee, line, column });
|
|
875
|
+
},
|
|
876
|
+
onHeadInput(input, callee, call) {
|
|
877
|
+
if (callee === "useHead") {
|
|
878
|
+
const conversion = analyzeUseHeadForUseSeoMeta(input, piece.code);
|
|
879
|
+
if (conversion) {
|
|
880
|
+
const { line, column } = lineCol(source, piece.offset + call.start);
|
|
881
|
+
diagnostics.push({
|
|
882
|
+
ruleId: "prefer-use-seo-meta",
|
|
883
|
+
message: 'This useHead call is meta-only. Switch to useSeoMeta \u2014 meta name/property values are typed as plain strings here, so a typo (e.g. "descriptipon") compiles and silently ships broken meta. useSeoMeta keys are typed and catch typos at write time.',
|
|
884
|
+
line,
|
|
885
|
+
column,
|
|
886
|
+
severity: "warning"
|
|
887
|
+
});
|
|
888
|
+
if (shouldFix && magic) {
|
|
889
|
+
const calleeNode = call.callee;
|
|
890
|
+
const calleeStart = calleeNode?.type === "Identifier" ? calleeNode.start : call.start;
|
|
891
|
+
const calleeEnd = calleeNode?.type === "Identifier" ? calleeNode.end : call.start;
|
|
892
|
+
if (calleeNode?.type === "Identifier") {
|
|
893
|
+
magic.overwrite(piece.offset + calleeStart, piece.offset + calleeEnd, "useSeoMeta");
|
|
894
|
+
}
|
|
895
|
+
const originalSource = piece.code.slice(input.start, input.end);
|
|
896
|
+
const newArg = renderUseSeoMetaArg(conversion, originalSource);
|
|
897
|
+
magic.overwrite(piece.offset + input.start, piece.offset + input.end, newArg);
|
|
898
|
+
edited = true;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const view = materializeHeadInput(input, callee);
|
|
903
|
+
if (view.props.title !== void 0) {
|
|
904
|
+
const titleProp = input.properties.find((p) => {
|
|
905
|
+
const k = p.key;
|
|
906
|
+
return k?.type === "Identifier" && k.name === "title" || k?.type === "Literal" && k.value === "title";
|
|
907
|
+
});
|
|
908
|
+
if (titleProp) {
|
|
909
|
+
const { line, column } = lineCol(source, piece.offset + titleProp.start);
|
|
910
|
+
titles.push({ value: view.props.title, line, column, callee });
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (view.props.titleTemplate !== void 0) {
|
|
914
|
+
const tplProp = input.properties.find((p) => {
|
|
915
|
+
const k = p.key;
|
|
916
|
+
return k?.type === "Identifier" && k.name === "titleTemplate" || k?.type === "Literal" && k.value === "titleTemplate";
|
|
917
|
+
});
|
|
918
|
+
if (tplProp) {
|
|
919
|
+
const { line, column } = lineCol(source, piece.offset + tplProp.start);
|
|
920
|
+
titleTemplates.push({ value: view.props.titleTemplate, line, column, callee });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
for (const name of predicateNames) {
|
|
924
|
+
const pred = headInputPredicates[name];
|
|
925
|
+
if (!pred)
|
|
926
|
+
continue;
|
|
927
|
+
for (const diag of pred(view))
|
|
928
|
+
emit(diag, input);
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
onTag(tag, tagType, info) {
|
|
932
|
+
const view = materializeTag(tag, tagType, info.inArray);
|
|
933
|
+
const ctx = {
|
|
934
|
+
get importedHelpers() {
|
|
935
|
+
if (!importedHelpers)
|
|
936
|
+
importedHelpers = collectImportedHelpers(program);
|
|
937
|
+
return importedHelpers;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
for (const name of predicateNames) {
|
|
941
|
+
const pred = tagPredicates[name] ?? migrationTagPredicates[name];
|
|
942
|
+
if (!pred)
|
|
943
|
+
continue;
|
|
944
|
+
for (const diag of pred(view, ctx))
|
|
945
|
+
emit(diag, tag);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
diagnostics,
|
|
952
|
+
headCalls,
|
|
953
|
+
titles,
|
|
954
|
+
titleTemplates,
|
|
955
|
+
candidateTitles,
|
|
956
|
+
candidateTemplates,
|
|
957
|
+
callGraph,
|
|
958
|
+
output: edited && magic ? magic.toString() : void 0
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
const PAGE_PATH_RE = /[\\/]pages[\\/].+\.vue$/;
|
|
962
|
+
function isPagePath(filePath) {
|
|
963
|
+
return PAGE_PATH_RE.test(filePath);
|
|
964
|
+
}
|
|
965
|
+
function computeHeadProvidingCallees(graphs) {
|
|
966
|
+
const merged = /* @__PURE__ */ new Map();
|
|
967
|
+
for (const g of graphs) {
|
|
968
|
+
for (const [name, calls] of g.functions) {
|
|
969
|
+
const existing = merged.get(name);
|
|
970
|
+
if (existing) {
|
|
971
|
+
for (const c of calls) existing.add(c);
|
|
972
|
+
} else {
|
|
973
|
+
merged.set(name, new Set(calls));
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const set = new Set(HEAD_INPUT_CALLEES);
|
|
978
|
+
let changed = true;
|
|
979
|
+
while (changed) {
|
|
980
|
+
changed = false;
|
|
981
|
+
for (const [name, calls] of merged) {
|
|
982
|
+
if (set.has(name))
|
|
983
|
+
continue;
|
|
984
|
+
for (const c of calls) {
|
|
985
|
+
if (set.has(c)) {
|
|
986
|
+
set.add(name);
|
|
987
|
+
changed = true;
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return set;
|
|
994
|
+
}
|
|
995
|
+
async function runAudit(opts) {
|
|
996
|
+
const files = await glob(opts.patterns, {
|
|
997
|
+
cwd: opts.cwd,
|
|
998
|
+
absolute: true,
|
|
999
|
+
ignore: opts.ignore
|
|
1000
|
+
});
|
|
1001
|
+
const shouldFix = opts.mode === "migrate";
|
|
1002
|
+
const predicateNames = opts.mode === "migrate" ? [...Object.keys(tagPredicates), ...Object.keys(headInputPredicates), ...Object.keys(migrationTagPredicates)] : [...Object.keys(tagPredicates), ...Object.keys(headInputPredicates)];
|
|
1003
|
+
const results = [];
|
|
1004
|
+
const allGraphs = [];
|
|
1005
|
+
const pageFiles = [];
|
|
1006
|
+
const pendingCandidates = [];
|
|
1007
|
+
for (const filePath of files) {
|
|
1008
|
+
const source = await readFile(filePath, "utf8");
|
|
1009
|
+
const ext = extname(filePath).toLowerCase();
|
|
1010
|
+
const lang = langForExt(ext);
|
|
1011
|
+
const pieces = lang ? [{ code: source, offset: 0, lang }] : ext === ".vue" || ext === ".svelte" ? extractScriptBlocks(source).map((b) => ({ code: b.code, offset: b.offset, lang: b.lang })) : [];
|
|
1012
|
+
if (pieces.length === 0)
|
|
1013
|
+
continue;
|
|
1014
|
+
const result = await auditFile(filePath, source, pieces, predicateNames, shouldFix);
|
|
1015
|
+
allGraphs.push(result.callGraph);
|
|
1016
|
+
const isPage = isPagePath(filePath);
|
|
1017
|
+
const hasInterestingResult = result.diagnostics.length > 0 || !!result.output || result.headCalls.length > 0;
|
|
1018
|
+
const hasTitles = result.titles.length > 0 || result.titleTemplates.length > 0;
|
|
1019
|
+
let resultIdx = -1;
|
|
1020
|
+
if (hasInterestingResult || hasTitles) {
|
|
1021
|
+
resultIdx = results.length;
|
|
1022
|
+
results.push({
|
|
1023
|
+
filePath,
|
|
1024
|
+
diagnostics: result.diagnostics,
|
|
1025
|
+
headCalls: result.headCalls,
|
|
1026
|
+
titles: result.titles,
|
|
1027
|
+
titleTemplates: result.titleTemplates,
|
|
1028
|
+
output: result.output
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
if (isPage && result.headCalls.length === 0) {
|
|
1032
|
+
pageFiles.push({ filePath, callGraph: result.callGraph, headCalls: result.headCalls, existingResultIdx: resultIdx });
|
|
1033
|
+
}
|
|
1034
|
+
if (result.candidateTitles.length > 0 || result.candidateTemplates.length > 0) {
|
|
1035
|
+
pendingCandidates.push({ filePath, candidateTitles: result.candidateTitles, candidateTemplates: result.candidateTemplates, existingResultIdx: resultIdx });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const headProviding = computeHeadProvidingCallees(allGraphs);
|
|
1039
|
+
for (const c of pendingCandidates) {
|
|
1040
|
+
const extraTitles = [];
|
|
1041
|
+
const extraTemplates = [];
|
|
1042
|
+
for (const t of c.candidateTitles) {
|
|
1043
|
+
if (HEAD_INPUT_CALLEES.has(t.callee))
|
|
1044
|
+
continue;
|
|
1045
|
+
if (!headProviding.has(t.callee))
|
|
1046
|
+
continue;
|
|
1047
|
+
extraTitles.push({ value: t.value, line: t.line, column: t.column, callee: t.callee });
|
|
1048
|
+
}
|
|
1049
|
+
for (const t of c.candidateTemplates) {
|
|
1050
|
+
if (HEAD_INPUT_CALLEES.has(t.callee))
|
|
1051
|
+
continue;
|
|
1052
|
+
if (!headProviding.has(t.callee))
|
|
1053
|
+
continue;
|
|
1054
|
+
extraTemplates.push({ value: t.value, line: t.line, column: t.column, callee: t.callee });
|
|
1055
|
+
}
|
|
1056
|
+
if (extraTitles.length === 0 && extraTemplates.length === 0)
|
|
1057
|
+
continue;
|
|
1058
|
+
if (c.existingResultIdx >= 0) {
|
|
1059
|
+
results[c.existingResultIdx].titles.push(...extraTitles);
|
|
1060
|
+
results[c.existingResultIdx].titleTemplates.push(...extraTemplates);
|
|
1061
|
+
} else {
|
|
1062
|
+
results.push({ filePath: c.filePath, diagnostics: [], headCalls: [], titles: extraTitles, titleTemplates: extraTemplates });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (pageFiles.length > 0) {
|
|
1066
|
+
for (const page of pageFiles) {
|
|
1067
|
+
let provides = false;
|
|
1068
|
+
for (const c of page.callGraph.allCalls) {
|
|
1069
|
+
if (headProviding.has(c)) {
|
|
1070
|
+
provides = true;
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (provides)
|
|
1075
|
+
continue;
|
|
1076
|
+
const diag = {
|
|
1077
|
+
ruleId: "page-missing-head",
|
|
1078
|
+
message: "Page does not call useHead/useSeoMeta directly or via a composable. Pages should set page-specific head metadata for SEO.",
|
|
1079
|
+
line: 1,
|
|
1080
|
+
column: 1,
|
|
1081
|
+
severity: "info"
|
|
1082
|
+
};
|
|
1083
|
+
if (page.existingResultIdx >= 0) {
|
|
1084
|
+
results[page.existingResultIdx].diagnostics.push(diag);
|
|
1085
|
+
} else {
|
|
1086
|
+
results.push({ filePath: page.filePath, diagnostics: [diag], headCalls: [], titles: [], titleTemplates: [] });
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return results;
|
|
1091
|
+
}
|
|
1092
|
+
function summarise(results) {
|
|
1093
|
+
let errorCount = 0;
|
|
1094
|
+
let warningCount = 0;
|
|
1095
|
+
let infoCount = 0;
|
|
1096
|
+
for (const r of results) {
|
|
1097
|
+
for (const d of r.diagnostics) {
|
|
1098
|
+
if (d.severity === "error")
|
|
1099
|
+
errorCount++;
|
|
1100
|
+
else if (d.severity === "warning")
|
|
1101
|
+
warningCount++;
|
|
1102
|
+
else
|
|
1103
|
+
infoCount++;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return { errorCount, warningCount, infoCount, fileCount: results.length };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const DEFAULT_PATTERNS$2 = ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,vue,svelte}"];
|
|
1110
|
+
const DEFAULT_IGNORE$2 = ["**/node_modules/**", "**/dist/**", "**/.output/**", "**/.nuxt/**"];
|
|
1111
|
+
const audit = defineCommand({
|
|
1112
|
+
meta: {
|
|
1113
|
+
name: "audit",
|
|
1114
|
+
description: "Lint your codebase for unhead misuse, type-narrowing issues, and SEO/perf foot-guns."
|
|
1115
|
+
},
|
|
1116
|
+
args: {
|
|
1117
|
+
cwd: {
|
|
1118
|
+
type: "string",
|
|
1119
|
+
description: "Project root.",
|
|
1120
|
+
default: "."
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
async run({ args }) {
|
|
1124
|
+
const cwd = resolve(process.cwd(), args.cwd);
|
|
1125
|
+
const positional = (args._ ?? []).map(String).filter(Boolean);
|
|
1126
|
+
const patterns = positional.length > 0 ? positional : DEFAULT_PATTERNS$2;
|
|
1127
|
+
const results = await runAudit({ patterns, mode: "audit", cwd, ignore: DEFAULT_IGNORE$2 });
|
|
1128
|
+
const { errorCount, warningCount } = summarise(results);
|
|
1129
|
+
const output = formatStylish(results, cwd, process.stdout.isTTY ?? false);
|
|
1130
|
+
if (output)
|
|
1131
|
+
process.stdout.write(output);
|
|
1132
|
+
if (errorCount > 0)
|
|
1133
|
+
process.exitCode = 1;
|
|
1134
|
+
else if (warningCount > 0 && !output)
|
|
1135
|
+
console.log(`unhead audit: ${warningCount} warning${warningCount === 1 ? "" : "s"}`);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
const DEFAULT_PATTERNS$1 = ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,vue,svelte}"];
|
|
1140
|
+
const DEFAULT_IGNORE$1 = ["**/node_modules/**", "**/dist/**", "**/.output/**", "**/.nuxt/**"];
|
|
1141
|
+
const migrate = defineCommand({
|
|
1142
|
+
meta: {
|
|
1143
|
+
name: "migrate",
|
|
1144
|
+
description: "Apply autofixes for v2-to-v3 migration: rewrite deprecated props and wrap tag literals in defineX helpers."
|
|
1145
|
+
},
|
|
1146
|
+
args: {
|
|
1147
|
+
"cwd": {
|
|
1148
|
+
type: "string",
|
|
1149
|
+
description: "Project root.",
|
|
1150
|
+
default: "."
|
|
1151
|
+
},
|
|
1152
|
+
"dry-run": {
|
|
1153
|
+
type: "boolean",
|
|
1154
|
+
description: "Report what would change without writing files.",
|
|
1155
|
+
default: false
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
async run({ args }) {
|
|
1159
|
+
const cwd = resolve(process.cwd(), args.cwd);
|
|
1160
|
+
const positional = (args._ ?? []).map(String).filter(Boolean);
|
|
1161
|
+
const patterns = positional.length > 0 ? positional : DEFAULT_PATTERNS$1;
|
|
1162
|
+
const dryRun = args["dry-run"] === true;
|
|
1163
|
+
const results = await runAudit({
|
|
1164
|
+
patterns,
|
|
1165
|
+
mode: "migrate",
|
|
1166
|
+
cwd,
|
|
1167
|
+
ignore: DEFAULT_IGNORE$1
|
|
1168
|
+
});
|
|
1169
|
+
const output = formatStylish(results, cwd, process.stdout.isTTY ?? false);
|
|
1170
|
+
if (output)
|
|
1171
|
+
process.stdout.write(output);
|
|
1172
|
+
if (dryRun) {
|
|
1173
|
+
const fixable = results.reduce((n, r) => n + (r.output ? 1 : 0), 0);
|
|
1174
|
+
console.log(`unhead migrate: ${fixable} file${fixable === 1 ? "" : "s"} would be modified (dry run)`);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
let written = 0;
|
|
1178
|
+
for (const r of results) {
|
|
1179
|
+
if (!r.output)
|
|
1180
|
+
continue;
|
|
1181
|
+
await writeFile(r.filePath, r.output);
|
|
1182
|
+
written++;
|
|
1183
|
+
}
|
|
1184
|
+
console.log(`unhead migrate: applied fixes to ${written} file${written === 1 ? "" : "s"}`);
|
|
1185
|
+
const { errorCount } = summarise(results);
|
|
1186
|
+
if (errorCount > 0)
|
|
1187
|
+
process.exitCode = 1;
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
function validateHtml(html, source) {
|
|
1192
|
+
const { input } = parseHtmlForUnheadExtraction(html);
|
|
1193
|
+
const captured = [];
|
|
1194
|
+
const head = createHead({
|
|
1195
|
+
plugins: [ValidatePlugin({ onReport: (rs) => captured.push(...rs) })]
|
|
1196
|
+
});
|
|
1197
|
+
head.push(input);
|
|
1198
|
+
renderSSRHead(head);
|
|
1199
|
+
return { source, rules: captured };
|
|
1200
|
+
}
|
|
1201
|
+
function severitySymbol(severity) {
|
|
1202
|
+
return severity === "warn" ? "\u26A0" : "\u2139";
|
|
1203
|
+
}
|
|
1204
|
+
function printReport({ source, rules }) {
|
|
1205
|
+
if (rules.length === 0) {
|
|
1206
|
+
console.log(`${source} \u2014 no issues found`);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
console.log(`
|
|
1210
|
+
${source}`);
|
|
1211
|
+
for (const rule of rules)
|
|
1212
|
+
console.log(` ${severitySymbol(rule.severity)} ${rule.id}: ${rule.message}`);
|
|
1213
|
+
const warnings = rules.filter((r) => r.severity === "warn").length;
|
|
1214
|
+
const info = rules.filter((r) => r.severity === "info").length;
|
|
1215
|
+
console.log(`
|
|
1216
|
+
${rules.length} issue${rules.length === 1 ? "" : "s"} (${warnings} warning${warnings === 1 ? "" : "s"}, ${info} info)`);
|
|
1217
|
+
}
|
|
1218
|
+
function jsonReplacer(key, value) {
|
|
1219
|
+
return key === "tag" ? void 0 : value;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const DEFAULT_PATTERNS = ["**/*.html"];
|
|
1223
|
+
const DEFAULT_IGNORE = ["**/node_modules/**"];
|
|
1224
|
+
const validateHtmlCommand = defineCommand({
|
|
1225
|
+
meta: {
|
|
1226
|
+
name: "validate-html",
|
|
1227
|
+
description: "Run the runtime ValidatePlugin over prerendered HTML files (e.g. dist/, .output/, build/)."
|
|
1228
|
+
},
|
|
1229
|
+
args: {
|
|
1230
|
+
cwd: {
|
|
1231
|
+
type: "string",
|
|
1232
|
+
description: "Project root.",
|
|
1233
|
+
default: "."
|
|
1234
|
+
},
|
|
1235
|
+
json: {
|
|
1236
|
+
type: "boolean",
|
|
1237
|
+
description: "Emit JSON instead of human-readable output.",
|
|
1238
|
+
default: false
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
async run({ args }) {
|
|
1242
|
+
const cwd = resolve(process.cwd(), String(args.cwd));
|
|
1243
|
+
const positional = (args._ ?? []).map(String).filter(Boolean);
|
|
1244
|
+
const patterns = positional.length > 0 ? positional : DEFAULT_PATTERNS;
|
|
1245
|
+
const files = await glob(patterns, {
|
|
1246
|
+
cwd,
|
|
1247
|
+
absolute: true,
|
|
1248
|
+
ignore: DEFAULT_IGNORE
|
|
1249
|
+
});
|
|
1250
|
+
if (files.length === 0) {
|
|
1251
|
+
console.error(`No HTML files matched ${patterns.join(", ")} in ${cwd}`);
|
|
1252
|
+
process.exitCode = 1;
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const reports = [];
|
|
1256
|
+
for (const file of files) {
|
|
1257
|
+
const html = await readFile(file, "utf8");
|
|
1258
|
+
reports.push(validateHtml(html, file));
|
|
1259
|
+
}
|
|
1260
|
+
if (args.json) {
|
|
1261
|
+
process.stdout.write(`${JSON.stringify(reports, jsonReplacer, 2)}
|
|
1262
|
+
`);
|
|
1263
|
+
} else {
|
|
1264
|
+
for (const report of reports)
|
|
1265
|
+
printReport(report);
|
|
1266
|
+
const totalIssues = reports.reduce((n, r) => n + r.rules.length, 0);
|
|
1267
|
+
const filesWithIssues = reports.filter((r) => r.rules.length > 0).length;
|
|
1268
|
+
console.log(`
|
|
1269
|
+
${filesWithIssues}/${files.length} file${files.length === 1 ? "" : "s"} with issues, ${totalIssues} total`);
|
|
1270
|
+
}
|
|
1271
|
+
if (reports.some((r) => r.rules.some((rule) => rule.severity === "warn")))
|
|
1272
|
+
process.exitCode = 1;
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
async function fetchHtml(url, userAgent, timeoutMs = 3e4) {
|
|
1277
|
+
const controller = new AbortController();
|
|
1278
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1279
|
+
try {
|
|
1280
|
+
const res = await fetch(url, {
|
|
1281
|
+
headers: {
|
|
1282
|
+
"user-agent": userAgent,
|
|
1283
|
+
"accept": "text/html,application/xhtml+xml"
|
|
1284
|
+
},
|
|
1285
|
+
redirect: "follow",
|
|
1286
|
+
signal: controller.signal
|
|
1287
|
+
});
|
|
1288
|
+
if (!res.ok)
|
|
1289
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
1290
|
+
const ct = res.headers.get("content-type") || "";
|
|
1291
|
+
if (ct && !/text\/html|application\/xhtml\+xml/i.test(ct))
|
|
1292
|
+
throw new Error(`Unexpected content-type "${ct}" for ${url} (expected HTML)`);
|
|
1293
|
+
return await res.text();
|
|
1294
|
+
} finally {
|
|
1295
|
+
clearTimeout(timer);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const validateUrlCommand = defineCommand({
|
|
1299
|
+
meta: {
|
|
1300
|
+
name: "validate-url",
|
|
1301
|
+
description: "Fetch a rendered URL and run unhead's SEO/perf validation rules over its <head>."
|
|
1302
|
+
},
|
|
1303
|
+
args: {
|
|
1304
|
+
"url": {
|
|
1305
|
+
type: "positional",
|
|
1306
|
+
description: "URL to fetch and validate.",
|
|
1307
|
+
required: true
|
|
1308
|
+
},
|
|
1309
|
+
"user-agent": {
|
|
1310
|
+
type: "string",
|
|
1311
|
+
description: "User-Agent header to send (default: facebookexternalhit so social-crawler-aware rules engage).",
|
|
1312
|
+
default: "facebookexternalhit/1.1 (+https://unhead.unjs.io)"
|
|
1313
|
+
},
|
|
1314
|
+
"timeout": {
|
|
1315
|
+
type: "string",
|
|
1316
|
+
description: "Fetch timeout in milliseconds.",
|
|
1317
|
+
default: "30000"
|
|
1318
|
+
},
|
|
1319
|
+
"json": {
|
|
1320
|
+
type: "boolean",
|
|
1321
|
+
description: "Emit JSON instead of human-readable output.",
|
|
1322
|
+
default: false
|
|
1323
|
+
}
|
|
1324
|
+
},
|
|
1325
|
+
async run({ args }) {
|
|
1326
|
+
const url = String(args.url);
|
|
1327
|
+
const timeoutMs = Number(args.timeout) || 3e4;
|
|
1328
|
+
const html = await fetchHtml(url, String(args["user-agent"]), timeoutMs);
|
|
1329
|
+
const result = validateHtml(html, url);
|
|
1330
|
+
if (args.json)
|
|
1331
|
+
process.stdout.write(`${JSON.stringify(result, jsonReplacer, 2)}
|
|
1332
|
+
`);
|
|
1333
|
+
else
|
|
1334
|
+
printReport(result);
|
|
1335
|
+
if (result.rules.some((r) => r.severity === "warn"))
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
const main = defineCommand({
|
|
1341
|
+
meta: {
|
|
1342
|
+
name: "unhead",
|
|
1343
|
+
description: "Audit, migrate, and validate unhead head usage."
|
|
1344
|
+
},
|
|
1345
|
+
subCommands: {
|
|
1346
|
+
"audit": audit,
|
|
1347
|
+
"migrate": migrate,
|
|
1348
|
+
"validate-html": validateHtmlCommand,
|
|
1349
|
+
"validate-url": validateUrlCommand
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
function run() {
|
|
1353
|
+
return runMain(main);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
export { audit, migrate, run, validateHtmlCommand, validateUrlCommand };
|