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.
@@ -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
- const siteName = config.site.name || 'Site';
18
- const homeUrl = pageUrlFor(config, '/');
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
- 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);
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
- /** Extract SEO candidates from raw page HTML. (kept from 0.3.x semantics) */
88
+ /**
89
+ * Decode the HTML entities an SSG routinely writes into <title>/meta content
90
+ * (&amp; &lt; &gt; &quot; &#39;). Without this, an entity-encoded source fact
91
+ * flowed verbatim into the generated artifacts: the feed re-escaped the
92
+ * already-escaped '&amp;' into '&amp;amp;' (double-encoded text a reader
93
+ * sees as '&amp;'), 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 '&#8217;' or '&mdash;'
98
+ // was left literal by the old five-entry map, so the feed double-escaped it
99
+ // ('&amp;#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 '&#8217;' and hex '&#x2019;'. Doing these
115
+ // before the named pass keeps single-pass semantics ('&amp;#65;' stays
116
+ // the literal '&#65;' 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 m = html.match(/<meta name="title" content="([^"]+)"/i);
57
- if (m) return m[1];
58
- const t = html.match(/<title>([^<]+)<\/title>/i);
59
- return t ? t[1].split(' | ')[0] : null;
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 m = html.match(/<meta name="description" content="([^"]+)"/i);
64
- return m ? m[1] : null;
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 html.includes('property="og:title"') || html.includes('property="og:description"');
178
+ return /<meta[^>]+property=["']og:(title|description)["']/i.test(html);
75
179
  }
76
180
  if (type === 'twitter') {
77
- return html.includes('name="twitter:card"') || html.includes('name="twitter:title"');
181
+ return /<meta[^>]+name=["']twitter:(card|title)["']/i.test(html);
78
182
  }
79
183
  if (type === 'jsonld') {
80
- return html.includes('application/ld+json');
184
+ return /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
81
185
  }
82
186
  if (type === 'canonical') {
83
- return html.includes('rel="canonical"');
187
+ return /<link[^>]+rel=["']canonical["']/i.test(html);
84
188
  }
85
189
  if (type === 'description') {
86
- return /<meta[^>]*name=["']description["']/.test(html);
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
- .replace(/\?v=[0-9a-f]{8}/g, '')
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
- const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
23
- const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
24
- const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
23
+ // Entity-decoded, tag-free extraction (Pass 26.21 / 26.24): search.json is
24
+ // JSON consumed by JS, where '&amp;' renders literally as '&amp;' 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 = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file, '.html');
32
- const description = descMatch ? descMatch[1] : '';
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 });