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/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
- else if (item.name.endsWith('.html')) files.push(fp);
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 (p === '/index.html' || p === 'index.html') return '/';
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 p === '404.html' || p.endsWith('/404.html');
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 html.includes('property="og:title"') || html.includes('property="og:description"');
83
+ return /<meta[^>]+property=["']og:(title|description)["']/i.test(html);
75
84
  }
76
85
  if (type === 'twitter') {
77
- return html.includes('name="twitter:card"') || html.includes('name="twitter:title"');
86
+ return /<meta[^>]+name=["']twitter:(card|title)["']/i.test(html);
78
87
  }
79
88
  if (type === 'jsonld') {
80
- return html.includes('application/ld+json');
89
+ return /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
81
90
  }
82
91
  if (type === 'canonical') {
83
- return html.includes('rel="canonical"');
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, '.html');
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 per-page comment wins over config; relative values are
132
- // joined onto the site url, absolute HTTP(S) values pass through as-is.
133
- // No image anywhere: no og:image/twitter:image ship at all (prestruct
134
- // parity - a bare image tag pointing at the site root is a broken card).
135
- const rawOgImage = (overrides?.ogImage !== undefined && overrides.ogImage !== '')
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
- : config.og?.image || '';
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): a single object or an array of
184
- // objects appended after the built-in blocks. Ships through the same
185
- // missing-only gate as the built-ins, and '<' is neutralized so a value
186
- // cannot close the script tag early (same posture as the XML escape-or-fail).
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 blocks = Array.isArray(config.jsonLd) ? config.jsonLd : [config.jsonLd];
190
- for (let i = 0; i < blocks.length; i++) {
191
- const block = blocks[i];
192
- if (!block || typeof block !== 'object' || Array.isArray(block)) {
193
- console.log('[freestruct] config warning: jsonLd[' + i + '] is not an object; block skipped');
194
- continue;
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
- html = html.replace(/<\/head>/i, '<!-- freestruct -->\n' + seoInjection + '\n' + versionTag + '\n</head>');
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
- // Custom header injection before </head>
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, '$&' + bodyStart);
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
- const basePath = config.basePath ? _norm(config.basePath) : '';
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, '.html');
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
- 0.9.0
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