belt 0.6.5 → 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 +4 -4
- data/CHANGELOG.md +16 -0
- data/SKILL.md +1 -0
- data/lib/belt/cli/explain_command.rb +2 -0
- data/lib/belt/cli/frontend_deploy_command.rb +49 -6
- data/lib/belt/docs/frontend.md +35 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/index.html.erb +1 -0
- data/lib/templates/frontend/react/package.json.erb +1 -1
- data/lib/templates/frontend/react/scripts/prerender.js +172 -0
- data/lib/templates/frontend/react/src/App.jsx +5 -6
- data/lib/templates/frontend/react/src/entry-server.jsx +12 -0
- data/lib/templates/frontend/react/src/main.jsx +15 -2
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +3 -3
- data/lib/templates/frontend/react/src/seo.js.erb +27 -0
- data/lib/templates/new_app/AGENTS.md.erb +17 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd4b86a1b7dc7acf4f88189a24b5d39c5cfcf71f512aafb5f04fb0af6d558a65
|
|
4
|
+
data.tar.gz: cdc8752352bca6b2ff3cce8a9abcf249c6975c0949d3a95b745be7fc2096544c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 380937f0579d856a0f34407d5d1496306d5c9be64fb192d409e0bb78d2f6faea1d2016ae7db773312d92ff3f71876681b8e0d0384f2fc9127abd7eca386f5fbc
|
|
7
|
+
data.tar.gz: 927a7c107926e787be5fe8ade9a0df86429b75ece20468176d4afa8de6ef15ccd10b3d99471c3cc52f3f7870dc6e5c18486a1578d0c6a8a58565a0d06c93023d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.6.5
|
|
4
20
|
|
|
5
21
|
### 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>` |
|
|
@@ -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',
|
|
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
|
-
#
|
|
143
|
-
run!('aws', 's3', '
|
|
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
|
data/lib/belt/docs/frontend.md
CHANGED
|
@@ -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
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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 {
|
|
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
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
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
|
-
|
|
7
|
+
const root = document.getElementById('root')
|
|
8
|
+
const app = (
|
|
7
9
|
<React.StrictMode>
|
|
8
|
-
<
|
|
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
|
|
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
|
|
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
|
|
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
|
+
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
|