belt 0.6.5 → 0.6.7

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: 39a9c396d05b2acfc5c69adc875f0050beb9395a2dc9f31dd4ac2d2221e011db
4
- data.tar.gz: ddf3e2d95ea98eafc808f0d2e144196257f4880fb1b8d65ae063dd27becb9213
3
+ metadata.gz: ef904a59417a3f019eab7e6339fc6d13290bf05ff3d1d435c434834cea189bc2
4
+ data.tar.gz: 93a8820ef48b523d41e7a4aa8d0544090b2472bc7805b96bfee9f0f45c8e8114
5
5
  SHA512:
6
- metadata.gz: f3463e2d5e75fc653437accecb76a3a66683cd28bf7781a9aadbef167b2cf3f18ad4d5e32fb3cb5f57c6e406929253f6f762ebecec5a4d20b2723c9a8118efd8
7
- data.tar.gz: f1ccdeca8439a8063940c981f9c05a24abb319bcb0aa76c7212e0883fc6a9c4db7ee10f0e2da1f93105d1bca68844bd108ca7e820b8a462c900d309d35231905
6
+ metadata.gz: b562534dbed0dcad13db7d4e6f0ff9ff99f469af4de025a682902375d39ffe0bf73447a749674443acb81632e40f2f2c4b0e34ceced872a37d4a904dd5725b7f
7
+ data.tar.gz: d0915177422b65ef6f9a2dfb542cdd63b578acdbae1abf97cc4699354400b046e507818f71f7756a73f54183916921d2ad6862c877c4b8385fc6feb22ebd7b2d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.6.7
6
+
7
+ ### Changed
8
+
9
+ - **Non-prod deploys auto-approve by default.** `belt deploy <env>` no longer
10
+ prompts for confirmation when the environment isn't `prod`/`production` — the
11
+ Terraform plan is applied straight away. `prod` still prompts unless you pass
12
+ `--auto`. Pass `--confirm` (alias `--no-auto`) to force the prompt on any
13
+ environment. No more typing `--auto` on every dev deploy.
14
+
15
+ ## 0.6.6
16
+
17
+ ### Added
18
+
19
+ - **SEO/AEO by default for React frontends.** `belt generate frontend react` now
20
+ prerenders every public page listed in `src/seo.js` at build time (React SSR via
21
+ `src/entry-server.jsx` + `scripts/prerender.js`), so crawlers and AI answer engines
22
+ that don't run JavaScript get real content plus a per-page `<title>`, description,
23
+ canonical URL, Open Graph tags and JSON-LD. The build also writes `robots.txt`,
24
+ `sitemap.xml` and `llms.txt`. Only `prod` is indexable by default; every other
25
+ environment ships `Disallow: /` + `noindex`. `belt explain seo`. (Fizzy #1385)
26
+ - **`belt deploy frontend` uploads prerendered pages** to their extensionless S3 keys
27
+ as `text/html`, passes `BELT_ENV` and `VITE_SITE_URL` to the build, and serves HTML,
28
+ `robots.txt`, `sitemap.xml` and `llms.txt` with `no-cache` instead of the immutable
29
+ header meant for hashed assets.
30
+
3
31
  ## 0.6.5
4
32
 
5
33
  ### Added
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>` |
@@ -32,6 +32,7 @@ module Belt
32
32
  end
33
33
 
34
34
  auto_approve = false
35
+ force_confirm = false
35
36
  rebuild = false
36
37
  skip_backup = false
37
38
  backup_only = false
@@ -42,6 +43,8 @@ module Belt
42
43
  case arg
43
44
  when '--auto', '--yes', '-y'
44
45
  auto_approve = true
46
+ when '--confirm', '--no-auto'
47
+ force_confirm = true
45
48
  when '--rebuild'
46
49
  rebuild = true
47
50
  when '--skip-backup', '--no-backup'
@@ -69,13 +72,14 @@ module Belt
69
72
  puts 'Usage: belt deploy [environment] [options]'
70
73
  puts "\nDefaults to BELT_ENV or the first available environment."
71
74
  puts "\nOptions:"
72
- puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
75
+ puts ' --auto, --yes, -y Skip confirmation prompt (prod only; non-prod auto-approves)'
76
+ puts ' --confirm, --no-auto Force the confirmation prompt even for non-prod envs'
73
77
  puts ' --rebuild Rebuild and push Lambda code directly (bypasses Terraform)'
74
78
  puts ' -h, --help Show this help'
75
79
  puts "\nExamples:"
76
- puts ' belt deploy # Deploy dev (or BELT_ENV)'
77
- puts ' belt deploy prod # Deploy to prod'
78
- puts ' belt deploy dev --auto # Deploy without confirmation'
80
+ puts ' belt deploy # Deploy dev (or BELT_ENV), no prompt'
81
+ puts ' belt deploy prod # Deploy to prod (prompts)'
82
+ puts ' belt deploy prod --auto # Deploy prod without confirmation'
79
83
  puts ' belt deploy --rebuild # Fast code push (no infra changes)'
80
84
  puts ' belt deploy frontend dev # Deploy frontend(s)'
81
85
  puts ' belt deploy frontend dev --frontend ops'
@@ -84,6 +88,10 @@ module Belt
84
88
  end
85
89
  end
86
90
 
91
+ # Non-prod environments auto-approve by default — prod always prompts
92
+ # unless --auto is explicit. Pass --confirm to force the prompt anywhere.
93
+ auto_approve = true if !force_confirm && !%w[prod production].include?(env)
94
+
87
95
  options = { auto_approve: auto_approve, skip_migrations: skip_migrations, extra_args: filtered_args }
88
96
  if backup_only
89
97
  new(env, **options, skip_backup: false).run_backup_only
@@ -108,13 +116,16 @@ module Belt
108
116
  4. Build sidecar Lambda zips Terraform needs at plan time
109
117
  5. terraform init (initialize providers/modules)
110
118
  6. terraform plan (preview changes)
111
- 7. Prompt for confirmation (unless --auto)
119
+ 7. Prompt for confirmation (prod only; non-prod auto-approves)
112
120
  8. terraform apply (deploy changes)
113
121
  9. Nested envs: share parent Cognito + copy DynamoDB if empty
114
122
  10. Run pending data migrations in db/migrate (see `belt db:migrate`)
115
123
 
116
124
  Options:
117
- --auto, --yes, -y Skip confirmation prompt (auto-approve)
125
+ --auto, --yes, -y Skip confirmation prompt (auto-approve). Non-prod
126
+ environments auto-approve by default, so this is only
127
+ needed for prod.
128
+ --confirm, --no-auto Force the confirmation prompt even for non-prod envs
118
129
  --rebuild Rebuild and push Lambda code directly (bypasses Terraform).
119
130
  Much faster for code-only changes — packages gems via Docker,
120
131
  zips, and pushes with `aws lambda update-function-code`.
@@ -140,9 +151,10 @@ module Belt
140
151
  end
141
152
 
142
153
  Examples:
143
- belt deploy # Deploy dev (or BELT_ENV)
144
- belt deploy prod # Deploy to prod
145
- belt deploy dev --auto # Deploy without confirmation (CI mode)
154
+ belt deploy # Deploy dev (or BELT_ENV), no prompt
155
+ belt deploy prod # Deploy to prod (prompts for confirmation)
156
+ belt deploy prod --auto # Deploy to prod without confirmation (CI mode)
157
+ belt deploy dev --confirm # Force the confirmation prompt on dev
146
158
  belt deploy --rebuild # Fast code push to dev (no infra changes)
147
159
  belt deploy prod --rebuild # Fast code push to prod
148
160
  belt deploy prod --skip-backup # Skip backups for this deploy
@@ -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
@@ -7,13 +7,21 @@ Lambda packaging, and pre-deploy backups.
7
7
  ## Quick Deploy
8
8
 
9
9
  ```bash
10
- belt deploy <env> # init → plan → apply (interactive)
11
- belt deploy prod --auto # skip confirmation prompt
10
+ belt deploy <env> # init → plan → apply
11
+ belt deploy prod # prod prompts for confirmation
12
+ belt deploy prod --auto # skip prod's confirmation prompt (CI)
13
+ belt deploy dev # non-prod auto-approves — no prompt
14
+ belt deploy dev --confirm # force the prompt on a non-prod env
12
15
  belt deploy prod --skip-backup # skip pre-deploy backup
13
16
  belt deploy --backup-only # create recovery point without deploying
14
17
  belt deploy dev --skip-migrations # don't run pending db/migrate migrations
15
18
  ```
16
19
 
20
+ Non-prod environments auto-approve the Terraform plan by default — only `prod`
21
+ (and `production`) prompt. Pass `--confirm` (alias `--no-auto`) to force the
22
+ prompt anywhere; `--auto` still works everywhere and is what you want in CI for
23
+ prod.
24
+
17
25
  ## First-Time Setup
18
26
 
19
27
  ```bash
@@ -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
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.5'
4
+ VERSION = '0.6.7'
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.5
4
+ version: 0.6.7
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