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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dhaupin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,287 @@
1
- # Temporary Holding Version
1
+ # Freestruct
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The post-build SEO layer for static doc sites.**
4
+
5
+ Build your docs with any SSG - Jekyll, Hugo, Docusaurus, MkDocs, VitePress, Astro, Eleventy.
6
+ Then run Freestruct over the output: complete SEO metadata (title, description, canonical,
7
+ Open Graph, Twitter, JSON-LD), `sitemap.xml` with real per-page `lastmod`, `robots.txt`
8
+ with the right sitemap pointer, an RSS `feed.xml` with honest per-page dates, a `404.html`
9
+ that carries noindex, cache-busted assets, and internal-link validation. One dependency
10
+ (`js-yaml`), no SSG plugin, no template edits.
11
+
12
+ Part of the [Creadev](https://creadev.org) doc-system family - the same docs frame and
13
+ engineering conventions as its siblings (see Project family below).
14
+
15
+ ---
16
+
17
+ ## Quick start
18
+
19
+ Freestruct runs **after** your SSG, straight into the build output.
20
+
21
+ ### 1. Install
22
+
23
+ ```bash
24
+ npm install --save-dev freestruct
25
+ ```
26
+
27
+ ### 2. Create the config
28
+
29
+ Create `docs/freestruct.config.yml` next to your docs folder (the legacy name
30
+ `docs/ssr-config.yml` is still read; discovery walks upward from your cwd):
31
+
32
+ ```yaml
33
+ # docs/freestruct.config.yml
34
+ site:
35
+ name: My Docs
36
+ url: https://example.com/docs
37
+ description: My documentation
38
+ twitter:
39
+ username: "@handle"
40
+ og:
41
+ image: /og-default.png
42
+ ```
43
+
44
+ `site.name` and `site.url` are required; everything else has a working default
45
+ (the full reference is the [Configuration](#configuration) table below, and the
46
+ [config guide](https://freestruct.creadev.org/guides/freestruct-config) goes key by key).
47
+
48
+ ### 3. Build, then inject
49
+
50
+ ```bash
51
+ # your SSG build first
52
+ jekyll build # or hugo, mkdocs, docusaurus build, ...
53
+
54
+ # then freestruct
55
+ npx freestruct # discovers config + output dir automatically
56
+ ```
57
+
58
+ Or wire both into one script:
59
+
60
+ ```json
61
+ // package.json
62
+ {
63
+ "scripts": {
64
+ "build": "jekyll build && freestruct"
65
+ }
66
+ }
67
+ ```
68
+
69
+ ### 4. Verify
70
+
71
+ ```bash
72
+ # The injected pages carry canonical + og meta and a build marker
73
+ grep -l 'rel="canonical"' docs/_site/*.html
74
+
75
+ # Cache-busting marker
76
+ grep -o 'freestruct-build' docs/_site/index.html
77
+
78
+ # Generated artifacts
79
+ ls docs/_site/{sitemap.xml,robots.txt,feed.xml,404.html}
80
+ ```
81
+
82
+ If any of those fail, Freestruct tells you why: config errors, a missing
83
+ template, and an empty output dir each fail loudly with their own exit code
84
+ (`1` config, `2` template, `3` output dir; `--tolerant` downgrades fatals to
85
+ exit 0 for CI tolerance).
86
+
87
+ ### Useful flags and subcommands
88
+
89
+ ```bash
90
+ npx freestruct --version # installed version
91
+ npx freestruct --strict # fail (nonzero) on broken internal links
92
+ # (noindex pages are structurally excluded from
93
+ # artifacts and wire-guarded by the crashtest pack)
94
+ npx freestruct --tolerant # report everything, never fail the CI build
95
+ npx freestruct path/to/output # explicit output dir override
96
+ npx freestruct validate # config-only preflight; exit 1 on fatal errors
97
+ npx freestruct init # scaffold the starter kit into ./docs
98
+ # (npx freestruct init mydir -> mydir/docs;
99
+ # existing files are never clobbered)
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Features
105
+
106
+ ### SEO
107
+
108
+ - Injects missing title, description, canonical, Open Graph, Twitter cards, and
109
+ JSON-LD (missing-only: existing tags are left alone)
110
+ - Attribute-safe everywhere: og/twitter values are HTML-escaped, JSON-LD
111
+ literals are JSON-escaped (a raw `<` cannot close the script tag),
112
+ `og:image`/`twitter:image` ship only when an image is configured, and an
113
+ optional `author` key rides the Organization JSON-LD as a schema.org founder
114
+ - Page-level tuning survives the build: a `<!-- freestruct: {...} -->` comment
115
+ overrides title/description/ogType/ogImage/twitterCard and adds
116
+ `article:published_time`/`author`/`section`; custom `jsonLd` objects append
117
+ structured data
118
+ - Honest canonical URLs including sub-path (`basePath`) installs - the same
119
+ contract holds for the `sitemap.xml` locs, `robots.txt` sitemap pointer, the
120
+ RSS channel/self links, and the generated 404's home link
121
+ - Tagged with a `freestruct-build` marker comment
122
+
123
+ ### Search
124
+
125
+ - **Opt-in** `search.json` (set `searchIndex: true`); default runs generate
126
+ nothing - bring your own indexer (Pagefind/Algolia), the search guide covers
127
+ the wiring
128
+
129
+ ### Sitemap, robots, feed & 404
130
+
131
+ - `sitemap.xml`: encoded URLs, real per-file `lastmod` (page mtime, not build
132
+ time), noindex pages excluded
133
+ - `robots.txt`: `User-agent: *` + `Allow: /` + a basePath-aware sitemap pointer
134
+ - `feed.xml` (RSS): escaped titles/descriptions, per-page `pubDate` from each
135
+ page's own mtime, basePath-aware channel/self links
136
+ - `404.html`: generated when missing, gets the full SEO pipeline + explicit
137
+ noindex; a user-supplied 404 gets the same treatment
138
+
139
+ ### Performance
140
+
141
+ - Cache-busting: asset query params (`?v={hash}`), optional canonical hash
142
+ - Lazy loading on images, reading time + last-modified meta
143
+
144
+ ### DX
145
+
146
+ - Config schema with loud `[freestruct] config warning` diagnostics; unknown
147
+ and retired keys never die silently
148
+ - Link validation (report-only by default, `--strict` to make fatal - our own
149
+ docs CI runs `--strict` and it has already caught three production 404s)
150
+ - Post-render hooks with purge-command safety (disabled by default; see
151
+ [Security](#security))
152
+
153
+ ---
154
+
155
+ ## Configuration
156
+
157
+ Full key list (unknown or retired keys produce warnings, not silence):
158
+
159
+ | Key | Default | Meaning |
160
+ |----------------------------------------|----------------|------------------------------------------------------------------------------------------------------|
161
+ | `site.name` | - (required) | Site name; composes page titles |
162
+ | `site.url` | - (required) | Canonical origin; URLs build from this |
163
+ | `site.description` | - | Fallback description for pages without one |
164
+ | `author` | *(unset)* | Author identity; ships as schema.org founder on the Organization JSON-LD |
165
+ | `jsonLd` | *(unset)* | Custom JSON-LD object(s) appended to the injected structured data |
166
+ | `basePath` | *(unset)* | URL path prefix the output tree is served under; all generated URLs get exactly this prefix |
167
+ | `outputDir` | `docs/_site` | Build output directory (or pass it as a CLI arg) |
168
+ | `generateSitemap` | `true` | Generate `sitemap.xml` |
169
+ | `generateRobots` | `true` | Generate `robots.txt` |
170
+ | `generateFeed` | `true` | Generate `feed.xml` (RSS) |
171
+ | `generate404` | `true` | Generate `404.html` when missing |
172
+ | `readingTime` | `true` | Inject `<meta name="reading-time">` |
173
+ | `lastModified` | `true` | Inject `<meta name="last-modified">` |
174
+ | `lazyLoad` | `true` | Add `loading="lazy"` to images |
175
+ | `linkCheck` | `true` | Validate internal links (report-only; `--strict` makes them fatal) |
176
+ | `searchIndex` | `false` | **Opt-in**: generate `search.json` for client-side search |
177
+ | `minify` | `false` | Experimental HTML minifier (preserves `pre`/`script`/`style`/`textarea`) |
178
+ | `runHooks` | `false` | Gateway: only `true` lets `cacheBusting.purge` commands execute |
179
+ | `cacheBusting.assetQueryParam` | `true` | Append `?v={buildHash}` to linked assets |
180
+ | `cacheBusting.hashInCanonicalUrl` | `false` | Append `?v={buildHash}` to canonical URLs |
181
+ | `cacheBusting.purge` | *(unset)* | Purge hook list (name + command); disabled without `runHooks: true` |
182
+
183
+ The docs ([freestruct.creadev.org](https://freestruct.creadev.org)) carry the
184
+ key-by-key guides: the [config reference](https://freestruct.creadev.org/guides/freestruct-config),
185
+ [cache busting](https://freestruct.creadev.org/guides/cache-busting), [custom
186
+ injection](https://freestruct.creadev.org/guides/custom-injection),
187
+ [CLI reference](https://freestruct.creadev.org/guides/cli),
188
+ [post-render hooks](https://freestruct.creadev.org/guides/post-render-hooks),
189
+ [CI/CD](https://freestruct.creadev.org/guides/cicd) and
190
+ [troubleshooting](https://freestruct.creadev.org/guides/troubleshooting).
191
+
192
+ ---
193
+
194
+ ## Security
195
+
196
+ Purge hooks run shell commands via `execSync`. Enable explicitly in config:
197
+
198
+ ```yaml
199
+ # In freestruct.config.yml
200
+ runHooks: true # must be set to enable
201
+ cacheBusting:
202
+ purge:
203
+ - name: cloudflare
204
+ command: "cf-cli purge $SITE_URL"
205
+ ```
206
+
207
+ Use only trusted commands. Disabled by default. Available variables:
208
+ `$SITE_URL`, `$BUILD_HASH`, `$OUTPUT_DIR`.
209
+
210
+ ---
211
+
212
+ ## Scaffolding + the crashtest pack
213
+
214
+ Two shipped surfaces beyond the engine:
215
+
216
+ - **`init/`** - the consumer scaffold kit: a commented starter config,
217
+ template copies, and a README ("copy + go"). Version-stamped on
218
+ `package.json`; `npx freestruct` resolves templates user-staged first,
219
+ package fallback second.
220
+ - **`packs/`** - Freestruct's [crashtest](https://crashtest.creadev.org) pack
221
+ surface: `freestruct-docs-shapes` attacks a built docs tree the way a
222
+ static host serves it (traversal refusal, 404 noindex, noindex artifact
223
+ leaks, canonical query poisoning, XML shape). See
224
+ [packs/README.md](packs/README.md). The docs CI runs it against every
225
+ Pages build.
226
+
227
+ ---
228
+
229
+ ## Releases
230
+
231
+ - Versions live in one place: `package.json`, pinned in CI against the
232
+ README/CHANGELOG truth. Single source, no drift.
233
+ - `CHANGELOG.md` is the changelog; releases are tagged (`v*`) and
234
+ CHANGELOG-truthful or they don't ship.
235
+ - Current release: **0.9.0** (the owner's version call, 2026-10-22; see
236
+ `labs/TASKS.md` for the version policy).
237
+ - Deployment truth lives in [DEPLOY.md](DEPLOY.md): the GitHub Pages pipeline
238
+ (this repo's own docs site deploys through it) and the npm release runbook.
239
+
240
+ ## v0.9.0
241
+
242
+ This number carries the full rehaul-era stack, accumulated under the 0.6.0
243
+ freeze and released as one truthful number by owner call:
244
+
245
+ - Config renamed to `freestruct.config.yml` with legacy read; schema-checked
246
+ with loud diagnostics; FAST_FAIL exit codes + `--tolerant`
247
+ - Module decomposition: the engine is `engine/` (one orchestrator + eight
248
+ modules) with `bin/` shim and shipped templates; `init/` is the consumer
249
+ scaffold kit; template copies provably in sync via CI
250
+ - `searchIndex` opt-in; noindex honored across sitemap/feed/search/404;
251
+ basePath contract across every generated URL (canonical, sitemap, feed,
252
+ robots pointer, 404 home link)
253
+ - Honest XML: escaped values, per-page `lastmod`, per-page RSS dates;
254
+ escaped-or-fail on un-escapable content
255
+ - Docs: single-source nav, audit gates wired to CI (style, truthfulness,
256
+ English-only language gate, engine comment format), the docs CI itself
257
+ runs `--strict` plus the crashtest pack dogfood
258
+ - Release truth: tarball e2e CI gate + hard publish gate; version pinned
259
+ - crashtest pack surface (`packs/public/freestruct-docs-shapes`) with CI
260
+ teeth on every push and the Pages build
261
+
262
+ ---
263
+
264
+ ## Project family
265
+
266
+ Freestruct is one member of the Creadev doc-system family. The family shares
267
+ conventions and a docs frame, but each project is separate and
268
+ complimentary - freestruct imports nothing from its siblings.
269
+
270
+ - [Freestruct docs](https://freestruct.creadev.org) - the docs site built from
271
+ this repo (`docs/` is itself the worked example: Jekyll + Freestruct + Pagefind)
272
+ - [docs.creadev.org](https://docs.creadev.org) - the Creadev docs system hub
273
+ - [prestruct](https://github.com/dhaupin/prestruct) /
274
+ [prestruct.creadev.org](https://prestruct.creadev.org) - a separate,
275
+ complimentary project: a React SSG for Cloudflare Pages that shares the
276
+ docs frame and conventions (and some gates' patterns) with freestruct, but
277
+ installs, builds and ships nothing together with it
278
+ - [prestruct docs](https://docs.creadev.org/prestruct) - the prestruct docs
279
+ layer on the shared system
280
+ - [crashtest](https://crashtest.creadev.org) - adversarial QA for agent-built
281
+ apps; Freestruct ships and CI-runs its own pack
282
+ - [vant](https://github.com/dhaupin/vant) - the prototype Freestruct was
283
+ extracted from
284
+
285
+ For agents: [AGENTS.md](AGENTS.md) is the orientation file (scope, house
286
+ rules, gates). Humans and agents are welcome equally - see
287
+ [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Thin CLI entry (prestruct's bin/cli.mjs pattern): the engine lives in
3
+ // engine/, this shim only forwards. Kept separate so the packaged runtime
4
+ // surface (exports) never has to depend on where the CLI wires up.
5
+ import { runInject } from '../engine/inject.js';
6
+
7
+ runInject();
@@ -0,0 +1,2 @@
1
+ <!-- Custom body start - injected after <body> -->
2
+ <!-- Placeholders: {{siteName}}, {{siteUrl}} -->
@@ -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,2 @@
1
+ <!-- Custom header - injected before </head> -->
2
+ <!-- Placeholders: {{siteName}}, {{siteUrl}} -->
@@ -0,0 +1,132 @@
1
+ // engine/config.js - config discovery, schema and validation (Phase 2)
2
+ // Discovery order (owner-ratified rename): freestruct.config.yml first, with
3
+ // back-compat reads of the old ssr-config.yml name.
4
+ // Config-first, no magic: unknown flags and retired flags produce loud
5
+ // [freestruct] diagnostics; fatal errors exit (CLI may opt out via --tolerant).
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { load as yamlLoad } from 'js-yaml';
10
+
11
+ export class ConfigError extends Error {}
12
+
13
+ export const CONFIG_NEW = ['freestruct.config.yml'];
14
+ export const CONFIG_LEGACY = ['ssr-config.yml'];
15
+
16
+ // Every flag freestruct understands. Phase 2 contract: documented, schema-
17
+ // checked, warn-and-continue on the unknown; nothing dies silently in config
18
+ // files with 'docs said I could do this'.
19
+ export const KNOWN_CONFIG_KEYS = Object.freeze([
20
+ 'site', 'twitter', 'og', 'author', 'jsonLd', 'basePath', 'outputDir', 'minify', 'runHooks',
21
+ 'generateSitemap', 'generateFeed', 'generate404', 'generateRobots',
22
+ 'readingTime', 'lastModified', 'lazyLoad', 'linkCheck', 'searchIndex',
23
+ 'cacheBusting',
24
+ ]);
25
+
26
+ // Flags that EXISTED and no longer do anything. We warn loudly, never guess.
27
+ export const RETIRED_CONFIG_KEYS = Object.freeze([
28
+ { key: 'preserveExistingMeta', note: 'freestruct only ever injects what is missing (missing-only gate is unconditional); remove this key' },
29
+ { key: 'generateNav', note: 'nav generation lives in your SSG or the docs frame, not freestruct; remove this key' },
30
+ ]);
31
+
32
+ export const RETIRED_CACHE_KEYS = Object.freeze([
33
+ { key: 'hash', note: 'dead since 0.4.x (hash is always generated); remove this key' },
34
+ ]);
35
+
36
+ /** Which top-level and nested keys are known, for diagnostics. */
37
+ const KNOWN_CACHE_KEYS = ['assetQueryParam', 'hashInCanonicalUrl', 'purge'];
38
+
39
+ /** Walk upward from cwd looking for freestruct.config.yml (then ssr-config.yml). */
40
+ export function findConfigPath(cwd = process.cwd()) {
41
+ let dir = path.resolve(cwd);
42
+ for (;;) {
43
+ for (const name of [...CONFIG_NEW, ...CONFIG_LEGACY]) {
44
+ const candidate = path.join(dir, 'docs', name);
45
+ if (fs.existsSync(candidate)) return candidate;
46
+ }
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir) return null;
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ /** Collect human-readable diagnostics (warnings) for a parsed config. */
54
+ export function collectConfigDiagnostics(config, configName) {
55
+ const warnings = [];
56
+ const labels = { others: configName || 'config' };
57
+
58
+ for (const key of Object.keys(config)) {
59
+ if (KNOWN_CONFIG_KEYS.includes(key)) continue;
60
+ const retired = RETIRED_CONFIG_KEYS.find(r => r.key === key);
61
+ if (retired) {
62
+ warnings.push(`retired config key "${key}" has no effect: ${retired.note}`);
63
+ } else {
64
+ warnings.push(`unknown config key "${key}" (typo? see the config reference in README.md)`);
65
+ }
66
+ }
67
+
68
+ if (config.cacheBusting && typeof config.cacheBusting === 'object') {
69
+ for (const key of Object.keys(config.cacheBusting)) {
70
+ if (KNOWN_CACHE_KEYS.includes(key)) continue;
71
+ const retired = RETIRED_CACHE_KEYS.find(r => r.key === key);
72
+ if (retired) {
73
+ warnings.push(`retired cacheBusting.${key} has no effect: ${retired.note}`);
74
+ } else {
75
+ warnings.push(`unknown cacheBusting key "${key}"`);
76
+ }
77
+ }
78
+ }
79
+
80
+ if (config.site) {
81
+ if (config.site.name === undefined) warnings.push('site.name missing - page titles will compose without a site name');
82
+ if (config.site.url === undefined) warnings.push('site.url missing - canonical/sitemap/feed urls will be broken');
83
+ }
84
+
85
+ for (const w of warnings) {
86
+ console.log('[freestruct] config warning: ' + w);
87
+ }
88
+ return warnings;
89
+ }
90
+
91
+ /**
92
+ * Load + validate the config. Throws ConfigError with consumer-facing
93
+ * messages (the CLI prints and exits; --tolerant may downgrade).
94
+ */
95
+ export function loadConfig(cwd = process.cwd()) {
96
+ const configPath = findConfigPath(cwd);
97
+ if (!configPath) {
98
+ throw new ConfigError(`Config not found: docs/freestruct.config.yml (legacy docs/ssr-config.yml also accepted; searched upward from ${cwd})`);
99
+ }
100
+
101
+ let parsed;
102
+ try {
103
+ parsed = yamlLoad(fs.readFileSync(configPath, 'utf8'));
104
+ } catch (e) {
105
+ throw new ConfigError('Config parse error: ' + e.message);
106
+ }
107
+
108
+ if (!parsed || !parsed.site) {
109
+ throw new ConfigError('Invalid config: must have site section');
110
+ }
111
+ if (!parsed.site.url || !parsed.site.name) {
112
+ throw new ConfigError('Invalid config: site.url and site.name are required');
113
+ }
114
+
115
+ // Documented env override (ssr-config.md "Environment Overrides"):
116
+ // FREESTRUCT_URL replaces site.url for staging-style CI runs. Announced,
117
+ // never silent, and it lands before every canonical/sitemap/feed consumer.
118
+ if (process.env.FREESTRUCT_URL) {
119
+ console.log('[freestruct] env override: site.url -> ' + process.env.FREESTRUCT_URL);
120
+ parsed.site.url = process.env.FREESTRUCT_URL;
121
+ }
122
+
123
+ const configName = path.basename(configPath);
124
+ collectConfigDiagnostics(parsed, configName);
125
+
126
+ return {
127
+ config: parsed,
128
+ configPath,
129
+ configName,
130
+ configDir: path.dirname(configPath),
131
+ };
132
+ }
@@ -0,0 +1,45 @@
1
+ // engine/hooks.js - purge hooks (Phase 1.1 split)
2
+ // Same security posture as 0.4.x: hooks run only with runHooks: true.
3
+
4
+ import { execSync } from 'child_process';
5
+
6
+ export function runPurgeHooks(config, buildHash, outputDir) {
7
+ const siteUrl = config.site.url;
8
+ const purgeList = Array.isArray(config.cacheBusting.purge)
9
+ ? config.cacheBusting.purge
10
+ : [config.cacheBusting.purge];
11
+
12
+ for (const purge of purgeList) {
13
+ const name = purge.name || 'unnamed';
14
+ let command = typeof purge === 'string' ? purge : purge.command;
15
+
16
+ if (!command) {
17
+ console.log('Purge hook ' + name + ': no command configured');
18
+ continue;
19
+ }
20
+
21
+ command = command
22
+ .replace(/\$SITE_URL/g, siteUrl)
23
+ .replace(/\$BUILD_HASH/g, buildHash)
24
+ .replace(/\$OUTPUT_DIR/g, outputDir);
25
+
26
+ command = command
27
+ .replace(/\$\{SITE_URL\}/g, siteUrl)
28
+ .replace(/\$\{BUILD_HASH\}/g, buildHash)
29
+ .replace(/\$\{OUTPUT_DIR\}/g, outputDir);
30
+
31
+ console.log('Purge hook ' + name + ': running...');
32
+ try {
33
+ if (!config.runHooks) {
34
+ console.log(' Command: ' + command.substring(0, 100) + '...');
35
+ console.log(' Purge hooks disabled (set runHooks: true to enable)');
36
+ return;
37
+ }
38
+ execSync(command, { stdio: 'inherit', timeout: 30000 });
39
+ console.log(' Command: ' + command.substring(0, 100) + '...');
40
+ console.log('Purge hook ' + name + ': done');
41
+ } catch (e) {
42
+ console.log('Purge hook ' + name + ': error - ' + e.message);
43
+ }
44
+ }
45
+ }