freestruct 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -119
- package/engine/_freestruct/inject-brand.html +1 -5
- package/engine/audit.js +487 -0
- package/engine/config.js +208 -1
- package/engine/hooks.js +81 -15
- package/engine/indexnow.js +149 -0
- package/engine/inject.js +303 -22
- package/engine/linkcheck.js +83 -11
- package/engine/llms.js +63 -0
- package/engine/notfound.js +21 -5
- package/engine/ogimage.js +142 -0
- package/engine/scan.js +155 -20
- package/engine/search.js +12 -9
- package/engine/seo.js +682 -71
- package/engine/urls.js +48 -1
- package/engine/xmllike.js +7 -4
- package/init/README.md +6 -20
- package/init/VERSION +1 -1
- package/init/docs/_freestruct/inject-brand.html +1 -5
- package/init/docs/freestruct.config.yml +16 -0
- package/package.json +9 -2
package/engine/audit.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// engine/audit.js - the `freestruct audit` subcommand (issue #18)
|
|
2
|
+
// Post-build verification that the SEO injection ACTUALLY landed, page by
|
|
3
|
+
// page, plus site-level artifact checks - with an exit code for CI.
|
|
4
|
+
// Ported from the donated zero-dep audit script (prestruct#34, MIT-compatible)
|
|
5
|
+
// and remapped onto freestruct's own contract:
|
|
6
|
+
// - meta extraction is attribute-order tolerant (per the donor's notes)
|
|
7
|
+
// - og:image / twitter:image / twitter:site/creator are config-gated in
|
|
8
|
+
// this engine (missing-only), so their absence is a WARN, not a FAIL.
|
|
9
|
+
// The og:image / twitter:image absences are CONFIG-shaped (identical on
|
|
10
|
+
// every page), so they collapse to one line each with a page count
|
|
11
|
+
// (issue #28); twitter:site/creator are graded only when a handle is set.
|
|
12
|
+
// - the FAIL og/twitter class is the SHARED core (og:title/og:description,
|
|
13
|
+
// twitter:card/twitter:title); the rest of the injected set rides WARN
|
|
14
|
+
// because missing-only means SSG-tagged pages legitimately keep a
|
|
15
|
+
// partial set (post-injection the audit cannot tell SSG-owned heads
|
|
16
|
+
// apart, so partial-set coherence is report-grade)
|
|
17
|
+
// - the SVG og:image trap stays a FAIL here (audit-grade; the run-warning
|
|
18
|
+
// ladder from issue #20 is the pre-build enforcement point)
|
|
19
|
+
// - the 404 check is the noindex contract only (issue #11's shape)
|
|
20
|
+
// - external http(s) links: opt-in via --external (SEO-gap pass) - the
|
|
21
|
+
// baked tree is audited for the network, not the default audit; HEAD
|
|
22
|
+
// with a GET retry (some CDNs refuse HEAD), resolution failures and
|
|
23
|
+
// HTTP >= 400 come back as WARN rows (report-grade: bots get 403/429)
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { decodeEntities } from './scan.js';
|
|
28
|
+
|
|
29
|
+
// FAIL class = the tags core to the engine's own contract. The REST of the
|
|
30
|
+
// injected set (og:url/type/site_name/locale, twitter:description) rides as
|
|
31
|
+
// WARN because missing-only means SSG-tagged pages LEGITIMATELY keep a
|
|
32
|
+
// partial set - fail-failing those would punish contract-abiding output.
|
|
33
|
+
const OG_CORE = ['og:title', 'og:description'];
|
|
34
|
+
const OG_SET = ['og:url', 'og:type', 'og:site_name', 'og:locale'];
|
|
35
|
+
const TW_CORE = ['twitter:card', 'twitter:title'];
|
|
36
|
+
const TW_SET = ['twitter:description', 'twitter:image', 'twitter:site', 'twitter:creator'];
|
|
37
|
+
// LocalBusiness recommended fields per the donated script (schema.org).
|
|
38
|
+
const LB_RECOMMENDED = ['name', 'url', 'image', 'logo', 'telephone', 'priceRange', 'openingHoursSpecification', 'geo', 'sameAs'];
|
|
39
|
+
|
|
40
|
+
// ---------- scans ----------
|
|
41
|
+
|
|
42
|
+
/** Attribute-order tolerant meta extraction: hits aligned multi-space output. */
|
|
43
|
+
function grabMeta(head, attrHasKey, name) {
|
|
44
|
+
// `<meta\b[^>]*` (not `<meta[^>]`) so a leading attribute - `<meta charset=...
|
|
45
|
+
// name="description">` - is found too. The single-char class made the audit
|
|
46
|
+
// false-FAIL "missing meta description" on any page whose description meta
|
|
47
|
+
// carried another attribute first.
|
|
48
|
+
const keyRe = new RegExp(`<meta\\b[^>]*\\b${attrHasKey}=["']${name}["'][^>]*>`, 'i');
|
|
49
|
+
const tag = head.match(keyRe);
|
|
50
|
+
if (!tag) return null;
|
|
51
|
+
const content = attrValue(tag[0], 'content');
|
|
52
|
+
return content;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Quoted-attribute reader. The naive `["']([^"']*)["']` stopped at the FIRST
|
|
56
|
+
// quote of either kind, so any value holding the other quote (a description
|
|
57
|
+
// with an apostrophe - "this repo's docs") was truncated at the apostrophe.
|
|
58
|
+
// The backreference forces the closing quote to match the opening one, so the
|
|
59
|
+
// value survives intact. Unquoted values fall through to a bare read.
|
|
60
|
+
function attrValue(tag, attr) {
|
|
61
|
+
const quoted = tag.match(new RegExp(`\\b${attr}=(["'])([\\s\\S]*?)\\1`, 'i'));
|
|
62
|
+
if (quoted) return quoted[2];
|
|
63
|
+
const bare = tag.match(new RegExp(`\\b${attr}=([^\\s>]+)`, 'i'));
|
|
64
|
+
return bare ? bare[1] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function grabCanonical(head) {
|
|
68
|
+
const link = head.match(/<link[^>]+rel=["']canonical["'][^>]*>/i)
|
|
69
|
+
|| head.match(/<link[^>]+href=["'][^"']+["'][^>]+rel=["']canonical["'][^>]*>/i);
|
|
70
|
+
if (!link) return null;
|
|
71
|
+
return attrValue(link[0], 'href');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Count every occurrence of a head shape - the duplicate-class probe. */
|
|
75
|
+
function countHead(head, re) {
|
|
76
|
+
return (head.match(re) || []).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function walkHtml(dir) {
|
|
80
|
+
let out = [];
|
|
81
|
+
for (const name of readdirSync(dir)) {
|
|
82
|
+
const p = path.join(dir, name);
|
|
83
|
+
if (statSync(p).isDirectory()) out = out.concat(walkHtml(p));
|
|
84
|
+
else if (/\.html?$/i.test(name)) out.push(p);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stripTags(s) {
|
|
90
|
+
return String(s).replace(/<[^>]+>/g, '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Title/description length is graded against what a SEARCH ENGINE sees: the
|
|
94
|
+
// DECODED text, not the raw markup. An entity-heavy title ('&', ''',
|
|
95
|
+
// 'A') inflated the raw length and tripped the > 65 / > 165 warnings on
|
|
96
|
+
// text that renders well inside the band.
|
|
97
|
+
function decodedLength(s) {
|
|
98
|
+
return decodeEntities(stripTags(s)).length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Head-referenced site-local URL -> { resolved, inside } | null.
|
|
103
|
+
*
|
|
104
|
+
* null means "not a site-local path" (external host, protocol-relative,
|
|
105
|
+
* relative, empty) - nothing on disk to check. `inside` is false when the
|
|
106
|
+
* path resolves OUTSIDE the output tree: a traversal shape like
|
|
107
|
+
* '/../../etc/hostname', which must never be probed as if it were an
|
|
108
|
+
* artifact. (Before Pass 26.21 urlToDisk resolved and existsSync'd such a
|
|
109
|
+
* path, so a value pointing at any file on the host read back as "exists".)
|
|
110
|
+
*/
|
|
111
|
+
function sitePathInfo(urlValue, { outputDir, config }) {
|
|
112
|
+
if (!urlValue) return null;
|
|
113
|
+
let pathName = urlValue.split(/[?#]/)[0];
|
|
114
|
+
if (/^https?:\/\//i.test(pathName)) {
|
|
115
|
+
try {
|
|
116
|
+
const u = new URL(pathName);
|
|
117
|
+
const site = config?.site?.url ? new URL(config.site.url) : null;
|
|
118
|
+
// Only site-local files can exist in the output tree.
|
|
119
|
+
if (site && u.host !== site.host) return null;
|
|
120
|
+
pathName = u.pathname + u.search;
|
|
121
|
+
} catch (e) { return null; }
|
|
122
|
+
} else if (/^\/\//.test(pathName)) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (!pathName.startsWith('/')) return null;
|
|
126
|
+
// basePath normalization mirrors urls.js (issue #32 contract): the
|
|
127
|
+
// configured prefix may lack its leading slash, and a prefix must match a
|
|
128
|
+
// whole path segment ('/myproject' never strips '/myprojectx/...').
|
|
129
|
+
const bp = config?.basePath
|
|
130
|
+
? ('/' + String(config.basePath).replace(/^\/+/, '')).replace(/\/+$/, '')
|
|
131
|
+
: '';
|
|
132
|
+
if (bp && (pathName === bp || pathName.startsWith(bp + '/'))) {
|
|
133
|
+
pathName = pathName.slice(bp.length);
|
|
134
|
+
}
|
|
135
|
+
const resolved = path.resolve(outputDir, pathName.replace(/^\//, ''));
|
|
136
|
+
const root = path.resolve(outputDir);
|
|
137
|
+
const inside = resolved === root || resolved.startsWith(root + path.sep);
|
|
138
|
+
return { resolved, inside };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------- the audit ----------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Audit a baked output directory. Returns { fails, warns, pages, sitemapLocs }.
|
|
145
|
+
* Prints a human report by default, or a JSON digest with `json: true`;
|
|
146
|
+
* `capture: true` (used when external checks ride along in json mode) skips
|
|
147
|
+
* printing and hands the caller the raw result.
|
|
148
|
+
* NEVER exits: the caller owns the exit code (0 clean / 1 findings).
|
|
149
|
+
*/
|
|
150
|
+
export function auditOutput({ outputDir, config, json = false, capture = false }) {
|
|
151
|
+
const fails = [];
|
|
152
|
+
const warns = [];
|
|
153
|
+
const fail = (page, msg) => fails.push({ page, msg });
|
|
154
|
+
const warn = (page, msg) => warns.push({ page, msg });
|
|
155
|
+
|
|
156
|
+
// Config-gated absences (issue #28): a condition that can only ever be
|
|
157
|
+
// true/false at the CONFIG level repeats identically on every page
|
|
158
|
+
// (og:image / twitter:image absent because no og.image is configured).
|
|
159
|
+
// Collect affected pages here and flush ONE line each with a count at the
|
|
160
|
+
// end, instead of N page-suffixed rows that bury the real findings.
|
|
161
|
+
// Page-shaped classes (title/description length, JSON-LD absence, h1
|
|
162
|
+
// sanity) still repeat - their findings genuinely differ per page.
|
|
163
|
+
const configGated = new Map(); // key -> Set(page labels)
|
|
164
|
+
const configGate = (key, label) => {
|
|
165
|
+
if (!configGated.has(key)) configGated.set(key, new Set());
|
|
166
|
+
configGated.get(key).add(label);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// File-at-path hardening (Pass 26.13): a file at the outputDir path passes
|
|
170
|
+
// existsSync, and walkHtml's readdirSync would arrive here uncaught.
|
|
171
|
+
if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) {
|
|
172
|
+
if (json) console.log(JSON.stringify({ ok: false, error: 'output dir not found: ' + outputDir }));
|
|
173
|
+
else console.error('[freestruct] audit: no "' + outputDir + '" directory - run the build first.');
|
|
174
|
+
return { fails: [{ page: 'outputDir', msg: 'missing' }], warns, pages: 0, sitemapLocs: 0 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const pages = walkHtml(outputDir);
|
|
178
|
+
let sitemapLocs = 0;
|
|
179
|
+
|
|
180
|
+
for (const filePath of pages) {
|
|
181
|
+
let rel;
|
|
182
|
+
try { rel = path.relative(outputDir, filePath); } catch (e) { rel = filePath; }
|
|
183
|
+
const html = readFileSync(filePath, 'utf8');
|
|
184
|
+
const head = html.split('</head>')[0];
|
|
185
|
+
const label = rel.replace(/\\/g, '/');
|
|
186
|
+
|
|
187
|
+
if (/(^|\/)404\.html?$/i.test(label)) {
|
|
188
|
+
const robots = grabMeta(head, 'name', 'robots') || '';
|
|
189
|
+
if (!/noindex/i.test(robots)) fail(label, '404 page missing its noindex robots directive');
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
194
|
+
if (!titleMatch || !titleMatch[1].trim()) fail(label, 'missing <title>');
|
|
195
|
+
else {
|
|
196
|
+
const t = decodedLength(titleMatch[1]);
|
|
197
|
+
if (t > 65) warn(label, 'title ' + t + ' chars (> 65, may truncate)');
|
|
198
|
+
if (t < 10) warn(label, 'title ' + t + ' chars (suspiciously short)');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A page that declares `robots noindex` is deliberately out of the index
|
|
202
|
+
// (a redirect stub, a thank-you page): the content-quality classes below
|
|
203
|
+
// - description length band, h1 shape, JSON-LD absence - grade indexable
|
|
204
|
+
// content, so they would only emit cosmetic noise on a page whose whole
|
|
205
|
+
// point is to not rank. The structural classes (title/canonical presence,
|
|
206
|
+
// duplicate tags, absolute canonical) still apply.
|
|
207
|
+
const noindex = /noindex/i.test(grabMeta(head, 'name', 'robots') || '');
|
|
208
|
+
|
|
209
|
+
const desc = grabMeta(head, 'name', 'description');
|
|
210
|
+
if (!desc) fail(label, 'missing meta description');
|
|
211
|
+
else if (!noindex) {
|
|
212
|
+
const d = decodeEntities(desc).length;
|
|
213
|
+
if (d < 70 || d > 165) warn(label, 'description ' + d + ' chars (outside 70-165, may truncate)');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const canonical = grabCanonical(head);
|
|
217
|
+
if (!canonical || canonical === '') fail(label, 'missing canonical link');
|
|
218
|
+
else if (!/^https?:\/\//i.test(canonical)) fail(label, 'canonical not absolute: ' + canonical);
|
|
219
|
+
|
|
220
|
+
// Duplicate tags ride the FAIL class: missing-only injection never
|
|
221
|
+
// adds a tag the page already carries, so any duplicate in a baked
|
|
222
|
+
// head is a page/SSG edit gone wrong (Pass 26.11). Attribute-order
|
|
223
|
+
// tolerant, same as the missing checks.
|
|
224
|
+
for (const dup of [
|
|
225
|
+
['duplicate canonical link (a page ships exactly one)', /<link[^>]+rel=["']canonical["']/gi],
|
|
226
|
+
['duplicate meta description', /<meta[^>]+name=["']description["']/gi],
|
|
227
|
+
['multiple <title> elements', /<title[^>]*>/gi],
|
|
228
|
+
['duplicate og:title', /<meta[^>]+property=["']og:title["']/gi],
|
|
229
|
+
['duplicate og:description', /<meta[^>]+property=["']og:description["']/gi],
|
|
230
|
+
['duplicate twitter:title', /<meta[^>]+name=["']twitter:title["']/gi],
|
|
231
|
+
['duplicate twitter:card', /<meta[^>]+name=["']twitter:card["']/gi],
|
|
232
|
+
]) {
|
|
233
|
+
if (countHead(head, dup[1]) > 1) fail(label, dup[0]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// og/twitter CORE class. FAIL only when the page carries NO tag of the
|
|
237
|
+
// set at all: that is the "the engine failed to inject on a bare page"
|
|
238
|
+
// case the class exists to catch. A page shipping a PARTIAL set of its
|
|
239
|
+
// own (an SSG theme emitting only og:title, say) makes the engine's
|
|
240
|
+
// group-level missing-only gate back off the whole group BY DESIGN, so
|
|
241
|
+
// its missing companions are report-grade - failing them punished the
|
|
242
|
+
// very contract (SSG-owned partial sets) the injection rule preserves,
|
|
243
|
+
// and the audit cannot tell an SSG-owned partial head from an engine one.
|
|
244
|
+
const ogAny = countHead(head, /<meta[^>]+property=["']og:[^"']*["']/gi);
|
|
245
|
+
for (const prop of OG_CORE) {
|
|
246
|
+
if (grabMeta(head, 'property', prop)) continue;
|
|
247
|
+
if (ogAny === 0) fail(label, 'missing ' + prop);
|
|
248
|
+
else warn(label, prop + ' absent (page ships a partial og set; SSG-owned partial sets keep contract)');
|
|
249
|
+
}
|
|
250
|
+
for (const prop of OG_SET) {
|
|
251
|
+
if (!grabMeta(head, 'property', prop)) {
|
|
252
|
+
warn(label, prop + ' absent (part of the engine-injected set; SSG-owned partial sets keep contract)');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!grabMeta(head, 'property', 'og:image')) {
|
|
256
|
+
configGate('og:image', label);
|
|
257
|
+
}
|
|
258
|
+
const twAny = countHead(head, /<meta[^>]+name=["']twitter:[^"']*["']/gi);
|
|
259
|
+
for (const name of TW_CORE) {
|
|
260
|
+
if (grabMeta(head, 'name', name)) continue;
|
|
261
|
+
if (twAny === 0) fail(label, 'missing ' + name);
|
|
262
|
+
else warn(label, name + ' absent (page ships a partial twitter set; SSG-owned partial sets keep contract)');
|
|
263
|
+
}
|
|
264
|
+
for (const name of TW_SET) {
|
|
265
|
+
// Symmetry with the engine's own late pass (seo.js): without a
|
|
266
|
+
// configured handle the injector ships neither twitter:site nor
|
|
267
|
+
// twitter:creator, so the audit grades those absences only when the
|
|
268
|
+
// handle exists. The 'config-gated' wording in the warning now holds.
|
|
269
|
+
if ((name === 'twitter:site' || name === 'twitter:creator') && !config?.twitter?.username) continue;
|
|
270
|
+
if (!grabMeta(head, 'name', name)) {
|
|
271
|
+
// twitter:image is config-gated (no og.image -> the engine ships no
|
|
272
|
+
// twitter:image either): collapse it like og:image. The rest of the
|
|
273
|
+
// twitter set is SSG-owned-partial-set shaped and stays per-page.
|
|
274
|
+
if (name === 'twitter:image') configGate('twitter:image', label);
|
|
275
|
+
else warn(label, name + ' absent (config-gated or SSG-owned partial set; add a twitter username / og.image as needed)');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const ogImage = grabMeta(head, 'property', 'og:image');
|
|
280
|
+
if (ogImage && /\.svg(\?|#|$)/i.test(ogImage)) {
|
|
281
|
+
fail(label, 'og:image is SVG (' + ogImage + ') - most social scrapers cannot render it; ship a raster PNG/JPG');
|
|
282
|
+
}
|
|
283
|
+
if (ogImage && !grabMeta(head, 'property', 'og:image:width')) {
|
|
284
|
+
warn(label, 'og:image:width/height/alt absent (the og.image object form supplies them)');
|
|
285
|
+
}
|
|
286
|
+
if (ogImage) {
|
|
287
|
+
const info = sitePathInfo(ogImage, { outputDir, config });
|
|
288
|
+
if (info && !info.inside) {
|
|
289
|
+
// A site-local og:image that escapes the output tree can never be a
|
|
290
|
+
// served artifact; report it (same report-grade posture as missing).
|
|
291
|
+
warn(label, 'og:image points at ' + ogImage + ' which escapes the output directory');
|
|
292
|
+
} else if (info && !existsSync(info.resolved)) {
|
|
293
|
+
// Report-grade on purpose (issue #18 sketch: 'worth having'): an og
|
|
294
|
+
// image can legitimately be emitted on an SSG-side pass or a same-host
|
|
295
|
+
// CDN, so a hard FAIL would punish valid setups; the JSON digest
|
|
296
|
+
// carries it for teams who want to harden it in CI.
|
|
297
|
+
warn(label, 'og:image points at ' + ogImage + ' which does not exist on disk');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const ldBlocks = [...html.matchAll(/<script type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
|
|
302
|
+
if (!ldBlocks.length && !noindex) warn(label, 'no JSON-LD structured data');
|
|
303
|
+
for (const block of ldBlocks) {
|
|
304
|
+
let parsed;
|
|
305
|
+
try { parsed = JSON.parse(block[1]); } catch (e) {
|
|
306
|
+
fail(label, 'invalid JSON-LD: ' + e.message);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const nodes = Array.isArray(parsed) ? parsed : (parsed['@graph'] ? parsed['@graph'] : [parsed]);
|
|
310
|
+
for (const node of nodes) {
|
|
311
|
+
if (!node || typeof node !== 'object') continue;
|
|
312
|
+
if (!node['@type']) { warn(label, 'JSON-LD node without @type'); continue; }
|
|
313
|
+
const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
|
|
314
|
+
if (types.includes('LocalBusiness')) {
|
|
315
|
+
const missing = LB_RECOMMENDED.filter(f => !(f in node));
|
|
316
|
+
if (missing.length) warn(label, 'LocalBusiness missing recommended: ' + missing.join(', '));
|
|
317
|
+
}
|
|
318
|
+
// schema-carried logo/image files: same exists-on-disk check as og:image
|
|
319
|
+
for (const key of ['logo', 'image']) {
|
|
320
|
+
const v = node[key];
|
|
321
|
+
const info = sitePathInfo(typeof v === 'string' ? v : null, { outputDir, config });
|
|
322
|
+
if (info && !info.inside) {
|
|
323
|
+
warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which escapes the output directory');
|
|
324
|
+
} else if (info && !existsSync(info.resolved)) {
|
|
325
|
+
warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which does not exist on disk');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const h1s = [...html.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/gi)];
|
|
332
|
+
const h1Texts = h1s.map(m => stripTags(m[1])).filter(Boolean);
|
|
333
|
+
if (!h1Texts.length && !noindex) warn(label, 'no non-empty <h1>');
|
|
334
|
+
else if (h1Texts.length > 1) warn(label, h1Texts.length + ' <h1> elements (prefer one)');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------- site-level artifacts ----------
|
|
338
|
+
|
|
339
|
+
// Honor the config's own generate switches: with `generateSitemap: false`
|
|
340
|
+
// (or robots), the run never writes the artifact, so its absence is the
|
|
341
|
+
// documented behavior, not a failure. The audit previously FAILed the
|
|
342
|
+
// missing file the config explicitly asked not to produce, so a
|
|
343
|
+
// legitimately-configured site could never pass its own audit. Configless
|
|
344
|
+
// audits (config === null) keep the strict check - absence is then a real
|
|
345
|
+
// gap. A DISABLED artifact that is present is still checked for shape.
|
|
346
|
+
const sitemapDisabled = config?.generateSitemap === false;
|
|
347
|
+
const sitemapPath = path.join(outputDir, 'sitemap.xml');
|
|
348
|
+
if (!existsSync(sitemapPath)) {
|
|
349
|
+
if (!sitemapDisabled) fail('sitemap.xml', 'missing');
|
|
350
|
+
} else {
|
|
351
|
+
const s = readFileSync(sitemapPath, 'utf8');
|
|
352
|
+
sitemapLocs = (s.match(/<loc>/g) || []).length;
|
|
353
|
+
if (sitemapLocs === 0) fail('sitemap.xml', 'no <loc> entries');
|
|
354
|
+
if (!s.includes('<lastmod>')) warn('sitemap.xml', 'no <lastmod> dates (engine runs always add them; hand-stripped?)');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const robotsDisabled = config?.generateRobots === false;
|
|
358
|
+
const robotsPath = path.join(outputDir, 'robots.txt');
|
|
359
|
+
if (!existsSync(robotsPath)) {
|
|
360
|
+
if (!robotsDisabled) fail('robots.txt', 'missing');
|
|
361
|
+
} else {
|
|
362
|
+
const r = readFileSync(robotsPath, 'utf8');
|
|
363
|
+
if (!/Sitemap:/i.test(r)) fail('robots.txt', 'no Sitemap: pointer');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- report ----------
|
|
367
|
+
|
|
368
|
+
// Flush config-gated absences as ONE line each (issue #28), with the
|
|
369
|
+
// affected-page count. Deterministic order (og:image before twitter:image).
|
|
370
|
+
// The og:image wording stays truthful when the config DOES supply an image
|
|
371
|
+
// but not for every page (og.scoped, ogImageGeneration): the line then
|
|
372
|
+
// reads "absent on N page(s)" rather than the false "not configured".
|
|
373
|
+
const ogScopedCfg = config?.og?.scoped;
|
|
374
|
+
const ogImageConfigured = Boolean(config?.og?.image)
|
|
375
|
+
|| Boolean(config?.ogImageGeneration)
|
|
376
|
+
|| Boolean(ogScopedCfg && typeof ogScopedCfg === 'object' && !Array.isArray(ogScopedCfg)
|
|
377
|
+
&& Object.keys(ogScopedCfg).length > 0);
|
|
378
|
+
for (const [key, pages] of [...configGated.entries()].sort()) {
|
|
379
|
+
const n = pages.size;
|
|
380
|
+
let msg;
|
|
381
|
+
if (key === 'og:image') {
|
|
382
|
+
msg = ogImageConfigured
|
|
383
|
+
? 'og:image absent on ' + n + ' page(s) - no og.image covers them (add one, or extend og.scoped)'
|
|
384
|
+
: 'og:image not configured - ' + n + ' page(s) affected (add one for share previews)';
|
|
385
|
+
} else {
|
|
386
|
+
// twitter:image ships alongside og:image (the injector derives it), so
|
|
387
|
+
// its absence tracks the same config state: "not configured" only when
|
|
388
|
+
// NO og image source exists at all, else "absent on N page(s)".
|
|
389
|
+
msg = ogImageConfigured
|
|
390
|
+
? key + ' absent on ' + n + ' page(s) - no og.image covers them (add one, or extend og.scoped)'
|
|
391
|
+
: key + ' not configured - ' + n + ' page(s) affected (add a twitter username / og.image as needed)';
|
|
392
|
+
}
|
|
393
|
+
warn('config', msg);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (capture) {
|
|
397
|
+
return { fails, warns, pages: pages.length, sitemapLocs };
|
|
398
|
+
}
|
|
399
|
+
if (json) {
|
|
400
|
+
console.log(JSON.stringify({ ok: fails.length === 0, pages: pages.length, fails, warns }));
|
|
401
|
+
} else {
|
|
402
|
+
console.log('[freestruct] SEO audit: ' + pages.length + ' baked page(s) checked, sitemap.xml has ' + sitemapLocs + ' <loc> entries');
|
|
403
|
+
for (const f of fails) console.log(' FAIL ' + f.page + ': ' + f.msg);
|
|
404
|
+
for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg);
|
|
405
|
+
if (fails.length === 0) {
|
|
406
|
+
console.log('[freestruct] audit: 0 failures, ' + warns.length + ' warning(s) - clean');
|
|
407
|
+
} else {
|
|
408
|
+
console.log('[freestruct] audit: ' + fails.length + ' failure(s), ' + warns.length + ' warning(s) - exiting 1');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { fails, warns, pages: pages.length, sitemapLocs };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------- external links (opt-in, SEO-gap pass) ----------
|
|
415
|
+
|
|
416
|
+
const EXTERNAL_LIMIT = 50;
|
|
417
|
+
|
|
418
|
+
/** Collect external http(s) href URLs from the baked pages, deduped, with referencing pages. */
|
|
419
|
+
function collectExternalLinks({ outputDir, pages }) {
|
|
420
|
+
const byUrl = new Map();
|
|
421
|
+
for (const filePath of pages) {
|
|
422
|
+
let rel;
|
|
423
|
+
try { rel = path.relative(outputDir, filePath).replace(/\\/g, '/'); } catch (e) { rel = filePath; }
|
|
424
|
+
const html = readFileSync(filePath, 'utf8');
|
|
425
|
+
for (const m of html.matchAll(/href\s*=\s*["'](https?:\/\/[^"'\s]+)["']/gi)) {
|
|
426
|
+
const url = m[1].replace(/&/g, '&');
|
|
427
|
+
if (!byUrl.has(url)) byUrl.set(url, []);
|
|
428
|
+
byUrl.get(url).push(rel);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return byUrl;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** HEAD with a GET retry (some CDNs refuse HEAD); returns the problem string or null. */
|
|
435
|
+
async function headCheck(url) {
|
|
436
|
+
const tryOnce = async (method) => {
|
|
437
|
+
const res = await fetch(url, {
|
|
438
|
+
method,
|
|
439
|
+
redirect: 'follow',
|
|
440
|
+
signal: AbortSignal.timeout(8000),
|
|
441
|
+
headers: { 'user-agent': 'freestruct-audit (+https://github.com/dhaupin/freestruct)' },
|
|
442
|
+
});
|
|
443
|
+
return res.status >= 400 ? 'HTTP ' + res.status + (res.statusText ? ' ' + res.statusText : '') : null;
|
|
444
|
+
};
|
|
445
|
+
try {
|
|
446
|
+
return await tryOnce('HEAD');
|
|
447
|
+
} catch (headErr) {
|
|
448
|
+
try {
|
|
449
|
+
return await tryOnce('GET');
|
|
450
|
+
} catch (getErr) {
|
|
451
|
+
const cause = (getErr && getErr.cause && getErr.cause.code) ? getErr.cause.code : getErr.message;
|
|
452
|
+
return 'unreachable (' + cause + ')';
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* External-link audit (opt-in): dedupe the tree's external hrefs, HEAD-check
|
|
459
|
+
* up to EXTERNAL_LIMIT unique URLs, report every failure as a WARN row
|
|
460
|
+
* (report-grade by design: bots get 403/429 legitimately). NEVER exits.
|
|
461
|
+
*/
|
|
462
|
+
export async function auditExternalLinks({ outputDir, json = false }) {
|
|
463
|
+
const empty = { warns: [], checked: 0 };
|
|
464
|
+
if (!existsSync(outputDir)) return empty;
|
|
465
|
+
const pages = walkHtml(outputDir);
|
|
466
|
+
const byUrl = collectExternalLinks({ outputDir, pages });
|
|
467
|
+
if (byUrl.size === 0) return empty;
|
|
468
|
+
const capped = [...byUrl.keys()].slice(0, EXTERNAL_LIMIT);
|
|
469
|
+
const results = await Promise.all(capped.map(async (url) => [url, await headCheck(url)]));
|
|
470
|
+
const warns = [];
|
|
471
|
+
for (const [url, problem] of results) {
|
|
472
|
+
if (!problem) continue;
|
|
473
|
+
const referencing = byUrl.get(url);
|
|
474
|
+
const label = referencing.length > 1
|
|
475
|
+
? referencing[0] + ' (+' + (referencing.length - 1) + ' more page(s))'
|
|
476
|
+
: referencing[0];
|
|
477
|
+
warns.push({ page: label, msg: 'external link ' + url + ': ' + problem });
|
|
478
|
+
}
|
|
479
|
+
if (byUrl.size > EXTERNAL_LIMIT) {
|
|
480
|
+
warns.push({ page: 'external', msg: 'checked only the first ' + EXTERNAL_LIMIT + ' unique external urls of ' + byUrl.size });
|
|
481
|
+
}
|
|
482
|
+
if (!json && warns.length > 0) {
|
|
483
|
+
console.log('[freestruct] external link check: ' + capped.length + ' unique url(s) probed');
|
|
484
|
+
for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg);
|
|
485
|
+
}
|
|
486
|
+
return { warns, checked: capped.length };
|
|
487
|
+
}
|