freestruct 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +286 -2
- package/bin/freestruct.mjs +7 -0
- package/engine/_freestruct/inject-body-start.html +2 -0
- package/engine/_freestruct/inject-brand.html +49 -0
- package/engine/_freestruct/inject-footer.html +13 -0
- package/engine/_freestruct/inject-header.html +2 -0
- package/engine/config.js +132 -0
- package/engine/hooks.js +45 -0
- package/engine/inject.js +287 -0
- package/engine/linkcheck.js +49 -0
- package/engine/notfound.js +73 -0
- package/engine/scan.js +151 -0
- package/engine/search.js +41 -0
- package/engine/seo.js +331 -0
- package/engine/urls.js +28 -0
- package/engine/xmllike.js +118 -0
- package/init/README.md +48 -0
- package/init/VERSION +1 -0
- package/init/docs/_freestruct/inject-body-start.html +2 -0
- package/init/docs/_freestruct/inject-brand.html +49 -0
- package/init/docs/_freestruct/inject-footer.html +13 -0
- package/init/docs/_freestruct/inject-header.html +2 -0
- package/init/docs/freestruct.config.yml +14 -0
- package/package.json +56 -4
package/engine/seo.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// engine/seo.js - meta/OG/Twitter/JSON-LD injection + late passes (Phase 1.1)
|
|
2
|
+
// Same observable behavior as 0.4.x with one deliberate fix:
|
|
3
|
+
// - the template's plain <meta name="description"> is now actually injected
|
|
4
|
+
// for pages without one (it was silently dropped in 0.4.x).
|
|
5
|
+
// Late passes (reading-time / last-modified / lazy-load) are exported here and
|
|
6
|
+
// fed by the orchestrator, 404 pages included (PRD 1.4 consistency).
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { hasSeoType } from './scan.js';
|
|
11
|
+
import { pageUrlFor } from './urls.js';
|
|
12
|
+
|
|
13
|
+
function minifyHtml(html) {
|
|
14
|
+
// Conservative HTML minifier - removes unnecessary whitespace
|
|
15
|
+
// Preserves content inside <pre>, <textarea>, <script>, <style> tags
|
|
16
|
+
let inBlock = false;
|
|
17
|
+
let result = '';
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < html.length) {
|
|
20
|
+
if (html.slice(i, i + 5) === '<pre>') inBlock = true;
|
|
21
|
+
else if (html.slice(i, i + 10) === '<textarea') inBlock = true;
|
|
22
|
+
else if (html.slice(i, i + 8) === '<script>') inBlock = true;
|
|
23
|
+
else if (html.slice(i, i + 7) === '<style>') inBlock = true;
|
|
24
|
+
else if (html.slice(i, i + 6) === '</pre>') inBlock = false;
|
|
25
|
+
else if (html.slice(i, i + 11) === '</textarea>') inBlock = false;
|
|
26
|
+
else if (html.slice(i, i + 9) === '</script>') inBlock = false;
|
|
27
|
+
else if (html.slice(i, i + 8) === '</style>') inBlock = false;
|
|
28
|
+
|
|
29
|
+
if (inBlock) result += html[i];
|
|
30
|
+
else if (html[i] === '\n' || html[i] === '\r' || html[i] === '\t') { /* skip */ }
|
|
31
|
+
else result += html[i];
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
.replace(/>\s+</g, '><')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------- injection value escaping ----------
|
|
41
|
+
|
|
42
|
+
// HTML-attribute escaping for config/page values that land inside
|
|
43
|
+
// content="..." or href="...": a raw " in a site description silently
|
|
44
|
+
// truncates every generated tag (ported from prestruct's escapeHtmlAttr).
|
|
45
|
+
// '&' is deliberately NOT escaped: the battery pins the raw ampersand in
|
|
46
|
+
// canonical attributes as valid HTML, and escaping it would double-encode
|
|
47
|
+
// legit URLs that already contain & from the SSG.
|
|
48
|
+
function escAttr(value) {
|
|
49
|
+
return String(value == null ? '' : value)
|
|
50
|
+
.replace(/</g, '<')
|
|
51
|
+
.replace(/>/g, '>')
|
|
52
|
+
.replace(/"/g, '"')
|
|
53
|
+
.replace(/'/g, ''');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// JSON-safe embedding for values that go INSIDE the generated JSON-LD
|
|
57
|
+
// literals (the attribute escaper would put " into invalid JSON).
|
|
58
|
+
// Backslash-escape JSON special characters via JSON.stringify semantics,
|
|
59
|
+
// then render '<' as \u003c so a value can never close the script tag early.
|
|
60
|
+
function escJson(value) {
|
|
61
|
+
return JSON.stringify(String(value == null ? '' : value)).slice(1, -1).replace(/</g, '\\u003c');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------- per-page overrides ----------
|
|
65
|
+
|
|
66
|
+
// Per-page override contract (ssr-config.md "Per-Page Options"): an HTML
|
|
67
|
+
// comment in the built page tunes freestruct's own injected tags:
|
|
68
|
+
// <!-- freestruct: {"title": "My Page", "ogType": "article"} -->
|
|
69
|
+
// Missing-only still wins: a page that already ships its own og/twitter
|
|
70
|
+
// keeps them, and override attempts on that group warn loudly instead of
|
|
71
|
+
// silently doing nothing.
|
|
72
|
+
const PERPAGE_KEYS = Object.freeze([
|
|
73
|
+
'title', 'description', 'ogType', 'ogImage', 'twitterCard',
|
|
74
|
+
'publishedTime', 'author', 'section',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
function pageWarn(page, message) {
|
|
78
|
+
console.log('[freestruct] page warning: ' + page.rel + ': ' + message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readPageOverrides(html, page) {
|
|
82
|
+
const comment = /<!--\s*freestruct:([\s\S]*?)-->/.exec(html);
|
|
83
|
+
if (!comment) return null;
|
|
84
|
+
const body = comment[1].slice(comment[1].indexOf('{'), comment[1].lastIndexOf('}') + 1);
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(body);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
pageWarn(page, 'malformed JSON in the freestruct per-page comment (' + e.message + '); ignoring it');
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(parsed) || !parsed || typeof parsed !== 'object') {
|
|
93
|
+
pageWarn(page, 'freestruct per-page comment must be a flat {...} object; ignoring it');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const unknown = Object.keys(parsed).filter(k => !PERPAGE_KEYS.includes(k));
|
|
97
|
+
if (unknown.length > 0) {
|
|
98
|
+
pageWarn(page, 'unknown per-page key(s) "' + unknown.join('", "') + '" ignored (known: ' + PERPAGE_KEYS.join(', ') + ')');
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inject the SEO pipeline into one page (written to disk).
|
|
105
|
+
* Missing-only semantics against the PRE-INJECTION snapshot (page.html):
|
|
106
|
+
* pages that ship their own OG/Twitter/JSON-LD/canonical/description keep them.
|
|
107
|
+
* Page-level freestruct comments refine the injected tags (see PERPAGE_KEYS).
|
|
108
|
+
*/
|
|
109
|
+
export function injectPage(page, { config, template, buildHash, snippetDir }) {
|
|
110
|
+
let html = page.html;
|
|
111
|
+
const siteName = config.site.name;
|
|
112
|
+
const siteUrl = config.site.url;
|
|
113
|
+
const overrides = readPageOverrides(html, page);
|
|
114
|
+
|
|
115
|
+
let pageTitle = (page.title || siteName) + ' | ' + siteName;
|
|
116
|
+
let pageDescription = (page.description !== undefined && page.description !== null)
|
|
117
|
+
? page.description
|
|
118
|
+
: config.site.description;
|
|
119
|
+
let ogType = config.og?.type || 'website';
|
|
120
|
+
let twitterCard = config.twitter?.card || 'summary';
|
|
121
|
+
if (overrides) {
|
|
122
|
+
if (overrides.title !== undefined && overrides.title !== '') pageTitle = String(overrides.title) + ' | ' + siteName;
|
|
123
|
+
if (overrides.description !== undefined) pageDescription = String(overrides.description);
|
|
124
|
+
if (overrides.ogType) ogType = String(overrides.ogType);
|
|
125
|
+
if (overrides.twitterCard) twitterCard = String(overrides.twitterCard);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const canonicalUrl = pageUrlFor(config, page.slug)
|
|
129
|
+
+ (config.cacheBusting?.hashInCanonicalUrl === true ? '?v=' + buildHash : '');
|
|
130
|
+
|
|
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 !== '')
|
|
136
|
+
? String(overrides.ogImage)
|
|
137
|
+
: config.og?.image || '';
|
|
138
|
+
const ogImageFull = rawOgImage && !/^(https?:)?\/\//i.test(rawOgImage)
|
|
139
|
+
? siteUrl + rawOgImage
|
|
140
|
+
: rawOgImage;
|
|
141
|
+
|
|
142
|
+
// Add cache-busting query param to assets (css, js, images, fonts, ...)
|
|
143
|
+
if (config.cacheBusting?.assetQueryParam !== false) {
|
|
144
|
+
const assetExts = '.css.js.png.jpg.jpeg.gif.svg.webp.ico.woff.woff2.ttf.otf.wasm.json';
|
|
145
|
+
html = html.replace(/(href|src)=\"([^\"]+)\"/gi,
|
|
146
|
+
(match, attr, url) => {
|
|
147
|
+
const cleanUrl = url.replace(/\?v=[^"]*/, '');
|
|
148
|
+
const ext = cleanUrl.substring(cleanUrl.lastIndexOf('.'));
|
|
149
|
+
if (assetExts.includes(ext)) {
|
|
150
|
+
return attr + '=\"' + cleanUrl + '?v=' + buildHash + '\"';
|
|
151
|
+
}
|
|
152
|
+
return match;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const replacements = {
|
|
157
|
+
'{{pageTitle}}': escAttr(pageTitle),
|
|
158
|
+
'{{pageDescription}}': escAttr(pageDescription),
|
|
159
|
+
'{{pageUrl}}': escAttr(canonicalUrl),
|
|
160
|
+
'{{canonicalUrl}}': escAttr(canonicalUrl),
|
|
161
|
+
'{{siteUrl}}': escAttr(siteUrl),
|
|
162
|
+
'{{siteName}}': escAttr(siteName),
|
|
163
|
+
'{{siteDescription}}': escAttr(config.site.description),
|
|
164
|
+
'{{buildHash}}': buildHash,
|
|
165
|
+
'{{twitterUsername}}': escAttr(config.twitter?.username || ''),
|
|
166
|
+
'{{twitterCard}}': escAttr(twitterCard),
|
|
167
|
+
'{{ogImage}}': escAttr(rawOgImage),
|
|
168
|
+
'{{ogImageFull}}': escAttr(ogImageFull),
|
|
169
|
+
'{{ogType}}': escAttr(ogType),
|
|
170
|
+
'{{ogLocale}}': escAttr(config.og?.locale || 'en_US'),
|
|
171
|
+
// JSON-LD internals: JSON-escaped, not attribute-escaped (see escJson)
|
|
172
|
+
'{{siteNameJson}}': escJson(siteName),
|
|
173
|
+
'{{siteUrlJson}}': escJson(siteUrl),
|
|
174
|
+
'{{siteDescriptionJson}}': escJson(config.site.description),
|
|
175
|
+
// Conditional schema.org founder for the Organization block (config.author);
|
|
176
|
+
// expands to '' or a comma-led "founder": {...} fragment.
|
|
177
|
+
'{{authorJson}}': escJson(config.author?.name)
|
|
178
|
+
? ',\n "founder": { "@type": "Person", "name": "' + escJson(config.author.name) + '"'
|
|
179
|
+
+ (config.author.url ? ', "url": "' + escJson(config.author.url) + '"' : '') + ' }'
|
|
180
|
+
: '',
|
|
181
|
+
};
|
|
182
|
+
|
|
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).
|
|
187
|
+
let customJsonLd = '';
|
|
188
|
+
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;
|
|
195
|
+
}
|
|
196
|
+
const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
|
|
197
|
+
customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let seo = template;
|
|
202
|
+
for (const [k, v] of Object.entries(replacements)) seo = seo.split(k).join(v);
|
|
203
|
+
if (customJsonLd) seo += customJsonLd;
|
|
204
|
+
// image tags ship only when an og image exists (config-level or per-page)
|
|
205
|
+
if (!ogImageFull) {
|
|
206
|
+
seo = seo.replace(/^[^\n]*<meta (property="og:image"|name="twitter:image")[^\n]*>\n?/gim, '');
|
|
207
|
+
}
|
|
208
|
+
// Twitter identity tags ship only when a handle is configured: without this
|
|
209
|
+
// filter the template's {{twitterUsername}} resolves to '' and every page
|
|
210
|
+
// gets <meta name="twitter:site" content="">. No handle -> no tags; card
|
|
211
|
+
// type and twitter:title still inject.
|
|
212
|
+
if (!config.twitter?.username) {
|
|
213
|
+
seo = seo.replace(/^[^\n]*<meta name="twitter:(site|creator)"[^\n]*>\n?/gim, '');
|
|
214
|
+
}
|
|
215
|
+
seo = seo.replace(/<!--[\s\S]*?-->/g, '');
|
|
216
|
+
|
|
217
|
+
// Remove existing freestruct-build tag (always refresh)
|
|
218
|
+
html = html.replace(/<meta[^>]*name="freestruct-build"[^>]*>/gi, '');
|
|
219
|
+
|
|
220
|
+
// Only inject SEO if page doesn't have it from SSG/frontmatter
|
|
221
|
+
const hasOg = hasSeoType(html, 'og');
|
|
222
|
+
const hasTwitter = hasSeoType(html, 'twitter');
|
|
223
|
+
const hasJsonLd = hasSeoType(html, 'jsonld');
|
|
224
|
+
const hasCanonical = hasSeoType(html, 'canonical');
|
|
225
|
+
|
|
226
|
+
let seoInjection = '';
|
|
227
|
+
// FIX (was 0.4.x wart): plain description meta now really reaches bare pages
|
|
228
|
+
if (!hasSeoType(html, 'description')) {
|
|
229
|
+
const descMatch = seo.match(/<meta name="description"[^>]*>/i);
|
|
230
|
+
if (descMatch) seoInjection += '\n' + descMatch[0];
|
|
231
|
+
}
|
|
232
|
+
if (!hasOg) {
|
|
233
|
+
const ogMatch = seo.match(/<meta property="og:[^"]*"[^>]*>/gi);
|
|
234
|
+
if (ogMatch) seoInjection += '\n' + ogMatch.join('\n');
|
|
235
|
+
// article:* companions for per-page article facts
|
|
236
|
+
const articleTags = [];
|
|
237
|
+
if (overrides?.publishedTime) articleTags.push('<meta property="article:published_time" content="' + escAttr(overrides.publishedTime) + '">');
|
|
238
|
+
if (overrides?.author) articleTags.push('<meta property="article:author" content="' + escAttr(overrides.author) + '">');
|
|
239
|
+
if (overrides?.section) articleTags.push('<meta property="article:section" content="' + escAttr(overrides.section) + '">');
|
|
240
|
+
if (articleTags.length) seoInjection += '\n' + articleTags.join('\n');
|
|
241
|
+
} else if (overrides && [overrides.ogType, overrides.ogImage, overrides.publishedTime, overrides.author, overrides.section].some(v => v !== undefined)) {
|
|
242
|
+
pageWarn(page, 'page ships its own Open Graph tags; og/author per-page overrides were not applied');
|
|
243
|
+
}
|
|
244
|
+
if (!hasTwitter) {
|
|
245
|
+
const twMatch = seo.match(/<meta name="twitter:[^"]*"[^>]*>/gi);
|
|
246
|
+
if (twMatch) seoInjection += '\n' + twMatch.join('\n');
|
|
247
|
+
} else if (overrides && overrides.twitterCard !== undefined) {
|
|
248
|
+
pageWarn(page, 'page ships its own Twitter tags; the twitterCard override was not applied');
|
|
249
|
+
}
|
|
250
|
+
if (overrides && overrides.description !== undefined && hasSeoType(html, 'description')) {
|
|
251
|
+
pageWarn(page, 'page has its own description meta; the description override was not applied');
|
|
252
|
+
}
|
|
253
|
+
if (!hasJsonLd) {
|
|
254
|
+
const ldMatch = seo.match(/<script type="application\/ld\+json">[\s\S]*?<\/script>/gi);
|
|
255
|
+
if (ldMatch) seoInjection += '\n' + ldMatch.join('\n');
|
|
256
|
+
}
|
|
257
|
+
if (!hasCanonical) {
|
|
258
|
+
const canMatch = seo.match(/<link rel="canonical"[^>]*>/);
|
|
259
|
+
if (canMatch) seoInjection += '\n' + canMatch[0];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const versionTag = '<meta name="freestruct-build" content="' + buildHash + '">';
|
|
263
|
+
// Marker goes FIRST so the entire generated tail (seo block, later snippet
|
|
264
|
+
// and late-pass insertions land downstream of it) is one strippable region
|
|
265
|
+
// for computeBuildHash's stable view (scan.js) across reruns.
|
|
266
|
+
html = html.replace(/<\/head>/i, '<!-- freestruct -->\n' + seoInjection + '\n' + versionTag + '\n</head>');
|
|
267
|
+
|
|
268
|
+
// Custom snippet passes: comments stripped, advertised placeholders
|
|
269
|
+
// ({{siteName}}, {{siteUrl}}, {{buildHash}}, ...) substituted - FIX for
|
|
270
|
+
// issue #10: 0.4.x advertised placeholder substitution but never did it.
|
|
271
|
+
function readSnippet(name) {
|
|
272
|
+
const snippetPath = path.join(snippetDir, name);
|
|
273
|
+
if (!fs.existsSync(snippetPath)) return null;
|
|
274
|
+
let snippet = fs.readFileSync(snippetPath, 'utf8');
|
|
275
|
+
for (const [k, v] of Object.entries(replacements)) snippet = snippet.split(k).join(v);
|
|
276
|
+
return snippet.replace(/<!--[\s\S]*?-->/g, '');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Custom header injection before </head>
|
|
280
|
+
const header = readSnippet('inject-header.html');
|
|
281
|
+
if (header) html = html.replace(/<\/head>/i, header + '\n</head>');
|
|
282
|
+
|
|
283
|
+
// Custom body-start injection after <body>
|
|
284
|
+
const bodyStart = readSnippet('inject-body-start.html');
|
|
285
|
+
if (bodyStart) html = html.replace(/<body[^>]*>/i, '$&' + bodyStart);
|
|
286
|
+
|
|
287
|
+
// Custom footer injection before </body>
|
|
288
|
+
const footer = readSnippet('inject-footer.html');
|
|
289
|
+
if (footer) html = html.replace(/<\/body>/i, footer + '\n</body>');
|
|
290
|
+
|
|
291
|
+
if (config.minify) html = minifyHtml(html);
|
|
292
|
+
|
|
293
|
+
page.html = html;
|
|
294
|
+
fs.writeFileSync(page.file, html);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Reading time (~200 wpm), based on the body word count. */
|
|
298
|
+
export function addReadingTime(filePath) {
|
|
299
|
+
let html = fs.readFileSync(filePath, 'utf8');
|
|
300
|
+
html = html.replace(/<meta[^>]*name="reading-time"[^>]*>/gi, '');
|
|
301
|
+
|
|
302
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
303
|
+
if (!bodyMatch) return;
|
|
304
|
+
|
|
305
|
+
const text = bodyMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
306
|
+
const words = text.split(/\s+/).filter(w => w.length > 0);
|
|
307
|
+
const minutes = Math.max(1, Math.ceil(words.length / 200));
|
|
308
|
+
|
|
309
|
+
html = html.replace(/<\/head>/i, `<meta name="reading-time" content="${minutes} min read">\n</head>`);
|
|
310
|
+
fs.writeFileSync(filePath, html);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Last-modified ISO timestamp meta. */
|
|
314
|
+
export function addLastModified(filePath) {
|
|
315
|
+
let html = fs.readFileSync(filePath, 'utf8');
|
|
316
|
+
html = html.replace(/<meta[^>]*name="last-modified"[^>]*>/gi, '');
|
|
317
|
+
|
|
318
|
+
const now = new Date().toISOString();
|
|
319
|
+
html = html.replace(/<\/head>/i, `<meta name="last-modified" content="${now}">\n</head>`);
|
|
320
|
+
fs.writeFileSync(filePath, html);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Add loading="lazy" to images without it. */
|
|
324
|
+
export function addLazyLoading(filePath) {
|
|
325
|
+
let html = fs.readFileSync(filePath, 'utf8');
|
|
326
|
+
html = html.replace(/(<img[^>]*(?<!loading)=)[^>]*>/gi, (match) => {
|
|
327
|
+
if (match.includes('loading=')) return match;
|
|
328
|
+
return match.replace(/>$/, ' loading="lazy">');
|
|
329
|
+
});
|
|
330
|
+
fs.writeFileSync(filePath, html);
|
|
331
|
+
}
|
package/engine/urls.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// engine/urls.js - URL construction, basePath semantics (Phase 2)
|
|
2
|
+
// FIX (freestruct issue #3, crashtest-class): 0.4.x stripped basePath from the
|
|
3
|
+
// relative path and concatenated whatever site.url said, silently mapping
|
|
4
|
+
// sub-path installs onto the wrong URLs (404s). The contract now:
|
|
5
|
+
// canonical = site.url + basePath + relSlug
|
|
6
|
+
// - basePath unset: canonical = site.url + relSlug (output tree root == site.url)
|
|
7
|
+
// - basePath set: it is the URL path prefix the output tree is served under.
|
|
8
|
+
// If the tree ALSO nests that directory, the nested pages win (no doubling).
|
|
9
|
+
|
|
10
|
+
export function canonicalBase(config) {
|
|
11
|
+
return config.site.url.replace(/\/$/, '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// lead-slash only: directory slugs keep their trailing '/' (it is canonical)
|
|
15
|
+
const _norm = s => '/' + String(s).replace(/^\/+/, '');
|
|
16
|
+
|
|
17
|
+
export function pageUrlFor(config, slug) {
|
|
18
|
+
const base = canonicalBase(config);
|
|
19
|
+
const basePath = config.basePath ? _norm(config.basePath) : '';
|
|
20
|
+
const slugPath = _norm(slug);
|
|
21
|
+
|
|
22
|
+
// rel path already carries the deploy directory (e.g. tree nests docs/) -
|
|
23
|
+
// trust the tree, never double the prefix
|
|
24
|
+
if (basePath && (slugPath + '/').startsWith(basePath + '/')) {
|
|
25
|
+
return base + slugPath;
|
|
26
|
+
}
|
|
27
|
+
return base + basePath + slugPath;
|
|
28
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// engine/xmllike.js - escaping + sitemap + robots + RSS (Phase 1.3)
|
|
2
|
+
// THE XML CORRECTNESS CLASS FIX (freestruct issue #9):
|
|
3
|
+
// - every generated XML value goes through escXml
|
|
4
|
+
// - sitemap gains a real <lastmod> per page (pre-injection mtime, i.e. the
|
|
5
|
+
// date the page content last actually changed, not the build time)
|
|
6
|
+
// - RSS pubDate comes from each page's own mtime, not one shared build stamp
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { pageUrlFor } from './urls.js';
|
|
11
|
+
|
|
12
|
+
/** Escape a value for XML text/attribute contexts. */
|
|
13
|
+
export function escXml(value) {
|
|
14
|
+
return String(value)
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Loud-fail contract (PRD: a value that still produces invalid XML is a build
|
|
23
|
+
* error, not a bad sitemap): escaping fixables &, <, >, "; characters that
|
|
24
|
+
* cannot appear in XML at all (control chars) abort the build loudly.
|
|
25
|
+
*/
|
|
26
|
+
export function assertXmlSafe(value, field) {
|
|
27
|
+
const text = String(value);
|
|
28
|
+
const bad = text.match(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/);
|
|
29
|
+
if (bad) {
|
|
30
|
+
throw new Error('XML safety: value for ' + field + ' contains control character U+'
|
|
31
|
+
+ bad[0].charCodeAt(0).toString(16).padStart(4, '0') + ' which cannot be escaped - refusing to write invalid XML');
|
|
32
|
+
}
|
|
33
|
+
return escXml(text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** W3C date (YYYY-MM-DD) from a page's own pre-injection mtime. */
|
|
37
|
+
function lastmodFor(page) {
|
|
38
|
+
return page.mtime.toISOString().slice(0, 10);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sitemap with real per-page lastmod. Noindex pages are filtered upstream
|
|
43
|
+
* (owner decision: noindex honored by default, freestruct issue #5).
|
|
44
|
+
*/
|
|
45
|
+
export function generateSitemap(pages, config, outputDir) {
|
|
46
|
+
let urls = '';
|
|
47
|
+
for (const page of pages) {
|
|
48
|
+
urls += ' <url>\n'
|
|
49
|
+
+ ' <loc>' + assertXmlSafe(pageUrlFor(config, page.slug), 'sitemap loc') + '</loc>\n'
|
|
50
|
+
+ ' <lastmod>' + assertXmlSafe(lastmodFor(page), 'sitemap lastmod') + '</lastmod>\n'
|
|
51
|
+
+ ' <changefreq>weekly</changefreq>\n'
|
|
52
|
+
+ ' </url>\n';
|
|
53
|
+
}
|
|
54
|
+
const sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
55
|
+
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
|
56
|
+
+ urls
|
|
57
|
+
+ '</urlset>';
|
|
58
|
+
fs.writeFileSync(path.join(outputDir, 'sitemap.xml'), sitemap);
|
|
59
|
+
console.log('sitemap.xml generated');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* robots.txt with a sitemap pointer. The pointer is basePath-aware
|
|
64
|
+
* ('sitemap' artifacts follow the pageUrlFor contract - a raw site.url +
|
|
65
|
+
* '/sitemap.xml' points at a 404 on sub-path installs, the #3 bug class).
|
|
66
|
+
*/
|
|
67
|
+
export function generateRobots(config, outputDir) {
|
|
68
|
+
const sitemapUrl = pageUrlFor(config, 'sitemap.xml');
|
|
69
|
+
const robots = '# Robots.txt\nUser-agent: *\nAllow: /\n\nSitemap: ' + sitemapUrl + '\n';
|
|
70
|
+
fs.writeFileSync(path.join(outputDir, 'robots.txt'), robots);
|
|
71
|
+
console.log('robots.txt generated');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* RSS with honest per-page pubDate (the date the page last changed, per its
|
|
76
|
+
* pre-injection mtime). channel.lastBuildDate stays the run stamp.
|
|
77
|
+
*/
|
|
78
|
+
export function generateFeed(pages, config, outputDir) {
|
|
79
|
+
const siteUrl = pageUrlFor(config, '/');
|
|
80
|
+
const siteName = config.site.name || 'Site';
|
|
81
|
+
const description = config.site.description || 'RSS Feed';
|
|
82
|
+
const feedUrl = pageUrlFor(config, 'feed.xml');
|
|
83
|
+
const now = new Date().toUTCString();
|
|
84
|
+
|
|
85
|
+
let items = '';
|
|
86
|
+
for (const page of pages) {
|
|
87
|
+
const html = fs.readFileSync(page.file, 'utf8');
|
|
88
|
+
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
|
89
|
+
const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
|
|
90
|
+
const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file, '.html');
|
|
91
|
+
const desc = descMatch ? descMatch[1] : '';
|
|
92
|
+
const pageUrl = pageUrlFor(config, page.slug);
|
|
93
|
+
const pubDate = page.mtime.toUTCString();
|
|
94
|
+
items += '\n<item>\n'
|
|
95
|
+
+ ' <title>' + assertXmlSafe(title, 'feed title') + '</title>\n'
|
|
96
|
+
+ ' <link>' + assertXmlSafe(pageUrl, 'feed link') + '</link>\n'
|
|
97
|
+
+ ' <description>' + assertXmlSafe(desc, 'feed description') + '</description>\n'
|
|
98
|
+
+ ' <pubDate>' + assertXmlSafe(pubDate, 'feed pubDate') + '</pubDate>\n'
|
|
99
|
+
+ ' <guid>' + assertXmlSafe(pageUrl, 'feed guid') + '</guid>\n'
|
|
100
|
+
+ '</item>';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const feed = `<?xml version="1.0" encoding="UTF-8"?>\n`
|
|
104
|
+
+ `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n`
|
|
105
|
+
+ `<channel>\n`
|
|
106
|
+
+ ` <title>${assertXmlSafe(siteName, 'feed channel title')}</title>\n`
|
|
107
|
+
+ ` <link>${assertXmlSafe(siteUrl, 'feed channel link')}</link>\n`
|
|
108
|
+
+ ` <description>${assertXmlSafe(description, 'feed channel description')}</description>\n`
|
|
109
|
+
+ ` <language>en-us</language>\n`
|
|
110
|
+
+ ` <lastBuildDate>${assertXmlSafe(now, 'feed lastBuildDate')}</lastBuildDate>\n`
|
|
111
|
+
+ ` <atom:link href="${assertXmlSafe(feedUrl, 'feed atom link')}" rel="self" type="application/rss+xml"/>\n`
|
|
112
|
+
+ `${items}\n`
|
|
113
|
+
+ `</channel>\n`
|
|
114
|
+
+ `</rss>`;
|
|
115
|
+
|
|
116
|
+
fs.writeFileSync(path.join(outputDir, 'feed.xml'), feed);
|
|
117
|
+
console.log('feed.xml generated');
|
|
118
|
+
}
|
package/init/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Freestruct init kit
|
|
2
|
+
|
|
3
|
+
This is the consumer scaffold kit (prestruct's `init/` pattern, sized to
|
|
4
|
+
Freestruct): the minimum a brand-new project copies in to be Freestruct-ready
|
|
5
|
+
in minutes. Freestruct runs AFTER your static site generator builds, so the
|
|
6
|
+
kit is deliberately SSG-agnostic - your Jekyll/Hugo/Docusaurus/MkDocs setup
|
|
7
|
+
stays untouched.
|
|
8
|
+
|
|
9
|
+
## What's in the kit
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
init/
|
|
13
|
+
VERSION - the freestruct release this kit is stamped with
|
|
14
|
+
README.md - this file
|
|
15
|
+
docs/
|
|
16
|
+
freestruct.config.yml - starter config (site.name + site.url required)
|
|
17
|
+
_freestruct/ - template copies (synced from engine/_freestruct)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Start (copy + go)
|
|
21
|
+
|
|
22
|
+
From YOUR project root:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install --save-dev freestruct
|
|
26
|
+
cp -r node_modules/freestruct/init/docs .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then:
|
|
30
|
+
|
|
31
|
+
1. Edit `docs/freestruct.config.yml`: `site.name` and `site.url` are required;
|
|
32
|
+
adjust `site.description` to fit.
|
|
33
|
+
2. Let your SSG build. `npx freestruct` discovers the output dir
|
|
34
|
+
(default `docs/_site`; override via the `outputDir` config key or a
|
|
35
|
+
positional CLI argument).
|
|
36
|
+
3. Verify: `docs/_site` gains `sitemap.xml`, `robots.txt`, `feed.xml` and
|
|
37
|
+
(when missing) a `404.html`; injected pages carry canonical/OG tags and a
|
|
38
|
+
`freestruct-build` marker.
|
|
39
|
+
4. Customize templates only if you need to: the packaged engine templates are
|
|
40
|
+
used automatically when no staged copy exists. To customize, edit
|
|
41
|
+
`docs/_freestruct/` copies (which you just copied in) - a staged template
|
|
42
|
+
always wins over the package-shipped one.
|
|
43
|
+
|
|
44
|
+
## Why a kit (not a lucky guess)
|
|
45
|
+
|
|
46
|
+
Copying this kit means your first run exercises the exact shape the test
|
|
47
|
+
battery and the tarball e2e verify: staged config + staged templates next to
|
|
48
|
+
the docs folder, discovered by walking up from your project root.
|
package/init/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.9.0
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!-- freestruct SEO template - post-build injection -->
|
|
2
|
+
<!-- Placeholders: {{pageTitle}}, {{pageDescription}}, {{pageUrl}}, {{canonicalUrl}} -->
|
|
3
|
+
<!-- Config: {{siteUrl}}, {{siteName}}, {{siteDescription}}, {{twitterUsername}}, {{twitterCard}}, {{ogImage}}, {{ogType}}, {{ogLocale}} -->
|
|
4
|
+
|
|
5
|
+
<meta name="description" content="{{pageDescription}}">
|
|
6
|
+
<link rel="canonical" href="{{canonicalUrl}}">
|
|
7
|
+
|
|
8
|
+
<!-- Open Graph -->
|
|
9
|
+
<meta property="og:title" content="{{pageTitle}}">
|
|
10
|
+
<meta property="og:description" content="{{pageDescription}}">
|
|
11
|
+
<meta property="og:url" content="{{pageUrl}}">
|
|
12
|
+
<meta property="og:site_name" content="{{siteName}}">
|
|
13
|
+
<meta property="og:type" content="{{ogType}}">
|
|
14
|
+
<meta property="og:locale" content="{{ogLocale}}">
|
|
15
|
+
<meta property="og:image" content="{{ogImageFull}}">
|
|
16
|
+
|
|
17
|
+
<!-- Twitter -->
|
|
18
|
+
<meta name="twitter:card" content="{{twitterCard}}">
|
|
19
|
+
<meta name="twitter:title" content="{{pageTitle}}">
|
|
20
|
+
<meta name="twitter:description" content="{{pageDescription}}">
|
|
21
|
+
<meta name="twitter:site" content="{{twitterUsername}}">
|
|
22
|
+
<meta name="twitter:creator" content="{{twitterUsername}}">
|
|
23
|
+
<meta name="twitter:image" content="{{ogImageFull}}">
|
|
24
|
+
|
|
25
|
+
<!-- JSON-LD -->
|
|
26
|
+
<script type="application/ld+json">
|
|
27
|
+
{
|
|
28
|
+
"@context": "https://schema.org",
|
|
29
|
+
"@type": "Organization",
|
|
30
|
+
"name": "{{siteNameJson}}",
|
|
31
|
+
"url": "{{siteUrlJson}}",
|
|
32
|
+
"description": "{{siteDescriptionJson}}"{{authorJson}}
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<script type="application/ld+json">
|
|
37
|
+
{
|
|
38
|
+
"@context": "https://schema.org",
|
|
39
|
+
"@type": "WebSite",
|
|
40
|
+
"name": "{{siteNameJson}}",
|
|
41
|
+
"url": "{{siteUrlJson}}",
|
|
42
|
+
"potentialAction": {
|
|
43
|
+
"@type": "SearchAction",
|
|
44
|
+
"target": "{{siteUrlJson}}/?s={search_term_string}"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<!-- injected by freestruct: https://github.com/dhaupin/freestruct -->
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- Custom footer - injected into every page before </body> -->
|
|
2
|
+
<!-- Placeholders: {{siteName}}, {{siteUrl}}, {{buildHash}} available -->
|
|
3
|
+
|
|
4
|
+
<!-- Example: Analytics -->
|
|
5
|
+
<!-- <script defer src="{{siteUrl}}/js/analytics.js"></script> -->
|
|
6
|
+
|
|
7
|
+
<!-- Example: Community chat -->
|
|
8
|
+
<!-- <script src="https://example.com/chat.js"></script> -->
|
|
9
|
+
|
|
10
|
+
<!-- Example: Custom feedback widget (placeholders: {{siteName}}, {{siteUrl}}, {{buildHash}}) -->
|
|
11
|
+
<!-- <div id="feedback"><a href="{{siteUrl}}/feedback">Feedback</a></div> -->
|
|
12
|
+
|
|
13
|
+
<!-- freestruct footer: https://github.com/dhaupin/freestruct -->
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# freestruct starter config (part of the init kit).
|
|
2
|
+
# Copy this whole docs/ folder into your project, then fill in site.name and
|
|
3
|
+
# site.url - they are required and everything else has a working default.
|
|
4
|
+
|
|
5
|
+
site:
|
|
6
|
+
name: My Docs
|
|
7
|
+
url: https://example.com/docs
|
|
8
|
+
description: My documentation
|
|
9
|
+
|
|
10
|
+
# The rest is optional; see the README Configuration table for the list.
|
|
11
|
+
# basePath: /project-docs # when the output tree is served under a prefix
|
|
12
|
+
# searchIndex: true # opt-in: generate search.json
|
|
13
|
+
# outputDir: _site # your SSG's output folder (relative to docs/)
|
|
14
|
+
# minify: true # experimental HTML minifier
|