belt 0.6.4 → 0.6.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fcb629702530cd06ffb9af298dcd259c4fc0503107ef19ab58a58da476617b4
4
- data.tar.gz: 167a12259478e5079620446f76dad474cde1d456f08b328663fe11f82860fa66
3
+ metadata.gz: dd4b86a1b7dc7acf4f88189a24b5d39c5cfcf71f512aafb5f04fb0af6d558a65
4
+ data.tar.gz: cdc8752352bca6b2ff3cce8a9abcf249c6975c0949d3a95b745be7fc2096544c
5
5
  SHA512:
6
- metadata.gz: bb37dd0fe9e0cd392a0824d14b0ab4c51c5d002b36240fc7795ad11ce2313dd0014bea535c020fab6e4ec8988c7cf4d5a90992e2f8cfcdbcea35bc780586592f
7
- data.tar.gz: d8d7423e5b3cd0c3d1598ff7b9e82446c9de4705b68313ade0ee0479ec26a87b5d4e3acd23a52b664ec2f50fd37e66cb046e1383f18bbf696cd01a13d24b140e
6
+ metadata.gz: 380937f0579d856a0f34407d5d1496306d5c9be64fb192d409e0bb78d2f6faea1d2016ae7db773312d92ff3f71876681b8e0d0384f2fc9127abd7eca386f5fbc
7
+ data.tar.gz: 927a7c107926e787be5fe8ade9a0df86429b75ece20468176d4afa8de6ef15ccd10b3d99471c3cc52f3f7870dc6e5c18486a1578d0c6a8a58565a0d06c93023d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.6
4
+
5
+ ### Added
6
+
7
+ - **SEO/AEO by default for React frontends.** `belt generate frontend react` now
8
+ prerenders every public page listed in `src/seo.js` at build time (React SSR via
9
+ `src/entry-server.jsx` + `scripts/prerender.js`), so crawlers and AI answer engines
10
+ that don't run JavaScript get real content plus a per-page `<title>`, description,
11
+ canonical URL, Open Graph tags and JSON-LD. The build also writes `robots.txt`,
12
+ `sitemap.xml` and `llms.txt`. Only `prod` is indexable by default; every other
13
+ environment ships `Disallow: /` + `noindex`. `belt explain seo`. (Fizzy #1385)
14
+ - **`belt deploy frontend` uploads prerendered pages** to their extensionless S3 keys
15
+ as `text/html`, passes `BELT_ENV` and `VITE_SITE_URL` to the build, and serves HTML,
16
+ `robots.txt`, `sitemap.xml` and `llms.txt` with `no-cache` instead of the immutable
17
+ header meant for hashed assets.
18
+
19
+ ## 0.6.5
20
+
21
+ ### Added
22
+
23
+ - **Per-route throttling in `routes.rb`.** Any route or resource accepts
24
+ `throttle: { rate: 2, burst: 5 }` (requests/second + burst). `belt routes -f json` emits it
25
+ as `throttle` on the route and conveyor-belt 0.0.20+ applies it as API Gateway stage method
26
+ settings, winning over the Lambda's `throttle:` in `config/lambda/<fn>.yml`. Malformed values
27
+ raise at load time. (Fizzy #1756)
28
+
3
29
  ## 0.6.4
4
30
 
5
31
  ### Added
data/README.md CHANGED
@@ -209,6 +209,15 @@ end
209
209
 
210
210
  **Key point:** `namespace` and `scope` are purely organizational — they affect URL paths and controller module resolution but never change which Lambda handles the request. Use `function` when you need routes to go to a different Lambda.
211
211
 
212
+ **Per-route throttling:** any route or resource accepts `throttle: { rate:, burst: }` (steady-state requests per second plus the burst the token bucket absorbs). `belt routes -f json` emits it and the conveyor-belt provider (0.0.20+) applies it as API Gateway stage method settings. A route's `throttle` wins over the serving Lambda's `throttle:` in `config/lambda/<fn>.yml`, which in turn wins over the provider's stage-level `throttle` block.
213
+
214
+ ```ruby
215
+ gateway :api, auth: :none do
216
+ post '/connect/chat', to: 'chat#create', throttle: { rate: 2, burst: 5 }
217
+ resources :leads, only: [:create], throttle: { rate: 1, burst: 3 }
218
+ end
219
+ ```
220
+
212
221
  ### Nested Resource DSL
213
222
 
214
223
  Inside a `resources` block, you can use Rails-like `member`, `collection`, `scope`, and singular `resource` for DRYer route definitions:
data/SKILL.md CHANGED
@@ -56,6 +56,7 @@ belt doctor # check deps + AWS config
56
56
  | Deploy to AWS | `belt deploy <env>` (`--auto` to skip prompt) |
57
57
  | Poke at data live | `belt console <env>` |
58
58
  | Run a local frontend | `belt server` (see references for env targeting) |
59
+ | Make a public page findable (SEO/AEO) | Add it to `frontend/src/seo.js` → `pages`; see `belt explain seo` |
59
60
  | Add Cognito auth | `belt generate auth` → `cognito_authenticatable` in a model |
60
61
  | Build a plugin | `belt plugin new <name>` |
61
62
  | Learn a concept | `belt explain <topic>` |
@@ -45,6 +45,8 @@ module Belt
45
45
  'repl' => 'console',
46
46
  'frontends' => 'frontend',
47
47
  'spa' => 'frontend',
48
+ 'seo' => 'frontend',
49
+ 'aeo' => 'frontend',
48
50
  'seeds' => 'data_seeding',
49
51
  'seed' => 'data_seeding',
50
52
  'seeding' => 'data_seeding',
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'shellwords'
4
5
  require 'open3'
5
6
  require_relative 'app_detection'
@@ -14,6 +15,10 @@ module Belt
14
15
  class FrontendDeployCommand
15
16
  include AppDetection
16
17
 
18
+ PAGES_MANIFEST = '.belt-pages.json'
19
+ # Uploaded with no-cache instead of the immutable header hashed assets get
20
+ REVALIDATED_FILES = %w[*.html robots.txt sitemap.xml llms.txt].freeze
21
+
17
22
  def self.run(args)
18
23
  frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
19
24
  env = EnvResolver.resolve(args)
@@ -109,12 +114,19 @@ module Belt
109
114
  run!(env, 'npm', 'run', 'build', chdir: @frontend.path)
110
115
  end
111
116
 
117
+ # BELT_ENV and VITE_SITE_URL drive the SEO prerender step (scripts/prerender.js):
118
+ # only indexable environments get crawled, and canonical/sitemap URLs need the
119
+ # site's origin. The env map or the shell can override either.
112
120
  def frontend_build_env
113
- FrontendEnvMap.new(
121
+ env = FrontendEnvMap.new(
114
122
  @env,
115
123
  env_dir: @env_dir,
116
124
  frontend_path: @frontend.path
117
125
  ).process_env
126
+ env['BELT_ENV'] ||= @env
127
+ site_url = ENV['VITE_SITE_URL'] || fetch_frontend_url
128
+ env['VITE_SITE_URL'] ||= site_url if site_url
129
+ env
118
130
  end
119
131
 
120
132
  def sync_to_s3
@@ -130,18 +142,49 @@ module Belt
130
142
  puts "☁️ Deploying to S3... (#{bucket})"
131
143
 
132
144
  dist_prefix = dist.end_with?('/') ? dist : "#{dist}/"
145
+ pages = prerendered_pages(dist)
133
146
 
134
- # Hashed assets get immutable cache headers
147
+ # Hashed assets get immutable cache headers. Prerendered page keys are
148
+ # excluded so --delete doesn't remove them before they're re-uploaded.
135
149
  run!('aws', 's3', 'sync', dist_prefix, "s3://#{bucket}", '--delete',
136
150
  '--size-only', '--cache-control', 'public, max-age=31536000, immutable',
137
- '--exclude', 'index.html')
151
+ *[*REVALIDATED_FILES, PAGES_MANIFEST].flat_map { |f| ['--exclude', f] },
152
+ *pages.keys.flat_map { |key| ['--exclude', key] })
138
153
 
139
154
  index = File.join(dist, 'index.html')
140
155
  abort "Error: #{index} not found after build." unless File.exist?(index)
141
156
 
142
- # index.html always revalidates
143
- run!('aws', 's3', 'cp', index, "s3://#{bucket}/index.html",
144
- '--cache-control', 'no-cache')
157
+ # HTML and crawler files (robots.txt, sitemap.xml, llms.txt) always revalidate
158
+ run!('aws', 's3', 'sync', dist_prefix, "s3://#{bucket}", '--delete',
159
+ '--cache-control', 'no-cache', '--exclude', '*',
160
+ *REVALIDATED_FILES.flat_map { |f| ['--include', f] })
161
+
162
+ upload_prerendered_pages(dist, bucket, pages)
163
+ end
164
+
165
+ # scripts/prerender.js writes dist/.belt-pages.json mapping each page's S3 key
166
+ # to its file ({ "about" => "about.html" }). CloudFront has no directory
167
+ # indexes, so /about must be an object named `about` served as text/html.
168
+ def prerendered_pages(dist)
169
+ manifest = File.join(dist, PAGES_MANIFEST)
170
+ return {} unless File.file?(manifest)
171
+
172
+ pages = JSON.parse(File.read(manifest))
173
+ pages.is_a?(Hash) ? pages : {}
174
+ rescue JSON::ParserError => e
175
+ warn "⚠️ Ignoring #{manifest}: #{e.message}"
176
+ {}
177
+ end
178
+
179
+ def upload_prerendered_pages(dist, bucket, pages)
180
+ return if pages.empty?
181
+
182
+ puts "🔎 Uploading #{pages.size} prerendered page(s)..."
183
+ pages.each do |key, file|
184
+ run!('aws', 's3', 'cp', File.join(dist, file), "s3://#{bucket}/#{key}",
185
+ '--content-type', 'text/html; charset=utf-8', '--cache-control', 'no-cache',
186
+ '--only-show-errors')
187
+ end
145
188
  end
146
189
 
147
190
  def invalidate_cloudfront
@@ -165,6 +165,7 @@ module Belt
165
165
  }
166
166
  rc = route.response_context.to_s
167
167
  hash[:response_context] = rc unless rc.empty?
168
+ hash[:throttle] = route.throttle if route.throttle
168
169
  hash
169
170
  end
170
171
 
@@ -85,6 +85,41 @@ The default `frontend/` directory also accepts `.belt/frontend_env.yml` as a
85
85
  fallback. See the existing env-map behavior: only mapped keys are written into
86
86
  `.env`; missing terraform outputs warn and do not clobber local values.
87
87
 
88
+ ## SEO and AI answer engines
89
+
90
+ Search crawlers and AI answer engines (ChatGPT, Claude, Perplexity, Google AI)
91
+ mostly don't run JavaScript, so an SPA that ships an empty `<div id="root">` is
92
+ invisible to them. React frontends from `belt generate frontend react` prerender
93
+ their public pages at build time:
94
+
95
+ ```
96
+ src/seo.js # site name/description, public pages, private paths
97
+ src/entry-server.jsx # renders <App /> at a URL (build time only)
98
+ scripts/prerender.js # runs at the end of `npm run build`
99
+ ```
100
+
101
+ For every entry in `seo.pages`, `npm run build` writes real HTML with the page's
102
+ rendered content, `<title>`, meta description, canonical URL, Open Graph tags and
103
+ JSON-LD. It also writes `robots.txt`, `sitemap.xml` (needs a site URL) and
104
+ `llms.txt`. `belt deploy frontend` uploads each page to its extensionless S3 key
105
+ (`/pricing` → `pricing`, served as `text/html`) and serves HTML and crawler files
106
+ with `no-cache`.
107
+
108
+ **When you add a public route, add it to `seo.pages`** with a specific title and a
109
+ one- or two-sentence description that says plainly what the page is. Login-gated
110
+ routes go in `privatePaths`. Components on public pages must render without
111
+ `window`/`document`/`localStorage` — touch those in `useEffect`. A page that throws
112
+ during prerender still gets its tags, but no content, and the build warns.
113
+
114
+ Only environments in `seo.indexableEnvironments` (default `['prod']`) are
115
+ indexable; every other environment and local builds ship `Disallow: /` and
116
+ `noindex`, so dev and ephemeral environments stay out of search results.
117
+ `belt deploy frontend` sets `BELT_ENV` and `VITE_SITE_URL` (the frontend URL) for
118
+ the build; set `SEO_INDEXABLE=true|false` to override.
119
+
120
+ After the first prod deploy, submit `https://<domain>/sitemap.xml` to Google
121
+ Search Console and Bing Webmaster Tools.
122
+
88
123
  ## Infrastructure
89
124
 
90
125
  `belt setup frontend` (and `belt generate frontend`) writes S3 + CloudFront into
@@ -9,7 +9,7 @@ module Belt
9
9
 
10
10
  class Route
11
11
  attr_reader :method, :path, :auth, :lambda, :cors, :tables, :route_type,
12
- :controller, :action, :request_model, :response_model, :response_context
12
+ :controller, :action, :request_model, :response_model, :response_context, :throttle
13
13
 
14
14
  def initialize(method, path, options = {})
15
15
  @method = method.to_s.upcase
@@ -17,6 +17,7 @@ module Belt
17
17
  @auth = options[:auth]
18
18
  @lambda = options[:lambda]
19
19
  @cors = options.fetch(:cors, true)
20
+ @throttle = self.class.normalize_throttle(options[:throttle])
20
21
  @tables = options[:tables] || []
21
22
  @route_type = options[:route_type] || :action
22
23
  @controller = options[:controller]
@@ -26,6 +27,31 @@ module Belt
26
27
  @response_context = options[:response_context]&.to_s
27
28
  end
28
29
 
30
+ # Per-route API Gateway limit: `throttle: { rate: 2, burst: 5 }` (requests per
31
+ # second, token bucket). Emitted by `belt routes -f json` and applied by the
32
+ # conveyor-belt provider (0.0.20+) as stage method settings; it wins over the
33
+ # Lambda's `throttle:` in config/lambda/<fn>.yml. Returns nil when unset.
34
+ def self.normalize_throttle(raw)
35
+ return nil if raw.nil?
36
+
37
+ unless raw.is_a?(Hash)
38
+ raise ArgumentError, "throttle must be a Hash like { rate: 2, burst: 5 } (got #{raw.inspect})"
39
+ end
40
+
41
+ rate = raw[:rate] || raw['rate']
42
+ burst = raw[:burst] || raw['burst']
43
+ raise ArgumentError, 'throttle needs rate (requests/second) and burst' if rate.nil? || burst.nil?
44
+
45
+ unless rate.is_a?(Numeric) && rate.positive?
46
+ raise ArgumentError, "throttle rate must be a number > 0 (got #{rate.inspect})"
47
+ end
48
+ unless burst.is_a?(Integer) && burst.positive?
49
+ raise ArgumentError, "throttle burst must be an integer > 0 (got #{burst.inspect})"
50
+ end
51
+
52
+ { rate: rate, burst: burst }
53
+ end
54
+
29
55
  def resource?
30
56
  @route_type == :resource || @route_type == :resources
31
57
  end
@@ -494,7 +520,8 @@ module Belt
494
520
  action: action,
495
521
  request_model: options[:request_model],
496
522
  response_model: options[:response_model],
497
- response_context: options[:response_context]
523
+ response_context: options[:response_context],
524
+ throttle: options[:throttle]
498
525
  }
499
526
 
500
527
  @routes << Belt::Route.new(method, path, route_options)
data/lib/belt/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.6.4'
4
+ VERSION = '0.6.6'
5
5
 
6
6
  # Ruby version new apps run on in Lambda. Rendered into the conveyor-belt
7
7
  # provider block (`ruby_version`) by `belt new` / `belt generate environment`.
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title><%= @module_name %></title>
7
+ <meta name="description" content="<%= @module_name %>" />
7
8
  </head>
8
9
  <body>
9
10
  <div id="root"></div>
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
- "build": "vite build",
8
+ "build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.js",
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Post-build SEO/AEO step (runs as part of `npm run build`).
3
+ *
4
+ * Search and AI answer-engine crawlers mostly don't run JavaScript, so an SPA's
5
+ * empty <div id="root"> tells them nothing. Using src/seo.js, this writes into dist/:
6
+ *
7
+ * index.html, <path>.html — each page in seo.pages rendered to HTML (via
8
+ * dist-ssr/entry-server.js) with its own <title>,
9
+ * description, canonical URL, Open Graph tags and JSON-LD
10
+ * robots.txt — allows crawlers in indexable environments, `Disallow: /` elsewhere
11
+ * sitemap.xml — every public page (needs a site URL)
12
+ * llms.txt — plain-text site summary for LLMs (https://llmstxt.org)
13
+ * .belt-pages.json — S3 key → file map; `belt deploy frontend` uploads each page
14
+ * to its extensionless key (/about → about.html) as text/html
15
+ *
16
+ * Environment:
17
+ * VITE_SITE_URL canonical origin (belt deploy injects the frontend URL)
18
+ * BELT_ENV environment being built (belt deploy injects it)
19
+ * SEO_INDEXABLE "true"/"false" to override seo.indexableEnvironments
20
+ */
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
22
+ import { dirname, join } from 'node:path'
23
+ import { fileURLToPath, pathToFileURL } from 'node:url'
24
+ import seo from '../src/seo.js'
25
+
26
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
27
+ const DIST = join(ROOT, 'dist')
28
+ const SSR_ENTRY = join(ROOT, 'dist-ssr', 'entry-server.js')
29
+
30
+ const siteUrl = (process.env.VITE_SITE_URL || seo.siteUrl || '').replace(/\/+$/, '')
31
+ const indexable = process.env.SEO_INDEXABLE
32
+ ? process.env.SEO_INDEXABLE === 'true'
33
+ : (seo.indexableEnvironments || ['prod']).includes(process.env.BELT_ENV)
34
+
35
+ const normalize = (path) => `/${path.replace(/^\/+|\/+$/g, '')}`
36
+ const privatePaths = (seo.privatePaths || []).map(normalize)
37
+ const isPrivate = (path) => privatePaths.some((p) => path === p || path.startsWith(`${p}/`))
38
+ const pages = (seo.pages || [])
39
+ .map((page) => ({ ...page, path: normalize(page.path) }))
40
+ .filter((page) => !isPrivate(page.path))
41
+
42
+ const escapeHtml = (s) =>
43
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
44
+ const absolute = (path) => `${siteUrl}${path === '/' ? '/' : path}`
45
+
46
+ async function loadRenderer() {
47
+ if (!existsSync(SSR_ENTRY)) {
48
+ console.warn(`⚠️ ${SSR_ENTRY} not found — pages get SEO tags but no prerendered content`)
49
+ return null
50
+ }
51
+ try {
52
+ return (await import(pathToFileURL(SSR_ENTRY).href)).render
53
+ } catch (error) {
54
+ console.warn(`⚠️ Could not load the SSR bundle (${error.message}) — pages get SEO tags but no prerendered content`)
55
+ return null
56
+ }
57
+ }
58
+
59
+ function renderBody(render, path) {
60
+ if (!render) return ''
61
+ try {
62
+ return render(path)
63
+ } catch (error) {
64
+ // Usually a component touching window/document/localStorage during render.
65
+ // Move that into useEffect to get the content prerendered.
66
+ console.warn(`⚠️ Prerender failed for ${path} (${error.message}) — serving SEO tags only`)
67
+ return ''
68
+ }
69
+ }
70
+
71
+ function jsonLd(page) {
72
+ const data = page.jsonLd || {
73
+ '@context': 'https://schema.org',
74
+ '@type': page.path === '/' ? 'WebSite' : 'WebPage',
75
+ name: page.title,
76
+ description: page.description,
77
+ ...(siteUrl ? { url: absolute(page.path) } : {})
78
+ }
79
+ return JSON.stringify(data).replace(/</g, '\\u003c')
80
+ }
81
+
82
+ function headTags(page) {
83
+ const tags = []
84
+ if (siteUrl) tags.push(`<link rel="canonical" href="${absolute(page.path)}" />`)
85
+ if (!indexable) tags.push('<meta name="robots" content="noindex, nofollow" />')
86
+ tags.push(
87
+ `<meta property="og:type" content="website" />`,
88
+ `<meta property="og:site_name" content="${escapeHtml(seo.siteName)}" />`,
89
+ `<meta property="og:title" content="${escapeHtml(page.title)}" />`,
90
+ `<meta property="og:description" content="${escapeHtml(page.description)}" />`
91
+ )
92
+ if (siteUrl) tags.push(`<meta property="og:url" content="${absolute(page.path)}" />`)
93
+ if (page.image) tags.push(`<meta property="og:image" content="${escapeHtml(page.image)}" />`)
94
+ tags.push(
95
+ `<meta name="twitter:card" content="${page.image ? 'summary_large_image' : 'summary'}" />`,
96
+ '<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt" />',
97
+ `<script type="application/ld+json">${jsonLd(page)}</script>`
98
+ )
99
+ return tags.map((tag) => ` ${tag}\n`).join('')
100
+ }
101
+
102
+ function pageHtml(template, page, body) {
103
+ return template
104
+ .replace(/<title>[\s\S]*?<\/title>/, () => `<title>${escapeHtml(page.title)}</title>`)
105
+ .replace(/\s*<meta name="description"[^>]*>/, '')
106
+ .replace(
107
+ '</head>',
108
+ () => ` <meta name="description" content="${escapeHtml(page.description)}" />\n${headTags(page)} </head>`
109
+ )
110
+ .replace(
111
+ '<div id="root"></div>',
112
+ // data-prerendered tells main.jsx to hydrate rather than render fresh
113
+ () => (body ? `<div id="root" data-prerendered="${escapeHtml(page.path)}">${body}</div>` : '<div id="root"></div>')
114
+ )
115
+ }
116
+
117
+ function robotsTxt() {
118
+ if (!indexable) return 'User-agent: *\nDisallow: /\n'
119
+ const lines = ['User-agent: *', 'Allow: /', ...privatePaths.map((p) => `Disallow: ${p}`)]
120
+ if (siteUrl) lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`)
121
+ return `${lines.join('\n')}\n`
122
+ }
123
+
124
+ function sitemapXml() {
125
+ const urls = pages.map((page) => ` <url><loc>${escapeHtml(absolute(page.path))}</loc></url>`).join('\n')
126
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`
127
+ }
128
+
129
+ function llmsTxt() {
130
+ const lines = [`# ${seo.siteName}`, '', `> ${seo.description}`, '', '## Pages', '']
131
+ for (const page of pages) lines.push(`- [${page.title}](${absolute(page.path)}): ${page.description}`)
132
+ return `${lines.join('\n')}\n`
133
+ }
134
+
135
+ async function main() {
136
+ const indexFile = join(DIST, 'index.html')
137
+ if (!existsSync(indexFile)) {
138
+ console.error('dist/index.html not found — run `vite build` first')
139
+ process.exit(1)
140
+ }
141
+ if (!pages.some((page) => page.path === '/')) pages.unshift({ path: '/', title: seo.siteName })
142
+ for (const page of pages) {
143
+ page.title ||= seo.siteName
144
+ page.description ||= seo.description
145
+ }
146
+
147
+ const template = readFileSync(indexFile, 'utf-8')
148
+ const render = await loadRenderer()
149
+ const manifest = {}
150
+
151
+ for (const page of pages) {
152
+ const html = pageHtml(template, page, renderBody(render, page.path))
153
+ const key = page.path.slice(1)
154
+ const file = key ? `${key}.html` : 'index.html'
155
+ mkdirSync(dirname(join(DIST, file)), { recursive: true })
156
+ writeFileSync(join(DIST, file), html)
157
+ if (key) manifest[key] = file
158
+ }
159
+
160
+ writeFileSync(join(DIST, '.belt-pages.json'), `${JSON.stringify(manifest, null, 2)}\n`)
161
+ writeFileSync(join(DIST, 'robots.txt'), robotsTxt())
162
+ writeFileSync(join(DIST, 'llms.txt'), llmsTxt())
163
+ if (siteUrl) writeFileSync(join(DIST, 'sitemap.xml'), sitemapXml())
164
+
165
+ if (!siteUrl) console.warn('⚠️ No site URL (VITE_SITE_URL or seo.siteUrl) — skipped canonical URLs and sitemap.xml')
166
+ console.log(
167
+ `SEO: prerendered ${pages.length} page(s), ${indexable ? 'indexable' : 'noindex'}` +
168
+ `${process.env.BELT_ENV ? ` (${process.env.BELT_ENV})` : ''}`
169
+ )
170
+ }
171
+
172
+ main()
@@ -1,13 +1,12 @@
1
- import { BrowserRouter, Routes, Route } from 'react-router-dom'
1
+ import { Routes, Route } from 'react-router-dom'
2
2
  import Home from './pages/Home'
3
3
 
4
+ // The router lives in main.jsx (browser) and entry-server.jsx (prerender).
4
5
  function App() {
5
6
  return (
6
- <BrowserRouter>
7
- <Routes>
8
- <Route path="/" element={<Home />} />
9
- </Routes>
10
- </BrowserRouter>
7
+ <Routes>
8
+ <Route path="/" element={<Home />} />
9
+ </Routes>
11
10
  )
12
11
  }
13
12
 
@@ -0,0 +1,12 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { StaticRouter } from 'react-router-dom/server'
3
+ import App from './App'
4
+
5
+ // Build-time render used by scripts/prerender.js — not shipped to the browser.
6
+ export function render(url) {
7
+ return renderToString(
8
+ <StaticRouter location={url}>
9
+ <App />
10
+ </StaticRouter>
11
+ )
12
+ }
@@ -1,10 +1,23 @@
1
1
  import React from 'react'
2
2
  import ReactDOM from 'react-dom/client'
3
+ import { BrowserRouter } from 'react-router-dom'
3
4
  import App from './App'
4
5
  import './index.css'
5
6
 
6
- ReactDOM.createRoot(document.getElementById('root')).render(
7
+ const root = document.getElementById('root')
8
+ const app = (
7
9
  <React.StrictMode>
8
- <App />
10
+ <BrowserRouter>
11
+ <App />
12
+ </BrowserRouter>
9
13
  </React.StrictMode>
10
14
  )
15
+
16
+ // Pages prerendered at build time hydrate. Anything else (including the SPA
17
+ // fallback serving index.html for a deep link) renders fresh.
18
+ const normalize = (path) => path.replace(/\/+$/, '') || '/'
19
+ if (root.dataset.prerendered && normalize(root.dataset.prerendered) === normalize(window.location.pathname)) {
20
+ ReactDOM.hydrateRoot(root, app)
21
+ } else {
22
+ ReactDOM.createRoot(root).render(app)
23
+ }
@@ -211,7 +211,7 @@ function Home() {
211
211
  if (loading) {
212
212
  return (
213
213
  <div className="belt-welcome">
214
- <style>{welcomeStyles}</style>
214
+ <style dangerouslySetInnerHTML={{ __html: welcomeStyles }} />
215
215
  <main className="status-main">
216
216
  <p className="muted">Connecting to API…</p>
217
217
  </main>
@@ -222,7 +222,7 @@ function Home() {
222
222
  if (error) {
223
223
  return (
224
224
  <div className="belt-welcome">
225
- <style>{welcomeStyles}</style>
225
+ <style dangerouslySetInnerHTML={{ __html: welcomeStyles }} />
226
226
  <main className="status-main">
227
227
  <h1><%= @module_name %></h1>
228
228
  <p className="error">Could not load welcome data from the API.</p>
@@ -242,7 +242,7 @@ function Home() {
242
242
 
243
243
  return (
244
244
  <div className="belt-welcome">
245
- <style>{welcomeStyles}</style>
245
+ <style dangerouslySetInnerHTML={{ __html: welcomeStyles }} />
246
246
 
247
247
  <div className="hero">
248
248
  {welcome.background_image ? (
@@ -0,0 +1,27 @@
1
+ // SEO/AEO config — what search engines and AI answer engines (Google, Bing,
2
+ // ChatGPT, Claude, Perplexity) see. Most of those crawlers don't run JavaScript,
3
+ // so `npm run build` prerenders every page listed here to real HTML.
4
+ //
5
+ // For each page the build writes static HTML with its own <title>, description,
6
+ // canonical URL, Open Graph tags and JSON-LD, plus robots.txt, sitemap.xml and
7
+ // llms.txt for the whole site (scripts/prerender.js).
8
+ //
9
+ // Rules of thumb:
10
+ // - Every public route gets an entry in `pages` with a specific title and a
11
+ // one- or two-sentence description that says plainly what the page is.
12
+ // - Login-gated or app-only routes go in `privatePaths` (disallowed in robots.txt).
13
+ // - Only `indexableEnvironments` are indexable. Every other environment, and
14
+ // local builds, ship `Disallow: /` and <meta name="robots" content="noindex">
15
+ // so dev and ephemeral environments never end up in search results.
16
+ export default {
17
+ siteName: '<%= @module_name %>',
18
+ description: '<%= @module_name %> — say what this app does and who it is for in one or two plain sentences.',
19
+ // Canonical production URL, e.g. 'https://example.com'. `belt deploy frontend`
20
+ // injects VITE_SITE_URL from terraform, which wins when set.
21
+ siteUrl: '',
22
+ indexableEnvironments: ['prod'],
23
+ pages: [
24
+ { path: '/', title: '<%= @module_name %>', description: '' }
25
+ ],
26
+ privatePaths: ['/login', '/signup', '/confirm']
27
+ }
@@ -259,6 +259,23 @@ belt setup tables <env> # Generate DynamoDB table resources
259
259
  belt deploy <env> # Deploy everything (init → plan → apply)
260
260
  ```
261
261
 
262
+ ## SEO / AEO (public pages)
263
+
264
+ If the app has a React frontend, its public pages are prerendered for search and AI
265
+ answer engines (`belt explain frontend`). When you add or change a public page:
266
+
267
+ - Add it to `frontend/src/seo.js` → `pages` with a specific `title` and a one- or
268
+ two-sentence `description` that names what the page is and who it's for.
269
+ - Put login-gated routes in `privatePaths` (they're disallowed in `robots.txt`).
270
+ - Keep the page's content in the rendered markup — no `window`/`document`/
271
+ `localStorage` during render (use `useEffect`), and no content that only appears
272
+ after an API call if crawlers need to see it.
273
+ - Use one `<h1>` per page and real links (`<Link>`/`<a href>`), not click handlers.
274
+ - Check `npm run build` output: `SEO: prerendered N page(s)` with no warnings, and
275
+ `dist/<page>.html` contains the content.
276
+
277
+ Only `prod` is indexable; other environments ship `noindex`.
278
+
262
279
  ## Key Differences from Rails
263
280
 
264
281
  | Rails | Belt |
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.4
4
+ version: 0.6.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -213,11 +213,14 @@ files:
213
213
  - lib/templates/frontend/react/env.yml.example
214
214
  - lib/templates/frontend/react/index.html.erb
215
215
  - lib/templates/frontend/react/package.json.erb
216
+ - lib/templates/frontend/react/scripts/prerender.js
216
217
  - lib/templates/frontend/react/src/App.jsx
218
+ - lib/templates/frontend/react/src/entry-server.jsx
217
219
  - lib/templates/frontend/react/src/index.css
218
220
  - lib/templates/frontend/react/src/lib/apiClient.js.erb
219
221
  - lib/templates/frontend/react/src/main.jsx
220
222
  - lib/templates/frontend/react/src/pages/Home.jsx.erb
223
+ - lib/templates/frontend/react/src/seo.js.erb
221
224
  - lib/templates/frontend/react/vite.config.js
222
225
  - lib/templates/frontend_infra/frontend.tf.erb
223
226
  - lib/templates/generate/auth/cognito.tf.erb