prestruct 0.0.0-stage → 1.0.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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * scripts/validate-config.js
3
+ * ==========================
4
+ * Validates ssr.config.js shape and emits structured diagnostics.
5
+ *
6
+ * Shared by prerender.js and inject-brand.js. The contract:
7
+ * - Returns { ok, errors } where errors are consumable strings, one line each
8
+ * - Never throws, never exits -- callers decide policy (prerender exits 0
9
+ * on CF Pages unless PRESTRUCT_STRICT=1 is set; see prerender.js)
10
+ */
11
+
12
+ const PLATFORMS = ['cloudflare', 'vercel', 'netlify']
13
+ const CHANGEFREQS = ['daily', 'weekly', 'monthly', 'yearly']
14
+
15
+ // Identity helper: `import { defineConfig } from 'prestruct'` for type-aware
16
+ // ssr.config.js files. No preprocessing -- config is passed through.
17
+ /**
18
+ * Identity helper: `import { defineConfig } from 'prestruct'` for type-aware
19
+ * ssr.config.js files. Passes the config straight through.
20
+ *
21
+ * @template T
22
+ * @param {T} config - the config object
23
+ * @returns {T} the same config object
24
+ */
25
+ export function defineConfig(config) {
26
+ return config
27
+ }
28
+
29
+ /**
30
+ * Validate the ssr.config.js shape and emit structured diagnostics.
31
+ * Shape checks: siteUrl form, route paths (leading slash, traversal guard),
32
+ * priority/changefreq values, meta objects, duplicate paths, empty lists,
33
+ * platform and appLayoutPath know-values. Never throws, never exits.
34
+ *
35
+ * @param {object} config - full ssr.config.js export
36
+ * @returns {{ok: boolean, errors: string[]}} diagnostics consumable by callers
37
+ */
38
+ export function validateConfig(config) {
39
+ const errors = []
40
+ if (!config || typeof config !== 'object') {
41
+ return { ok: false, errors: ['config must export a default object'] }
42
+ }
43
+
44
+ // Required: siteUrl (origin or full URL, no trailing slash enforced upstream)
45
+ if (typeof config.siteUrl !== 'string' || !/^https?:\/\//.test(config.siteUrl.trim())) {
46
+ errors.push('siteUrl is required and must start with http:// or https://')
47
+ }
48
+
49
+ // routes: array of route objects; duplicate paths are an error because two
50
+ // routes with the same path would collide in the cache key AND write the
51
+ // same dist file twice with different meta (silent SEO corruption).
52
+ if (!Array.isArray(config.routes)) {
53
+ errors.push('routes must be an array (or omitted entirely)')
54
+ } else if (!config.routes.length) {
55
+ // Hammer finding (2026-10): an explicitly EMPTY routes array validated as
56
+ // "valid (0 routes)" -- the build would prerender nothing while reporting
57
+ // a green configured site. Zero routes is a misconfiguration (route lists
58
+ // WITHOUT the home route are fine: vite still ships index.html).
59
+ errors.push('routes is empty -- configure at least one route to prerender')
60
+ } else {
61
+ config.routes.forEach((route, i) => {
62
+ if (!route || typeof route !== 'object') {
63
+ errors.push(`routes[${i}]: must be an object`)
64
+ return
65
+ }
66
+ if (typeof route.path !== 'string' || !route.path.startsWith('/')) {
67
+ errors.push(`routes[${i}].path: missing or does not start with "/"`)
68
+ }
69
+ // Path traversal guard: prerender writes dist/<path>/index.html. A
70
+ // '..' or backslash segment escapes dist/ (and on POSIX could land
71
+ // outside the project root entirely). Route paths are URL paths --
72
+ // they never need either.
73
+ if (typeof route.path === 'string' && (route.path.includes('..') || route.path.includes('\\'))) {
74
+ errors.push(`routes[${i}].path: "${route.path}" contains ".." or backslash -- route paths cannot escape dist/`)
75
+ }
76
+ if (route.priority !== undefined && !/^(0?\.\d+|1(\.0+)?)$/.test(String(route.priority))) {
77
+ errors.push(`routes[${i}].priority: "${route.priority}" is not a number between 0 and 1`)
78
+ }
79
+ if (route.changefreq !== undefined && !CHANGEFREQS.includes(route.changefreq)) {
80
+ errors.push(`routes[${i}].changefreq: "${route.changefreq}" not in ${CHANGEFREQS.join(', ')}`)
81
+ }
82
+ if (route.meta !== undefined && (typeof route.meta !== 'object' || route.meta === null)) {
83
+ errors.push(`routes[${i}].meta: must be an object like { title, description }`)
84
+ }
85
+ })
86
+ const seen = new Set()
87
+ config.routes.forEach((route, i) => {
88
+ if (route && typeof route === 'object' && typeof route.path === 'string') {
89
+ if (seen.has(route.path)) {
90
+ errors.push(`routes[${i}].path: duplicate path "${route.path}" (first seen earlier) -- every path must be unique`)
91
+ }
92
+ seen.add(route.path)
93
+ }
94
+ })
95
+ }
96
+
97
+ // Optional known-value fields
98
+ if (config.platform !== undefined && !PLATFORMS.includes(config.platform)) {
99
+ errors.push(`platform: "${config.platform}" not in ${PLATFORMS.join(', ')}`)
100
+ }
101
+ if (config.appLayoutPath !== undefined && (typeof config.appLayoutPath !== 'string' || !config.appLayoutPath.startsWith('/'))) {
102
+ errors.push('appLayoutPath: must be a Vite-rooted path starting with "/"')
103
+ }
104
+
105
+ return { ok: errors.length === 0, errors }
106
+ }
107
+
108
+ // Structured one-liner format callers print for each diagnostic
109
+ /**
110
+ * Format diagnostics as the printed one-liners (`[prestruct] config: <msg>`).
111
+ *
112
+ * @param {string[]} errors - raw diagnostic messages
113
+ * @returns {string[]} prefixed one-liner strings
114
+ */
115
+ export function formatDiagnostics(errors) {
116
+ return errors.map(e => `[prestruct] config: ${e}`)
117
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * src/App.jsx
3
+ * ===========
4
+ * TEMPLATE FILE -- wire your AppLayout here, keep BrowserRouter in this file only.
5
+ *
6
+ * BrowserRouter must NOT be imported in AppLayout.jsx or any file it imports.
7
+ * If it is, ssrLoadModule will initialize BrowserRouter against window.location
8
+ * (which defaults to '/' in Node), and every route will prerender as the
9
+ * homepage with no error message. See AGENTS.md for the full explanation.
10
+ */
11
+
12
+ import { BrowserRouter } from 'react-router-dom'
13
+ import AppLayout from './AppLayout.jsx'
14
+
15
+ export default function App() {
16
+ return (
17
+ <BrowserRouter>
18
+ <AppLayout />
19
+ </BrowserRouter>
20
+ )
21
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * src/AppIslands.jsx
3
+ * ==================
4
+ * TEMPLATE FILE -- register your island components here.
5
+ *
6
+ * Each entry maps a data-pre-island name to a React component.
7
+ * The name must match exactly what you put in the HTML attribute.
8
+ *
9
+ * Island components:
10
+ * - Run only in the browser, never during SSR prerender.
11
+ * - Receive no props. Read data from localStorage, fetch, or a global store.
12
+ * - Replace any fallback content inside the <pre-island> element.
13
+ *
14
+ * Usage in your page JSX:
15
+ *
16
+ * // eager (default) -- mounts immediately after hydration
17
+ * <pre-island data-pre-island="recently-viewed" />
18
+ *
19
+ * // visible -- mounts when scrolled into viewport
20
+ * <pre-island data-pre-island="cart-widget" data-pre-load="visible">
21
+ * <span className="island-loading">Loading cart...</span>
22
+ * </pre-island>
23
+ *
24
+ * // idle -- mounts during browser idle time
25
+ * <pre-island data-pre-island="promo-banner" data-pre-load="idle" />
26
+ *
27
+ * Add your components below and uncomment the import lines.
28
+ * Remove unused examples before shipping.
29
+ */
30
+
31
+ // import RecentlyViewed from './islands/RecentlyViewed.jsx'
32
+ // import CartWidget from './islands/CartWidget.jsx'
33
+ // import PromoBanner from './islands/PromoBanner.jsx'
34
+
35
+ export const islands = {
36
+ // 'recently-viewed': RecentlyViewed,
37
+ // 'cart-widget': CartWidget,
38
+ // 'promo-banner': PromoBanner,
39
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * src/AppLayout.jsx
3
+ * =================
4
+ * TEMPLATE FILE -- copy and wire to your own routes, components, and pages.
5
+ *
6
+ * Router-agnostic layout shell. No BrowserRouter here -- ever.
7
+ *
8
+ * Imported by:
9
+ * App.jsx (wrapped in BrowserRouter for the client)
10
+ * prerender.js (wrapped in StaticRouter via ssrLoadModule)
11
+ *
12
+ * CRITICAL: Nothing in this file's import graph may import BrowserRouter.
13
+ * If it does, ssrLoadModule will initialize BrowserRouter against window.location
14
+ * (which defaults to '/' in Node), overriding StaticRouter's location context.
15
+ * Every route will prerender as the homepage with no error message.
16
+ * See AGENTS.md for the full root cause analysis.
17
+ *
18
+ * SSR SAFETY: Any component in this tree that accesses window, document, or
19
+ * localStorage must guard with `typeof window !== 'undefined'` because SSR
20
+ * runs in Node where these globals don't exist.
21
+ */
22
+
23
+ import { Routes, Route, useLocation } from 'react-router-dom'
24
+ import { useEffect } from 'react'
25
+ import { mountIslands } from './islands.js'
26
+ import { islands } from './AppIslands.jsx'
27
+
28
+ // Replace these with your actual components and pages. A live-fire check
29
+ // (2026-10): uncommenting a <Route> whose import is still commented makes
30
+ // prerender die with "X is not defined" per route. Rule: only ever uncomment
31
+ // a <Route> line AFTER its import line is uncommented too.
32
+ // import Nav from './components/Nav'
33
+ // import Footer from './components/Footer'
34
+ // import Home from './pages/Home'
35
+ // import About from './pages/About'
36
+
37
+ function ScrollToTop() {
38
+ const { pathname } = useLocation()
39
+ useEffect(() => {
40
+ if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'instant' })
41
+ // Re-scan for islands on every route change. The WeakSet in islands.js
42
+ // prevents double-mounting elements that are already active. The setTimeout(0)
43
+ // defers the scan one tick so React has committed the new page to the DOM.
44
+ const t = setTimeout(() => mountIslands(islands), 0)
45
+ return () => clearTimeout(t)
46
+ }, [pathname])
47
+ return null
48
+ }
49
+
50
+ export default function AppLayout() {
51
+ return (
52
+ <>
53
+ <ScrollToTop />
54
+ {/* <Nav /> */}
55
+ <main>
56
+ <Routes>
57
+ {/* Replace with your routes */}
58
+ {/* <Route path="/" element={<Home />} /> */}
59
+ {/* <Route path="/about" element={<About />} /> */}
60
+ {/* <Route path="*" element={<NotFound />} /> */}
61
+ </Routes>
62
+ </main>
63
+ {/* <Footer /> */}
64
+ </>
65
+ )
66
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * src/hooks/usePageMeta.js
3
+ * ========================
4
+ * Updates route-specific head tags on client-side navigation.
5
+ *
6
+ * SSR (prerender.js) bakes correct tags into each route's HTML at build time --
7
+ * bots without JS and initial page loads see those tags directly.
8
+ * This hook keeps them in sync when the user navigates within the SPA,
9
+ * covering JS-enabled bots (Googlebot, social crawlers) and the browser tab.
10
+ *
11
+ * Tags updated on each navigation:
12
+ * <title>
13
+ * <meta name="description">
14
+ * <link rel="canonical">
15
+ * <meta property="og:url">
16
+ * <meta property="og:title">
17
+ * <meta property="og:description">
18
+ * <meta name="twitter:title">
19
+ * <meta name="twitter:description">
20
+ *
21
+ * Tags intentionally NOT updated (same on every page):
22
+ * og:image, og:type, og:locale, og:site_name, twitter:card, twitter:image
23
+ *
24
+ * NO external dependencies -- does not import from ssr.config.js or any app file.
25
+ * siteUrl is passed as a prop or read from VITE_SITE_URL env.
26
+ *
27
+ * Usage (explicit siteUrl):
28
+ * usePageMeta({
29
+ * siteUrl: 'https://yoursite.com',
30
+ * path: '/about',
31
+ * title: 'About | Your Site',
32
+ * description: 'About page description.',
33
+ * })
34
+ *
35
+ * Usage (env-driven, set VITE_SITE_URL in .env or vite.config.js define):
36
+ * usePageMeta({ path: '/about', title: '...', description: '...' })
37
+ *
38
+ * Tip: wrap in your own hook to avoid repeating siteUrl:
39
+ * // src/hooks/useMeta.js
40
+ * import usePageMeta from './usePageMeta.js'
41
+ * const SITE_URL = import.meta.env.VITE_SITE_URL || 'https://yoursite.com'
42
+ * export default (args) => usePageMeta({ siteUrl: SITE_URL, ...args })
43
+ */
44
+
45
+ import { useEffect } from 'react'
46
+ import { useLocation } from 'react-router-dom'
47
+
48
+ /** Update the SPA head tags on every route change (see the module head doc). */
49
+ export default function usePageMeta({ title, description, path, siteUrl }) {
50
+ const base = siteUrl || import.meta.env?.VITE_SITE_URL || ''
51
+ const location = useLocation()
52
+
53
+ useEffect(() => {
54
+ // Without a base URL a computed canonical would be relative ("/about"),
55
+ // which is not a canonical URL -- skip rather than write a wrong tag.
56
+ const canonical = base ? `${base}${path === '/' ? '' : path}` : ''
57
+
58
+ const set = (selector, attr, value) => {
59
+ const el = document.querySelector(selector)
60
+ if (el && value) el.setAttribute(attr, value)
61
+ }
62
+
63
+ if (title) document.title = title
64
+ if (description) set('meta[name="description"]', 'content', description)
65
+ if (canonical) {
66
+ set('link[rel="canonical"]', 'href', canonical)
67
+ set('meta[property="og:url"]', 'content', canonical)
68
+ }
69
+ if (title) {
70
+ set('meta[property="og:title"]', 'content', title)
71
+ set('meta[name="twitter:title"]', 'content', title)
72
+ }
73
+ if (description) {
74
+ set('meta[property="og:description"]', 'content', description)
75
+ set('meta[name="twitter:description"]', 'content', description)
76
+ }
77
+ }, [title, description, path, base, location])
78
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * src/islands.js
3
+ * ==============
4
+ * Engine file -- copy once, never edit.
5
+ *
6
+ * Mounts dynamic React components into <pre-island> placeholders after
7
+ * the main app hydrates. Each island is a hole punched through the
8
+ * prerendered HTML: invisible to crawlers, filled for human visitors.
9
+ *
10
+ * Usage -- call mountIslands() in AppLayout's route-change effect after each
11
+ * navigation. This ensures islands mount on both hard loads and SPA transitions.
12
+ * The WeakSet prevents double-mounting if the same element is scanned twice.
13
+ *
14
+ * import { mountIslands } from './islands.js'
15
+ * import { islands } from './AppIslands.jsx'
16
+ *
17
+ * // Inside ScrollToTop's useEffect([pathname]):
18
+ * const t = setTimeout(() => mountIslands(islands), 0)
19
+ * return () => clearTimeout(t)
20
+ *
21
+ * Islands are declared in src/AppIslands.jsx as a name -> component map.
22
+ * Each island in the HTML is a <pre-island> custom element:
23
+ *
24
+ * <pre-island data-pre-island="recently-viewed"></pre-island>
25
+ * <pre-island data-pre-island="cart-widget" data-pre-load="visible"></pre-island>
26
+ * <pre-island data-pre-island="promo-banner" data-pre-load="idle"></pre-island>
27
+ *
28
+ * Load strategies (data-pre-load):
29
+ * eager -- mount immediately. Default if omitted.
30
+ * visible -- mount when the element enters the viewport (IntersectionObserver).
31
+ * idle -- mount during browser idle time (requestIdleCallback).
32
+ *
33
+ * Fallback content:
34
+ * Any children inside <pre-island> are shown before the island mounts.
35
+ * They are replaced when the React component renders.
36
+ *
37
+ * <pre-island data-pre-island="cart-widget">
38
+ * <span class="island-loading">Loading cart...</span>
39
+ * </pre-island>
40
+ *
41
+ * SSR behavior:
42
+ * <pre-island> is a custom element. React's renderToString passes it
43
+ * through as plain HTML -- no guard needed. Fallback content renders
44
+ * in the static HTML and is visible to crawlers. The React component
45
+ * never runs at build time.
46
+ *
47
+ * Island components receive no props by default. To pass data, use
48
+ * data attributes and read them from the element ref inside the component,
49
+ * or pull from a global store / localStorage / fetch.
50
+ */
51
+
52
+ import ReactDOM from 'react-dom/client'
53
+ import React from 'react'
54
+
55
+ // Tracks elements that have already been mounted to prevent double createRoot
56
+ // calls when mountIslands is called again on SPA route change.
57
+ const mounted = new WeakSet()
58
+
59
+ function mount(el, Component) {
60
+ if (mounted.has(el)) return
61
+ mounted.add(el)
62
+ el.innerHTML = ''
63
+ ReactDOM.createRoot(el).render(React.createElement(Component))
64
+ }
65
+
66
+ function observe(el, Component) {
67
+ if (mounted.has(el)) return
68
+ const io = new IntersectionObserver((entries, observer) => {
69
+ for (const entry of entries) {
70
+ if (entry.isIntersecting) {
71
+ observer.unobserve(el)
72
+ io.disconnect() // Clean up the observer after use
73
+ mount(el, Component)
74
+ }
75
+ }
76
+ }, { rootMargin: '0px', threshold: 0.1 })
77
+ io.observe(el)
78
+ }
79
+
80
+ function whenIdle(el, Component) {
81
+ if (mounted.has(el)) return
82
+ if ('requestIdleCallback' in window) {
83
+ requestIdleCallback(() => {
84
+ // Guard against the element being removed from the DOM before the
85
+ // callback fires (e.g. user navigated away within the idle window).
86
+ if (el.isConnected) mount(el, Component)
87
+ }, { timeout: 2000 })
88
+ } else {
89
+ // requestIdleCallback not available -- fall back to a short timeout
90
+ setTimeout(() => { if (el.isConnected) mount(el, Component) }, 200)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * mountIslands(registry)
96
+ *
97
+ * Scans the DOM for <pre-island> elements and mounts the matching
98
+ * React component from the registry into each one.
99
+ *
100
+ * Unregistered island names are skipped with a console warning.
101
+ * Safe to call with an empty registry -- nothing happens.
102
+ *
103
+ * @param {Record<string, Function>} [registry={}] - island name -> React component
104
+ * @returns {void}
105
+ */
106
+ export function mountIslands(registry = {}) {
107
+ if (typeof window === 'undefined') return
108
+
109
+ const elements = document.querySelectorAll('pre-island[data-pre-island]')
110
+
111
+ for (const el of elements) {
112
+ const name = el.dataset.preIsland
113
+ const strategy = el.dataset.preLoad || 'eager'
114
+ const Component = registry[name]
115
+
116
+ if (!Component) {
117
+ console.warn(`[islands] no component registered for "${name}" -- skipping`)
118
+ continue
119
+ }
120
+
121
+ if (strategy === 'visible') {
122
+ observe(el, Component)
123
+ } else if (strategy === 'idle') {
124
+ whenIdle(el, Component)
125
+ } else {
126
+ mount(el, Component)
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * src/main.jsx
3
+ * ============
4
+ * TEMPLATE FILE -- copy as-is or adjust StrictMode to taste.
5
+ *
6
+ * Responsibilities:
7
+ * 1. Hydrate the prerendered SSR HTML (hydrateRoot) or do a fresh render
8
+ * (createRoot) depending on whether SSR content is present.
9
+ *
10
+ * hydrateRoot vs createRoot:
11
+ * hydrateRoot attaches React's event system to existing SSR DOM without
12
+ * replacing it. Use it when dist/index.html contains prerendered content
13
+ * (data-server-rendered="true" on #root). Without it, the browser discards
14
+ * the SSR HTML and re-renders from scratch, causing FOUC on every page load.
15
+ *
16
+ * Islands:
17
+ * mountIslands() is called inside AppLayout on every route change (including
18
+ * the initial render). This ensures islands are mounted whether the page was
19
+ * reached by a hard load or SPA navigation. The engine tracks already-mounted
20
+ * elements with a WeakSet so repeated calls are safe.
21
+ *
22
+ * Do NOT import BrowserRouter here. It belongs in App.jsx.
23
+ */
24
+
25
+ import React from 'react'
26
+ import ReactDOM from 'react-dom/client'
27
+ import App from './App.jsx'
28
+
29
+ const root = document.getElementById('root')
30
+
31
+ if (root && root.dataset.serverRendered) {
32
+ ReactDOM.hydrateRoot(root, <React.StrictMode><App /></React.StrictMode>)
33
+ } else if (root) {
34
+ ReactDOM.createRoot(root).render(<React.StrictMode><App /></React.StrictMode>)
35
+ }
@@ -0,0 +1,151 @@
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
+ // === Routes ===
51
+ // Each route gets prerendered to static HTML at build time.
52
+ // Add your routes here (at least one; zero routes fails validation).
53
+ // Subroutes-only lists are fine -- vite still ships index.html.
54
+ routes: [
55
+ {
56
+ path: '/',
57
+ priority: '1.0',
58
+ changefreq: 'monthly',
59
+ meta: {
60
+ title: 'Your Site Name',
61
+ description: 'Your site description for search engines.',
62
+ // ogImage: 'https://example.com/og-image.png', // route-specific OG image
63
+ },
64
+ },
65
+ // {
66
+ // path: '/about',
67
+ // priority: '0.8',
68
+ // changefreq: 'monthly',
69
+ // meta: {
70
+ // title: 'About | Your Site Name',
71
+ // description: 'Learn more about what we do.',
72
+ // },
73
+ // },
74
+ // {
75
+ // path: '/blog',
76
+ // priority: '0.7',
77
+ // changefreq: 'weekly',
78
+ // meta: {
79
+ // title: 'Blog | Your Site Name',
80
+ // description: 'Latest updates and articles.',
81
+ // },
82
+ // },
83
+ ],
84
+
85
+ // === Dynamic Routes (CMS) ===
86
+ // Optional async hook for routes from a headless CMS.
87
+ // Runs at build time only - no runtime overhead.
88
+ // fetchRoutes() merges with static routes above.
89
+ // Static routes take precedence on duplicates.
90
+ // async fetchRoutes() {
91
+ // const posts = await fetch('https://your-cms.com/api/routes')
92
+ // .then(r => r.json())
93
+ // return posts.map(post => ({
94
+ // path: `/blog/${post.slug}`,
95
+ // priority: '0.6',
96
+ // meta: {
97
+ // title: post.title,
98
+ // description: post.excerpt,
99
+ // },
100
+ // }))
101
+ // },
102
+
103
+ // === JSON-LD Structured Data ===
104
+ // Optional function returning schema.org structured data.
105
+ // Return a single object or array of objects.
106
+ // buildJsonLd() {
107
+ // return [
108
+ // {
109
+ // '@context': 'https://schema.org',
110
+ // '@type': 'WebSite',
111
+ // name: 'Your Site Name',
112
+ // url: 'https://example.com',
113
+ // author: {
114
+ // '@type': 'Person',
115
+ // name: 'Your Name',
116
+ // url: 'https://github.com/yourname',
117
+ // },
118
+ // },
119
+ // ]
120
+ // },
121
+
122
+ // === 404 Page ===
123
+ // Custom 404 page content. Prerendered with noindex meta.
124
+ notFound: {
125
+ heading: 'Page not found',
126
+ body: "That page doesn't exist.",
127
+ primaryCta: { label: 'Go home', href: '/' },
128
+ },
129
+
130
+ // === Bot Proxy (optional) ===
131
+ // Proxy renders dynamic routes not in the prerender list.
132
+ // Works with Node.js/Puppeteer (VPS) or Cloudflare Worker.
133
+ // proxy: {
134
+ // // Your proxy server URL. Signals the build to widen the CSP
135
+ // // connect-src allow-list with this origin.
136
+ // url: 'https://your-proxy.example.com',
137
+ // // Cache-refresh shared secret is an env var, not config:
138
+ // // PRESTRUCT_SECRET (unset = refresh feature disabled entirely).
139
+ // // Render a different origin than siteUrl (for local dev, staging)
140
+ // targetUrl: 'https://staging.example.com',
141
+ // // Bot list for proxy - customize if needed
142
+ // botList: [
143
+ // 'Googlebot',
144
+ // 'Google-Extended',
145
+ // 'Bingbot',
146
+ // 'BingPreview',
147
+ // 'DuckDuckBot',
148
+ // 'Slurp',
149
+ // ],
150
+ // },
151
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * vite.config.js (template)
3
+ * =========================
4
+ * Canonical engine source. Generated into init/ only -- example/ owns its own
5
+ * vite.config.js (vendor chunking etc.), so this is NOT in the example contract.
6
+ *
7
+ * Why it matters: without @vitejs/plugin-react, JSX compiles through esbuild's
8
+ * classic runtime, which expects an in-scope `React` binding in every JSX file.
9
+ * Under ssrLoadModule that surfaces as "React is not defined" per route and
10
+ * prerender silently degrades to the SPA fallback. The automatic runtime fixes
11
+ * ssrLoadModule JSX compilation for the whole template.
12
+ */
13
+
14
+ import react from '@vitejs/plugin-react'
15
+
16
+ export default {
17
+ plugins: [react()],
18
+ }
@@ -0,0 +1,14 @@
1
+ // vitest.config.js
2
+ import { defineConfig } from 'vitest/config'
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.js'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'html'],
11
+ include: ['scripts/**/*.js'],
12
+ },
13
+ },
14
+ })