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 +101 -7
- 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/engine/scan.js
CHANGED
|
@@ -13,31 +13,34 @@ export function getHtmlFiles(dir) {
|
|
|
13
13
|
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
14
14
|
const fp = path.join(dir, item.name);
|
|
15
15
|
if (item.isDirectory()) files.push(...getHtmlFiles(fp));
|
|
16
|
-
|
|
16
|
+
// Case-insensitive .html/.htm (Pass 26.10): a consumer SSG that emits
|
|
17
|
+
// .HTM or upper-case extensions is a page like any other - the old
|
|
18
|
+
// suffix test silently dropped it from every artifact.
|
|
19
|
+
else if (/\.html?$/i.test(item.name)) files.push(fp);
|
|
17
20
|
}
|
|
18
21
|
return files;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
/**
|
|
22
|
-
* Canonical slug for a file relative to the output root.
|
|
24
|
+
/** Canonical slug for a file relative to the output root.
|
|
23
25
|
* index.html -> '/'
|
|
24
26
|
* guide/index.html -> '/guide/'
|
|
25
27
|
* a&b.html -> '/a&b'
|
|
28
|
+
* legacy.htm -> '/legacy' (case-insensitive .html/.htm)
|
|
26
29
|
*/
|
|
27
30
|
export function slugFor(rel) {
|
|
28
31
|
let p = String(rel).replace(/\\/g, '/');
|
|
29
|
-
if (
|
|
32
|
+
if (/^\/?index\.html?$/i.test(p)) return '/';
|
|
30
33
|
p = '/' + p;
|
|
31
34
|
p = p
|
|
32
|
-
.replace(/\/index\.html
|
|
33
|
-
.replace(/\.html
|
|
35
|
+
.replace(/\/index\.html?$/i, '/')
|
|
36
|
+
.replace(/\.html?$/i, '');
|
|
34
37
|
return p;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** 404.html at root or any directory level is the 404 pipeline's business. */
|
|
38
41
|
export function is404File(rel) {
|
|
39
42
|
const p = String(rel).replace(/\\/g, '/');
|
|
40
|
-
return
|
|
43
|
+
return /(^|\/)404\.html?$/i.test(p);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** Robots noindex detection on (possibly injected) HTML. */
|
|
@@ -68,19 +71,25 @@ export function extractDescription(html) {
|
|
|
68
71
|
* Does the page already import SEO of the given type from SSG/frontmatter?
|
|
69
72
|
* Used by the missing-only injection gate ('description' added in Phase 1: a
|
|
70
73
|
* plain <meta name="description"> counts as covered).
|
|
74
|
+
*
|
|
75
|
+
* Every probe is TAG-ANCHORED (Pass 26.12): substring matches fire on the
|
|
76
|
+
* page's own PROSE too - a docs page that quotes 'application/ld+json' in
|
|
77
|
+
* a code span silenced its own structured-data injection, exactly the
|
|
78
|
+
* configuration-reference page. Markup quotes markup: anchors, never
|
|
79
|
+
* discussion.
|
|
71
80
|
*/
|
|
72
81
|
export function hasSeoType(html, type) {
|
|
73
82
|
if (type === 'og') {
|
|
74
|
-
return
|
|
83
|
+
return /<meta[^>]+property=["']og:(title|description)["']/i.test(html);
|
|
75
84
|
}
|
|
76
85
|
if (type === 'twitter') {
|
|
77
|
-
return
|
|
86
|
+
return /<meta[^>]+name=["']twitter:(card|title)["']/i.test(html);
|
|
78
87
|
}
|
|
79
88
|
if (type === 'jsonld') {
|
|
80
|
-
return
|
|
89
|
+
return /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
|
|
81
90
|
}
|
|
82
91
|
if (type === 'canonical') {
|
|
83
|
-
return
|
|
92
|
+
return /<link[^>]+rel=["']canonical["']/i.test(html);
|
|
84
93
|
}
|
|
85
94
|
if (type === 'description') {
|
|
86
95
|
return /<meta[^>]*name=["']description["']/.test(html);
|
package/engine/search.js
CHANGED
|
@@ -28,7 +28,7 @@ export function generateSearchIndex(pages, config, outputDir) {
|
|
|
28
28
|
? bodyMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
29
29
|
: '';
|
|
30
30
|
|
|
31
|
-
const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file, '
|
|
31
|
+
const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file).replace(/\.html?$/i, '');
|
|
32
32
|
const description = descMatch ? descMatch[1] : '';
|
|
33
33
|
const heading = h1Match ? h1Match[1] : title;
|
|
34
34
|
const url = pageUrlFor(config, page.slug);
|
package/engine/seo.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { hasSeoType } from './scan.js';
|
|
11
|
-
import { pageUrlFor } from './urls.js';
|
|
10
|
+
import { hasSeoType, htmlIsNoindex } from './scan.js';
|
|
11
|
+
import { pageUrlFor, slugMatchesGlob, globSpecificity } from './urls.js';
|
|
12
12
|
|
|
13
13
|
function minifyHtml(html) {
|
|
14
14
|
// Conservative HTML minifier - removes unnecessary whitespace
|
|
@@ -37,8 +37,141 @@ function minifyHtml(html) {
|
|
|
37
37
|
.trim();
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// ---------- SEO-gap pass helpers (feed link / SearchAction / breadcrumb / article / hreflang) ----------
|
|
41
|
+
|
|
42
|
+
// The root-home stem is the root document; glob '**' retries use it too.
|
|
43
|
+
const rootHomeSlug = '/';
|
|
44
|
+
|
|
45
|
+
// Literal prefix of a locale pattern: the part every matched slug must start
|
|
46
|
+
// with (glob patterns drop the glob tail; the root document retries to '').
|
|
47
|
+
function stemForPattern(pattern) {
|
|
48
|
+
if (pattern === rootHomeSlug) return '';
|
|
49
|
+
if (typeof pattern === 'string') {
|
|
50
|
+
const star = pattern.indexOf('*');
|
|
51
|
+
if (star !== -1) return pattern.slice(0, star);
|
|
52
|
+
return pattern;
|
|
53
|
+
}
|
|
54
|
+
return rootHomeSlug;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Root-home retries: a rest of '/
|
|
58
|
+
' or '/' is the home page, not '//'.
|
|
59
|
+
function slugForHomePage(slug) { return slug === rootHomeSlug ? rootHomeSlug : slug; }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Per-page article LD+JSON type (SEO-gap pass): config.article.scoped is a
|
|
63
|
+
* slug-glob map of type strings; the most specific pattern matching the page
|
|
64
|
+
* wins (exact > glob), invalid types are skipped (config.js warned loudly
|
|
65
|
+
* at load).
|
|
66
|
+
*/
|
|
67
|
+
const ARTICLE_TYPES = Object.freeze(['Article', 'TechArticle', 'NewsArticle']);
|
|
68
|
+
function articleTypeForPage(page, config) {
|
|
69
|
+
const scoped = config.article?.scoped;
|
|
70
|
+
if (!scoped || typeof scoped !== 'object' || Array.isArray(scoped)) return null;
|
|
71
|
+
let bestScore = -1;
|
|
72
|
+
let best = null;
|
|
73
|
+
for (const [pattern, v] of Object.entries(scoped)) {
|
|
74
|
+
if (!ARTICLE_TYPES.includes(String(v))) continue;
|
|
75
|
+
if (!slugMatchesGlob(pattern, page.slug)) continue;
|
|
76
|
+
const score = globSpecificity(pattern);
|
|
77
|
+
if (score > bestScore) { bestScore = score; best = String(v); }
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Auto BreadcrumbList (SEO-gap pass): config.breadcrumb true builds the
|
|
84
|
+
* breadcrumb from the page's own position in the URL tree - the site root,
|
|
85
|
+
* then every URL segment in order, with the page's own title for the tail.
|
|
86
|
+
* The root page (no segments) gets nothing - a one-element breadcrumb is
|
|
87
|
+
* not a breadcrumb.
|
|
88
|
+
*/
|
|
89
|
+
function breadcrumbJsonLd(page, config) {
|
|
90
|
+
if (config.breadcrumb !== true) return null;
|
|
91
|
+
const segs = String(page.slug).split('/').filter(Boolean);
|
|
92
|
+
if (segs.length === 0) return null;
|
|
93
|
+
const items = [{ position: 1, name: config.site.name, url: pageUrlFor(config, rootHomeSlug) }];
|
|
94
|
+
let acc = '';
|
|
95
|
+
segs.forEach((seg, i) => {
|
|
96
|
+
acc += '/' + seg;
|
|
97
|
+
items.push({
|
|
98
|
+
position: i + 2,
|
|
99
|
+
name: (i === segs.length - 1 && page.title) ? page.title : seg.replace(/[-_]/g, ' '),
|
|
100
|
+
url: pageUrlFor(config, acc),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
'@context': 'https://schema.org',
|
|
105
|
+
'@type': 'BreadcrumbList',
|
|
106
|
+
itemListElement: items.map((it) => ({
|
|
107
|
+
'@type': 'ListItem',
|
|
108
|
+
position: it.position,
|
|
109
|
+
name: it.name,
|
|
110
|
+
item: it.url,
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does this page RECEIVE a scoped jsonLd block? The scoped map form keys
|
|
117
|
+
* slug globs to block lists (config.js reads legacy forms - a schema object
|
|
118
|
+
* or an array of them - as the global set, with non-glob keys). Pages that
|
|
119
|
+
* receive a scoped block skip the GENERATED article block (the documented
|
|
120
|
+
* skip: a consumer-authored block outranks the derived one), while the
|
|
121
|
+
* breadcrumb trail coexists with any config jsonLd form.
|
|
122
|
+
*/
|
|
123
|
+
function pageReceivesScopedJsonLd(page, config) {
|
|
124
|
+
const jsonLd = config.jsonLd;
|
|
125
|
+
if (!jsonLd || typeof jsonLd !== 'object' || Array.isArray(jsonLd)) return false;
|
|
126
|
+
const keys = Object.keys(jsonLd);
|
|
127
|
+
if (keys.length === 0) return false;
|
|
128
|
+
if (!keys.every((k) => typeof k === 'string' && k.startsWith('/'))) return false;
|
|
129
|
+
return keys.some((k) => slugMatchesGlob(k, page.slug));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* hreflang alternates for a page: the locales map keys slug globs to
|
|
134
|
+
* language codes; the most specific matching pattern decides the page's own
|
|
135
|
+
* language, the others give the replica slugs (the page's slug with the
|
|
136
|
+
* matched stem swapped for each sibling stem). This is a stem-SWAP guess,
|
|
137
|
+
* not a file-existence contract: a page that only exists in one language
|
|
138
|
+
* still gets the alternates entry, and its 404 is the SSG's problem.
|
|
139
|
+
*/
|
|
140
|
+
function hreflangAlternatesFor(page, config) {
|
|
141
|
+
const locales = config.locales;
|
|
142
|
+
if (!locales || typeof locales !== 'object' || Array.isArray(locales)) return null;
|
|
143
|
+
const entries = Object.entries(locales).filter(([, v]) => typeof v === 'string' && v !== '');
|
|
144
|
+
if (entries.length === 0) return null;
|
|
145
|
+
let bestScore = -1;
|
|
146
|
+
let ownPattern = null;
|
|
147
|
+
let ownLang = null;
|
|
148
|
+
for (const [pattern, lang] of entries) {
|
|
149
|
+
if (!slugMatchesGlob(pattern, page.slug)) continue;
|
|
150
|
+
const score = globSpecificity(pattern);
|
|
151
|
+
if (score > bestScore) { bestScore = score; ownPattern = pattern; ownLang = lang; }
|
|
152
|
+
}
|
|
153
|
+
if (!ownPattern) return null;
|
|
154
|
+
const ownStem = stemForPattern(ownPattern);
|
|
155
|
+
const stripLen = ownStem === rootHomeSlug ? 0 : ownStem.length;
|
|
156
|
+
let rest = String(page.slug).slice(stripLen);
|
|
157
|
+
if (!rest.startsWith('/')) rest = '/' + rest;
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const [pattern, lang] of entries) {
|
|
160
|
+
if (lang === ownLang) continue;
|
|
161
|
+
const stem = stemForPattern(pattern);
|
|
162
|
+
const stemClean = stem === rootHomeSlug ? '' : stem.replace(/\/+$/, '');
|
|
163
|
+
out.push([lang, pageUrlFor(config, stemClean === '' ? (rest === '/' ? rootHomeSlug : rest) : slugForHomePage(stemClean + rest))]);
|
|
164
|
+
}
|
|
165
|
+
// x-default follows the root document's locale when one is configured.
|
|
166
|
+
const rootEntry = entries.find(([pattern]) => stemForPattern(pattern) === rootHomeSlug);
|
|
167
|
+
if (rootEntry && rootEntry[1] !== ownLang) {
|
|
168
|
+
out.push(['x-default', pageUrlFor(config, rest === '/' ? rootHomeSlug : rest)]);
|
|
169
|
+
}
|
|
170
|
+
return out.length ? out : null;
|
|
171
|
+
}
|
|
40
172
|
// ---------- injection value escaping ----------
|
|
41
173
|
|
|
174
|
+
|
|
42
175
|
// HTML-attribute escaping for config/page values that land inside
|
|
43
176
|
// content="..." or href="...": a raw " in a site description silently
|
|
44
177
|
// truncates every generated tag (ported from prestruct's escapeHtmlAttr).
|
|
@@ -106,12 +239,21 @@ function readPageOverrides(html, page) {
|
|
|
106
239
|
* pages that ship their own OG/Twitter/JSON-LD/canonical/description keep them.
|
|
107
240
|
* Page-level freestruct comments refine the injected tags (see PERPAGE_KEYS).
|
|
108
241
|
*/
|
|
109
|
-
export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
242
|
+
export function injectPage(page, { config, template, buildHash, snippetDir, injectWarnings, generatedImages }) {
|
|
110
243
|
let html = page.html;
|
|
111
244
|
const siteName = config.site.name;
|
|
112
245
|
const siteUrl = config.site.url;
|
|
113
246
|
const overrides = readPageOverrides(html, page);
|
|
114
247
|
|
|
248
|
+
// Headless-page diagnostics (Pass 26.13): every injected surface anchors
|
|
249
|
+
// on the head close tag, so a .html file without one silently takes NO
|
|
250
|
+
// head injection - the audit names the gaps afterwards, but the RUN says
|
|
251
|
+
// so here too (headless fragments are the octet-stream post class: never
|
|
252
|
+
// die silently).
|
|
253
|
+
if (!/<\/head>/i.test(html)) {
|
|
254
|
+
pageWarn(page, 'no </head> found - the whole head-injection surface skips this page');
|
|
255
|
+
}
|
|
256
|
+
|
|
115
257
|
let pageTitle = (page.title || siteName) + ' | ' + siteName;
|
|
116
258
|
let pageDescription = (page.description !== undefined && page.description !== null)
|
|
117
259
|
? page.description
|
|
@@ -128,17 +270,74 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
|
128
270
|
const canonicalUrl = pageUrlFor(config, page.slug)
|
|
129
271
|
+ (config.cacheBusting?.hashInCanonicalUrl === true ? '?v=' + buildHash : '');
|
|
130
272
|
|
|
131
|
-
// og image: the
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
273
|
+
// og image (issue #20): config accepts the back-compat string form or the
|
|
274
|
+
// structured { src, width, height, alt } object form (shared prestruct
|
|
275
|
+
// shape so configs move between engines). Per-page overrides win over the
|
|
276
|
+
// config either way; relative values join onto the site url, absolute
|
|
277
|
+
// HTTP(S) values pass through as-is. No image anywhere: no og:image/
|
|
278
|
+
// twitter:image ship at all (a bare image tag pointing at the site root is
|
|
279
|
+
// a broken card).
|
|
280
|
+
const ogImageCfg = config.og?.image;
|
|
281
|
+
const ogImageObj = (ogImageCfg !== undefined && ogImageCfg !== null
|
|
282
|
+
&& typeof ogImageCfg === 'object' && !Array.isArray(ogImageCfg))
|
|
283
|
+
? ogImageCfg : null;
|
|
284
|
+
const hasPerpageOgImage = overrides?.ogImage !== undefined && overrides.ogImage !== '';
|
|
285
|
+
// Resolution order (issue #20 + #22): per-page override > per-page
|
|
286
|
+
// GENERATED image (issue #22 deliverables, keyed by slug) > the config
|
|
287
|
+
// (object or string form). Pages that ship their own og:image keep it
|
|
288
|
+
// before any of this matters - missing-only.
|
|
289
|
+
const generatedForPage = (generatedImages && generatedImages[page.slug]) || null;
|
|
290
|
+
// og.scoped (issue #25 follow-on): image variants keyed by the same slug
|
|
291
|
+
// globs as jsonLd; the MOST SPECIFIC matching pattern wins (exact > glob).
|
|
292
|
+
// Per-page overrides and per-page generated images (#22) still rank ahead;
|
|
293
|
+
// the global og.image is the deepest fallback.
|
|
294
|
+
const ogScopedCfg = config.og?.scoped;
|
|
295
|
+
let scopedForPage = null;
|
|
296
|
+
if (ogScopedCfg && typeof ogScopedCfg === 'object' && !Array.isArray(ogScopedCfg)) {
|
|
297
|
+
let bestScore = -1;
|
|
298
|
+
for (const [pattern, variant] of Object.entries(ogScopedCfg)) {
|
|
299
|
+
if (!slugMatchesGlob(pattern, page.slug)) continue;
|
|
300
|
+
const score = globSpecificity(pattern);
|
|
301
|
+
if (score > bestScore) { bestScore = score; scopedForPage = variant; }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Defensive shape gate (config.js already warns loudly at load): a variant
|
|
305
|
+
// that is neither a string nor a plain object (god forbid a list - one page
|
|
306
|
+
// matches ONE image) resolves to NO image, never to synthesized garbage.
|
|
307
|
+
if (scopedForPage !== null && scopedForPage !== undefined
|
|
308
|
+
&& typeof scopedForPage !== 'string'
|
|
309
|
+
&& (typeof scopedForPage !== 'object' || Array.isArray(scopedForPage))) {
|
|
310
|
+
scopedForPage = null;
|
|
311
|
+
}
|
|
312
|
+
const scopedIsObject = scopedForPage !== null && typeof scopedForPage === 'object' && !Array.isArray(scopedForPage);
|
|
313
|
+
const allowSvgAccept = scopedForPage === null
|
|
314
|
+
? (ogImageObj ? ogImageObj.allowSvg === true : false)
|
|
315
|
+
: (scopedIsObject ? scopedForPage.allowSvg === true : false);
|
|
316
|
+
const companionsSource = hasPerpageOgImage ? null
|
|
317
|
+
: (generatedForPage || (scopedIsObject ? scopedForPage : null) || (!scopedForPage ? ogImageObj : null));
|
|
318
|
+
const rawOgImage = hasPerpageOgImage
|
|
136
319
|
? String(overrides.ogImage)
|
|
137
|
-
:
|
|
320
|
+
: (generatedForPage ? generatedForPage.src
|
|
321
|
+
: (scopedForPage !== null
|
|
322
|
+
? (scopedIsObject ? String(scopedForPage.src || '') : String(scopedForPage))
|
|
323
|
+
: (ogImageObj ? String(ogImageObj.src || '') : (ogImageCfg || ''))));
|
|
138
324
|
const ogImageFull = rawOgImage && !/^(https?:)?\/\//i.test(rawOgImage)
|
|
139
325
|
? siteUrl + rawOgImage
|
|
140
326
|
: rawOgImage;
|
|
141
327
|
|
|
328
|
+
// issue #20, the SVG trap: an SVG og:image cannot be rendered by most
|
|
329
|
+
// social scrapers and the share preview blanks. Loud warning always from
|
|
330
|
+
// here (config value or per-page override); under --strict the orchestrator
|
|
331
|
+
// graduates the collected offenses to build failures (same ladder as the
|
|
332
|
+
// report classes), and allowSvg: true is the explicit way out.
|
|
333
|
+
if (rawOgImage && /\.svg$/i.test(rawOgImage.split(/[?#]/)[0])
|
|
334
|
+
&& hasPerpageOgImage !== true && !allowSvgAccept) {
|
|
335
|
+
// Loud in every run; under --strict the orchestrator also graduates the
|
|
336
|
+
// collected offenses (injectWarnings) to build failures.
|
|
337
|
+
pageWarn(page, 'og image "' + rawOgImage + '" is an SVG: most social scrapers cannot render it and the share preview blanks (ship a raster image, or set allowSvg: true in the og.image object to accept it)');
|
|
338
|
+
injectWarnings.push('og image "' + rawOgImage + '" is an SVG: most social scrapers cannot render it and the share preview blanks (ship a raster image, or set allowSvg: true in the og.image object to accept it)');
|
|
339
|
+
}
|
|
340
|
+
|
|
142
341
|
// Add cache-busting query param to assets (css, js, images, fonts, ...)
|
|
143
342
|
if (config.cacheBusting?.assetQueryParam !== false) {
|
|
144
343
|
const assetExts = '.css.js.png.jpg.jpeg.gif.svg.webp.ico.woff.woff2.ttf.otf.wasm.json';
|
|
@@ -153,6 +352,16 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
|
153
352
|
});
|
|
154
353
|
}
|
|
155
354
|
|
|
355
|
+
// SearchAction (config.search.url, SEO-gap pass): the seam expands to ''
|
|
356
|
+
// (no potentialAction at all - the hardcoded ?s= default is gone) or to
|
|
357
|
+
// the configured search target. The {search_term_string} placeholder is
|
|
358
|
+
// required by the schema.org contract; config.js already warns loudly at
|
|
359
|
+
// load when it is missing (and the block then never ships).
|
|
360
|
+
const searchActionJson = (config.search?.url && config.search.url.includes('{search_term_string}'))
|
|
361
|
+
? ',\n "potentialAction": { "@type": "SearchAction", "target": "'
|
|
362
|
+
+ escJson(config.search.url) + '", "query-input": "required name=search_term_string" }'
|
|
363
|
+
: '';
|
|
364
|
+
|
|
156
365
|
const replacements = {
|
|
157
366
|
'{{pageTitle}}': escAttr(pageTitle),
|
|
158
367
|
'{{pageDescription}}': escAttr(pageDescription),
|
|
@@ -178,24 +387,95 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
|
178
387
|
? ',\n "founder": { "@type": "Person", "name": "' + escJson(config.author.name) + '"'
|
|
179
388
|
+ (config.author.url ? ', "url": "' + escJson(config.author.url) + '"' : '') + ' }'
|
|
180
389
|
: '',
|
|
390
|
+
// searchActionJson seam (SEO-gap pass): '' or the configured block
|
|
391
|
+
'{{searchActionJson}}': searchActionJson,
|
|
181
392
|
};
|
|
182
393
|
|
|
183
|
-
// Custom JSON-LD hook (config.jsonLd):
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
394
|
+
// Custom JSON-LD hook (config.jsonLd, issue #25): three accepted shapes -
|
|
395
|
+
// single object, array of objects (legacy, every page), or the scoped
|
|
396
|
+
// map form keyed by page-slug globs ('/' exact, '/blog/*' one segment,
|
|
397
|
+
// '/guides/**' the tree). The scoped form lands per page: global first,
|
|
398
|
+
// then every matching scoped block in specificity order (exact >
|
|
399
|
+
// literal-heavy > '**'). missing-only is untouched: pages shipping
|
|
400
|
+
// their own JSON-LD get nothing here, scoped or not. All blocks ship
|
|
401
|
+
// through the same serialization ('<' neutralized) as the legacy form.
|
|
187
402
|
let customJsonLd = '';
|
|
188
403
|
if (config.jsonLd !== undefined) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
404
|
+
const jc = config.jsonLd;
|
|
405
|
+
const isScopedMap = !Array.isArray(jc) && jc && typeof jc === 'object'
|
|
406
|
+
&& Object.keys(jc).length > 0
|
|
407
|
+
&& Object.keys(jc).every((k) => /(^\/|^\*$|^\*\*$|\*)/.test(k));
|
|
408
|
+
if (isScopedMap) {
|
|
409
|
+
const keys = Object.keys(jc).sort((a, b) => globSpecificity(b) - globSpecificity(a));
|
|
410
|
+
for (const key of keys) {
|
|
411
|
+
if (!slugMatchesGlob(key, page.slug)) continue;
|
|
412
|
+
const blocks = Array.isArray(jc[key]) ? jc[key] : [jc[key]];
|
|
413
|
+
if (!blocks.length || blocks.some((b) => !b || typeof b !== 'object' || Array.isArray(b))) {
|
|
414
|
+
console.log('[freestruct] config warning: jsonLd "' + key + '" must map to an LD+JSON object or a list of them; block skipped');
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
for (const block of blocks) {
|
|
418
|
+
const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
|
|
419
|
+
customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
|
|
420
|
+
}
|
|
195
421
|
}
|
|
422
|
+
} else {
|
|
423
|
+
const blocks = Array.isArray(jc) ? jc : [jc];
|
|
424
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
425
|
+
const block = blocks[i];
|
|
426
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
427
|
+
console.log('[freestruct] config warning: jsonLd[' + i + '] is not an object; block skipped');
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
|
|
431
|
+
customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Per-page Article/TechArticle LD+JSON (SEO-gap pass, config.article
|
|
437
|
+
// scoped) + auto BreadcrumbList (config.breadcrumb, SEO-gap pass): both
|
|
438
|
+
// auto-emit ONLY for pages that ship no JSON-LD of their own AND get no
|
|
439
|
+
// configured jsonLd block at all (missing-only, and a scoped jsonLd key
|
|
440
|
+
// counts as the consumer's explicit per-page choice - no double block).
|
|
441
|
+
// 404 pages keep the FULL pipeline's per-page tags (issue #11: the 404 is
|
|
442
|
+
// finished like every page), but the generated extras are probe-junk on a
|
|
443
|
+
// noindex page: a breadcrumb trail for a 404, typed article schema, hreflang
|
|
444
|
+
// alternates, the feed alternate and verification metas all say nothing to
|
|
445
|
+
// a crawler that has been told to skip the page. The extras therefore skip
|
|
446
|
+
// 404s AND robots-noindex pages (the same exclusion class sitemap/feed/
|
|
447
|
+
// search/llms already honor); the core injected set stays everywhere.
|
|
448
|
+
const noindexExtrasGate = page.is404 === true || htmlIsNoindex(html);
|
|
449
|
+
// Missing-only class: the page's OWN pre-existing ld+json suppresses the
|
|
450
|
+
// generated extras. Config-level jsonLd is OUR block - it coexists with
|
|
451
|
+
// the generated ones (Pass 26.9: the old gate refused the extras whenever
|
|
452
|
+
// ANY config jsonLd existed, silently killing breadcrumb/article on sites
|
|
453
|
+
// that also ship a global custom block - the docs site itself was bit).
|
|
454
|
+
const pageOwnsJsonLd = hasSeoType(html, 'jsonld');
|
|
455
|
+
if (!noindexExtrasGate && !pageOwnsJsonLd) {
|
|
456
|
+
const articleType = !pageReceivesScopedJsonLd(page, config)
|
|
457
|
+
? articleTypeForPage(page, config)
|
|
458
|
+
: null;
|
|
459
|
+
if (articleType) {
|
|
460
|
+
const block = {
|
|
461
|
+
'@context': 'https://schema.org',
|
|
462
|
+
'@type': articleType,
|
|
463
|
+
headline: pageTitle,
|
|
464
|
+
url: canonicalUrl,
|
|
465
|
+
};
|
|
466
|
+
const artAuthor = overrides?.author || config.author?.name;
|
|
467
|
+
if (artAuthor) block.author = { '@type': 'Person', name: String(artAuthor) };
|
|
468
|
+
if (overrides?.publishedTime) block.datePublished = String(overrides.publishedTime);
|
|
469
|
+
if (pageDescription) block.description = pageDescription;
|
|
470
|
+
if (ogImageFull) block.image = ogImageFull;
|
|
196
471
|
const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
|
|
197
472
|
customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
|
|
198
473
|
}
|
|
474
|
+
const breadcrumbLd = breadcrumbJsonLd(page, config);
|
|
475
|
+
if (breadcrumbLd) {
|
|
476
|
+
const serialized = JSON.stringify(breadcrumbLd).replace(/</g, '\\u003c');
|
|
477
|
+
customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
|
|
478
|
+
}
|
|
199
479
|
}
|
|
200
480
|
|
|
201
481
|
let seo = template;
|
|
@@ -232,6 +512,16 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
|
232
512
|
if (!hasOg) {
|
|
233
513
|
const ogMatch = seo.match(/<meta property="og:[^"]*"[^>]*>/gi);
|
|
234
514
|
if (ogMatch) seoInjection += '\n' + ogMatch.join('\n');
|
|
515
|
+
// issue #20: the structured og.image object form also emits its
|
|
516
|
+
// companions; only when the shipped image IS the config object - a
|
|
517
|
+
// per-page override ships its own image and replaces the set wholesale.
|
|
518
|
+
if (ogImageFull && companionsSource && !hasPerpageOgImage) {
|
|
519
|
+
const companions = [];
|
|
520
|
+
if (companionsSource.width !== undefined && companionsSource.width !== '') companions.push('<meta property="og:image:width" content="' + escAttr(companionsSource.width) + '">');
|
|
521
|
+
if (companionsSource.height !== undefined && companionsSource.height !== '') companions.push('<meta property="og:image:height" content="' + escAttr(companionsSource.height) + '">');
|
|
522
|
+
if (companionsSource.alt !== undefined && companionsSource.alt !== '') companions.push('<meta property="og:image:alt" content="' + escAttr(companionsSource.alt) + '">');
|
|
523
|
+
if (companions.length) seoInjection += '\n' + companions.join('\n');
|
|
524
|
+
}
|
|
235
525
|
// article:* companions for per-page article facts
|
|
236
526
|
const articleTags = [];
|
|
237
527
|
if (overrides?.publishedTime) articleTags.push('<meta property="article:published_time" content="' + escAttr(overrides.publishedTime) + '">');
|
|
@@ -259,11 +549,54 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
|
259
549
|
if (canMatch) seoInjection += '\n' + canMatch[0];
|
|
260
550
|
}
|
|
261
551
|
|
|
552
|
+
// Feed autodiscovery (SEO-gap pass): feed.xml ships with honest dates,
|
|
553
|
+
// but nothing in any head pointed feed readers at it. One line in every
|
|
554
|
+
// injected head - gated on the page not carrying an alternate/rss link of
|
|
555
|
+
// its own, in either attribute order (missing-only, as always).
|
|
556
|
+
if (config.generateFeed !== false && !noindexExtrasGate
|
|
557
|
+
&& !/(rel\s*=\s*["']alternate["'][^>]*type\s*=\s*["']application\/(rss|atom)\+xml["'])/i.test(html)
|
|
558
|
+
&& !/(type\s*=\s*["']application\/(rss|atom)\+xml["'][^>]*rel\s*=\s*["']alternate["'])/i.test(html)) {
|
|
559
|
+
seoInjection += '\n<link rel="alternate" type="application/rss+xml" title="' + escAttr(siteName) + ' feed" href="' + escAttr(pageUrlFor(config, '/feed.xml')) + '">';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Site verification metas (SEO-gap pass, config.verification): the
|
|
563
|
+
// provider -> code map drops straight into site-owned meta tags, with the
|
|
564
|
+
// engine-owned name spellings (search engines match on name, not content).
|
|
565
|
+
const VERIFICATION_META_NAMES = { google: 'google-site-verification', bing: 'msvalidate.01', yandex: 'yandexVerification', pinterest: 'pinterest-site-verification' };
|
|
566
|
+
if (config.verification && !noindexExtrasGate && typeof config.verification === 'object' && !Array.isArray(config.verification)) {
|
|
567
|
+
const verificationMetas = [];
|
|
568
|
+
for (const [provider, code] of Object.entries(config.verification)) {
|
|
569
|
+
const metaName = VERIFICATION_META_NAMES[provider];
|
|
570
|
+
if (!metaName || typeof code !== 'string' || code === '') continue; // config.js warned
|
|
571
|
+
// missing-only, attribute-ORDER tolerant: a page that writes its own
|
|
572
|
+
// verification meta in the other attribute order still suppresses ours
|
|
573
|
+
if (new RegExp('<meta[^>]*name=["\']' + metaName + '["\']', 'i').test(html)) continue;
|
|
574
|
+
verificationMetas.push('<meta name="' + metaName + '" content="' + escAttr(code) + '">');
|
|
575
|
+
}
|
|
576
|
+
if (verificationMetas.length) seoInjection += '\n' + verificationMetas.join('\n');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// hreflang/i18n alternates (SEO-gap pass, config.locales): slug-stem swap
|
|
580
|
+
// alternates for pages inside a locale tree; skipped entirely when the
|
|
581
|
+
// page carries any hreflang of its own (the SSG or the author owns it).
|
|
582
|
+
if (config.locales && !noindexExtrasGate && typeof config.locales === 'object' && !Array.isArray(config.locales)
|
|
583
|
+
&& !/(\shreflang\s*=)/i.test(html)) {
|
|
584
|
+
const alternates = hreflangAlternatesFor(page, config);
|
|
585
|
+
if (alternates) {
|
|
586
|
+
seoInjection += '\n' + alternates
|
|
587
|
+
.map(([lang, url]) => '<link rel="alternate" hreflang="' + escAttr(lang) + '" href="' + escAttr(url) + '">')
|
|
588
|
+
.join('\n');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
262
592
|
const versionTag = '<meta name="freestruct-build" content="' + buildHash + '">';
|
|
263
593
|
// Marker goes FIRST so the entire generated tail (seo block, later snippet
|
|
264
594
|
// and late-pass insertions land downstream of it) is one strippable region
|
|
265
595
|
// for computeBuildHash's stable view (scan.js) across reruns.
|
|
266
|
-
|
|
596
|
+
// Sequence 3, spot 2 (issue #24): seoInjection is config/page-carried, so
|
|
597
|
+
// it rides a FUNCTION replacer - in string form any $& / $1..9 inside the
|
|
598
|
+
// injected head would be treated as replacement syntax and rewrite the page.
|
|
599
|
+
html = html.replace(/<\/head>/i, () => '<!-- freestruct -->\n' + seoInjection + '\n' + versionTag + '\n</head>');
|
|
267
600
|
|
|
268
601
|
// Custom snippet passes: comments stripped, advertised placeholders
|
|
269
602
|
// ({{siteName}}, {{siteUrl}}, {{buildHash}}, ...) substituted - FIX for
|
|
@@ -276,17 +609,19 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
|
276
609
|
return snippet.replace(/<!--[\s\S]*?-->/g, '');
|
|
277
610
|
}
|
|
278
611
|
|
|
279
|
-
//
|
|
612
|
+
// Snippet bodies (issue #24): snippet/config-carried text always rides
|
|
613
|
+
// function replacers - a literal $ sequence in a snippet must print
|
|
614
|
+
// literally, never interfere with the surrounding document.
|
|
280
615
|
const header = readSnippet('inject-header.html');
|
|
281
|
-
if (header) html = html.replace(/<\/head>/i, header + '\n</head>');
|
|
616
|
+
if (header) html = html.replace(/<\/head>/i, () => header + '\n</head>');
|
|
282
617
|
|
|
283
618
|
// Custom body-start injection after <body>
|
|
284
619
|
const bodyStart = readSnippet('inject-body-start.html');
|
|
285
|
-
if (bodyStart) html = html.replace(/<body[^>]*>/i,
|
|
620
|
+
if (bodyStart) html = html.replace(/<body[^>]*>/i, (m) => m + bodyStart);
|
|
286
621
|
|
|
287
622
|
// Custom footer injection before </body>
|
|
288
623
|
const footer = readSnippet('inject-footer.html');
|
|
289
|
-
if (footer) html = html.replace(/<\/body>/i, footer + '\n</body>');
|
|
624
|
+
if (footer) html = html.replace(/<\/body>/i, () => footer + '\n</body>');
|
|
290
625
|
|
|
291
626
|
if (config.minify) html = minifyHtml(html);
|
|
292
627
|
|
package/engine/urls.js
CHANGED
|
@@ -16,7 +16,14 @@ const _norm = s => '/' + String(s).replace(/^\/+/, '');
|
|
|
16
16
|
|
|
17
17
|
export function pageUrlFor(config, slug) {
|
|
18
18
|
const base = canonicalBase(config);
|
|
19
|
-
|
|
19
|
+
// Trailing slashes on a CONSUMER-PROVIDED basePath normalize away
|
|
20
|
+
// (Pass 26.13): '/myproject/' joined onto a lead-slash slug doubled the
|
|
21
|
+
// junction ('/myproject//guide/') through every injected URL class
|
|
22
|
+
// (canonical, sitemap, feed, hreflang). Directory SLUGS keep theirs -
|
|
23
|
+
// a directory page's trailing slash is canonical - only basePath strips.
|
|
24
|
+
const basePath = config.basePath
|
|
25
|
+
? _norm(config.basePath).replace(/\/+$/, '')
|
|
26
|
+
: '';
|
|
20
27
|
const slugPath = _norm(slug);
|
|
21
28
|
|
|
22
29
|
// rel path already carries the deploy directory (e.g. tree nests docs/) -
|
|
@@ -26,3 +33,43 @@ export function pageUrlFor(config, slug) {
|
|
|
26
33
|
}
|
|
27
34
|
return base + basePath + slugPath;
|
|
28
35
|
}
|
|
36
|
+
|
|
37
|
+
// ---------- slug/glob matching (issues #25 + follow-on) ----------
|
|
38
|
+
// The scoping contracts (jsonLd map keys, og.scoped keys) key on page slugs,
|
|
39
|
+
// NOT raw urls - the engine keys pages by slug and never knows a deploy host.
|
|
40
|
+
// '/' exact root
|
|
41
|
+
// '/faq' exact page (trailing slashes normalized away)
|
|
42
|
+
// '/blog/*' exactly one segment under /blog
|
|
43
|
+
// '/blog/**' /blog itself AND anything below it ('**' at the end only)
|
|
44
|
+
// Specificity ranks exact > literal-heavy glob > '**'; callers use it to
|
|
45
|
+
// pick the winning pattern when one value is decided (images).
|
|
46
|
+
|
|
47
|
+
function _forMatch(s) {
|
|
48
|
+
return _norm(s).replace(/\/+$/, '') || '/';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function slugMatchesGlob(pattern, slug) {
|
|
52
|
+
const p = _forMatch(pattern);
|
|
53
|
+
const s = _forMatch(slug);
|
|
54
|
+
// '**' (with or without the root slash) means everything
|
|
55
|
+
if (p === '**' || p === '/**') return true;
|
|
56
|
+
if (!p.includes('*')) return p === s;
|
|
57
|
+
// trailing '**' covers the stem directory itself AND everything below
|
|
58
|
+
if (p.endsWith('/**')) {
|
|
59
|
+
const stem = _forMatch(p.replace(/\/\*\*$/, ''));
|
|
60
|
+
return s === stem || s.startsWith(stem + '/');
|
|
61
|
+
}
|
|
62
|
+
// single '*' (and mid-pattern '**' degrading the same way): one segment
|
|
63
|
+
const re = new RegExp('^' + p
|
|
64
|
+
.split('*').map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
65
|
+
.join('[^/]+') + '$');
|
|
66
|
+
return re.test(s);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Higher number wins: exact match outranks every glob; between globs the
|
|
70
|
+
// one carrying more literal path characters is the more specific contract.
|
|
71
|
+
export function globSpecificity(pattern) {
|
|
72
|
+
const p = _forMatch(pattern);
|
|
73
|
+
if (!p.includes('*')) return 100000 + p.length;
|
|
74
|
+
return p.replace(/\*+/g, '').length;
|
|
75
|
+
}
|
package/engine/xmllike.js
CHANGED
|
@@ -87,7 +87,7 @@ export function generateFeed(pages, config, outputDir) {
|
|
|
87
87
|
const html = fs.readFileSync(page.file, 'utf8');
|
|
88
88
|
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
|
89
89
|
const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
|
|
90
|
-
const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file, '
|
|
90
|
+
const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file).replace(/\.html?$/i, '');
|
|
91
91
|
const desc = descMatch ? descMatch[1] : '';
|
|
92
92
|
const pageUrl = pageUrlFor(config, page.slug);
|
|
93
93
|
const pubDate = page.mtime.toUTCString();
|
package/init/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.1.0
|
|
@@ -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
|
|