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.
@@ -10,7 +10,11 @@
10
10
  */
11
11
 
12
12
  const PLATFORMS = ['cloudflare', 'vercel', 'netlify']
13
- const CHANGEFREQS = ['daily', 'weekly', 'monthly', 'yearly']
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 { useLocation } from 'react-router-dom'
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
- const base = siteUrl || import.meta.env?.VITE_SITE_URL || ''
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(() => {
@@ -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
- const io = new IntersectionObserver((entries, observer) => {
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
- observer.unobserve(el)
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.0.0
1
+ 1.2.0
@@ -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=... # must match config.proxy.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
  *