freestruct 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -119
- package/engine/_freestruct/inject-brand.html +1 -5
- package/engine/audit.js +487 -0
- package/engine/config.js +208 -1
- package/engine/hooks.js +81 -15
- package/engine/indexnow.js +149 -0
- package/engine/inject.js +303 -22
- package/engine/linkcheck.js +83 -11
- package/engine/llms.js +63 -0
- package/engine/notfound.js +21 -5
- package/engine/ogimage.js +142 -0
- package/engine/scan.js +155 -20
- package/engine/search.js +12 -9
- package/engine/seo.js +682 -71
- package/engine/urls.js +48 -1
- package/engine/xmllike.js +7 -4
- package/init/README.md +6 -20
- package/init/VERSION +1 -1
- package/init/docs/_freestruct/inject-brand.html +1 -5
- package/init/docs/freestruct.config.yml +16 -0
- package/package.json +9 -2
package/engine/notfound.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
|
-
import { injectPage } from './seo.js';
|
|
8
|
+
import { injectPage, escAttr } from './seo.js';
|
|
9
9
|
import { pageUrlFor } from './urls.js';
|
|
10
10
|
import { stripRobotsMeta } from './scan.js';
|
|
11
11
|
|
|
@@ -14,8 +14,12 @@ export function generate404(config, outputDir, buildHash, template, snippetDir)
|
|
|
14
14
|
const file = path.join(outputDir, '404.html');
|
|
15
15
|
// Links follow the pageUrlFor contract (basePath-aware) - raw site.url
|
|
16
16
|
// guesses like '/docs/' point at 404s on sub-path installs (#3 class).
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// The site name and the home URL land in HTML text/attribute slots, so they
|
|
18
|
+
// ride escAttr: a quote-bearing or '<'-bearing site name otherwise broke the
|
|
19
|
+
// <title> and could inject markup into the error page (the Pass 26.21 walk
|
|
20
|
+
// claimed this was escaped - it was not).
|
|
21
|
+
const siteName = escAttr(config.site.name || 'Site');
|
|
22
|
+
const homeUrl = escAttr(pageUrlFor(config, '/'));
|
|
19
23
|
const notFound = `<!DOCTYPE html>
|
|
20
24
|
<html lang="en">
|
|
21
25
|
<head>
|
|
@@ -64,10 +68,22 @@ export function pipeline404(page, context) {
|
|
|
64
68
|
// drop any pre-existing robots meta so ours is the single source of truth
|
|
65
69
|
// (scan.js's stripper exists for exactly this - one regex, one owner)
|
|
66
70
|
html = stripRobotsMeta(html);
|
|
67
|
-
html = html.replace(/<\/head>/i, '<meta name="robots" content="noindex, nofollow">\n</head>');
|
|
68
|
-
fs.writeFileSync(page.file, html);
|
|
69
71
|
page.html = html;
|
|
72
|
+
fs.writeFileSync(page.file, html);
|
|
70
73
|
|
|
71
74
|
injectPage(page, context);
|
|
75
|
+
|
|
76
|
+
// Assert the noindex AFTER injectPage, not before: injectPage's rerun
|
|
77
|
+
// hygiene (stripInjectedRegions) deletes everything from the
|
|
78
|
+
// `<!-- freestruct -->` marker to `</head>`, so a noindex inserted before
|
|
79
|
+
// the marker on run 1 fell INSIDE that span once the marker existed on
|
|
80
|
+
// run 2 and was stripped - a second `freestruct` run silently dropped the
|
|
81
|
+
// 404 noindex and the audit then failed the page. Re-assert it here, past
|
|
82
|
+
// the strip, and it stays put across reruns.
|
|
83
|
+
let out = fs.readFileSync(page.file, 'utf8');
|
|
84
|
+
out = stripRobotsMeta(out);
|
|
85
|
+
out = out.replace(/<\/head>/i, '<meta name="robots" content="noindex, nofollow">\n</head>');
|
|
86
|
+
fs.writeFileSync(page.file, out);
|
|
87
|
+
page.html = out;
|
|
72
88
|
return page;
|
|
73
89
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// engine/ogimage.js - per-page OG image generation (issue #22)
|
|
2
|
+
// Post-render OG-image generation: read each page's title/description (the
|
|
3
|
+
// engine already extracts both), render a branded 1200x630 image per page
|
|
4
|
+
// from a consumer-authored SVG template, write it into the output tree, and
|
|
5
|
+
// hand the per-page source to the SEO pipeline so og/twitter tags wire to
|
|
6
|
+
// it (missing-only intact: pages with their own og:image, or a per-page
|
|
7
|
+
// ogImage override, keep theirs).
|
|
8
|
+
//
|
|
9
|
+
// Rasterization honesty (the pitch's own 'hard part'): PNG cannot be made
|
|
10
|
+
// in pure Node. The rasterizer is the OPTIONAL npm package
|
|
11
|
+
// @resvg/resvg-js - loaded lazily with createRequire and loud-skipped when
|
|
12
|
+
// it is not installed (the run never breaks and never ships a half
|
|
13
|
+
// feature). Pure-Node output shape: SVG rendering is deterministic (same
|
|
14
|
+
// page meta -> same bytes; the build hash stays content-derived). Fonts:
|
|
15
|
+
// system fonts are unreliable in build sandboxes, so ogImageGeneration.font
|
|
16
|
+
// loads a TTF/OTF buffer straight into the rasterizer.
|
|
17
|
+
//
|
|
18
|
+
// Config shape (configDir-relative paths):
|
|
19
|
+
// ogImageGeneration:
|
|
20
|
+
// template: og-template.svg # 1200x630 SVG with {{ogTitle}} /
|
|
21
|
+
// # {{ogDescription}} / {{ogUrl}}
|
|
22
|
+
// font: ./brand/brand-bold.ttf # optional
|
|
23
|
+
// width: 1200 # optional; must be 1200x630 ratio-safe
|
|
24
|
+
// height: 630 # optional
|
|
25
|
+
|
|
26
|
+
import fs from 'fs';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import { createRequire } from 'module';
|
|
29
|
+
import { escXml } from './xmllike.js';
|
|
30
|
+
import { pageUrlFor } from './urls.js';
|
|
31
|
+
|
|
32
|
+
const _require = createRequire(import.meta.url);
|
|
33
|
+
|
|
34
|
+
/** Directory-slug for the image filename: '/' and nested paths flatten. */
|
|
35
|
+
function imageSlug(page) {
|
|
36
|
+
return page.slug.replace(/^\/+|\/+$/g, '').replace(/\//g, '-') || 'index';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Lazily resolve the optional rasterizer CLASS; null when not installed. */
|
|
40
|
+
function resolveRasterizer() {
|
|
41
|
+
try {
|
|
42
|
+
const mod = _require('@resvg/resvg-js');
|
|
43
|
+
// CJS shape: { default?: Resvg, Resvg } ; ESM default handled too.
|
|
44
|
+
const ctor = mod.Resvg || mod.default || mod;
|
|
45
|
+
return (typeof ctor === 'function') ? ctor : null;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate + resolve the generation block. Returns null when it is not
|
|
53
|
+
* configured; every disqualifying shape logs a loud single warning (config
|
|
54
|
+
* diagnostics already handled key-name mistakes).
|
|
55
|
+
*/
|
|
56
|
+
export function resolveOgImageGeneration(config, configDir) {
|
|
57
|
+
const gen = config.ogImageGeneration;
|
|
58
|
+
if (gen === undefined || gen === null || gen === false) return null;
|
|
59
|
+
if (typeof gen !== 'object' || Array.isArray(gen)) {
|
|
60
|
+
console.log('[freestruct] config warning: ogImageGeneration must be a { template, font?, width?, height? } object');
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (typeof gen.template !== 'string' || gen.template === '') {
|
|
64
|
+
console.log('[freestruct] config warning: ogImageGeneration.template missing - no og image generation');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const templatePath = path.resolve(configDir, gen.template);
|
|
68
|
+
if (!fs.existsSync(templatePath)) {
|
|
69
|
+
console.log('[freestruct] config warning: ogImageGeneration.template "'
|
|
70
|
+
+ gen.template + '" not found at ' + templatePath + ' - no og image generation');
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const width = Number(gen.width ?? 1200);
|
|
74
|
+
const height = Number(gen.height ?? 630);
|
|
75
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
76
|
+
console.log('[freestruct] config warning: ogImageGeneration width/height must be positive numbers - no og image generation');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const fontPath = gen.font ? path.resolve(configDir, gen.font) : null;
|
|
80
|
+
if (gen.font && (!fontPath || !fs.existsSync(fontPath))) {
|
|
81
|
+
console.log('[freestruct] config warning: ogImageGeneration.font "'
|
|
82
|
+
+ gen.font + '" not found - the rasterizer will fall back to system fonts (unreliable in build sandboxes)');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
86
|
+
const fontBytes = fontPath ? fs.readFileSync(fontPath) : null;
|
|
87
|
+
if (/<text/i.test(template) && !fontBytes) {
|
|
88
|
+
console.log('[freestruct] ogimage: template carries <text> but no font is configured - add ogImageGeneration.font so build sandboxes without system fonts do not drop titles');
|
|
89
|
+
}
|
|
90
|
+
return { template, templatePath, fontPath, fontBytes, width, height };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render one per-page image and hand back the tag-ready facts.
|
|
95
|
+
* Returns null when the rasterizer is not installed (loud skip by caller)
|
|
96
|
+
* or the SVG renders to no bytes.
|
|
97
|
+
*/
|
|
98
|
+
export function renderPageOgImage(generation, page, config, outputDir) {
|
|
99
|
+
const Resvg = resolveRasterizer();
|
|
100
|
+
if (!Resvg) {
|
|
101
|
+
if (!renderPageOgImage._skippedLoudly) {
|
|
102
|
+
renderPageOgImage._skippedLoudly = true;
|
|
103
|
+
console.log('[freestruct] ogimage: @resvg/resvg-js is not installed - generation skips loudly'
|
|
104
|
+
+ ' (pure-Node cannot rasterize; install it with: npm install --save-dev @resvg/resvg-js)');
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const pageUrl = pageUrlFor(config, page.slug);
|
|
110
|
+
const displayTitle = page.title || page.rel || 'Preview';
|
|
111
|
+
let svg = generation.template
|
|
112
|
+
.split('{{ogTitle}}').join(escXml(displayTitle))
|
|
113
|
+
.split('{{ogDescription}}').join(escXml(page.description || ''))
|
|
114
|
+
.split('{{ogUrl}}').join(escXml(pageUrl));
|
|
115
|
+
|
|
116
|
+
const fontBuffers = generation.fontBytes ? [generation.fontBytes] : undefined;
|
|
117
|
+
let png;
|
|
118
|
+
try {
|
|
119
|
+
const rendered = new Resvg(svg, {
|
|
120
|
+
fitTo: { mode: 'width', value: generation.width },
|
|
121
|
+
font: fontBuffers ? { fontBuffers, loadSystemFonts: false } : { loadSystemFonts: false },
|
|
122
|
+
});
|
|
123
|
+
png = rendered.render().asPng();
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.log('[freestruct] ogimage: page ' + page.rel + ' failed to rasterize: ' + e.message);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const name = imageSlug(page);
|
|
130
|
+
const genDir = path.join(outputDir, 'og');
|
|
131
|
+
if (!fs.existsSync(genDir)) fs.mkdirSync(genDir, { recursive: true });
|
|
132
|
+
const file = path.join(genDir, name + '.png');
|
|
133
|
+
fs.writeFileSync(file, png);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
file,
|
|
137
|
+
src: '/og/' + name + '.png',
|
|
138
|
+
width: generation.width,
|
|
139
|
+
height: generation.height,
|
|
140
|
+
alt: displayTitle + ' - page preview image',
|
|
141
|
+
};
|
|
142
|
+
}
|
package/engine/scan.js
CHANGED
|
@@ -13,31 +13,65 @@ 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);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collapse pages that map to the SAME slug (one URL) to a single entry, so a
|
|
48
|
+
* tree carrying both `a.html` and `a.htm` (or `index.html` and `index.htm` in
|
|
49
|
+
* one directory) does not list one URL twice in sitemap.xml / feed.xml /
|
|
50
|
+
* search.json / llms.txt. Deterministic winner: `.html` over `.htm` (the
|
|
51
|
+
* pretty-URL resolver's own preference), then the lexicographically smaller
|
|
52
|
+
* rel path. Order is preserved by first appearance.
|
|
53
|
+
*/
|
|
54
|
+
export function dedupeBySlug(pages) {
|
|
55
|
+
const bySlug = new Map();
|
|
56
|
+
for (const page of pages) {
|
|
57
|
+
const current = bySlug.get(page.slug);
|
|
58
|
+
if (!current) { bySlug.set(page.slug, page); continue; }
|
|
59
|
+
const better = (a, b) => {
|
|
60
|
+
const aHtml = /\.html$/i.test(a.rel) ? 0 : 1;
|
|
61
|
+
const bHtml = /\.html$/i.test(b.rel) ? 0 : 1;
|
|
62
|
+
if (aHtml !== bHtml) return aHtml < bHtml ? a : b;
|
|
63
|
+
return a.rel <= b.rel ? a : b;
|
|
64
|
+
};
|
|
65
|
+
bySlug.set(page.slug, better(current, page));
|
|
66
|
+
}
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const page of pages) {
|
|
70
|
+
if (seen.has(page.slug)) continue;
|
|
71
|
+
seen.add(page.slug);
|
|
72
|
+
out.push(bySlug.get(page.slug));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
41
75
|
}
|
|
42
76
|
|
|
43
77
|
/** Robots noindex detection on (possibly injected) HTML. */
|
|
@@ -51,43 +85,135 @@ export function stripRobotsMeta(html) {
|
|
|
51
85
|
return html.replace(/<meta[^>]*name=["']robots["'][^>]*>/i, '');
|
|
52
86
|
}
|
|
53
87
|
|
|
54
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Decode the HTML entities an SSG routinely writes into <title>/meta content
|
|
90
|
+
* (& < > " '). Without this, an entity-encoded source fact
|
|
91
|
+
* flowed verbatim into the generated artifacts: the feed re-escaped the
|
|
92
|
+
* already-escaped '&' into '&amp;' (double-encoded text a reader
|
|
93
|
+
* sees as '&'), and search.json/llms.txt carried raw entities into
|
|
94
|
+
* plain-text/JSON contexts that render them literally.
|
|
95
|
+
*/
|
|
96
|
+
// Common named entities (the ones that realistically appear in titles and
|
|
97
|
+
// descriptions) plus numeric forms. A title carrying '’' or '—'
|
|
98
|
+
// was left literal by the old five-entry map, so the feed double-escaped it
|
|
99
|
+
// ('&#8217;', which renders as the literal entity text) and the length
|
|
100
|
+
// graders counted the markup instead of the character.
|
|
101
|
+
const NAMED_ENTITIES = {
|
|
102
|
+
lt: '<', gt: '>', quot: '"', apos: "'", amp: '&',
|
|
103
|
+
nbsp: '\u00a0', mdash: '\u2014', ndash: '\u2013', hellip: '\u2026',
|
|
104
|
+
lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d',
|
|
105
|
+
copy: '\u00a9', reg: '\u00ae', trade: '\u2122', deg: '\u00b0',
|
|
106
|
+
times: '\u00d7', laquo: '\u00ab', raquo: '\u00bb', middot: '\u00b7',
|
|
107
|
+
bull: '\u2022', euro: '\u20ac', pound: '\u00a3', yen: '\u00a5',
|
|
108
|
+
cent: '\u00a2', sect: '\u00a7', para: '\u00b6', dagger: '\u2020',
|
|
109
|
+
plusmn: '\u00b1', frac12: '\u00bd', frac14: '\u00bc', frac34: '\u00be',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function decodeEntities(value) {
|
|
113
|
+
return String(value == null ? '' : value)
|
|
114
|
+
// Numeric forms first: decimal '’' and hex '’'. Doing these
|
|
115
|
+
// before the named pass keeps single-pass semantics ('&#65;' stays
|
|
116
|
+
// the literal 'A' after the amp pass, never '<').
|
|
117
|
+
.replace(/&#x([0-9a-f]+);/gi, (m, hex) => {
|
|
118
|
+
const cp = parseInt(hex, 16);
|
|
119
|
+
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
|
|
120
|
+
})
|
|
121
|
+
.replace(/&#(\d+);/g, (m, dec) => {
|
|
122
|
+
const cp = parseInt(dec, 10);
|
|
123
|
+
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
|
|
124
|
+
})
|
|
125
|
+
.replace(/&([a-z][a-z0-9]*);/gi, (m, name) => {
|
|
126
|
+
const key = name.toLowerCase();
|
|
127
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, key) ? NAMED_ENTITIES[key] : m;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Quoted-attribute reader (same shape audit.js owns): the closing quote must
|
|
132
|
+
// MATCH the opening one, so a value holding the other quote kind (an
|
|
133
|
+
// apostrophe in a description) is read whole instead of truncated at it.
|
|
134
|
+
function attrValue(tag, attr) {
|
|
135
|
+
const quoted = tag.match(new RegExp(`\\b${attr}=(["'])([\\s\\S]*?)\\1`, 'i'));
|
|
136
|
+
if (quoted) return quoted[2];
|
|
137
|
+
const bare = tag.match(new RegExp(`\\b${attr}=([^\\s>]+)`, 'i'));
|
|
138
|
+
return bare ? bare[1] : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extract SEO candidates from raw page HTML. Attribute-ORDER tolerant and
|
|
143
|
+
* quote-agnostic (Pass 26.21): the old fixed `name="title" content="..."`
|
|
144
|
+
* shape missed a reordered or single-quoted meta, so the page silently fell
|
|
145
|
+
* back to the site-level value. Values are entity-decoded so downstream
|
|
146
|
+
* artifacts (feed/search/llms) receive plain text, not raw entities.
|
|
147
|
+
*/
|
|
55
148
|
export function extractTitle(html) {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
149
|
+
const meta = html.match(/<meta\b[^>]*\bname=["']title["'][^>]*>/i);
|
|
150
|
+
if (meta) {
|
|
151
|
+
const v = attrValue(meta[0], 'content');
|
|
152
|
+
if (v) return decodeEntities(v);
|
|
153
|
+
}
|
|
154
|
+
const t = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
155
|
+
return t ? decodeEntities(t[1]).split(' | ')[0] : null;
|
|
60
156
|
}
|
|
61
157
|
|
|
62
158
|
export function extractDescription(html) {
|
|
63
|
-
const
|
|
64
|
-
|
|
159
|
+
const meta = html.match(/<meta\b[^>]*\bname=["']description["'][^>]*>/i);
|
|
160
|
+
if (!meta) return null;
|
|
161
|
+
const v = attrValue(meta[0], 'content');
|
|
162
|
+
return v ? decodeEntities(v) : null;
|
|
65
163
|
}
|
|
66
164
|
|
|
67
165
|
/**
|
|
68
166
|
* Does the page already import SEO of the given type from SSG/frontmatter?
|
|
69
167
|
* Used by the missing-only injection gate ('description' added in Phase 1: a
|
|
70
168
|
* plain <meta name="description"> counts as covered).
|
|
169
|
+
*
|
|
170
|
+
* Every probe is TAG-ANCHORED (Pass 26.12): substring matches fire on the
|
|
171
|
+
* page's own PROSE too - a docs page that quotes 'application/ld+json' in
|
|
172
|
+
* a code span silenced its own structured-data injection, exactly the
|
|
173
|
+
* configuration-reference page. Markup quotes markup: anchors, never
|
|
174
|
+
* discussion.
|
|
71
175
|
*/
|
|
72
176
|
export function hasSeoType(html, type) {
|
|
73
177
|
if (type === 'og') {
|
|
74
|
-
return
|
|
178
|
+
return /<meta[^>]+property=["']og:(title|description)["']/i.test(html);
|
|
75
179
|
}
|
|
76
180
|
if (type === 'twitter') {
|
|
77
|
-
return
|
|
181
|
+
return /<meta[^>]+name=["']twitter:(card|title)["']/i.test(html);
|
|
78
182
|
}
|
|
79
183
|
if (type === 'jsonld') {
|
|
80
|
-
return
|
|
184
|
+
return /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
|
|
81
185
|
}
|
|
82
186
|
if (type === 'canonical') {
|
|
83
|
-
return
|
|
187
|
+
return /<link[^>]+rel=["']canonical["']/i.test(html);
|
|
84
188
|
}
|
|
85
189
|
if (type === 'description') {
|
|
86
|
-
|
|
190
|
+
// Case-insensitive like every branch above: an SSG/author emitting an
|
|
191
|
+
// upper- or mixed-case meta (<META NAME="description" ...>, valid HTML)
|
|
192
|
+
// missed this gate, so freestruct injected a SECOND description and its
|
|
193
|
+
// own audit then failed the page for the duplicate it caused.
|
|
194
|
+
return /<meta[^>]*name=["']description["']/i.test(html);
|
|
87
195
|
}
|
|
88
196
|
return false;
|
|
89
197
|
}
|
|
90
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Visible text of an HTML fragment: script/style bodies and comments drop
|
|
201
|
+
* first, then every tag, whitespace collapses, and entities decode. Shared by
|
|
202
|
+
* the search index and the reading-time estimator - both count page prose, and
|
|
203
|
+
* both were inflated by raw JS/CSS when each stripped tags alone.
|
|
204
|
+
*/
|
|
205
|
+
export function htmlToText(html) {
|
|
206
|
+
return decodeEntities(
|
|
207
|
+
String(html)
|
|
208
|
+
.replace(/<script[\s\S]*?<\/script\s*>/gi, ' ')
|
|
209
|
+
.replace(/<style[\s\S]*?<\/style\s*>/gi, ' ')
|
|
210
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
211
|
+
.replace(/<[^>]+>/g, ' ')
|
|
212
|
+
.replace(/\s+/g, ' ')
|
|
213
|
+
.trim(),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
91
217
|
/**
|
|
92
218
|
* Snapshot every page once, before freestruct writes anything.
|
|
93
219
|
* mtime is captured HERE so sitemap lastmod / RSS pubDate reflect the last
|
|
@@ -141,7 +267,16 @@ export function computeBuildHash(config, pages) {
|
|
|
141
267
|
const stable = (html) => html
|
|
142
268
|
.replace(/<meta[^>]*name="(freestruct-build|last-modified|reading-time|robots)"[^>]*>/gi, '')
|
|
143
269
|
.replace(/<!-- freestruct -->[\s\S]*?(?=<\/head>)/i, '')
|
|
144
|
-
|
|
270
|
+
// Body-start/footer snippets (marker-wrapped by seo.js) are the tool's own
|
|
271
|
+
// output; strip them too or a rerun's body text differs from run 1 and the
|
|
272
|
+
// content-derived hash churns every pass.
|
|
273
|
+
.replace(/<!-- freestruct:snippet -->[\s\S]*?<!-- \/freestruct:snippet -->/gi, '')
|
|
274
|
+
// The tableWrap pass (seo.js addTableWrap) wraps each outermost table in a
|
|
275
|
+
// tool-owned div AFTER the hash is computed, so a rerun's page content
|
|
276
|
+
// carries the wrapper the first run did not - without this strip the
|
|
277
|
+
// content-derived hash churned on every pass for any site with a table.
|
|
278
|
+
.replace(/<div class="fs-table-wrap"[^>]*>\s*(<table[\s\S]*?<\/table>)\s*<\/div>/gi, '$1')
|
|
279
|
+
.replace(/[?&]v=[0-9a-f]{8}/g, '')
|
|
145
280
|
.replace(/ loading="lazy"/g, '');
|
|
146
281
|
for (const page of pages.filter(p => !p.is404).sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))) {
|
|
147
282
|
h.update(page.rel);
|
package/engine/search.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { pageUrlFor } from './urls.js';
|
|
11
|
+
import { extractTitle, extractDescription, htmlToText } from './scan.js';
|
|
11
12
|
|
|
12
13
|
export function generateSearchIndex(pages, config, outputDir) {
|
|
13
14
|
const index = [];
|
|
@@ -19,18 +20,20 @@ export function generateSearchIndex(pages, config, outputDir) {
|
|
|
19
20
|
// would search; raw file content at this point is the injected page).
|
|
20
21
|
const html = fs.readFileSync(page.file, 'utf8');
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Entity-decoded, tag-free extraction (Pass 26.21 / 26.24): search.json is
|
|
24
|
+
// JSON consumed by JS, where '&' renders literally as '&' and a
|
|
25
|
+
// stray '<em>' renders as nothing. htmlToText drops script/style bodies
|
|
26
|
+
// and comments, strips every tag, and decodes entities, so the index
|
|
27
|
+
// carries plain page text (the old tag-only strip shipped raw JS/CSS, and
|
|
28
|
+
// the heading pass returned the h1's inner HTML with its tags).
|
|
29
|
+
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
25
30
|
|
|
26
31
|
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
27
|
-
const content = bodyMatch
|
|
28
|
-
? bodyMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
29
|
-
: '';
|
|
32
|
+
const content = bodyMatch ? htmlToText(bodyMatch[1]).slice(0, 500) : '';
|
|
30
33
|
|
|
31
|
-
const title =
|
|
32
|
-
const description =
|
|
33
|
-
const heading = h1Match ? h1Match[1] : title;
|
|
34
|
+
const title = extractTitle(html) || path.basename(page.file).replace(/\.html?$/i, '');
|
|
35
|
+
const description = extractDescription(html) || '';
|
|
36
|
+
const heading = h1Match ? htmlToText(h1Match[1]) : title;
|
|
34
37
|
const url = pageUrlFor(config, page.slug);
|
|
35
38
|
|
|
36
39
|
index.push({ title, heading, description, content, url });
|