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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/belt/cli/frontend_deploy_command.rb +5 -3
- data/lib/belt/cli/frontend_env_command.rb +18 -0
- data/lib/belt/cli/frontend_prerender.rb +86 -0
- data/lib/belt/cli.rb +1 -0
- data/lib/belt/docs/frontend.md +32 -12
- data/lib/{templates/frontend/react/scripts/prerender.js → belt/frontend/prerender.mjs} +37 -14
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/package.json.erb +1 -1
- data/lib/templates/frontend/react/src/entry-server.jsx +2 -1
- data/lib/templates/frontend/react/src/seo.js.erb +3 -2
- data/lib/templates/new_app/AGENTS.md.erb +2 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3fc587d6980e4a754477a032f264e7f36de83898b451b0134741e7ccbe46706
|
|
4
|
+
data.tar.gz: 34797d0fa6c1e05d3873a57ec3d2219c9c9fcea4cc13c443ed17c5d1b509b550
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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 (
|
|
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
|
-
#
|
|
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
|
data/lib/belt/docs/frontend.md
CHANGED
|
@@ -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.
|
|
93
|
-
|
|
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 #
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
9
|
-
* description, canonical URL, Open Graph tags
|
|
10
|
-
*
|
|
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 {
|
|
24
|
-
import
|
|
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
|
|
27
|
-
const
|
|
28
|
-
const
|
|
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(
|
|
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
|
@@ -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
|
|
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 `
|
|
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
|
|
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
|
-
-
|
|
280
|
-
`dist/<page>.html`
|
|
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.
|
|
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
|