@symbo.ls/brender 3.14.661 → 3.14.663
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.
- package/CHANGELOG.md +74 -0
- package/dist/esm/env.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/prefetch.js +1 -1
- package/dist/esm/render.js +46 -102
- package/dist/esm/shell.js +8 -0
- package/env.js +62 -2
- package/index.js +8 -3
- package/package.json +11 -7
- package/prefetch.js +50 -10
- package/render.js +404 -324
- package/shell.js +187 -0
package/shell.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose a server render INTO an already-built app document (the "shell").
|
|
3
|
+
*
|
|
4
|
+
* `smbls build` with `"brender": true` pre-renders every static route after
|
|
5
|
+
* the bundler has written `dist/index.html`. That file is the only thing that
|
|
6
|
+
* boots the app — it carries the bundle's <script>/<link> tags (parcel, vite)
|
|
7
|
+
* or the inlined runtime and project (symbols-runner). Writing brender's
|
|
8
|
+
* standalone document over it (renderPage without `shell`) shipped pages with
|
|
9
|
+
* no app code at all: static markup that never became interactive. So the
|
|
10
|
+
* build path renders each route and puts the result into a copy of the shell
|
|
11
|
+
* instead:
|
|
12
|
+
*
|
|
13
|
+
* <head> route metadata first (each <title>/<meta>/canonical/icon the
|
|
14
|
+
* render produced replaces the shell's tag with the same identity —
|
|
15
|
+
* never a second <title>), then the shell's own head, then the
|
|
16
|
+
* render's font links and <style> blocks just before </head>.
|
|
17
|
+
* <body> `window.__BRENDER__=true` and the rendered markup right after
|
|
18
|
+
* <body>, BEFORE the shell's scripts. The client's createDomqlElement
|
|
19
|
+
* sees the flag, removes the server markup and its <style> blocks,
|
|
20
|
+
* and renders the app over the same body (packages/smbls
|
|
21
|
+
* createDomql.js fallbackRender) — no duplicated tree.
|
|
22
|
+
*
|
|
23
|
+
* Relative asset URLs (`src="./index.js"`, `href="style.css"`) on the shell's
|
|
24
|
+
* <script>/<link> tags are rebased by `depth` directories, so a route written
|
|
25
|
+
* to `docs/intro.html` or `about/index.html` still loads the bundle.
|
|
26
|
+
*
|
|
27
|
+
* Pure string work on purpose: the shell's inline scripts (symbols-runner
|
|
28
|
+
* inlines the whole runtime) must reach the output byte-for-byte, which a
|
|
29
|
+
* DOM parse + serialize round-trip does not guarantee.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const HEAD_OPEN = /<head(\s[^>]*)?>/i
|
|
33
|
+
const BODY_OPEN = /<body(\s[^>]*)?>/i
|
|
34
|
+
|
|
35
|
+
const attr = (tag, name) => {
|
|
36
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag)
|
|
37
|
+
return m ? (m[2] ?? m[3] ?? m[4] ?? '') : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The identity a head tag is deduplicated by — or null when the tag is not
|
|
41
|
+
// one the route's metadata owns (scripts, styles, stylesheets, preloads…).
|
|
42
|
+
export const headTagKey = (tag) => {
|
|
43
|
+
const lower = tag.slice(0, 12).toLowerCase()
|
|
44
|
+
if (lower.startsWith('<title')) return 'title'
|
|
45
|
+
if (lower.startsWith('<meta')) {
|
|
46
|
+
if (attr(tag, 'charset') !== null) return 'charset'
|
|
47
|
+
for (const a of ['name', 'property', 'http-equiv', 'itemprop']) {
|
|
48
|
+
const v = attr(tag, a)
|
|
49
|
+
if (v !== null) return `${a}:${v.toLowerCase()}`
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
if (lower.startsWith('<link')) {
|
|
54
|
+
const rel = (attr(tag, 'rel') || '').toLowerCase()
|
|
55
|
+
if (rel === 'canonical') return 'link:canonical'
|
|
56
|
+
if (rel === 'icon' || rel === 'shortcut icon') return `link:${rel}:${attr(tag, 'href') || ''}`
|
|
57
|
+
if (rel === 'alternate' && attr(tag, 'hreflang') !== null) return `link:alternate:${(attr(tag, 'hreflang') || '').toLowerCase()}`
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const HEAD_TAG = /<title[^>]*>[\s\S]*?<\/title\s*>|<meta\b[^>]*>|<link\b[^>]*>/gi
|
|
64
|
+
|
|
65
|
+
// Raw-text blocks: their content is never markup, so nothing below may match
|
|
66
|
+
// inside one. symbols-runner inlines the whole runtime, whose string literals
|
|
67
|
+
// contain `<link rel="icon" href="${href}"`, `<title>`, `<body>`…
|
|
68
|
+
const RAW_BLOCK = /<(script|style|noscript|template|textarea)\b[\s\S]*?<\/\1\s*>/gi
|
|
69
|
+
|
|
70
|
+
const rawRanges = (html) => [...html.matchAll(RAW_BLOCK)].map(m => [m.index, m.index + m[0].length, m[0]])
|
|
71
|
+
|
|
72
|
+
// Apply `fn` to the markup between raw blocks; `onRaw` (optional) to each
|
|
73
|
+
// raw block's text.
|
|
74
|
+
const mapOutsideRawBlocks = (html, fn, onRaw = (raw) => raw) => {
|
|
75
|
+
let out = ''
|
|
76
|
+
let last = 0
|
|
77
|
+
for (const [start, end, raw] of rawRanges(html)) {
|
|
78
|
+
out += fn(html.slice(last, start)) + onRaw(raw)
|
|
79
|
+
last = end
|
|
80
|
+
}
|
|
81
|
+
return out + fn(html.slice(last))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// First match of `re` at or after `from` that does not start inside a raw block.
|
|
85
|
+
const findOutside = (html, re, from = 0) => {
|
|
86
|
+
const ranges = rawRanges(html)
|
|
87
|
+
const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')
|
|
88
|
+
g.lastIndex = from
|
|
89
|
+
let m
|
|
90
|
+
while ((m = g.exec(html))) {
|
|
91
|
+
const at = m.index
|
|
92
|
+
const inside = ranges.find(([s, e]) => at > s && at < e)
|
|
93
|
+
if (!inside) return m
|
|
94
|
+
g.lastIndex = inside[1]
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isRelativeUrl = (url) => !!url &&
|
|
100
|
+
!/^(?:[a-z][a-z0-9+.-]*:|\/|#|\?)/i.test(url)
|
|
101
|
+
|
|
102
|
+
// Quoted or unquoted (minifiers drop the quotes: `src=index.js`).
|
|
103
|
+
const rebaseTag = (tag, prefix) =>
|
|
104
|
+
tag.replace(/(\s(?:src|href)\s*=\s*)(?:(["'])([^"']*)\2|([^\s"'>]+))/gi, (whole, lead, q, quoted, bare) => {
|
|
105
|
+
const url = q ? quoted : bare
|
|
106
|
+
if (!isRelativeUrl(url)) return whole
|
|
107
|
+
const quote = q || ''
|
|
108
|
+
return `${lead}${quote}${prefix}${url.replace(/^\.\//, '')}${quote}`
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Rebase relative src/href on <script>/<link> tags by `depth` directories —
|
|
112
|
+
// the tags only: an inline script's TEXT is never touched.
|
|
113
|
+
const rebaseAssets = (html, depth) => {
|
|
114
|
+
if (!depth) return html
|
|
115
|
+
const prefix = '../'.repeat(depth)
|
|
116
|
+
return mapOutsideRawBlocks(
|
|
117
|
+
html,
|
|
118
|
+
(markup) => markup.replace(/<link\b[^>]*>/gi, (tag) => rebaseTag(tag, prefix)),
|
|
119
|
+
(raw) => /^<script\b/i.test(raw)
|
|
120
|
+
? raw.replace(/^<script\b[^>]*>/i, (tag) => rebaseTag(tag, prefix))
|
|
121
|
+
: raw
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} shell - the built app document (e.g. dist/index.html)
|
|
127
|
+
* @param {object} parts
|
|
128
|
+
* @param {string} [parts.headTags] - route metadata tags (generateHeadHtml)
|
|
129
|
+
* @param {string} [parts.headEnd] - font links + <style> blocks for </head>
|
|
130
|
+
* @param {string} [parts.body] - rendered body markup
|
|
131
|
+
* @param {number} [parts.depth=0] - directory depth of the output file
|
|
132
|
+
* @param {string} [parts.bootScripts] - inline scripts placed right after the
|
|
133
|
+
* `__BRENDER__` flag, before the markup (the opt-in hydration registry and
|
|
134
|
+
* fetch seed — renderPage `adoptDom`)
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export const composeIntoShell = (shell, { headTags = '', headEnd = '', body = '', depth = 0, bootScripts = '' } = {}) => {
|
|
138
|
+
if (typeof shell !== 'string' || !shell.trim()) {
|
|
139
|
+
throw new Error('brender: the shell document is empty')
|
|
140
|
+
}
|
|
141
|
+
let html = rebaseAssets(shell, depth)
|
|
142
|
+
|
|
143
|
+
const owned = new Set()
|
|
144
|
+
for (const m of headTags.matchAll(HEAD_TAG)) {
|
|
145
|
+
const key = headTagKey(m[0])
|
|
146
|
+
if (key) owned.add(key)
|
|
147
|
+
}
|
|
148
|
+
const dropOwned = (markup) => mapOutsideRawBlocks(markup, (segment) =>
|
|
149
|
+
segment.replace(HEAD_TAG, (tag) => owned.has(headTagKey(tag)) ? '' : tag)
|
|
150
|
+
)
|
|
151
|
+
const headIn = (headTags ? `\n${headTags}` : '')
|
|
152
|
+
const headOut = (headEnd ? `\n${headEnd}\n` : '')
|
|
153
|
+
|
|
154
|
+
const headOpen = findOutside(html, HEAD_OPEN)
|
|
155
|
+
const headClose = headOpen && findOutside(html, /<\/head\s*>/i, headOpen.index)
|
|
156
|
+
if (headOpen && headClose) {
|
|
157
|
+
const start = headOpen.index + headOpen[0].length
|
|
158
|
+
const end = headClose.index
|
|
159
|
+
html = html.slice(0, start) + headIn + dropOwned(html.slice(start, end)) + headOut + html.slice(end)
|
|
160
|
+
} else {
|
|
161
|
+
// Optional <head>/<body> tags omitted — parcel's minifier (htmlnano)
|
|
162
|
+
// emits `<html lang=en><script type=importmap>…<meta charset=utf-8>
|
|
163
|
+
// <title>…<script type=module src=…>` with neither. Metadata and styles
|
|
164
|
+
// go right after <html> (or the doctype), where the parser opens the
|
|
165
|
+
// implied head; the shell's own metadata tags of the same identity are
|
|
166
|
+
// dropped wherever they sit.
|
|
167
|
+
html = dropOwned(html)
|
|
168
|
+
const htmlOpen = findOutside(html, /<html(\s[^>]*)?>/i) || findOutside(html, /<!doctype[^>]*>/i)
|
|
169
|
+
const at = htmlOpen ? htmlOpen.index + htmlOpen[0].length : 0
|
|
170
|
+
html = html.slice(0, at) + headIn + headOut + html.slice(at)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const flagged = `\n<script>window.__BRENDER__=true</script>\n${bootScripts ? bootScripts + '\n' : ''}${body}\n`
|
|
174
|
+
const bodyOpen = findOutside(html, BODY_OPEN)
|
|
175
|
+
if (bodyOpen) {
|
|
176
|
+
// Right after <body>: before every shell script, so a classic inline
|
|
177
|
+
// boot script (symbols-runner) already finds the flag and the markup.
|
|
178
|
+
const at = bodyOpen.index + bodyOpen[0].length
|
|
179
|
+
return html.slice(0, at) + flagged + html.slice(at)
|
|
180
|
+
}
|
|
181
|
+
// No <body> tag: the markup opens the implied body at the end of the
|
|
182
|
+
// document. Such shells load the app with module scripts (parcel, vite),
|
|
183
|
+
// which run after parsing — the flag and the markup are in place by then.
|
|
184
|
+
const htmlClose = findOutside(html, /<\/(?:body|html)\s*>/i)
|
|
185
|
+
const at = htmlClose ? htmlClose.index : html.length
|
|
186
|
+
return html.slice(0, at) + flagged + html.slice(at)
|
|
187
|
+
}
|