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/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
|
-
#
|
|
1
|
+
# Freestruct
|
|
2
2
|
|
|
3
|
-
|
|
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,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 -->
|
package/engine/config.js
ADDED
|
@@ -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
|
+
}
|
package/engine/hooks.js
ADDED
|
@@ -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
|
+
}
|