freestruct 0.9.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 +112 -11
- package/engine/_freestruct/inject-brand.html +1 -5
- package/engine/audit.js +355 -0
- package/engine/config.js +207 -1
- package/engine/hooks.js +68 -10
- package/engine/indexnow.js +140 -0
- package/engine/inject.js +218 -7
- package/engine/linkcheck.js +48 -8
- package/engine/llms.js +52 -0
- package/engine/ogimage.js +142 -0
- package/engine/scan.js +20 -11
- package/engine/search.js +1 -1
- package/engine/seo.js +359 -24
- package/engine/urls.js +48 -1
- package/engine/xmllike.js +1 -1
- package/init/VERSION +1 -1
- package/init/docs/_freestruct/inject-brand.html +1 -5
- package/package.json +4 -1
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,
|
|
84
|
-
(`1` config, `2` template, `3` output
|
|
85
|
-
exit 0 for CI
|
|
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,15 +301,39 @@ 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: **
|
|
236
|
-
|
|
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
|
|
|
240
|
-
##
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
|
|
332
|
+
## v1.0.0
|
|
333
|
+
|
|
334
|
+
First stable release. It carries the full rehaul-era stack (accumulated
|
|
335
|
+
under the 0.6.0 freeze, promoted truthfully as 0.9.0), the prestruct port
|
|
336
|
+
wave, and the toolchain maturity landed on 2026-10-22:
|
|
244
337
|
|
|
245
338
|
- Config renamed to `freestruct.config.yml` with legacy read; schema-checked
|
|
246
339
|
with loud diagnostics; FAST_FAIL exit codes + `--tolerant`
|
|
@@ -258,6 +351,12 @@ freeze and released as one truthful number by owner call:
|
|
|
258
351
|
- Release truth: tarball e2e CI gate + hard publish gate; version pinned
|
|
259
352
|
- crashtest pack surface (`packs/public/freestruct-docs-shapes`) with CI
|
|
260
353
|
teeth on every push and the Pages build
|
|
354
|
+
- Port wave from prestruct: attribute-escaped injection, og/twitter image
|
|
355
|
+
parity, `author` + JSON-LD founder, per-page override comments, custom
|
|
356
|
+
`jsonLd`, deterministic build hash, CLI subcommands (`validate`/`init`/
|
|
357
|
+
`help`), `FREESTRUCT_URL` env override
|
|
358
|
+
- Toolchain: Node-24-compatible GH action majors, Jekyll 4.4 + Ruby 3.4,
|
|
359
|
+
js-yaml 5.5, packaging validated dry (npm tarball + GitHub source tar)
|
|
261
360
|
|
|
262
361
|
---
|
|
263
362
|
|
|
@@ -281,6 +380,8 @@ complimentary - freestruct imports nothing from its siblings.
|
|
|
281
380
|
apps; Freestruct ships and CI-runs its own pack
|
|
282
381
|
- [vant](https://github.com/dhaupin/vant) - the prototype Freestruct was
|
|
283
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)
|
|
284
385
|
|
|
285
386
|
For agents: [AGENTS.md](AGENTS.md) is the orientation file (scope, house
|
|
286
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
|
|
package/engine/audit.js
ADDED
|
@@ -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(/&/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(/&/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
|
+
}
|