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,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,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
+ }
package/init/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ecosystem.cjs - pm2 starter config for the Prestruct render proxy
4
+ * =================================================================
5
+ * Ships with `prestruct init` (scripts/ + this file + proxy.package.json
6
+ * make up the VPS proxy deployment). pm2 keeps `scripts/proxy.js` alive,
7
+ * restarts it on crash, and caps memory: Puppeteer holds the cost of a
8
+ * warm Chromium (~100MB+), so a runaway instance should be killed and
9
+ * restarted, not OOM the box.
10
+ *
11
+ * Usage (from the proxy's own package root -- see proxy.package.json):
12
+ *
13
+ * npm install # express + puppeteer (downloads Chromium)
14
+ * export PRESTRUCT_SECRET=... # must match config.proxy.secret
15
+ * pm2 start ecosystem.cjs
16
+ * pm2 save && pm2 startup # survive reboots
17
+ *
18
+ * Logs / status / stop:
19
+ *
20
+ * pm2 logs prestruct-proxy
21
+ * pm2 status
22
+ * pm2 stop prestruct-proxy
23
+ *
24
+ * Prefer systemd? See scripts/prestruct-proxy.service for the unit file.
25
+ */
26
+
27
+ module.exports = {
28
+ apps: [
29
+ {
30
+ name: 'prestruct-proxy',
31
+ script: 'scripts/proxy.js',
32
+ cwd: __dirname,
33
+ exec_mode: 'fork', // single browser pool -- never clusterify
34
+ instances: 1,
35
+ max_memory_restart: '700M', // warm Chromium headroom; tune per box
36
+ restart_delay: 3000, // back off crashes instead of hot-looping
37
+ kill_timeout: 3000, // let the browser drain on restart
38
+ env: {
39
+ NODE_ENV: 'production',
40
+ // Set PRESTRUCT_SECRET in your shell (or an EnvironmentFile with
41
+ // systemd) rather than committing it here.
42
+ },
43
+ },
44
+ ],
45
+ }
@@ -0,0 +1,30 @@
1
+ // eslint.config.js
2
+ // Prestruct ESLint config
3
+ export default [
4
+ {
5
+ files: ['**/*.js', '**/*.mjs'],
6
+ languageOptions: {
7
+ ecmaVersion: 'latest',
8
+ sourceType: 'module',
9
+ },
10
+ rules: {
11
+ 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
12
+ 'no-console': 'off',
13
+ 'no-process-exit': 'off',
14
+ 'prefer-const': 'error',
15
+ 'no-var': 'error',
16
+ },
17
+ },
18
+ {
19
+ files: ['scripts/**/*.js'],
20
+ rules: {
21
+ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
22
+ },
23
+ },
24
+ {
25
+ files: ['tests/**/*.test.js'],
26
+ rules: {
27
+ 'no-unused-vars': 'off',
28
+ },
29
+ },
30
+ ]
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
7
+ <!--
8
+ Shell template. Do not add brand meta here directly.
9
+
10
+ inject-brand.js populates these tags at build time from ssr.config.js:
11
+ title, description, author, keywords, canonical,
12
+ Open Graph, Twitter Card, JSON-LD.
13
+
14
+ The placeholder values below give the inject regex something to match.
15
+ They are replaced before the site is deployed -- never served as-is.
16
+ -->
17
+
18
+ <!-- Primary SEO (populated by inject-brand.js) -->
19
+ <title>Site</title>
20
+ <meta name="description" content="" />
21
+ <meta name="keywords" content="" />
22
+ <meta name="author" content="" />
23
+ <meta name="robots" content="index, follow" />
24
+ <link rel="canonical" href="https://yoursite.com/" />
25
+
26
+ <!--
27
+ Favicons -- replace with your own.
28
+ Generate from https://favicon.io or similar.
29
+ -->
30
+ <!-- <link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" /> -->
31
+ <!-- <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> -->
32
+ <!-- <link rel="manifest" href="/site.webmanifest" /> -->
33
+
34
+ <!--
35
+ Fonts -- add your font imports here if needed.
36
+ Use <link rel="preconnect"> to warm the connection before the stylesheet.
37
+ -->
38
+ </head>
39
+ <body>
40
+ <div id="root"></div>
41
+ </body>
42
+ </html>