belt 0.6.9 → 0.6.10

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: d7f8eec96efe780f5d58b8242c0f2c4a8d6dc0e46f53e4484d1b10072ab059aa
4
- data.tar.gz: a065c3fbd7b2a5ada177a4b516a07314408988c53e498e302e88b0903c01029c
3
+ metadata.gz: b3fc587d6980e4a754477a032f264e7f36de83898b451b0134741e7ccbe46706
4
+ data.tar.gz: 34797d0fa6c1e05d3873a57ec3d2219c9c9fcea4cc13c443ed17c5d1b509b550
5
5
  SHA512:
6
- metadata.gz: 3aca3754425f363160d92b60b9385a8b3efaada2a4182e059361acf42e6763001ee9f53834386705e5b638a54c32e2608025f154a284e3280053de096558f99e
7
- data.tar.gz: 4c314ee70436b6fd009493051349e83e2986956d0e42f7cf22d3a55461ad7f05024aecc7901428da8873fba8d1e0c5c03bf6559763a098c4822b3dadec105945
6
+ metadata.gz: 52ef8567694bcae88ca060b71f04e8ba4deb9766c1e99971bdb52ba3bcbdc66f9e54abc7fc888681bd37d90b2d7ada71de602484929c8dadfa97a58a3456bf15
7
+ data.tar.gz: 6ff546e948f0dd67b25d9386e7db16c10a7b4fb7387594c608dfb898d567ee947cf0c50e6999275fe8a170b15944d77a45a40f6a56d7f763a8235b41b41a3a7f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.10
4
+
5
+ ### Changed
6
+
7
+ - **SEO prerender is a belt deploy step, not app code.** `belt deploy frontend`
8
+ now prerenders any frontend with a `src/seo.js` manifest after `npm run build`:
9
+ it builds `src/entry-server.*` with `vite build --ssr` (when present) and writes
10
+ per-page HTML (title, description, canonical, Open Graph, JSON-LD),
11
+ `robots.txt`, `sitemap.xml` and `llms.txt` into the build output, so the
12
+ `--delete` sync reproduces them on every deploy. The script ships in the gem
13
+ (`lib/belt/frontend/prerender.mjs`), so existing apps opt in with just a
14
+ manifest instead of copying `scripts/prerender.js`. New React frontends build
15
+ with plain `vite build`. Frontends whose build already prerenders (0.6.6–0.6.9
16
+ templates) are detected and skipped; frontends without `src/seo.js` deploy as
17
+ before. (Fizzy #1930)
18
+
19
+ ### Added
20
+
21
+ - **`belt frontend prerender [--frontend NAME]`** builds a frontend and runs the
22
+ SEO prerender locally.
23
+ - **`public/robots.txt` overrides the generated robots.txt** in indexable
24
+ environments. Non-indexable environments still get `Disallow: /`.
25
+
3
26
  ## 0.6.9
4
27
 
5
28
  ### Fixed
@@ -7,6 +7,7 @@ require_relative 'app_detection'
7
7
  require_relative 'env_resolver'
8
8
  require_relative 'environment_config'
9
9
  require_relative 'frontend_env_map'
10
+ require_relative 'frontend_prerender'
10
11
  require_relative 'frontend_registry'
11
12
  require_relative 'terraform_command'
12
13
 
@@ -15,7 +16,7 @@ module Belt
15
16
  class FrontendDeployCommand
16
17
  include AppDetection
17
18
 
18
- PAGES_MANIFEST = '.belt-pages.json'
19
+ PAGES_MANIFEST = FrontendPrerender::PAGES_MANIFEST
19
20
  # Uploaded with no-cache instead of the immutable header hashed assets get
20
21
  REVALIDATED_FILES = %w[*.html robots.txt sitemap.xml llms.txt].freeze
21
22
 
@@ -112,9 +113,10 @@ module Belt
112
113
  env = frontend_build_env
113
114
  puts " Injecting env: #{env.keys.sort.join(', ')}" if env.any?
114
115
  run!(env, 'npm', 'run', 'build', chdir: @frontend.path)
116
+ FrontendPrerender.new(@frontend, env: env).run
115
117
  end
116
118
 
117
- # BELT_ENV and VITE_SITE_URL drive the SEO prerender step (scripts/prerender.js):
119
+ # BELT_ENV and VITE_SITE_URL drive the SEO prerender step (FrontendPrerender):
118
120
  # only indexable environments get crawled, and canonical/sitemap URLs need the
119
121
  # site's origin. The env map or the shell can override either.
120
122
  def frontend_build_env
@@ -162,7 +164,7 @@ module Belt
162
164
  upload_prerendered_pages(dist, bucket, pages)
163
165
  end
164
166
 
165
- # scripts/prerender.js writes dist/.belt-pages.json mapping each page's S3 key
167
+ # The prerender step writes dist/.belt-pages.json mapping each page's S3 key
166
168
  # to its file ({ "about" => "about.html" }). CloudFront has no directory
167
169
  # indexes, so /about must be an object named `about` served as text/html.
168
170
  def prerendered_pages(dist)
@@ -4,6 +4,7 @@ require_relative 'app_detection'
4
4
  require_relative 'env_resolver'
5
5
  require_relative 'environment_config'
6
6
  require_relative 'frontend_env_map'
7
+ require_relative 'frontend_prerender'
7
8
  require_relative 'frontend_registry'
8
9
  require_relative 'terraform_command'
9
10
 
@@ -11,6 +12,7 @@ module Belt
11
12
  module CLI
12
13
  # belt frontend env <environment> [--frontend NAME]
13
14
  # belt frontend list
15
+ # belt frontend prerender [--frontend NAME]
14
16
  #
15
17
  # Smart-merges terraform outputs into <frontend>/.env using the declarative map
16
18
  # (<frontend>/env.yml or .belt/frontend_env.yml for the default frontend).
@@ -25,6 +27,8 @@ module Belt
25
27
  run_env(args)
26
28
  when 'list', 'ls'
27
29
  run_list
30
+ when 'prerender'
31
+ run_prerender(args)
28
32
  when nil, '-h', '--help', 'help'
29
33
  puts usage
30
34
  exit(subcommand.nil? ? 1 : 0)
@@ -50,6 +54,19 @@ module Belt
50
54
  end
51
55
  end
52
56
 
57
+ # Local check of the SEO prerender `belt deploy frontend` runs: builds the
58
+ # frontend and prerenders from src/seo.js. Not indexable unless BELT_ENV or
59
+ # SEO_INDEXABLE says so; VITE_SITE_URL from the shell sets canonical URLs.
60
+ def self.run_prerender(args)
61
+ name = FrontendRegistry.extract_flag!(args, '--frontend') || args.reject { |a| a.start_with?('-') }.first
62
+ frontend = FrontendRegistry.new.resolve!(name)
63
+ prerender = FrontendPrerender.new(frontend)
64
+ abort "No #{frontend.path}/src/seo.js — nothing to prerender. See `belt explain seo`." unless prerender.manifest
65
+
66
+ abort "\n✗ npm run build failed in #{frontend.path}/" unless system('npm', 'run', 'build', chdir: frontend.path)
67
+ prerender.run
68
+ end
69
+
53
70
  def self.run_env(args)
54
71
  frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
55
72
  env = EnvResolver.resolve(args)
@@ -88,6 +105,7 @@ module Belt
88
105
  Usage:
89
106
  belt frontend env <environment> [--frontend NAME]
90
107
  belt frontend list
108
+ belt frontend prerender [--frontend NAME] Build + SEO prerender locally (src/seo.js)
91
109
  belt frontend --help
92
110
 
93
111
  Env map (optional, per frontend):
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'shellwords'
5
+
6
+ module Belt
7
+ module CLI
8
+ # Belt-owned SEO/AEO prerender step, run after a frontend's `npm run build`.
9
+ #
10
+ # Opt-in by manifest: a frontend with src/seo.js gets per-page HTML (title,
11
+ # description, canonical, Open Graph, JSON-LD), robots.txt, sitemap.xml and
12
+ # llms.txt written into its build output, so `aws s3 sync --delete`
13
+ # reproduces them on every deploy. When src/entry-server.* exists it is built
14
+ # with `vite build --ssr` and each page's markup is prerendered too. No
15
+ # manifest → nothing happens.
16
+ class FrontendPrerender
17
+ SCRIPT = File.expand_path('../frontend/prerender.mjs', __dir__)
18
+ MANIFESTS = %w[src/seo.js src/seo.mjs].freeze
19
+ SSR_ENTRIES = %w[src/entry-server.jsx src/entry-server.tsx src/entry-server.js src/entry-server.ts].freeze
20
+ # Inside node_modules so the SSR bundle resolves the app's dependencies and
21
+ # existing apps don't grow an untracked build directory.
22
+ SSR_OUT_DIR = 'node_modules/.cache/belt-ssr'
23
+ PAGES_MANIFEST = '.belt-pages.json'
24
+
25
+ def initialize(frontend, env: {})
26
+ @frontend = frontend
27
+ @env = env
28
+ end
29
+
30
+ def manifest
31
+ @manifest ||= MANIFESTS.find { |file| File.file?(File.join(@frontend.path, file)) }
32
+ end
33
+
34
+ # Frontends scaffolded by belt 0.6.6–0.6.9 run their own scripts/prerender.js
35
+ # inside `npm run build`; their output is already in dist.
36
+ def prerendered_by_build?
37
+ File.file?(File.join(@frontend.dist_dir, PAGES_MANIFEST))
38
+ end
39
+
40
+ # Returns true when it prerendered, false when the frontend didn't opt in.
41
+ def run
42
+ return false unless manifest
43
+
44
+ if prerendered_by_build?
45
+ puts '🔎 SEO: prerendered by the app build (scripts/prerender.js) — skipping belt prerender'
46
+ return false
47
+ end
48
+
49
+ puts "🔎 SEO: prerendering pages from #{manifest}"
50
+ ssr = build_ssr_bundle
51
+ cmd = ['node', SCRIPT,
52
+ '--manifest', File.expand_path(manifest, @frontend.path),
53
+ '--dist', File.expand_path(@frontend.dist_dir),
54
+ '--public', File.expand_path('public', @frontend.path)]
55
+ cmd.push('--ssr', ssr) if ssr
56
+ return true if system(@env, *cmd, chdir: @frontend.path)
57
+
58
+ abort "\n✗ SEO prerender failed: #{cmd.shelljoin}"
59
+ end
60
+
61
+ private
62
+
63
+ # Builds the SSR bundle and returns its entry path, or nil when there's no
64
+ # entry or it doesn't build (typically a module touching window/document at
65
+ # import time). Without it, pages still get their tags, sitemap and robots.
66
+ def build_ssr_bundle
67
+ entry = SSR_ENTRIES.find { |file| File.file?(File.join(@frontend.path, file)) }
68
+ return nil unless entry
69
+
70
+ out_dir = File.join(@frontend.path, SSR_OUT_DIR)
71
+ ok = system(@env, 'npx', 'vite', 'build', '--ssr', entry, '--outDir', SSR_OUT_DIR, '--emptyOutDir',
72
+ '--logLevel', 'warn', chdir: @frontend.path)
73
+ unless ok
74
+ warn "⚠️ SSR build of #{entry} failed — pages get SEO tags but no prerendered content"
75
+ return nil
76
+ end
77
+
78
+ # Vite emits ESM; make sure Node loads it as such whatever the app's package type.
79
+ File.write(File.join(out_dir, 'package.json'), "#{JSON.generate(type: 'module')}\n")
80
+ name = File.basename(entry, '.*')
81
+ bundle = %w[.js .mjs].map { |ext| File.join(out_dir, "#{name}#{ext}") }.find { |f| File.file?(f) }
82
+ bundle && File.expand_path(bundle)
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/belt/cli.rb CHANGED
@@ -166,6 +166,7 @@ module Belt
166
166
  dns show Show root zone name servers for registrar
167
167
  frontend env <env> [--frontend NAME] Write <frontend>/.env from terraform outputs
168
168
  frontend list List configured frontends
169
+ frontend prerender [--frontend NAME] Build + SEO prerender a frontend locally
169
170
  routes [-g PATTERN] [-f json] Show route definitions
170
171
  contracts [-g PATTERN] [-f json] Show API request/response contracts
171
172
  lambda-config [-e ENV] [-f json|terraform] Show merged lambda configuration
@@ -89,27 +89,47 @@ fallback. See the existing env-map behavior: only mapped keys are written into
89
89
 
90
90
  Search crawlers and AI answer engines (ChatGPT, Claude, Perplexity, Google AI)
91
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:
92
+ invisible to them. Belt prerenders public pages as part of `belt deploy frontend`.
93
+ It's opt-in by manifest — the app owns the content, belt owns the mechanism:
94
94
 
95
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`
96
+ src/seo.js # the manifest: site name/description, public pages, private paths
97
+ src/entry-server.jsx # optional: exports render(url) → HTML of <App /> at that URL
99
98
  ```
100
99
 
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`.
100
+ React frontends from `belt generate frontend react` ship both. Any other Vite
101
+ frontend opts in by adding `src/seo.js` (same shape as the React template's). A
102
+ frontend without one deploys exactly as before.
103
+
104
+ After `npm run build`, belt builds `src/entry-server.*` with `vite build --ssr`
105
+ (into `node_modules/.cache/belt-ssr`) and, for every entry in `seo.pages`, writes
106
+ real HTML into the build output with the page's rendered content, `<title>`, meta
107
+ description, canonical URL, Open Graph tags and JSON-LD. It also writes
108
+ `robots.txt`, `sitemap.xml` (from the same page list; needs a site URL) and
109
+ `llms.txt`. No SSR entry, or one that fails to build, still gets the tags,
110
+ sitemap and robots — just no prerendered content. Because it all lands in the
111
+ build output, every deploy's `s3 sync --delete` reproduces it; nothing is
112
+ uploaded out of band. Each page goes to its extensionless S3 key (`/pricing` →
113
+ `pricing`, served as `text/html`), and HTML and crawler files are served with
114
+ `no-cache`.
115
+
116
+ `robots.txt`: in indexable environments a project `public/robots.txt` is used
117
+ verbatim; otherwise belt generates `Allow: /`, a `Disallow:` per private path and
118
+ the `Sitemap:` line. Non-indexable environments always get `Disallow: /`.
119
+
120
+ Preview locally with `belt frontend prerender [--frontend NAME]` (builds, then
121
+ prerenders into `dist/`). Frontends scaffolded by belt 0.6.6–0.6.9 prerender inside
122
+ `npm run build` (`scripts/prerender.js`); belt sees their `dist/.belt-pages.json`
123
+ and skips its own step.
107
124
 
108
125
  **When you add a public route, add it to `seo.pages`** with a specific title and a
109
126
  one- or two-sentence description that says plainly what the page is. Login-gated
110
127
  routes go in `privatePaths`. Components on public pages must render without
111
128
  `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.
129
+ during prerender still gets its tags, but no content, and the deploy warns.
130
+ The SSR build uses the app's `vite.config.js`, so a library that touches the
131
+ browser at import time (Cognito/Amplify, analytics) can be stubbed with a plugin
132
+ that only applies when `isSsrBuild` is true — public pages render signed-out.
113
133
 
114
134
  Only environments in `seo.indexableEnvironments` (default `['prod']`) are
115
135
  indexable; every other environment and local builds ship `Disallow: /` and
@@ -1,31 +1,51 @@
1
1
  /**
2
- * Post-build SEO/AEO step (runs as part of `npm run build`).
2
+ * Belt's SEO/AEO prerender step. `belt deploy frontend` (and `belt frontend
3
+ * prerender`) run it after `npm run build` for any frontend with a src/seo.js
4
+ * manifest. The app owns the content (seo.js); belt owns the mechanism.
3
5
  *
4
6
  * 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/:
7
+ * empty <div id="root"> tells them nothing. From the manifest this writes into dist/:
6
8
  *
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
9
+ * index.html, <path>.html — each page in seo.pages rendered to HTML (via the SSR
10
+ * bundle of src/entry-server.*, when there is one) with its
11
+ * own <title>, description, canonical URL, Open Graph tags
12
+ * and JSON-LD
13
+ * robots.txt — allows crawlers in indexable environments, `Disallow: /`
14
+ * elsewhere. A project public/robots.txt wins when indexable.
11
15
  * sitemap.xml — every public page (needs a site URL)
12
16
  * llms.txt — plain-text site summary for LLMs (https://llmstxt.org)
13
17
  * .belt-pages.json — S3 key → file map; `belt deploy frontend` uploads each page
14
18
  * to its extensionless key (/about → about.html) as text/html
15
19
  *
20
+ * Usage: node prerender.mjs --manifest src/seo.js --dist dist [--ssr entry-server.js] [--public public]
21
+ *
16
22
  * Environment:
17
23
  * VITE_SITE_URL canonical origin (belt deploy injects the frontend URL)
18
24
  * BELT_ENV environment being built (belt deploy injects it)
19
25
  * SEO_INDEXABLE "true"/"false" to override seo.indexableEnvironments
20
26
  */
21
27
  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'
28
+ import { dirname, join, resolve } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
30
+ import { parseArgs } from 'node:util'
31
+
32
+ const { values: args } = parseArgs({
33
+ options: {
34
+ manifest: { type: 'string' },
35
+ dist: { type: 'string' },
36
+ ssr: { type: 'string' },
37
+ public: { type: 'string' }
38
+ }
39
+ })
40
+ if (!args.manifest || !args.dist) {
41
+ console.error('Usage: node prerender.mjs --manifest src/seo.js --dist dist [--ssr entry-server.js] [--public public]')
42
+ process.exit(1)
43
+ }
25
44
 
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')
45
+ const DIST = resolve(args.dist)
46
+ const SSR_ENTRY = args.ssr ? resolve(args.ssr) : null
47
+ const PROJECT_ROBOTS = args.public ? join(resolve(args.public), 'robots.txt') : null
48
+ const seo = (await import(pathToFileURL(resolve(args.manifest)).href)).default
29
49
 
30
50
  const siteUrl = (process.env.VITE_SITE_URL || seo.siteUrl || '').replace(/\/+$/, '')
31
51
  const indexable = process.env.SEO_INDEXABLE
@@ -44,6 +64,7 @@ const escapeHtml = (s) =>
44
64
  const absolute = (path) => `${siteUrl}${path === '/' ? '/' : path}`
45
65
 
46
66
  async function loadRenderer() {
67
+ if (!SSR_ENTRY) return null
47
68
  if (!existsSync(SSR_ENTRY)) {
48
69
  console.warn(`⚠️ ${SSR_ENTRY} not found — pages get SEO tags but no prerendered content`)
49
70
  return null
@@ -116,6 +137,7 @@ function pageHtml(template, page, body) {
116
137
 
117
138
  function robotsTxt() {
118
139
  if (!indexable) return 'User-agent: *\nDisallow: /\n'
140
+ if (PROJECT_ROBOTS && existsSync(PROJECT_ROBOTS)) return readFileSync(PROJECT_ROBOTS, 'utf-8')
119
141
  const lines = ['User-agent: *', 'Allow: /', ...privatePaths.map((p) => `Disallow: ${p}`)]
120
142
  if (siteUrl) lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`)
121
143
  return `${lines.join('\n')}\n`
@@ -135,7 +157,7 @@ function llmsTxt() {
135
157
  async function main() {
136
158
  const indexFile = join(DIST, 'index.html')
137
159
  if (!existsSync(indexFile)) {
138
- console.error('dist/index.html not found — run `vite build` first')
160
+ console.error(`${indexFile} not found — run \`vite build\` first`)
139
161
  process.exit(1)
140
162
  }
141
163
  if (!pages.some((page) => page.path === '/')) pages.unshift({ path: '/', title: seo.siteName })
@@ -162,6 +184,7 @@ async function main() {
162
184
  writeFileSync(join(DIST, 'llms.txt'), llmsTxt())
163
185
  if (siteUrl) writeFileSync(join(DIST, 'sitemap.xml'), sitemapXml())
164
186
 
187
+ if (PROJECT_ROBOTS && indexable && existsSync(PROJECT_ROBOTS)) console.log('SEO: robots.txt from public/robots.txt')
165
188
  if (!siteUrl) console.warn('⚠️ No site URL (VITE_SITE_URL or seo.siteUrl) — skipped canonical URLs and sitemap.xml')
166
189
  console.log(
167
190
  `SEO: prerendered ${pages.length} page(s), ${indexable ? 'indexable' : 'noindex'}` +
@@ -169,4 +192,4 @@ async function main() {
169
192
  )
170
193
  }
171
194
 
172
- main()
195
+ await main()
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.9'
4
+ VERSION = '0.6.10'
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`.
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
- "build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.js",
8
+ "build": "vite build",
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
@@ -2,7 +2,8 @@ import { renderToString } from 'react-dom/server'
2
2
  import { StaticRouter } from 'react-router-dom/server'
3
3
  import App from './App'
4
4
 
5
- // Build-time render used by scripts/prerender.js — not shipped to the browser.
5
+ // Build-time render used by belt's SEO prerender (`belt deploy frontend`) — not
6
+ // shipped to the browser.
6
7
  export function render(url) {
7
8
  return renderToString(
8
9
  <StaticRouter location={url}>
@@ -1,10 +1,11 @@
1
1
  // SEO/AEO config — what search engines and AI answer engines (Google, Bing,
2
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.
3
+ // so `belt deploy frontend` prerenders every page listed here to real HTML.
4
4
  //
5
5
  // For each page the build writes static HTML with its own <title>, description,
6
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).
7
+ // llms.txt for the whole site. Preview locally with `belt frontend prerender`.
8
+ // A public/robots.txt replaces the generated one in indexable environments.
8
9
  //
9
10
  // Rules of thumb:
10
11
  // - Every public route gets an entry in `pages` with a specific title and a
@@ -276,8 +276,8 @@ answer engines (`belt explain frontend`). When you add or change a public page:
276
276
  `localStorage` during render (use `useEffect`), and no content that only appears
277
277
  after an API call if crawlers need to see it.
278
278
  - Use one `<h1>` per page and real links (`<Link>`/`<a href>`), not click handlers.
279
- - Check `npm run build` output: `SEO: prerendered N page(s)` with no warnings, and
280
- `dist/<page>.html` contains the content.
279
+ - Run `belt frontend prerender`: expect `SEO: prerendered N page(s)` with no
280
+ warnings, and `frontend/dist/<page>.html` containing the content.
281
281
 
282
282
  Only `prod` is indexable; other environments ship `noindex`.
283
283
 
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.9
4
+ version: 0.6.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -137,6 +137,7 @@ files:
137
137
  - lib/belt/cli/frontend_deploy_command.rb
138
138
  - lib/belt/cli/frontend_env_command.rb
139
139
  - lib/belt/cli/frontend_env_map.rb
140
+ - lib/belt/cli/frontend_prerender.rb
140
141
  - lib/belt/cli/frontend_registry.rb
141
142
  - lib/belt/cli/frontend_setup_command.rb
142
143
  - lib/belt/cli/generate_command.rb
@@ -179,6 +180,7 @@ files:
179
180
  - lib/belt/docs/structure.md
180
181
  - lib/belt/docs/testing.md
181
182
  - lib/belt/errors.rb
183
+ - lib/belt/frontend/prerender.mjs
182
184
  - lib/belt/helpers/cors_origin.rb
183
185
  - lib/belt/helpers/error_logging.rb
184
186
  - lib/belt/helpers/response.rb
@@ -214,7 +216,6 @@ files:
214
216
  - lib/templates/frontend/react/env.yml.example
215
217
  - lib/templates/frontend/react/index.html.erb
216
218
  - lib/templates/frontend/react/package.json.erb
217
- - lib/templates/frontend/react/scripts/prerender.js
218
219
  - lib/templates/frontend/react/src/App.jsx
219
220
  - lib/templates/frontend/react/src/entry-server.jsx
220
221
  - lib/templates/frontend/react/src/index.css