@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/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
+ }