prestruct 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -46
- package/bin/cli.mjs +218 -10
- package/engine/scripts/audit.js +530 -0
- package/engine/scripts/hooks.js +93 -0
- package/engine/scripts/indexnow.js +139 -0
- package/engine/scripts/inject-brand.js +112 -10
- package/engine/scripts/ogimage.js +165 -0
- package/engine/scripts/prerender.js +632 -104
- package/engine/scripts/validate-config.js +103 -1
- package/engine/src/hooks/usePageMeta.js +11 -2
- package/engine/src/islands.js +23 -4
- package/engine/ssr.config.example.js +196 -0
- package/init/VERSION +1 -1
- package/init/ecosystem.cjs +1 -1
- package/init/package-lock.json +375 -407
- package/init/package.json +14 -9
- package/init/scripts/audit-if-configured.cjs +26 -0
- package/init/scripts/audit.js +530 -0
- package/init/scripts/hooks.js +93 -0
- package/init/scripts/indexnow.js +139 -0
- package/init/scripts/inject-brand.js +112 -10
- package/init/scripts/ogimage.js +165 -0
- package/init/scripts/prerender.js +632 -104
- package/init/scripts/proxy.js +100 -17
- package/init/scripts/proxy.worker.js +150 -33
- package/init/scripts/validate-config.js +103 -1
- package/init/src/App.jsx +7 -1
- package/init/src/AppLayout.jsx +8 -1
- package/init/src/hooks/usePageMeta.js +11 -2
- package/init/src/islands.js +23 -4
- package/init/ssr.config.example.js +45 -0
- package/package.json +24 -10
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const PLATFORMS = ['cloudflare', 'vercel', 'netlify']
|
|
13
|
-
|
|
13
|
+
// The full sitemap-protocol set, not the common four: 'hourly', 'always'
|
|
14
|
+
// and 'never' are legal <changefreq> values, and correctly refusing nothing
|
|
15
|
+
// the protocol accepts is the validator's job (a false "not in" error is
|
|
16
|
+
// just a hard-to-name refusal of a host's honest config).
|
|
17
|
+
const CHANGEFREQS = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
|
|
14
18
|
|
|
15
19
|
// Identity helper: `import { defineConfig } from 'prestruct'` for type-aware
|
|
16
20
|
// ssr.config.js files. No preprocessing -- config is passed through.
|
|
@@ -26,6 +30,60 @@ export function defineConfig(config) {
|
|
|
26
30
|
return config
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
// Known config keys (#52): the load-time diagnostics list. A key that is
|
|
34
|
+
// neither known nor retired does NOTHING in the engine -- a typo'd seam
|
|
35
|
+
// (`ogimg` instead of `ogImage`) must say so loudly instead of passing a
|
|
36
|
+
// green build that silently loses SEO surface.
|
|
37
|
+
export const KNOWN_CONFIG_KEYS = [
|
|
38
|
+
'siteUrl', 'siteName', 'author', 'tagline', 'ogImage', 'keywords',
|
|
39
|
+
'appLayoutPath', 'routes', 'notFound', 'robots', 'proxy', 'faqs',
|
|
40
|
+
'deployId', 'deployIdFooter', 'buildJsonLd', 'fetchRoutes', 'platform',
|
|
41
|
+
// The family ride-along set (2026-10 issue walk): structured-data
|
|
42
|
+
// defaults and per-route nodes, the verification/discovery head set,
|
|
43
|
+
// IndexNow, the postBuild hook seam, and the llms.txt opt-out.
|
|
44
|
+
'jsonLdDefaults',
|
|
45
|
+
'alternates', 'verification', 'feed', 'searchUrl',
|
|
46
|
+
'indexNow', 'hooks', 'runHooks', 'ogImageGeneration',
|
|
47
|
+
'llms',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
// Retired keys: each entry names what owns the behavior now. Empty today --
|
|
51
|
+
// no shipped key has ever been removed without a replacement seam. Entries:
|
|
52
|
+
// { key: string, note: string }
|
|
53
|
+
export const RETIRED_CONFIG_KEYS = []
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collect unknown/retired ssr.config.js keys as warning strings (#52).
|
|
57
|
+
* Diagnostics, not gates: warnings never fail validation.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} config - full ssr.config.js export
|
|
60
|
+
* @returns {string[]} one warning per unknown/retired key
|
|
61
|
+
*/
|
|
62
|
+
export function configKeyWarnings(config) {
|
|
63
|
+
if (!config || typeof config !== 'object') return []
|
|
64
|
+
const warnings = []
|
|
65
|
+
for (const key of Object.keys(config)) {
|
|
66
|
+
if (KNOWN_CONFIG_KEYS.includes(key)) continue
|
|
67
|
+
const retired = RETIRED_CONFIG_KEYS.find(r => r.key === key)
|
|
68
|
+
if (retired) {
|
|
69
|
+
warnings.push(`retired config key "${key}" has no effect: ${retired.note} - remove this key`)
|
|
70
|
+
} else {
|
|
71
|
+
warnings.push(`unknown config key "${key}" (typo? see the config reference)`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return warnings
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format key warnings as printed one-liners (`[prestruct] config warning: ..`).
|
|
79
|
+
*
|
|
80
|
+
* @param {string[]} warnings - raw warning strings from configKeyWarnings()
|
|
81
|
+
* @returns {string[]} prefixed one-liner strings
|
|
82
|
+
*/
|
|
83
|
+
export function formatConfigWarnings(warnings) {
|
|
84
|
+
return warnings.map(w => `[prestruct] config warning: ${w}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
29
87
|
/**
|
|
30
88
|
* Validate the ssr.config.js shape and emit structured diagnostics.
|
|
31
89
|
* Shape checks: siteUrl form, route paths (leading slash, traversal guard),
|
|
@@ -94,6 +152,37 @@ export function validateConfig(config) {
|
|
|
94
152
|
})
|
|
95
153
|
}
|
|
96
154
|
|
|
155
|
+
// ogImage: legacy string shorthand AND the structured object form
|
|
156
|
+
// (freestruct proposal #38 commentary): { url, width, height, alt, type }.
|
|
157
|
+
// The object form is what lets og:image carry its width/height/alt
|
|
158
|
+
// companions -- the properties social crawlers use to size previews.
|
|
159
|
+
// Never throws. Bad shapes surface as errors, not as silently-dropped
|
|
160
|
+
// metadata. Companion content validation lives at the injection seam
|
|
161
|
+
// (non-numeric width/height / non-string alt or type are simply not
|
|
162
|
+
// emitted, so a typo'd field can never corrupt the head)
|
|
163
|
+
if (config.ogImage !== undefined && config.ogImage !== null) {
|
|
164
|
+
const isObj = typeof config.ogImage === 'object' && !Array.isArray(config.ogImage)
|
|
165
|
+
if (isObj) {
|
|
166
|
+
if (typeof config.ogImage.url !== 'string') {
|
|
167
|
+
errors.push('ogImage: object form requires a string "url" field')
|
|
168
|
+
}
|
|
169
|
+
for (const k of ['width', 'height']) {
|
|
170
|
+
const v = config.ogImage[k]
|
|
171
|
+
if (v !== undefined && !/^[0-9]+$/.test(String(v))) {
|
|
172
|
+
errors.push(`ogImage: "${k}" should be a number (pixels), got ${JSON.stringify(v)}`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const k of ['alt', 'type']) {
|
|
176
|
+
const v = config.ogImage[k]
|
|
177
|
+
if (v !== undefined && typeof v !== 'string') {
|
|
178
|
+
errors.push(`ogImage: "${k}" must be a string, got ${JSON.stringify(v)}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else if (typeof config.ogImage !== 'string') {
|
|
182
|
+
errors.push('ogImage: must be a string URL or an object { url, width, height, alt, type }')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
97
186
|
// Optional known-value fields
|
|
98
187
|
if (config.platform !== undefined && !PLATFORMS.includes(config.platform)) {
|
|
99
188
|
errors.push(`platform: "${config.platform}" not in ${PLATFORMS.join(', ')}`)
|
|
@@ -102,6 +191,19 @@ export function validateConfig(config) {
|
|
|
102
191
|
errors.push('appLayoutPath: must be a Vite-rooted path starting with "/"')
|
|
103
192
|
}
|
|
104
193
|
|
|
194
|
+
// indexNow (#36): the key must be 32 lowercase hex, or the boolean forms
|
|
195
|
+
// (true = persisted auto-key, false = off). resolveIndexNowKey RETURNS NULL
|
|
196
|
+
// on an invalid key ("diagnostics already warned") -- so without this check
|
|
197
|
+
// a mistyped/placeholder key silently disables IndexNow with no diagnostic
|
|
198
|
+
// anywhere: the example's own placeholder is 36 chars, and uncommenting it
|
|
199
|
+
// would drop the feature green. Surface it here, where the claim is made.
|
|
200
|
+
if (config.indexNow !== undefined && config.indexNow !== null && config.indexNow !== false && config.indexNow !== true) {
|
|
201
|
+
const key = typeof config.indexNow === 'string' ? config.indexNow : config.indexNow.key
|
|
202
|
+
if (typeof key !== 'string' || !/^[a-f0-9]{32}$/.test(key)) {
|
|
203
|
+
errors.push('indexNow: must be true, a 32-char lowercase hex key string, or { key: "<32-hex>" }')
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
105
207
|
return { ok: errors.length === 0, errors }
|
|
106
208
|
}
|
|
107
209
|
|
|
@@ -43,11 +43,20 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { useEffect } from 'react'
|
|
46
|
-
import
|
|
46
|
+
import * as rrdNS from 'react-router-dom'
|
|
47
|
+
// Neither plain named imports nor a bare namespace destructure survive
|
|
48
|
+
// both majors: v7 ships CJS (vite SSR rejects named imports; node interop
|
|
49
|
+
// gives a namespace whose only reliable export is `default` -- the exports
|
|
50
|
+
// object), while v6 is ESM with possibly no default. Destructure from
|
|
51
|
+
// whichever carries the exports.
|
|
52
|
+
const rrd = rrdNS.default ?? rrdNS
|
|
53
|
+
const { useLocation } = rrd
|
|
47
54
|
|
|
48
55
|
/** Update the SPA head tags on every route change (see the module head doc). */
|
|
49
56
|
export default function usePageMeta({ title, description, path, siteUrl }) {
|
|
50
|
-
|
|
57
|
+
// Trailing-slash normalization (#53): a configured base with its trailing
|
|
58
|
+
// slash would join into `https://site//about` on every navigation
|
|
59
|
+
const base = (siteUrl || import.meta.env?.VITE_SITE_URL || '').replace(/\/$/, '')
|
|
51
60
|
const location = useLocation()
|
|
52
61
|
|
|
53
62
|
useEffect(() => {
|
package/engine/src/islands.js
CHANGED
|
@@ -55,25 +55,44 @@ import React from 'react'
|
|
|
55
55
|
// Tracks elements that have already been mounted to prevent double createRoot
|
|
56
56
|
// calls when mountIslands is called again on SPA route change.
|
|
57
57
|
const mounted = new WeakSet()
|
|
58
|
+
// Tracks elements that already have a live IntersectionObserver. mountIslands
|
|
59
|
+
// is contractually called after EVERY navigation, and a never-intersected
|
|
60
|
+
// 'visible' island survives each scan as the same element: without this set,
|
|
61
|
+
// a duplicate scan stacks another observer on it (each with its own callback
|
|
62
|
+
// and its own disconnect lifecycle).
|
|
63
|
+
const observing = new WeakSet()
|
|
64
|
+
// The live observer per watched element, so mount() can disconnect exactly
|
|
65
|
+
// the observer that got it here instead of leaving the one-shot entry firing.
|
|
66
|
+
const observers = new WeakMap()
|
|
58
67
|
|
|
59
68
|
function mount(el, Component) {
|
|
60
69
|
if (mounted.has(el)) return
|
|
61
70
|
mounted.add(el)
|
|
71
|
+
// Disconnect the 'visible' island's observer exactly once, at the moment
|
|
72
|
+
// of the mount it was waiting for (instead of inside its own callback,
|
|
73
|
+
// which a duplicate observer would race with).
|
|
74
|
+
const io = observers.get(el)
|
|
75
|
+
if (io) {
|
|
76
|
+
observers.delete(el)
|
|
77
|
+
observing.delete(el)
|
|
78
|
+
io.disconnect()
|
|
79
|
+
}
|
|
62
80
|
el.innerHTML = ''
|
|
63
81
|
ReactDOM.createRoot(el).render(React.createElement(Component))
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
function observe(el, Component) {
|
|
67
85
|
if (mounted.has(el)) return
|
|
68
|
-
|
|
86
|
+
if (observing.has(el)) return // already waiting on one observer
|
|
87
|
+
observing.add(el)
|
|
88
|
+
const io = new IntersectionObserver((entries) => {
|
|
69
89
|
for (const entry of entries) {
|
|
70
90
|
if (entry.isIntersecting) {
|
|
71
|
-
|
|
72
|
-
io.disconnect() // Clean up the observer after use
|
|
73
|
-
mount(el, Component)
|
|
91
|
+
mount(el, Component) // mount() does the disconnect now
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
94
|
}, { rootMargin: '0px', threshold: 0.1 })
|
|
95
|
+
observers.set(el, io)
|
|
77
96
|
io.observe(el)
|
|
78
97
|
}
|
|
79
98
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssr.config.js
|
|
3
|
+
* =============
|
|
4
|
+
* Copy this file to your project root as ssr.config.js, then customize.
|
|
5
|
+
*
|
|
6
|
+
* The platform field selects host behavior. Cloudflare (default) is the only
|
|
7
|
+
* fully supported platform today: the template ships public/_headers and
|
|
8
|
+
* public/_redirects, and inject-brand appends the proxy origin to the CSP
|
|
9
|
+
* allow-list when proxy.url is set. 'vercel' and 'netlify' pass config
|
|
10
|
+
* validation but no host-specific files are generated yet.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: This file is gitignored. It's your environment-specific config.
|
|
13
|
+
* Copy ssr.config.example.js to ssr.config.js and customize for your site.
|
|
14
|
+
*
|
|
15
|
+
* Platform options:
|
|
16
|
+
* - cloudflare = default (only platform with generated/templated host files)
|
|
17
|
+
* - vercel = accepted in config; no file generation yet
|
|
18
|
+
* - netlify = accepted in config; no file generation yet
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
// === Platform ===
|
|
23
|
+
// platform: 'cloudflare', // 'netlify' | 'vercel' | 'cloudflare' (default)
|
|
24
|
+
|
|
25
|
+
// === Site Identity ===
|
|
26
|
+
siteUrl: 'https://example.com',
|
|
27
|
+
siteName: 'Your Site Name',
|
|
28
|
+
author: 'Your Name',
|
|
29
|
+
tagline: 'Your tagline - keeps it short',
|
|
30
|
+
ogImage: 'https://example.com/og-image.png',
|
|
31
|
+
keywords: 'keyword1, keyword2, keyword3',
|
|
32
|
+
|
|
33
|
+
// === App ===
|
|
34
|
+
// Path to your AppLayout component (no BrowserRouter, only Routes)
|
|
35
|
+
appLayoutPath: '/src/AppLayout.jsx',
|
|
36
|
+
|
|
37
|
+
// === Deploy ID ===
|
|
38
|
+
// Inject a deploy ID meta tag for debugging (visible in page source).
|
|
39
|
+
// Set deployIdFooter: true to also show in the footer (requires client-side JS).
|
|
40
|
+
// deployId: true, // inject <meta name="deploy-id"> (default: true)
|
|
41
|
+
// deployIdFooter: false, // show in footer (default: false)
|
|
42
|
+
|
|
43
|
+
// === Robots.txt (opt-in) ===
|
|
44
|
+
// Prestruct never touches dist/robots.txt unless you opt in -- a static
|
|
45
|
+
// public/robots.txt you author yourself is copied through untouched.
|
|
46
|
+
// robots: true, // minimal block (all agents + Sitemap line)
|
|
47
|
+
// robots: "User-agent: *\nDisallow: /private", // custom body; sitemap auto-appended
|
|
48
|
+
// robots: { body: "...", sitemap: false }, // custom body, no sitemap line
|
|
49
|
+
//
|
|
50
|
+
// AEO preset -- per-bot crawl policy from the 2026 vendor docs:
|
|
51
|
+
// robots: { aeo: true }, // search AI bots Allow, training crawls Disallow
|
|
52
|
+
// robots: { aeo: { aiBots: 'allow' } }, // same, but also allow training crawls
|
|
53
|
+
// Search group: OAI-SearchBot, PerplexityBot, Claude-SearchBot, Applebot,
|
|
54
|
+
// Googlebot, Bingbot. Training group: GPTBot, ClaudeBot, Google-Extended,
|
|
55
|
+
// Applebot-Extended, CCBot. Search access and training/data-use are separate
|
|
56
|
+
// choices; the Sitemap line is appended as usual.
|
|
57
|
+
|
|
58
|
+
// === Routes ===
|
|
59
|
+
// Each route gets prerendered to static HTML at build time.
|
|
60
|
+
// Add your routes here (at least one; zero routes fails validation).
|
|
61
|
+
// Subroutes-only lists are fine -- vite still ships index.html.
|
|
62
|
+
routes: [
|
|
63
|
+
{
|
|
64
|
+
path: '/',
|
|
65
|
+
priority: '1.0',
|
|
66
|
+
changefreq: 'monthly',
|
|
67
|
+
meta: {
|
|
68
|
+
title: 'Your Site Name',
|
|
69
|
+
description: 'Your site description for search engines.',
|
|
70
|
+
// ogImage: 'https://example.com/og-image.png', // route-specific OG image
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
// {
|
|
74
|
+
// path: '/about',
|
|
75
|
+
// priority: '0.8',
|
|
76
|
+
// changefreq: 'monthly',
|
|
77
|
+
// meta: {
|
|
78
|
+
// title: 'About | Your Site Name',
|
|
79
|
+
// description: 'Learn more about what we do.',
|
|
80
|
+
// },
|
|
81
|
+
// },
|
|
82
|
+
// {
|
|
83
|
+
// path: '/blog',
|
|
84
|
+
// priority: '0.7',
|
|
85
|
+
// changefreq: 'weekly',
|
|
86
|
+
// meta: {
|
|
87
|
+
// title: 'Blog | Your Site Name',
|
|
88
|
+
// description: 'Latest updates and articles.',
|
|
89
|
+
// },
|
|
90
|
+
// },
|
|
91
|
+
],
|
|
92
|
+
|
|
93
|
+
// === Dynamic Routes (CMS) ===
|
|
94
|
+
// Optional async hook for routes from a headless CMS.
|
|
95
|
+
// Runs at build time only - no runtime overhead.
|
|
96
|
+
// fetchRoutes() merges with static routes above.
|
|
97
|
+
// Static routes take precedence on duplicates.
|
|
98
|
+
// async fetchRoutes() {
|
|
99
|
+
// const posts = await fetch('https://your-cms.com/api/routes')
|
|
100
|
+
// .then(r => r.json())
|
|
101
|
+
// return posts.map(post => ({
|
|
102
|
+
// path: `/blog/${post.slug}`,
|
|
103
|
+
// priority: '0.6',
|
|
104
|
+
// meta: {
|
|
105
|
+
// title: post.title,
|
|
106
|
+
// description: post.excerpt,
|
|
107
|
+
// },
|
|
108
|
+
// }))
|
|
109
|
+
// },
|
|
110
|
+
|
|
111
|
+
// === JSON-LD Structured Data ===
|
|
112
|
+
// Optional function returning schema.org structured data.
|
|
113
|
+
// Return a single object or array of objects.
|
|
114
|
+
// buildJsonLd() {
|
|
115
|
+
// return [
|
|
116
|
+
// {
|
|
117
|
+
// '@context': 'https://schema.org',
|
|
118
|
+
// '@type': 'WebSite',
|
|
119
|
+
// name: 'Your Site Name',
|
|
120
|
+
// url: 'https://example.com',
|
|
121
|
+
// author: {
|
|
122
|
+
// '@type': 'Person',
|
|
123
|
+
// name: 'Your Name',
|
|
124
|
+
// url: 'https://github.com/yourname',
|
|
125
|
+
// },
|
|
126
|
+
// },
|
|
127
|
+
// ]
|
|
128
|
+
// },
|
|
129
|
+
|
|
130
|
+
// === Family ride-alongs (2026-10 issue walk) ===
|
|
131
|
+
// All optional; every block is missing-only and config-gated. Full
|
|
132
|
+
// semantics: docs/guides/config.md#family-ride-alongs.
|
|
133
|
+
|
|
134
|
+
// Config-driven structured-data defaults (#30): without buildJsonLd the
|
|
135
|
+
// engine emits Organization + WebSite from siteName/siteUrl/author.
|
|
136
|
+
// jsonLdDefaults: false, // skip the defaults
|
|
137
|
+
|
|
138
|
+
// Per-route schema: route.jsonLd (object or array) ships on that page only
|
|
139
|
+
// (FAQPage on the FAQ page, Article on the article). Feed discovery,
|
|
140
|
+
// hreflang alternates (route.alternates overrides the site-wide set),
|
|
141
|
+
// search-console verification metas, and a SearchAction on the home route:
|
|
142
|
+
// feed: { url: 'https://example.com/feed.xml', title: 'Feed' },
|
|
143
|
+
// searchUrl: 'https://example.com/search?q={query}',
|
|
144
|
+
// alternates: [{ hreflang: 'en', href: 'https://example.com' }],
|
|
145
|
+
// verification: { 'google-site-verification': 'token-here' },
|
|
146
|
+
|
|
147
|
+
// llms.txt (AI-consumable site summary from the baked route set),
|
|
148
|
+
// generated-only (a hand-rolled file is never touched). Opt-out:
|
|
149
|
+
// llms: false,
|
|
150
|
+
|
|
151
|
+
// IndexNow (#36): a 32-char lowercase-hex key string (or true = persisted
|
|
152
|
+
// auto-key in .prestruct/). build writes <key>.txt into dist/; `prestruct
|
|
153
|
+
// indexnow --dry-run` prints the submission payload.
|
|
154
|
+
// indexNow: 'replacewithyour32lowercasehexkey',
|
|
155
|
+
|
|
156
|
+
// postBuild hooks (#41): shell commands run BY the build after prerender
|
|
157
|
+
// (npm-chain scripts can be retired). Opt-in execution gate:
|
|
158
|
+
// runHooks: true,
|
|
159
|
+
// hooks: { postBuild: ['node scripts/inject-faq-schema.mjs'] },
|
|
160
|
+
|
|
161
|
+
// Branded per-route OG images (#43): render a 1200x630 PNG per route from
|
|
162
|
+
// a 1200x630 SVG template ({{ogTitle}}/{{ogDescription}}/{{ogUrl}} slots).
|
|
163
|
+
// Rasterizer is the optional dev dep @resvg/resvg-js - without it the
|
|
164
|
+
// generation skips loudly (build stays green, audit flags the head).
|
|
165
|
+
// ogImageGeneration: { template: 'og-template.svg', font: './brand/bold.ttf' },
|
|
166
|
+
|
|
167
|
+
// === 404 Page ===
|
|
168
|
+
// Custom 404 page content. Prerendered with noindex meta.
|
|
169
|
+
notFound: {
|
|
170
|
+
heading: 'Page not found',
|
|
171
|
+
body: "That page doesn't exist.",
|
|
172
|
+
primaryCta: { label: 'Go home', href: '/' },
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
// === Bot Proxy (optional) ===
|
|
176
|
+
// Proxy renders dynamic routes not in the prerender list.
|
|
177
|
+
// Works with Node.js/Puppeteer (VPS) or Cloudflare Worker.
|
|
178
|
+
// proxy: {
|
|
179
|
+
// // Your proxy server URL. Signals the build to widen the CSP
|
|
180
|
+
// // connect-src allow-list with this origin.
|
|
181
|
+
// url: 'https://your-proxy.example.com',
|
|
182
|
+
// // Cache-refresh shared secret is an env var, not config:
|
|
183
|
+
// // PRESTRUCT_SECRET (unset = refresh feature disabled entirely).
|
|
184
|
+
// // Render a different origin than siteUrl (for local dev, staging)
|
|
185
|
+
// targetUrl: 'https://staging.example.com',
|
|
186
|
+
// // Bot list for proxy - customize if needed
|
|
187
|
+
// botList: [
|
|
188
|
+
// 'Googlebot',
|
|
189
|
+
// 'Google-Extended',
|
|
190
|
+
// 'Bingbot',
|
|
191
|
+
// 'BingPreview',
|
|
192
|
+
// 'DuckDuckBot',
|
|
193
|
+
// 'Slurp',
|
|
194
|
+
// ],
|
|
195
|
+
// },
|
|
196
|
+
}
|
package/init/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.2.0
|
package/init/ecosystem.cjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Usage (from the proxy's own package root -- see proxy.package.json):
|
|
12
12
|
*
|
|
13
13
|
* npm install # express + puppeteer (downloads Chromium)
|
|
14
|
-
* export PRESTRUCT_SECRET=... #
|
|
14
|
+
* export PRESTRUCT_SECRET=... # env-var secret the proxy reads (never a config key)
|
|
15
15
|
* pm2 start ecosystem.cjs
|
|
16
16
|
* pm2 save && pm2 startup # survive reboots
|
|
17
17
|
*
|