@symbo.ls/brender 3.14.677 → 3.14.679

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/env.js CHANGED
@@ -101,6 +101,284 @@ const patchCustomPropertyNames = (document) => {
101
101
  })
102
102
  }
103
103
 
104
+ // ── Rules a browser keeps that linkedom's CSS parser cannot read ───────────
105
+ // linkedom gives every <style> a sheet from `cssom` 0.5: `style.sheet` parses
106
+ // the style's text, `sheet.insertRule` parses one rule. That parser knows
107
+ // @media, @supports, @font-face, @keyframes, @import, @document and @host and
108
+ // nothing newer: a rule that holds a container query threw ("Unexpected }").
109
+ // Under a `breakpointsFrom` ancestor the atomic engine writes every breakpoint
110
+ // as one (`@container breakpoints (max-width: 1024px){._cX{display:none}}`,
111
+ // @symbo.ls/element resolveMediaCssKey) and counts a throw as an invalid rule:
112
+ // the server page carried the class with no rule. On the merged landing
113
+ // (`Page` declares `breakpointsFrom: 'container'`) 153 of the 343 `_c`
114
+ // classes of `/` had none, and a phone's first frame showed the desktop header
115
+ // until the client replaced it (W7-FIXA). Reading the sheet of a <style> whose
116
+ // TEXT held such a rule threw too, and the render failed with it.
117
+ // Kept now, as a browser keeps them: a block rule whose at-keyword cssom does
118
+ // not know (@container, @layer, @scope, @starting-style), or an @media /
119
+ // @supports that holds one, is read as a grouping rule — its condition, its
120
+ // rules and its text — and a `@layer a, b;` statement as a statement. A rule
121
+ // a browser refuses still throws: text after the one rule (a stray `}`, a
122
+ // second rule), or a rule inside the block that cannot be read either. Every
123
+ // rule cssom reads is cssom's own, as before.
124
+ const GROUP_RULES = Symbol.for('@symbo.ls/brender:groupRules')
125
+ const GROUP_AT_RULE = /^@(container|layer|scope|starting-style|media|supports)(?![\w-])/i
126
+ const LAYER_STATEMENT = /^@layer\s+[-\w.]+(?:\s*,\s*[-\w.]+)*\s*;?$/i
127
+ // The CSSRule type a browser reports; a rule kind newer than the type
128
+ // constants reports 0.
129
+ const GROUP_RULE_TYPES = { media: 4, supports: 12 }
130
+
131
+ // The top-level rules of a CSS text, each as its own text; null when the text
132
+ // is not whole (an unclosed block, string or comment, a stray `}`). A comment
133
+ // between rules belongs to none of them.
134
+ const topLevelRules = (css) => {
135
+ const rules = []
136
+ let depth = 0
137
+ let start = 0
138
+ let quote = null
139
+ const take = (end) => {
140
+ const rule = css.slice(start, end).trim()
141
+ if (rule) rules.push(rule)
142
+ start = end
143
+ }
144
+ for (let i = 0; i < css.length; i++) {
145
+ const ch = css[i]
146
+ if (quote) {
147
+ if (ch === '\\') i++
148
+ else if (ch === quote) quote = null
149
+ continue
150
+ }
151
+ if (ch === '\\') {
152
+ i++
153
+ continue
154
+ }
155
+ if (ch === '/' && css[i + 1] === '*') {
156
+ const close = css.indexOf('*/', i + 2)
157
+ if (close === -1) return null
158
+ if (depth === 0 && !css.slice(start, i).trim()) start = close + 2
159
+ i = close + 1
160
+ continue
161
+ }
162
+ if (ch === '"' || ch === "'") quote = ch
163
+ else if (ch === '{') depth++
164
+ else if (ch === '}') {
165
+ if (depth === 0) return null
166
+ if (--depth === 0) take(i + 1)
167
+ } else if (ch === ';' && depth === 0) take(i + 1)
168
+ }
169
+ if (quote || depth) return null
170
+ take(css.length)
171
+ return rules
172
+ }
173
+
174
+ // A grouping rule cssom cannot read, the way a browser reads it.
175
+ class ServerGroupingRule {
176
+ constructor (keyword, condition, rules) {
177
+ this.keyword = keyword
178
+ this.condition = condition
179
+ this.cssRules = rules
180
+ this.parentRule = null
181
+ this.parentStyleSheet = null
182
+ for (const rule of rules) rule.parentRule = this
183
+ }
184
+
185
+ get type () { return GROUP_RULE_TYPES[this.keyword] || 0 }
186
+ get conditionText () { return this.condition }
187
+ get name () { return this.keyword === 'layer' ? this.condition : undefined }
188
+ get cssText () {
189
+ // the way cssom writes an @media rule: `@media <condition> {<rules>}`
190
+ let inner = ''
191
+ for (const rule of this.cssRules) inner += rule.cssText
192
+ return `@${this.keyword}${this.condition ? ' ' + this.condition : ''} {${inner}}`
193
+ }
194
+ }
195
+
196
+ // A `@layer a, b;` statement.
197
+ class ServerLayerStatementRule {
198
+ constructor (names) {
199
+ this.nameList = names
200
+ this.parentRule = null
201
+ this.parentStyleSheet = null
202
+ }
203
+
204
+ get type () { return 0 }
205
+ get cssText () { return `@layer ${this.nameList.join(', ')};` }
206
+ }
207
+
208
+ // One rule's text as a rule: cssom's reading of it, else the grouping rule
209
+ // (or @layer statement) a browser reads; else the error cssom threw.
210
+ // `cssomInsertRule` is cssom's own CSSStyleSheet#insertRule, run on a holder.
211
+ const readRule = (text, cssomInsertRule) => {
212
+ const holder = { cssRules: [] }
213
+ let error = null
214
+ try {
215
+ cssomInsertRule.call(holder, text, 0)
216
+ if (holder.cssRules[0]) return holder.cssRules[0]
217
+ } catch (e) {
218
+ error = e
219
+ }
220
+ const rule = groupingRuleOf(String(text).trim(), cssomInsertRule)
221
+ if (rule) return rule
222
+ throw error || new SyntaxError(`Failed to parse the rule '${text}'`)
223
+ }
224
+
225
+ const groupingRuleOf = (text, cssomInsertRule) => {
226
+ if (LAYER_STATEMENT.test(text)) {
227
+ return new ServerLayerStatementRule(text.replace(/^@layer\s+/i, '').replace(/\s*;$/, '').split(/\s*,\s*/))
228
+ }
229
+ const at = GROUP_AT_RULE.exec(text)
230
+ if (!at) return null
231
+ // exactly one block rule: `<prelude>{<rules>}` and nothing after it
232
+ const whole = topLevelRules(text)
233
+ if (!whole || whole.length !== 1 || !text.endsWith('}')) return null
234
+ const open = blockStart(text)
235
+ if (open === -1) return null
236
+ const inner = topLevelRules(text.slice(open + 1, -1))
237
+ if (!inner) return null
238
+ const rules = []
239
+ for (const ruleText of inner) {
240
+ try {
241
+ rules.push(readRule(ruleText, cssomInsertRule))
242
+ } catch (e) {
243
+ return null
244
+ }
245
+ }
246
+ return new ServerGroupingRule(at[1].toLowerCase(), text.slice(at[0].length, open).trim(), rules)
247
+ }
248
+
249
+ // Where a rule's block opens: its first `{` outside strings and comments.
250
+ const blockStart = (text) => {
251
+ let quote = null
252
+ for (let i = 0; i < text.length; i++) {
253
+ const ch = text[i]
254
+ if (quote) {
255
+ if (ch === '\\') i++
256
+ else if (ch === quote) quote = null
257
+ continue
258
+ }
259
+ if (ch === '\\') {
260
+ i++
261
+ continue
262
+ }
263
+ if (ch === '/' && text[i + 1] === '*') {
264
+ const close = text.indexOf('*/', i + 2)
265
+ if (close === -1) return -1
266
+ i = close + 1
267
+ continue
268
+ }
269
+ if (ch === '"' || ch === "'") quote = ch
270
+ else if (ch === '{') return i
271
+ }
272
+ return -1
273
+ }
274
+
275
+ const adopt = (rule, sheet) => {
276
+ rule.parentStyleSheet = sheet
277
+ if (rule instanceof ServerGroupingRule) for (const inner of rule.cssRules) adopt(inner, sheet)
278
+ }
279
+
280
+ // Patch linkedom's sheets once per process (the prototypes are cssom's and
281
+ // linkedom's, shared by every document): insertRule keeps a rule cssom cannot
282
+ // read and a browser keeps; `style.sheet` of a text holding one reads that
283
+ // text rule by rule, as a browser does, dropping only a rule it cannot read.
284
+ const patchStyleSheetRules = (document) => {
285
+ const style = document.createElement('style')
286
+ let sheet
287
+ try { sheet = style.sheet } catch (e) { return }
288
+ let sheetProto = sheet && Object.getPrototypeOf(sheet)
289
+ while (sheetProto && !Object.prototype.hasOwnProperty.call(sheetProto, 'insertRule')) sheetProto = Object.getPrototypeOf(sheetProto)
290
+ if (!sheetProto || Object.prototype.hasOwnProperty.call(sheetProto, GROUP_RULES)) return
291
+ const cssomInsertRule = sheetProto.insertRule
292
+ if (typeof cssomInsertRule !== 'function') return
293
+ const SheetConstructor = sheet.constructor
294
+ Object.defineProperty(sheetProto, GROUP_RULES, { configurable: true, value: true })
295
+ Object.defineProperty(sheetProto, 'insertRule', {
296
+ configurable: true,
297
+ writable: true,
298
+ value: function insertRule (rule, index) {
299
+ try {
300
+ return cssomInsertRule.apply(this, arguments)
301
+ } catch (error) {
302
+ const text = String(rule)
303
+ const kept = groupingRuleOf(text.trim(), cssomInsertRule)
304
+ if (!kept) throw error
305
+ const at = index === undefined ? 0 : Number(index)
306
+ if (!(at >= 0 && at <= this.cssRules.length)) throw new RangeError('INDEX_SIZE_ERR')
307
+ adopt(kept, this)
308
+ this.cssRules.splice(at, 0, kept)
309
+ return at
310
+ }
311
+ }
312
+ })
313
+
314
+ let styleProto = Object.getPrototypeOf(style)
315
+ let desc
316
+ while (styleProto && !(desc = Object.getOwnPropertyDescriptor(styleProto, 'sheet'))) styleProto = Object.getPrototypeOf(styleProto)
317
+ if (!styleProto || !desc || typeof desc.get !== 'function' || typeof SheetConstructor !== 'function') return
318
+ const readSheet = desc.get
319
+ // the sheet read from a style's text, while the text holds
320
+ const textSheets = new WeakMap()
321
+ Object.defineProperty(styleProto, 'sheet', {
322
+ configurable: true,
323
+ enumerable: desc.enumerable,
324
+ get () {
325
+ try {
326
+ return readSheet.call(this)
327
+ } catch (error) {
328
+ const text = this.textContent
329
+ const known = textSheets.get(this)
330
+ if (known && known.text === text) return known.sheet
331
+ const rules = topLevelRules(text)
332
+ if (!rules) throw error
333
+ const read = new SheetConstructor()
334
+ for (const rule of rules) {
335
+ try { read.insertRule(rule, read.cssRules.length) } catch (e) { /* a browser drops a rule it cannot read */ }
336
+ }
337
+ textSheets.set(this, { text, sheet: read })
338
+ return read
339
+ }
340
+ }
341
+ })
342
+ }
343
+
344
+ // ── A page timer that throws ends nothing ──────────────────────────────────
345
+ // In a browser, a timer callback that throws (or whose promise rejects) is a
346
+ // line in the console, and the page goes on. In Node it is an uncaught
347
+ // exception (an unhandled rejection), and a host that runs renders ends the
348
+ // process on one: the smbls CLI's handler exits 1. W7-FIXA (fly/landing-fly
349
+ // 1.5.0, a dry run that rendered every static route): `/ask` (fly/core
350
+ // askReveal / askStay) scheduled `win.setTimeout(...)` callbacks that call
351
+ // `win.getComputedStyle`, which the server window does not have; they fired
352
+ // during the next four renders, and `smbls publish` stopped in its
353
+ // pre-render, before it published. The window's own timers (setTimeout,
354
+ // setInterval, the requestAnimationFrame shim) now run their callback inside
355
+ // a try and report what it throws, once per message. What stays: page code
356
+ // that calls the BARE `setTimeout` gets Node's own, and a timer a render
357
+ // left behind still fires during a later render (render.js "One render at a
358
+ // time").
359
+ const reportedTimerErrors = new Set()
360
+ const reportTimerError = (err) => {
361
+ const message = (err && err.message) || String(err)
362
+ if (reportedTimerErrors.has(message)) return
363
+ if (reportedTimerErrors.size >= 200) reportedTimerErrors.clear()
364
+ reportedTimerErrors.add(message)
365
+ console.warn(`[brender] page code threw in a timer; reported as a browser reports it, the render goes on (once per message): ${message}`)
366
+ }
367
+ const containedCallback = (fn) => function (...args) {
368
+ try {
369
+ const out = fn.apply(this, args)
370
+ if (out && typeof out.then === 'function') out.then(null, reportTimerError)
371
+ return out
372
+ } catch (err) {
373
+ reportTimerError(err)
374
+ }
375
+ }
376
+ // The process's timer, read when the page calls it (as the window read it
377
+ // before: a host's fake timers still apply). Node refuses a callback that is
378
+ // not a function; so does this.
379
+ const pageTimer = (name) => (fn, ...rest) =>
380
+ globalThis[name](typeof fn === 'function' ? containedCallback(fn) : fn, ...rest)
381
+
104
382
  // Web Storage for one environment.
105
383
  const createStorage = () => {
106
384
  const store = {}
@@ -150,14 +428,19 @@ export const createEnv = (html = '<!DOCTYPE html><html><head></head><body></body
150
428
  pushState: () => {},
151
429
  replaceState: () => {},
152
430
  state: null
153
- }
431
+ },
432
+ // the page's timers: a callback that throws is reported, never the end of
433
+ // the process (see "A page timer that throws ends nothing"); the bare
434
+ // globals stay Node's
435
+ setTimeout: pageTimer('setTimeout'),
436
+ setInterval: pageTimer('setInterval')
154
437
  }
155
438
  const { window, document } = parseHTML(html, own)
156
439
 
157
440
  // Stub APIs that DOMQL/smbls may call during rendering
158
441
  if (!window.requestAnimationFrame) {
159
442
  window.requestAnimationFrame = (fn) => {
160
- const id = setTimeout(fn, 0)
443
+ const id = setTimeout(typeof fn === 'function' ? containedCallback(fn) : fn, 0)
161
444
  if (id?.unref) id.unref()
162
445
  return id
163
446
  }
@@ -202,6 +485,12 @@ export const createEnv = (html = '<!DOCTYPE html><html><head></head><body></body
202
485
  // as before.
203
486
  patchCustomPropertyNames(document)
204
487
 
488
+ // linkedom's sheets parse with `cssom` 0.5, which cannot read a container
489
+ // query: every breakpoint under a `breakpointsFrom` ancestor lost its rule
490
+ // (W7-FIXA: 153 of the 343 `_c` classes of the merged landing's `/`). A rule
491
+ // a browser keeps is kept (see patchStyleSheetRules).
492
+ patchStyleSheetRules(document)
493
+
205
494
  // Stub MutationObserver for SSR (not provided by linkedom)
206
495
  if (!window.MutationObserver) {
207
496
  window.MutationObserver = class MutationObserver {
package/faces.js ADDED
@@ -0,0 +1,193 @@
1
+ // ── The page's font faces: one sheet, marked for a host ─────────────────────
2
+ // renderPage writes every @font-face rule of the page into ONE
3
+ // `<style data-smbls-faces>` ahead of the server's styles (render.js,
4
+ // facesStyle). XMA r12, ask 2 (mirc #430, decided in #440/#441): a host
5
+ // that serves a returning reader the faces it already keeps by address, and
6
+ // the rest inline, needs to tell kept faces from new ones and to warm the
7
+ // files. So the sheet carries two marks:
8
+ //
9
+ // data-smbls-faces-version a content hash of the sheet's rules — equal
10
+ // rules (same text, same order) give the same
11
+ // version, any change to a rule a new one
12
+ // data-smbls-faces-files per @font-face rule, in rule order, its file
13
+ // address: the first url() of its `src` (a
14
+ // `local()` source before it is skipped), as the
15
+ // rule names it; `-` for a rule that names no
16
+ // file (no url(), or a data: url); a space in an
17
+ // address is written %20
18
+ //
19
+ // The marks describe the faces BY ADDRESS. `smbls build` marks each page
20
+ // again after its file: URLs point into the build output (the version then
21
+ // follows the files the page names, and every page of a build carries the
22
+ // same one when the font URLs are site-root or absolute); its
23
+ // `brender.faces.inline` step moves files into the sheet as data: URLs and
24
+ // keeps both marks as they are, so an inlined face still names its file.
25
+ // No sheet file is written: the host builds a by-address sheet from the list.
26
+ import { createHash } from 'crypto'
27
+ import { stylesheetRules } from './pageWeight.js'
28
+
29
+ export const FACES_SHEET_ATTR = 'data-smbls-faces'
30
+ export const FACES_VERSION_ATTR = 'data-smbls-faces-version'
31
+ export const FACES_FILES_ATTR = 'data-smbls-faces-files'
32
+ // The list entry of a rule that names no file.
33
+ export const NO_FACE_FILE = '-'
34
+
35
+ const FACE_RULE_START = /^@font-face\b/i
36
+ const isFaceRule = (rule) => typeof rule === 'string' && FACE_RULE_START.test(rule.trim())
37
+
38
+ // The declarations of one rule's block, name (lowercase) → value, read past
39
+ // strings and parentheses: a `;` inside `url(data:…;base64,…)` or a quoted
40
+ // url does not end a declaration.
41
+ const declarationsOf = (rule) => {
42
+ const out = {}
43
+ const open = rule.indexOf('{')
44
+ if (open === -1) return out
45
+ let start = open + 1
46
+ let quote = null
47
+ let parens = 0
48
+ const take = (end) => {
49
+ const text = rule.slice(start, end)
50
+ const colon = text.indexOf(':')
51
+ if (colon !== -1) {
52
+ const name = text.slice(0, colon).trim().toLowerCase()
53
+ if (name && !(name in out)) out[name] = text.slice(colon + 1).trim()
54
+ }
55
+ start = end + 1
56
+ }
57
+ for (let i = start; i < rule.length; i++) {
58
+ const ch = rule[i]
59
+ if (quote) {
60
+ if (ch === '\\') i++
61
+ else if (ch === quote) quote = null
62
+ continue
63
+ }
64
+ if (ch === '\\') { i++; continue }
65
+ if (ch === '"' || ch === "'") quote = ch
66
+ else if (ch === '(') parens++
67
+ else if (ch === ')') parens = Math.max(0, parens - 1)
68
+ else if (parens) continue
69
+ else if (ch === ';') take(i)
70
+ else if (ch === '}') { take(i); break }
71
+ }
72
+ return out
73
+ }
74
+
75
+ // A code point from a CSS hex escape (an invalid one is U+FFFD).
76
+ const codePoint = (hex) => {
77
+ const n = parseInt(hex, 16)
78
+ return n > 0 && n <= 0x10FFFF && !(n >= 0xD800 && n <= 0xDFFF) ? String.fromCodePoint(n) : '�'
79
+ }
80
+
81
+ // The text of a url() argument: quotes off, CSS escapes resolved.
82
+ const urlText = (text) => {
83
+ const t = text.trim()
84
+ const q = t[0]
85
+ const body = (q === '"' || q === "'") && t.length > 1 && t[t.length - 1] === q ? t.slice(1, -1) : t
86
+ return body.replace(/\\(?:([0-9a-fA-F]{1,6})\s?|([\s\S]))/g, (m, hex, ch) => hex ? codePoint(hex) : ch).trim()
87
+ }
88
+
89
+ /**
90
+ * The file address a `src` value names: its first `url()` (a `local()`
91
+ * before it is skipped). null when it names none, or only a data: url.
92
+ * @param {string} src - the value of a `@font-face` rule's `src`
93
+ * @returns {string|null}
94
+ */
95
+ export const faceFileOf = (src) => {
96
+ if (typeof src !== 'string') return null
97
+ let quote = null
98
+ for (let i = 0; i < src.length; i++) {
99
+ const ch = src[i]
100
+ if (quote) {
101
+ if (ch === '\\') i++
102
+ else if (ch === quote) quote = null
103
+ continue
104
+ }
105
+ if (ch === '"' || ch === "'") { quote = ch; continue }
106
+ if (!/^url\(/i.test(src.slice(i, i + 4)) || /[\w-]/.test(src[i - 1] || '')) continue
107
+ // the argument: a quoted string, or the raw text up to the `)`
108
+ let j = i + 4
109
+ while (j < src.length && /\s/.test(src[j])) j++
110
+ let end
111
+ if (src[j] === '"' || src[j] === "'") {
112
+ end = j + 1
113
+ while (end < src.length && src[end] !== src[j]) end += src[end] === '\\' ? 2 : 1
114
+ end++
115
+ } else {
116
+ end = src.indexOf(')', j)
117
+ if (end === -1) end = src.length
118
+ }
119
+ const value = urlText(src.slice(j, end))
120
+ return value && !/^data:/i.test(value) ? value : null
121
+ }
122
+ return null
123
+ }
124
+
125
+ // A whitespace character in an address, percent-encoded: the list is
126
+ // space-separated.
127
+ const listEntry = (file) => file
128
+ ? file.replace(/\s/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))
129
+ : NO_FACE_FILE
130
+
131
+ /**
132
+ * The marks of a faces sheet, from its rules.
133
+ * @param {string|string[]} rules - the sheet's text, or its rules in order
134
+ * @returns {{ version: string, files: string[] }|null} null for a sheet with
135
+ * no @font-face rule, or a text that cannot be read whole
136
+ */
137
+ export const facesSheetMarks = (rules) => {
138
+ const list = typeof rules === 'string' ? stylesheetRules(rules) : Array.isArray(rules) ? rules : null
139
+ if (!list) return null
140
+ const all = list.map((rule) => String(rule).trim()).filter(Boolean)
141
+ const faces = all.filter(isFaceRule)
142
+ if (!faces.length) return null
143
+ return {
144
+ version: createHash('sha256').update(all.join('\n')).digest('hex').slice(0, 12),
145
+ files: faces.map((rule) => listEntry(faceFileOf(declarationsOf(rule).src)))
146
+ }
147
+ }
148
+
149
+ const escapeAttr = (value) => String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
150
+
151
+ const marksText = (marks) => marks
152
+ ? ` ${FACES_VERSION_ATTR}="${escapeAttr(marks.version)}" ${FACES_FILES_ATTR}="${escapeAttr(marks.files.join(' '))}"`
153
+ : ''
154
+
155
+ /**
156
+ * The page's `<style data-smbls-faces>` for its @font-face rules, marked.
157
+ * @param {string[]} rules - the rules, in order
158
+ * @returns {string} '' for no rule
159
+ */
160
+ export const facesSheetTag = (rules) => {
161
+ if (!Array.isArray(rules) || !rules.length) return ''
162
+ return `<style ${FACES_SHEET_ATTR}${marksText(facesSheetMarks(rules))}>\n${rules.join('\n')}\n</style>`
163
+ }
164
+
165
+ // A <style> element, or a <script> one (skipped: its text is code, not markup).
166
+ const STYLE_OR_SCRIPT = /<script\b[\s\S]*?<\/script\s*>|<style\b([^>]*)>([\s\S]*?)(<\/style\s*>)/gi
167
+ const SHEET_ATTR = /(^|\s)data-smbls-faces(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?(?=[\s/]|$)/i
168
+ const OLD_MARK = /\s(?:data-smbls-faces-version|data-smbls-faces-files)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi
169
+
170
+ /**
171
+ * Mark a page's faces sheet again from the rules it holds now — after a
172
+ * step changed the addresses its rules name (`smbls build` maps file: URLs
173
+ * into the build output). The sheet's text and its other attributes stay as
174
+ * they are; the two marks are written again after `data-smbls-faces`. A page
175
+ * without a faces sheet, or with one whose text cannot be read whole, comes
176
+ * back as it was.
177
+ * @param {string} html
178
+ * @returns {string}
179
+ */
180
+ export const markFacesSheet = (html) => {
181
+ if (typeof html !== 'string' || !html.includes(FACES_SHEET_ATTR)) return html
182
+ let done = false
183
+ return html.replace(STYLE_OR_SCRIPT, (whole, attrs, css, close) => {
184
+ if (done || attrs === undefined || !SHEET_ATTR.test(attrs)) return whole
185
+ done = true
186
+ const marks = facesSheetMarks(css)
187
+ if (!marks) return whole
188
+ const rest = attrs.replace(OLD_MARK, '')
189
+ const at = SHEET_ATTR.exec(rest)
190
+ const end = at.index + at[0].length
191
+ return `<style${rest.slice(0, end)}${marksText(marks)}${rest.slice(end)}>${css}${close}`
192
+ })
193
+ }
package/index.js CHANGED
@@ -12,6 +12,13 @@ import { composeIntoShell } from './shell.js'
12
12
  // or trims a stored page as it serves it (stylesheetRules reads a stored
13
13
  // stylesheet text back into the rules classRulesForPage takes).
14
14
  import { trimSpriteToPage, classRulesForPage, markupClasses, trimVariablesToPage, stylesheetRules } from './pageWeight.js'
15
+ // The faces sheet's marks (XMA r12): a caller that changes the addresses a
16
+ // page's faces name (`smbls build` maps file: URLs into its output) marks the
17
+ // sheet again.
18
+ import { markFacesSheet, facesSheetMarks } from './faces.js'
19
+ // The faces a page's elements draw (XMA r12 results, item 2): `smbls build`
20
+ // inlines and awaits only those.
21
+ import { drawnFaces } from './drawnFaces.js'
15
22
 
16
23
  export {
17
24
  createEnv,
@@ -42,7 +49,10 @@ export {
42
49
  classRulesForPage,
43
50
  markupClasses,
44
51
  trimVariablesToPage,
45
- stylesheetRules
52
+ stylesheetRules,
53
+ markFacesSheet,
54
+ facesSheetMarks,
55
+ drawnFaces
46
56
  }
47
57
 
48
58
  export default {
@@ -74,5 +84,8 @@ export default {
74
84
  classRulesForPage,
75
85
  markupClasses,
76
86
  trimVariablesToPage,
77
- stylesheetRules
87
+ stylesheetRules,
88
+ markFacesSheet,
89
+ facesSheetMarks,
90
+ drawnFaces
78
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.14.677",
3
+ "version": "3.14.679",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -25,6 +25,16 @@
25
25
  "development": "./load.js",
26
26
  "import": "./dist/esm/load.js",
27
27
  "default": "./dist/esm/load.js"
28
+ },
29
+ "./faces": {
30
+ "development": "./faces.js",
31
+ "import": "./dist/esm/faces.js",
32
+ "default": "./dist/esm/faces.js"
33
+ },
34
+ "./drawnFaces": {
35
+ "development": "./drawnFaces.js",
36
+ "import": "./dist/esm/drawnFaces.js",
37
+ "default": "./dist/esm/drawnFaces.js"
28
38
  }
29
39
  },
30
40
  "source": "index.js",
@@ -48,16 +58,16 @@
48
58
  "dependencies": {
49
59
  "@symbo.ls/css": "^3.14.782",
50
60
  "@symbo.ls/default-config": "^3.14.789",
51
- "@symbo.ls/element": "^3.14.818",
52
- "@symbo.ls/fetch": "^3.14.673",
53
- "@symbo.ls/helmet": "^3.14.672",
54
- "@symbo.ls/scratch": "^3.14.795",
55
- "@symbo.ls/utils": "^3.14.853",
61
+ "@symbo.ls/element": "^3.14.819",
62
+ "@symbo.ls/fetch": "^3.14.675",
63
+ "@symbo.ls/helmet": "^3.14.674",
64
+ "@symbo.ls/scratch": "^3.14.796",
65
+ "@symbo.ls/utils": "^3.14.855",
56
66
  "esbuild": "^0.28.1",
57
67
  "linkedom": "^0.16.8"
58
68
  },
59
69
  "peerDependencies": {
60
- "smbls": "^3.14.859"
70
+ "smbls": "^3.14.862"
61
71
  },
62
72
  "optionalDependencies": {
63
73
  "@symbo.ls/funcql": "^3.14.0"
package/render.js CHANGED
@@ -11,6 +11,7 @@ import { extractMetadata, generateHeadHtml } from './metadata.js'
11
11
  import { hydrate } from './hydrate.js'
12
12
  import { composeIntoShell } from './shell.js'
13
13
  import { trimSpriteToPage, classRulesForPage, markupClasses, trimVariablesToPage } from './pageWeight.js'
14
+ import { facesSheetTag } from './faces.js'
14
15
  import { fetchSSRTranslations } from './prefetch.js'
15
16
  import { createEarlySeedScript } from '@symbo.ls/fetch'
16
17
  import { headerEntries } from '@symbo.ls/fetch/rest'
@@ -2114,7 +2115,10 @@ export const renderPage = async (data, route = '/', options = {}) => {
2114
2115
  const emotionCSS = cssClasses === 'page'
2115
2116
  ? classRulesForPage(classRules, markupClasses(bodyHtml, typeof shell === 'string' ? shell : '')).join('\n')
2116
2117
  : classRules.join('\n')
2117
- const facesStyle = faceRules.length ? `<style data-smbls-faces>\n${faceRules.join('\n')}\n</style>` : ''
2118
+ // Marked for a host (faces.js): `data-smbls-faces-version` (a hash of the
2119
+ // rules) and `data-smbls-faces-files` (each rule's file address, in rule
2120
+ // order) — XMA r12, ask 2.
2121
+ const facesStyle = facesSheetTag(faceRules)
2118
2122
 
2119
2123
  // Global CSS (variables, reset, keyframes): this render's own init output.
2120
2124
  // The style tags, built once for both documents below.