freestruct 1.0.0 → 1.1.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 CHANGED
@@ -80,9 +80,10 @@ ls docs/_site/{sitemap.xml,robots.txt,feed.xml,404.html}
80
80
  ```
81
81
 
82
82
  If any of those fail, Freestruct tells you why: config errors, a missing
83
- template, and an empty output dir each fail loudly with their own exit code
84
- (`1` config, `2` template, `3` output dir; `--tolerant` downgrades fatals to
85
- exit 0 for CI tolerance).
83
+ template, an empty output dir, and a failing `hooks.postBuild` command each
84
+ fail loudly with their own exit code (`1` config, `2` template, `3` output
85
+ dir, `4` hook failure; `--tolerant` downgrades fatals to exit 0 for CI
86
+ tolerance).
86
87
 
87
88
  ### Useful flags and subcommands
88
89
 
@@ -97,6 +98,11 @@ npx freestruct validate # config-only preflight; exit 1 on fatal errors
97
98
  npx freestruct init # scaffold the starter kit into ./docs
98
99
  # (npx freestruct init mydir -> mydir/docs;
99
100
  # existing files are never clobbered)
101
+ npx freestruct audit # verify the baked output (heads + sitemap/
102
+ # robots/404-noindex); --json for CI digest;
103
+ # --external also probes up to 50 unique
104
+ # external hrefs (warn-grade, needs network);
105
+ # exit 1 on FAIL findings (issue #18)
100
106
  ```
101
107
 
102
108
  ---
@@ -107,10 +113,28 @@ npx freestruct init # scaffold the starter kit into ./docs
107
113
 
108
114
  - Injects missing title, description, canonical, Open Graph, Twitter cards, and
109
115
  JSON-LD (missing-only: existing tags are left alone)
116
+ - Feed autodiscovery: every injected head carries the
117
+ `rel="alternate" type="application/rss+xml"` link (a page-owned one wins,
118
+ missing-only) so feed readers find feed.xml without knowing the URL
110
119
  - Attribute-safe everywhere: og/twitter values are HTML-escaped, JSON-LD
111
120
  literals are JSON-escaped (a raw `<` cannot close the script tag),
112
121
  `og:image`/`twitter:image` ship only when an image is configured, and an
113
122
  optional `author` key rides the Organization JSON-LD as a schema.org founder
123
+ - `search`, `breadcrumb`, `article`, `verification` and `locales`:
124
+ the SearchAction target for the WebSite JSON-LD (no more hardcoded
125
+ `?s=` default), auto BreadcrumbList from the URL tree, scoped per-page
126
+ `Article`/`TechArticle`/`NewsArticle` blocks, provider-keyed site
127
+ verification metas, and hreflang stem-swap alternates for multilingual
128
+ trees - all opt-in, missing-only, and loudly diagnostic (see the
129
+ Discovery Options in the config reference)
130
+ - `llms: true` emits the AI-consumable `llms.txt` summary over the same
131
+ indexable page set sitemap and feed honor (noindex stays honored;
132
+ an existing file is never overwritten)
133
+ - `og.image` takes the back-compat string form or the structured
134
+ `{ src, width, height, alt }` object form, which also emits the
135
+ `og:image:width`/`height`/`alt` companions; SVG sources warn loudly on
136
+ every run (most scrapers cannot render them), `allowSvg: true` accepts
137
+ one explicitly, and `--strict` fails the build until it is answered
114
138
  - Page-level tuning survives the build: a `<!-- freestruct: {...} -->` comment
115
139
  overrides title/description/ogType/ogImage/twitterCard and adds
116
140
  `article:published_time`/`author`/`section`; custom `jsonLd` objects append
@@ -147,6 +171,30 @@ npx freestruct init # scaffold the starter kit into ./docs
147
171
  and retired keys never die silently
148
172
  - Link validation (report-only by default, `--strict` to make fatal - our own
149
173
  docs CI runs `--strict` and it has already caught three production 404s)
174
+ - `freestruct audit` verifies the baked output independently - per-page
175
+ head hygiene, the 404 noindex contract, sitemap/robots sanity - with an
176
+ exit code for CI (`0` clean, `1` findings, `--json` digest for
177
+ annotations); it reads only the output dir, so it checks any SSG's build
178
+ with freestruct applied
179
+ - IndexNow support (issue #19): freestruct is the component that already
180
+ owns the sitemap, so the key file (the keyLocation ownership proof) ships
181
+ into the output during the run and `freestruct indexnow` POSTs the baked
182
+ URL list to api.indexnow.org afterwards - deploy first (keyLocation must
183
+ resolve), submission is the post-deploy step, and `--dry-run` prints the
184
+ request body for CI
185
+ - Slug-glob scoped config (issue #25): `jsonLd` accepts the legacy
186
+ object/list forms or a map keyed by page-slug globs (`/` exact, `/blog/*`
187
+ one segment, `/guides/**` a sub-tree) so page-specific schema lands only
188
+ where it is meant to; `og.scoped` does the same for og image variants
189
+ with the most specific match winning. Missing-only holds everywhere,
190
+ malformed entries warn loudly and skip only themselves
191
+ - Per-page OG image generation (issue #22): freestruct already extracts
192
+ each page's title/description, so it can render a branded 1200x630 PNG
193
+ per page from a consumer SVG template (`{{ogTitle}}`/`{{ogDescription}}`/
194
+ `{{ogUrl}}` placeholders) and wire og/twitter to it - missing-only keeps
195
+ pages with their own og set. Rasterization is honest: PNG needs a real
196
+ rasterizer, so the optional `@resvg/resvg-js` package is loaded lazily
197
+ and the run stays green with a loud skip when it is not installed
150
198
  - Post-render hooks with purge-command safety (disabled by default; see
151
199
  [Security](#security))
152
200
 
@@ -175,7 +223,10 @@ Full key list (unknown or retired keys produce warnings, not silence):
175
223
  | `linkCheck` | `true` | Validate internal links (report-only; `--strict` makes them fatal) |
176
224
  | `searchIndex` | `false` | **Opt-in**: generate `search.json` for client-side search |
177
225
  | `minify` | `false` | Experimental HTML minifier (preserves `pre`/`script`/`style`/`textarea`) |
178
- | `runHooks` | `false` | Gateway: only `true` lets `cacheBusting.purge` commands execute |
226
+ | `runHooks` | `false` | Gateway: only `true` lets `cacheBusting.purge` and `hooks.postBuild` commands execute |
227
+ | `hooks.postBuild` | *(unset)* | Post-build command list chaining after the pass; disabled without `runHooks: true`; nonzero exit fails the build (exit 4) |
228
+ | `indexNow` | *(unset)* | IndexNow notify support: `true` (generate-once-and-persist key) or `{ key: "<32-hex>" }`; ships `<key>.txt` into the output and enables the `freestruct indexnow` post-deploy submission |
229
+ | `ogImageGeneration` | *(unset)* | Per-page OG image generation (`{ template, font?, width?, height? }`): renders a branded 1200x630 PNG per page from a consumer SVG template; needs the optional `@resvg/resvg-js` dev package (loud skip without it) |
179
230
  | `cacheBusting.assetQueryParam` | `true` | Append `?v={buildHash}` to linked assets |
180
231
  | `cacheBusting.hashInCanonicalUrl` | `false` | Append `?v={buildHash}` to canonical URLs |
181
232
  | `cacheBusting.purge` | *(unset)* | Purge hook list (name + command); disabled without `runHooks: true` |
@@ -205,7 +256,25 @@ cacheBusting:
205
256
  ```
206
257
 
207
258
  Use only trusted commands. Disabled by default. Available variables:
208
- `$SITE_URL`, `$BUILD_HASH`, `$OUTPUT_DIR`.
259
+ `$SITE_URL`, `$BUILD_HASH`, `$OUTPUT_DIR` (as env vars on the child:
260
+ `FREESTRUCT_SITE_URL`, `FREESTRUCT_BUILD_HASH`, `FREESTRUCT_OUTPUT_DIR`).
261
+
262
+ The same gateway covers the general postBuild seam - commands that chain
263
+ after the entire pass (custom schema injection, audits, IndexNow
264
+ submissions). A nonzero hook exit is a build failure (exit code 4):
265
+
266
+ ```yaml
267
+ # In freestruct.config.yml
268
+ runHooks: true
269
+ hooks:
270
+ postBuild:
271
+ - node scripts/faqp-schema.mjs
272
+ - npm run seo:audit
273
+ ```
274
+
275
+ Purge errors are reported and the build continues (a failed CDN purge is
276
+ not a bad sitemap); postBuild failures fail the build on purpose - chain
277
+ only trusted commands there too.
209
278
 
210
279
  ---
211
280
 
@@ -232,11 +301,34 @@ Two shipped surfaces beyond the engine:
232
301
  README/CHANGELOG truth. Single source, no drift.
233
302
  - `CHANGELOG.md` is the changelog; releases are tagged (`v*`) and
234
303
  CHANGELOG-truthful or they don't ship.
235
- - Current release: **1.0.0** (the owner's version call, 2026-10-22 - the
236
- first stable release; see `labs/TASKS.md` for the version policy).
304
+ - Current release: **1.1.0** (the version sweep + first post-1.0.0 surface:
305
+ this release carries the issue sweep landed after the 2026-10-22 tag;
306
+ see `labs/TASKS.md` for the version policy).
237
307
  - Deployment truth lives in [DEPLOY.md](DEPLOY.md): the GitHub Pages pipeline
238
308
  (this repo's own docs site deploys through it) and the npm release runbook.
239
309
 
310
+ ## v1.1.0
311
+
312
+ The first post-stable release, carried by the all-items issue sweep
313
+ (#17-#25 plus the #25 follow-on) and the CI tooling that came with it:
314
+
315
+ - `freestruct audit` - independent baked-output verification (per-page head
316
+ hygiene, the 404 noindex contract, sitemap/robots) with `--json` for CI
317
+ - `hooks.postBuild` - the general post-build hook seam (exit code 4,
318
+ `FREESTRUCT_*` env on hook children)
319
+ - IndexNow - key-file emission + the `freestruct indexnow` post-deploy
320
+ submission (`--dry-run` for CI)
321
+ - Scoped config - slug-glob map forms for `jsonLd` and `og.scoped`
322
+ - Structured `og.image` - `{ src, width, height, alt, allowSvg }` with the
323
+ companion tags and the SVG warn/strict ladder
324
+ - Per-page OG-image generation - `ogImageGeneration` through the optional
325
+ `@resvg/resvg-js` rasterizer
326
+ - Latent hardening - every config/page-carried substitute now rides
327
+ function replacers ($-expansion cannot corrupt output), plus the
328
+ docs-layout dead-CSS removal
329
+ - CI - packstruct manifest lint joined the push/PR battery alongside the
330
+ crashtest pack selftest; the battery grew 49 -> 75 -> 103 pins
331
+
240
332
  ## v1.0.0
241
333
 
242
334
  First stable release. It carries the full rehaul-era stack (accumulated
@@ -288,6 +380,8 @@ complimentary - freestruct imports nothing from its siblings.
288
380
  apps; Freestruct ships and CI-runs its own pack
289
381
  - [vant](https://github.com/dhaupin/vant) - the prototype Freestruct was
290
382
  extracted from
383
+ - **The workshop** - [Creadev.org](https://creadev.org) (freestruct is made
384
+ by Rook @ Creadev.org - the same home as the docs and site surfaces above)
291
385
 
292
386
  For agents: [AGENTS.md](AGENTS.md) is the orientation file (scope, house
293
387
  rules, gates). Humans and agents are welcome equally - see
@@ -38,11 +38,7 @@
38
38
  "@context": "https://schema.org",
39
39
  "@type": "WebSite",
40
40
  "name": "{{siteNameJson}}",
41
- "url": "{{siteUrlJson}}",
42
- "potentialAction": {
43
- "@type": "SearchAction",
44
- "target": "{{siteUrlJson}}/?s={search_term_string}"
45
- }
41
+ "url": "{{siteUrlJson}}"{{searchActionJson}}
46
42
  }
47
43
  </script>
48
44
 
@@ -0,0 +1,355 @@
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 FAIL og/twitter class is the SHARED core (og:title/og:description,
10
+ // twitter:card/twitter:title); the rest of the injected set rides WARN
11
+ // because missing-only means SSG-tagged pages legitimately keep a
12
+ // partial set (post-injection the audit cannot tell SSG-owned heads
13
+ // apart, so partial-set coherence is report-grade)
14
+ // - the SVG og:image trap stays a FAIL here (audit-grade; the run-warning
15
+ // ladder from issue #20 is the pre-build enforcement point)
16
+ // - the 404 check is the noindex contract only (issue #11's shape)
17
+ // - external http(s) links: opt-in via --external (SEO-gap pass) - the
18
+ // baked tree is audited for the network, not the default audit; HEAD
19
+ // with a GET retry (some CDNs refuse HEAD), resolution failures and
20
+ // HTTP >= 400 come back as WARN rows (report-grade: bots get 403/429)
21
+
22
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
23
+ import path from 'path';
24
+
25
+ // FAIL class = the tags core to the engine's own contract. The REST of the
26
+ // injected set (og:url/type/site_name/locale, twitter:description) rides as
27
+ // WARN because missing-only means SSG-tagged pages LEGITIMATELY keep a
28
+ // partial set - fail-failing those would punish contract-abiding output.
29
+ const OG_CORE = ['og:title', 'og:description'];
30
+ const OG_SET = ['og:url', 'og:type', 'og:site_name', 'og:locale'];
31
+ const TW_CORE = ['twitter:card', 'twitter:title'];
32
+ const TW_SET = ['twitter:description', 'twitter:image', 'twitter:site', 'twitter:creator'];
33
+ // LocalBusiness recommended fields per the donated script (schema.org).
34
+ const LB_RECOMMENDED = ['name', 'url', 'image', 'logo', 'telephone', 'priceRange', 'openingHoursSpecification', 'geo', 'sameAs'];
35
+
36
+ // ---------- scans ----------
37
+
38
+ /** Attribute-order tolerant meta extraction: hits aligned multi-space output. */
39
+ function grabMeta(head, attrHasKey, name) {
40
+ const keyRe = new RegExp(`<meta[^>]${attrHasKey}=["']${name}["'][^>]*>`, 'i');
41
+ const tag = head.match(keyRe);
42
+ if (!tag) return null;
43
+ const content = tag[0].match(/content=["']([^"']*)["']/i);
44
+ return content ? content[1] : null;
45
+ }
46
+
47
+ function grabCanonical(head) {
48
+ const link = head.match(/<link[^>]+rel=["']canonical["'][^>]*>/i)
49
+ || head.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']canonical["'][^>]*>/i);
50
+ if (!link) return null;
51
+ const href = link[0].match(/href=["']([^"']+)["']/i);
52
+ return href ? href[1] : null;
53
+ }
54
+
55
+ /** Count every occurrence of a head shape - the duplicate-class probe. */
56
+ function countHead(head, re) {
57
+ return (head.match(re) || []).length;
58
+ }
59
+
60
+ function walkHtml(dir) {
61
+ let out = [];
62
+ for (const name of readdirSync(dir)) {
63
+ const p = path.join(dir, name);
64
+ if (statSync(p).isDirectory()) out = out.concat(walkHtml(p));
65
+ else if (/\.html?$/i.test(name)) out.push(p);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function stripTags(s) {
71
+ return String(s).replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').trim();
72
+ }
73
+
74
+ /** Head-referenced URL -> on-disk path under the output dir, or null. */
75
+ function urlToDisk(urlValue, { outputDir, config }) {
76
+ if (!urlValue) return null;
77
+ let pathName = urlValue.split(/[?#]/)[0];
78
+ if (/^https?:\/\//i.test(pathName)) {
79
+ try {
80
+ const u = new URL(pathName);
81
+ const site = config?.site?.url ? new URL(config.site.url) : null;
82
+ // Only site-local files can exist in the output tree.
83
+ if (site && u.host !== site.host) return null;
84
+ pathName = u.pathname + u.search;
85
+ } catch (e) { return null; }
86
+ } else if (/^\/\//.test(pathName)) {
87
+ return null;
88
+ }
89
+ if (pathName.startsWith('/')) {
90
+ if (config?.basePath && pathName.startsWith(config.basePath)) {
91
+ pathName = pathName.slice(config.basePath.length);
92
+ }
93
+ return path.join(outputDir, pathName.replace(/^\//, ''));
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // ---------- the audit ----------
99
+
100
+ /**
101
+ * Audit a baked output directory. Returns { fails, warns, pages, sitemapLocs }.
102
+ * Prints a human report by default, or a JSON digest with `json: true`;
103
+ * `capture: true` (used when external checks ride along in json mode) skips
104
+ * printing and hands the caller the raw result.
105
+ * NEVER exits: the caller owns the exit code (0 clean / 1 findings).
106
+ */
107
+ export function auditOutput({ outputDir, config, json = false, capture = false }) {
108
+ const fails = [];
109
+ const warns = [];
110
+ const fail = (page, msg) => fails.push({ page, msg });
111
+ const warn = (page, msg) => warns.push({ page, msg });
112
+
113
+ // File-at-path hardening (Pass 26.13): a file at the outputDir path passes
114
+ // existsSync, and walkHtml's readdirSync would arrive here uncaught.
115
+ if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) {
116
+ if (json) console.log(JSON.stringify({ ok: false, error: 'output dir not found: ' + outputDir }));
117
+ else console.error('[freestruct] audit: no "' + outputDir + '" directory - run the build first.');
118
+ return { fails: [{ page: 'outputDir', msg: 'missing' }], warns, pages: 0, sitemapLocs: 0 };
119
+ }
120
+
121
+ const pages = walkHtml(outputDir);
122
+ let sitemapLocs = 0;
123
+
124
+ for (const filePath of pages) {
125
+ let rel;
126
+ try { rel = path.relative(outputDir, filePath); } catch (e) { rel = filePath; }
127
+ const html = readFileSync(filePath, 'utf8');
128
+ const head = html.split('</head>')[0];
129
+ const label = rel.replace(/\\/g, '/');
130
+
131
+ if (/(^|\/)404\.html?$/i.test(label)) {
132
+ const robots = grabMeta(head, 'name', 'robots') || '';
133
+ if (!/noindex/i.test(robots)) fail(label, '404 page missing its noindex robots directive');
134
+ continue;
135
+ }
136
+
137
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
138
+ if (!titleMatch || !titleMatch[1].trim()) fail(label, 'missing <title>');
139
+ else {
140
+ const t = stripTags(titleMatch[1]).length;
141
+ if (t > 65) warn(label, 'title ' + t + ' chars (> 65, may truncate)');
142
+ if (t < 10) warn(label, 'title ' + t + ' chars (suspiciously short)');
143
+ }
144
+
145
+ const desc = grabMeta(head, 'name', 'description');
146
+ if (!desc) fail(label, 'missing meta description');
147
+ else if (desc.length < 70 || desc.length > 165) {
148
+ warn(label, 'description ' + desc.length + ' chars (outside 70-165, may truncate)');
149
+ }
150
+
151
+ const canonical = grabCanonical(head);
152
+ if (!canonical || canonical === '') fail(label, 'missing canonical link');
153
+ else if (!/^https?:\/\//i.test(canonical)) fail(label, 'canonical not absolute: ' + canonical);
154
+
155
+ // Duplicate tags ride the FAIL class: missing-only injection never
156
+ // adds a tag the page already carries, so any duplicate in a baked
157
+ // head is a page/SSG edit gone wrong (Pass 26.11). Attribute-order
158
+ // tolerant, same as the missing checks.
159
+ for (const dup of [
160
+ ['duplicate canonical link (a page ships exactly one)', /<link[^>]+rel=["']canonical["']/gi],
161
+ ['duplicate meta description', /<meta[^>]+name=["']description["']/gi],
162
+ ['multiple <title> elements', /<title[^>]*>/gi],
163
+ ['duplicate og:title', /<meta[^>]+property=["']og:title["']/gi],
164
+ ]) {
165
+ if (countHead(head, dup[1]) > 1) fail(label, dup[0]);
166
+ }
167
+
168
+ for (const prop of OG_CORE) {
169
+ if (!grabMeta(head, 'property', prop)) fail(label, 'missing ' + prop);
170
+ }
171
+ for (const prop of OG_SET) {
172
+ if (!grabMeta(head, 'property', prop)) {
173
+ warn(label, prop + ' absent (part of the engine-injected set; SSG-owned partial sets keep contract)');
174
+ }
175
+ }
176
+ if (!grabMeta(head, 'property', 'og:image')) {
177
+ warn(label, 'og:image absent (no og.image configured? add one for share previews)');
178
+ }
179
+ for (const name of TW_CORE) {
180
+ if (!grabMeta(head, 'name', name)) fail(label, 'missing ' + name);
181
+ }
182
+ for (const name of TW_SET) {
183
+ // Symmetry with the engine's own late pass (seo.js): without a
184
+ // configured handle the injector ships neither twitter:site nor
185
+ // twitter:creator, so the audit grades those absences only when the
186
+ // handle exists. The 'config-gated' wording in the warning now holds.
187
+ if ((name === 'twitter:site' || name === 'twitter:creator') && !config.twitter?.username) continue;
188
+ if (!grabMeta(head, 'name', name)) {
189
+ warn(label, name + ' absent (config-gated or SSG-owned partial set; add a twitter username / og.image as needed)');
190
+ }
191
+ }
192
+
193
+ const ogImage = grabMeta(head, 'property', 'og:image');
194
+ if (ogImage && /\.svg(\?|#|$)/i.test(ogImage)) {
195
+ fail(label, 'og:image is SVG (' + ogImage + ') - most social scrapers cannot render it; ship a raster PNG/JPG');
196
+ }
197
+ if (ogImage && !grabMeta(head, 'property', 'og:image:width')) {
198
+ warn(label, 'og:image:width/height/alt absent (the og.image object form supplies them)');
199
+ }
200
+ if (ogImage && urlToDisk(ogImage, { outputDir, config }) && !existsSync(urlToDisk(ogImage, { outputDir, config }))) {
201
+ // Report-grade on purpose (issue #18 sketch: 'worth having'): an og
202
+ // image can legitimately be emitted on an SSG-side pass or a same-host
203
+ // CDN, so a hard FAIL would punish valid setups; the JSON digest
204
+ // carries it for teams who want to harden it in CI.
205
+ warn(label, 'og:image points at ' + ogImage + ' which does not exist on disk');
206
+ }
207
+
208
+ const ldBlocks = [...html.matchAll(/<script type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
209
+ if (!ldBlocks.length) warn(label, 'no JSON-LD structured data');
210
+ for (const block of ldBlocks) {
211
+ let parsed;
212
+ try { parsed = JSON.parse(block[1]); } catch (e) {
213
+ fail(label, 'invalid JSON-LD: ' + e.message);
214
+ continue;
215
+ }
216
+ const nodes = Array.isArray(parsed) ? parsed : (parsed['@graph'] ? parsed['@graph'] : [parsed]);
217
+ for (const node of nodes) {
218
+ if (!node || typeof node !== 'object') continue;
219
+ if (!node['@type']) { warn(label, 'JSON-LD node without @type'); continue; }
220
+ const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
221
+ if (types.includes('LocalBusiness')) {
222
+ const missing = LB_RECOMMENDED.filter(f => !(f in node));
223
+ if (missing.length) warn(label, 'LocalBusiness missing recommended: ' + missing.join(', '));
224
+ }
225
+ // schema-carried logo/image files: same exists-on-disk check as og:image
226
+ for (const key of ['logo', 'image']) {
227
+ const v = node[key];
228
+ const target = urlToDisk(typeof v === 'string' ? v : null, { outputDir, config });
229
+ if (target && !existsSync(target)) {
230
+ warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which does not exist on disk');
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ const h1s = [...html.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/gi)];
237
+ const h1Texts = h1s.map(m => stripTags(m[1])).filter(Boolean);
238
+ if (!h1Texts.length) warn(label, 'no non-empty <h1>');
239
+ else if (h1Texts.length > 1) warn(label, h1Texts.length + ' <h1> elements (prefer one)');
240
+ }
241
+
242
+ // ---------- site-level artifacts ----------
243
+
244
+ const sitemapPath = path.join(outputDir, 'sitemap.xml');
245
+ if (!existsSync(sitemapPath)) {
246
+ fail('sitemap.xml', 'missing');
247
+ } else {
248
+ const s = readFileSync(sitemapPath, 'utf8');
249
+ sitemapLocs = (s.match(/<loc>/g) || []).length;
250
+ if (sitemapLocs === 0) fail('sitemap.xml', 'no <loc> entries');
251
+ if (!s.includes('<lastmod>')) warn('sitemap.xml', 'no <lastmod> dates (engine runs always add them; hand-stripped?)');
252
+ }
253
+
254
+ const robotsPath = path.join(outputDir, 'robots.txt');
255
+ if (!existsSync(robotsPath)) {
256
+ fail('robots.txt', 'missing');
257
+ } else {
258
+ const r = readFileSync(robotsPath, 'utf8');
259
+ if (!/Sitemap:/i.test(r)) fail('robots.txt', 'no Sitemap: pointer');
260
+ }
261
+
262
+ // ---------- report ----------
263
+
264
+ if (capture) {
265
+ return { fails, warns, pages: pages.length, sitemapLocs };
266
+ }
267
+ if (json) {
268
+ console.log(JSON.stringify({ ok: fails.length === 0, pages: pages.length, fails, warns }));
269
+ } else {
270
+ console.log('[freestruct] SEO audit: ' + pages.length + ' baked page(s) checked, sitemap.xml has ' + sitemapLocs + ' <loc> entries');
271
+ for (const f of fails) console.log(' FAIL ' + f.page + ': ' + f.msg);
272
+ for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg);
273
+ if (fails.length === 0) {
274
+ console.log('[freestruct] audit: 0 failures, ' + warns.length + ' warning(s) - clean');
275
+ } else {
276
+ console.log('[freestruct] audit: ' + fails.length + ' failure(s), ' + warns.length + ' warning(s) - exiting 1');
277
+ }
278
+ }
279
+ return { fails, warns, pages: pages.length, sitemapLocs };
280
+ }
281
+
282
+ // ---------- external links (opt-in, SEO-gap pass) ----------
283
+
284
+ const EXTERNAL_LIMIT = 50;
285
+
286
+ /** Collect external http(s) href URLs from the baked pages, deduped, with referencing pages. */
287
+ function collectExternalLinks({ outputDir, pages }) {
288
+ const byUrl = new Map();
289
+ for (const filePath of pages) {
290
+ let rel;
291
+ try { rel = path.relative(outputDir, filePath).replace(/\\/g, '/'); } catch (e) { rel = filePath; }
292
+ const html = readFileSync(filePath, 'utf8');
293
+ for (const m of html.matchAll(/href\s*=\s*["'](https?:\/\/[^"'\s]+)["']/gi)) {
294
+ const url = m[1].replace(/&amp;/g, '&');
295
+ if (!byUrl.has(url)) byUrl.set(url, []);
296
+ byUrl.get(url).push(rel);
297
+ }
298
+ }
299
+ return byUrl;
300
+ }
301
+
302
+ /** HEAD with a GET retry (some CDNs refuse HEAD); returns the problem string or null. */
303
+ async function headCheck(url) {
304
+ const tryOnce = async (method) => {
305
+ const res = await fetch(url, {
306
+ method,
307
+ redirect: 'follow',
308
+ signal: AbortSignal.timeout(8000),
309
+ headers: { 'user-agent': 'freestruct-audit (+https://github.com/dhaupin/freestruct)' },
310
+ });
311
+ return res.status >= 400 ? 'HTTP ' + res.status + (res.statusText ? ' ' + res.statusText : '') : null;
312
+ };
313
+ try {
314
+ return await tryOnce('HEAD');
315
+ } catch (headErr) {
316
+ try {
317
+ return await tryOnce('GET');
318
+ } catch (getErr) {
319
+ const cause = (getErr && getErr.cause && getErr.cause.code) ? getErr.cause.code : getErr.message;
320
+ return 'unreachable (' + cause + ')';
321
+ }
322
+ }
323
+ }
324
+
325
+ /**
326
+ * External-link audit (opt-in): dedupe the tree's external hrefs, HEAD-check
327
+ * up to EXTERNAL_LIMIT unique URLs, report every failure as a WARN row
328
+ * (report-grade by design: bots get 403/429 legitimately). NEVER exits.
329
+ */
330
+ export async function auditExternalLinks({ outputDir, json = false }) {
331
+ const empty = { warns: [], checked: 0 };
332
+ if (!existsSync(outputDir)) return empty;
333
+ const pages = walkHtml(outputDir);
334
+ const byUrl = collectExternalLinks({ outputDir, pages });
335
+ if (byUrl.size === 0) return empty;
336
+ const capped = [...byUrl.keys()].slice(0, EXTERNAL_LIMIT);
337
+ const results = await Promise.all(capped.map(async (url) => [url, await headCheck(url)]));
338
+ const warns = [];
339
+ for (const [url, problem] of results) {
340
+ if (!problem) continue;
341
+ const referencing = byUrl.get(url);
342
+ const label = referencing.length > 1
343
+ ? referencing[0] + ' (+' + (referencing.length - 1) + ' more page(s))'
344
+ : referencing[0];
345
+ warns.push({ page: label, msg: 'external link ' + url + ': ' + problem });
346
+ }
347
+ if (byUrl.size > EXTERNAL_LIMIT) {
348
+ warns.push({ page: 'external', msg: 'checked only the first ' + EXTERNAL_LIMIT + ' unique external urls of ' + byUrl.size });
349
+ }
350
+ if (!json && warns.length > 0) {
351
+ console.log('[freestruct] external link check: ' + capped.length + ' unique url(s) probed');
352
+ for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg);
353
+ }
354
+ return { warns, checked: capped.length };
355
+ }