prestruct 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 +159 -2
- package/bin/cli.mjs +212 -0
- package/engine/scripts/inject-brand.js +430 -0
- package/engine/scripts/prerender.js +726 -0
- package/engine/scripts/validate-config.js +117 -0
- package/engine/src/hooks/usePageMeta.js +78 -0
- package/engine/src/islands.js +129 -0
- package/engine/vite.config.js +18 -0
- package/init/VERSION +1 -0
- package/init/ecosystem.cjs +45 -0
- package/init/eslint.config.js +30 -0
- package/init/index.html +42 -0
- package/init/package-lock.json +3183 -0
- package/init/package.json +29 -0
- package/init/proxy.package.json +21 -0
- package/init/public/_headers +31 -0
- package/init/public/_redirects +16 -0
- package/init/scripts/inject-brand.js +430 -0
- package/init/scripts/prerender.js +726 -0
- package/init/scripts/prestruct-proxy.service +47 -0
- package/init/scripts/proxy.js +373 -0
- package/init/scripts/proxy.worker.js +217 -0
- package/init/scripts/validate-config.js +117 -0
- package/init/src/App.jsx +21 -0
- package/init/src/AppIslands.jsx +39 -0
- package/init/src/AppLayout.jsx +66 -0
- package/init/src/hooks/usePageMeta.js +78 -0
- package/init/src/islands.js +129 -0
- package/init/src/main.jsx +35 -0
- package/init/ssr.config.example.js +151 -0
- package/init/vite.config.js +18 -0
- package/init/vitest.config.js +14 -0
- package/init/wrangler.toml +32 -0
- package/package.json +70 -4
|
@@ -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
|
+
}
|
package/init/src/App.jsx
ADDED
|
@@ -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
|
+
})
|