@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/CHANGELOG.md +55 -0
- package/README.md +12 -0
- package/dist/esm/drawnFaces.js +3 -0
- package/dist/esm/env.js +1 -1
- package/dist/esm/faces.js +5 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/render.js +25 -28
- package/drawnFaces.js +783 -0
- package/env.js +291 -2
- package/faces.js +193 -0
- package/index.js +15 -2
- package/package.json +17 -7
- package/render.js +5 -1
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, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
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.
|
|
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.
|
|
52
|
-
"@symbo.ls/fetch": "^3.14.
|
|
53
|
-
"@symbo.ls/helmet": "^3.14.
|
|
54
|
-
"@symbo.ls/scratch": "^3.14.
|
|
55
|
-
"@symbo.ls/utils": "^3.14.
|
|
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.
|
|
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
|
-
|
|
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.
|