Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ Note:
The CSS of `<style>` elements applies to the whole tree, including the elements of components. The declarations of matching rules are merged with inline styles in cascade order, so the table above applies to them too. Selectors, specificity, `!important`, nesting, `@layer`, `@media`, `@supports`, `@container`, `@property` and the `media` attribute are supported, with these differences:

- `@media` matches the `width` and `height` of the image, on a static screen in light mode, e.g. `prefers-color-scheme: light` and `hover: none`.
- `@container` supports size queries, but `style()` and `scroll-state()` queries don't match.
- `@container` supports size queries, but `style()` and `scroll-state()` queries don't match. Container units like `cqw` are supported, but in values of custom properties, they're of the viewport.
- `@property` sets the initial values of custom properties and whether they're inherited, but values aren't checked or computed with its `syntax`.
- Invalid and unsupported declarations are dropped like in browsers, and passed to `onStyleError`. Values with `var()` are only checked when they're computed, where invalid values throw like in inline styles.
- `@supports` checks declarations the same way, but properties that Satori ignores count as supported. `selector()` is supported, but not `font-tech()` and `font-format()`.
Expand Down
187 changes: 163 additions & 24 deletions src/handler/stylesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ interface InitialValues {
notInherited: [name: string, value: string][]
}

/** What the styles of the rebuilt tree depend on, see `rebuild()`. */
interface RebuildContext {
invalid: Set<Declaration>
initial?: InitialValues
/** For container units, see `resolveContainerUnits()`. */
units?: { sizes?: Map<number, ContainerSize>; viewport: Viewport }
}

/** The condition of a `@container` rule, for the container with `name`. */
interface ContainerCondition {
name?: string
Expand Down Expand Up @@ -253,7 +261,7 @@ export async function applyStyleSheets(
node.matched = { '': matchRules(node, rules, sizes) }
// Containers are known before their descendants are matched, and before
// their own pseudo-elements, which can query them.
if (state.containerRules && !node.svg) {
if ((state.containerRules || state.containerUnits) && !node.svg) {
node.container = getContainer(node, containerCount)
if (node.container) containerCount++
}
Expand All @@ -273,7 +281,11 @@ export async function applyStyleSheets(
? await findInvalidDeclarations(nodes, validation)
: new Set<Declaration>()

const children = rebuild(root, roots, invalid, initial)
const children = rebuild(root, roots, {
invalid,
initial,
units: state.containerUnits ? { sizes, viewport } : undefined,
})
return isFragment(root)
? {
...(root as ReactElement),
Expand Down Expand Up @@ -327,6 +339,8 @@ interface CompileState {
supported?: Map<string, boolean>
/** Whether there are rules in `@container`. */
containerRules?: boolean
/** Whether declarations have container units, e.g. `cqw`. */
containerUnits?: boolean
/** The custom properties registered with `@property`, by name. */
properties: Map<string, RegisteredProperty>
}
Expand Down Expand Up @@ -361,6 +375,9 @@ function compileRules(
for (const item of items) {
if (Array.isArray(item)) {
declarations.push(item)
if (typeof item[1] === 'string' && CONTAINER_UNIT.test(item[1])) {
state.containerUnits = true
}
continue
}
flush()
Expand Down Expand Up @@ -671,30 +688,25 @@ function buildNodes(
* Rebuilds the children with the styles of the stylesheets, and the
* `::before` and `::after` pseudo-elements.
*/
function rebuild(
children: ReactNode,
nodes: Node[],
invalid: Set<Declaration>,
initial: InitialValues | undefined
) {
function rebuild(children: ReactNode, nodes: Node[], context: RebuildContext) {
let index = 0
return normalizeChildren(children).map((child) => {
if (!isReactElement(child) || typeof child.type !== 'string') return child
const node = nodes[index++]
const props: Record<string, any> = { ...child.props }
const style = cascade(node, invalid, undefined, initial)
const style = cascade(node, context)
if (style) props.style = style
if (node.matched.placeholder?.length) {
props[PLACEHOLDER_PROP] = cascade(node, invalid, 'placeholder', initial)
props[PLACEHOLDER_PROP] = cascade(node, context, 'placeholder')
}
if (node.container) props[CONTAINER_PROP] = node.container.index
let content: ReactNode[] | undefined =
props.children === undefined
? undefined
: rebuild(props.children, node.children, invalid, initial)
: rebuild(props.children, node.children, context)
if (node.matched.before) {
const before = generateContent(node, invalid, 'before', initial)
const after = generateContent(node, invalid, 'after', initial)
const before = generateContent(node, context, 'before')
const after = generateContent(node, context, 'after')
if (before || after) {
content = [before, ...(content ?? []), after].filter(Boolean)
}
Expand Down Expand Up @@ -739,11 +751,10 @@ const element = (type: string, props: Record<string, unknown>) =>
*/
function generateContent(
node: Node,
invalid: Set<Declaration>,
pseudo: 'before' | 'after',
initial: InitialValues | undefined
context: RebuildContext,
pseudo: 'before' | 'after'
) {
const style = cascade(node, invalid, pseudo, initial)
const style = cascade(node, context, pseudo)
const content = style?.content
if (!Array.isArray(content)) return
delete style.content
Expand Down Expand Up @@ -856,9 +867,18 @@ async function findInvalidDeclarations(
}
const groups = [...byKey.values()]
const errors = await Promise.all(
groups.map(([[name, value]]) =>
getError(toPropertyName(name), value as string)
)
groups.map(async ([[name, value]]) => {
// Container units are resolved in the cascade, so they're checked as
// lengths.
const original = value as string
const checked = CONTAINER_UNIT.test(original)
? replaceContainerUnits(original, () => '0px')
: original
const error = await getError(toPropertyName(name), checked)
return error && checked !== original
? new Error(error.message.split(checked).join(original))
: error
})
)
const invalid = new Set<Declaration>()
groups.forEach((declarations, index) => {
Expand All @@ -879,9 +899,8 @@ async function findInvalidDeclarations(
*/
function cascade(
node: Node,
invalid: Set<Declaration>,
pseudo?: 'before' | 'after' | 'placeholder',
initial?: InitialValues
{ invalid, initial, units }: RebuildContext,
pseudo?: 'before' | 'after' | 'placeholder'
) {
const normal: Match[] = []
const important: Match[] = []
Expand Down Expand Up @@ -941,9 +960,129 @@ function cascade(
for (const [name, value] of values) {
if (!(name in style)) style[name] = value
}
if (units) resolveContainerUnits(style, pseudo ? node : node.parent, units)
return style
}

/** A quick check for container units, see `replaceContainerUnits()`. */
const CONTAINER_UNIT = /\d(cqw|cqh|cqi|cqb|cqmin|cqmax)\b/i
const CONTAINER_UNITS = new Set(['cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax'])

const isDigit = (char: string | undefined) =>
char !== undefined && char >= '0' && char <= '9'
const isNameStart = (char: string | undefined) =>
char !== undefined &&
((char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
char === '_' ||
char >= '\u0080')
const isName = (char: string | undefined) =>
isNameStart(char) || isDigit(char) || char === '-'

/**
* Replaces the container units in a value, e.g. `1.5cqw`, with the result of
* `replace`. Like a CSS tokenizer, it skips strings and identifiers, and it
* reads each character once, as values come from stylesheets: a regular
* expression could backtrack for a long time on long numbers.
*/
function replaceContainerUnits(
value: string,
replace: (amount: number, unit: string) => string
) {
let result = ''
let i = 0
while (i < value.length) {
const char = value[i]
let end = i + 1
if (char === '"' || char === "'") {
while (end < value.length && value[end] !== char) {
end += value[end] === '\\' ? 2 : 1
}
end = Math.min(end + 1, value.length)
} else if (
isNameStart(char) ||
(char === '-' && (isNameStart(value[i + 1]) || value[i + 1] === '-'))
) {
while (isName(value[end])) end++
} else {
// A number, with a sign, a fraction and an exponent, then its unit.
let j = char === '+' || char === '-' ? i + 1 : i
if (isDigit(value[j]) || (value[j] === '.' && isDigit(value[j + 1]))) {
while (isDigit(value[j])) j++
if (value[j] === '.' && isDigit(value[j + 1])) {
j++
while (isDigit(value[j])) j++
}
const sign = value[j + 1] === '+' || value[j + 1] === '-' ? 1 : 0
if (
(value[j] === 'e' || value[j] === 'E') &&
isDigit(value[j + 1 + sign])
) {
j += 1 + sign
while (isDigit(value[j])) j++
}
end = j
while (isName(value[end])) end++
const unit = value.slice(j, end).toLowerCase()
if (CONTAINER_UNITS.has(unit)) {
result += replace(parseFloat(value.slice(i, j)), unit)
i = end
continue
}
}
}
result += value.slice(i, end)
i = end
}
return result
}

/**
* Resolves container units to px, with the size of the nearest query
* container that `start` is in or is, whose type allows the axis. Without
* one, or before the layout, they're of the viewport, like in browsers.
* Custom properties are resolved where they're used, so they're kept.
*/
function resolveContainerUnits(
style: Record<string, unknown>,
start: Node | null,
{ sizes, viewport }: NonNullable<RebuildContext['units']>
) {
const size = (block: boolean) => {
for (let node = start; node; node = node.parent) {
const container = node.container
if (!container || (block && container.type !== 'size')) continue
const containerSize = sizes?.get(container.index)
if (containerSize)
return block ? containerSize.height : containerSize.width
break
}
return (block ? viewport.height : viewport.width) ?? 0
}
for (const [name, value] of Object.entries(style)) {
if (
name.startsWith('--') ||
typeof value !== 'string' ||
!CONTAINER_UNIT.test(value)
) {
continue
}
style[name] = replaceContainerUnits(value, (amount, unit) => {
const inline = size(false)
const block = size(true)
const base = {
cqw: inline,
cqi: inline,
cqh: block,
cqb: block,
cqmin: Math.min(inline, block),
cqmax: Math.max(inline, block),
}[unit]
return `${(amount * base) / 100}px`
})
}
}

function getCustomProperties(style: Record<string, unknown> | undefined) {
if (!style) return
const names = Object.keys(style).filter((name) => name.startsWith('--'))
Expand Down Expand Up @@ -1336,7 +1475,7 @@ function parseContainerPrelude(prelude: string): ContainerCondition {
* its `container-name` for named queries.
*/
function getContainer(node: Node, index: number): Container | undefined {
const style = cascade(node, new Set()) ?? {}
const style = cascade(node, { invalid: new Set() }) ?? {}
let type = ''
let names = ''
// The shorthand and longhands override each other in cascade order.
Expand Down
8 changes: 8 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ export function lengthToNumber(
case 'ex':
return value * baseFontSize * 0.5
// The small, large and dynamic viewports of an image are the same.
// Container units are of the viewport without a query container, see
// `resolveContainerUnits()` for stylesheets.
case 'vw':
case 'cqw':
case 'cqi':
case 'svw':
case 'lvw':
case 'dvw':
Expand All @@ -133,6 +137,8 @@ export function lengthToNumber(
case 'dvi':
return ~~((value * viewportWidth) / 100)
case 'vh':
case 'cqh':
case 'cqb':
case 'svh':
case 'lvh':
case 'dvh':
Expand All @@ -142,11 +148,13 @@ export function lengthToNumber(
case 'dvb':
return ~~((value * viewportHeight) / 100)
case 'vmin':
case 'cqmin':
case 'svmin':
case 'lvmin':
case 'dvmin':
return ~~((value * Math.min(viewportWidth, viewportHeight)) / 100)
case 'vmax':
case 'cqmax':
case 'svmax':
case 'lvmax':
case 'dvmax':
Expand Down
64 changes: 64 additions & 0 deletions test/stylesheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,70 @@ describe('stylesheets', () => {
expect(reported.filter((key) => key.startsWith('__'))).toEqual([])
})

it('should resolve container units', async () => {
await expectSame(
`.i { container-type: inline-size; width: 100px }
.s { container-type: size; width: 80px; height: 40px }
.bar { height: 4px; background: red }
.i > .w { width: 50cqw }
.i > .h { width: 10cqh }
.s > .h { width: 10cqh }
.s > .min { width: 10cqmin }
.i::before { content: ""; display: block; width: 25cqi; height: 4px; background: blue }`,
<>
<div className='i'>
<div className='bar w' />
<div className='bar h' />
</div>
<div className='s'>
<div className='bar h' />
<div className='bar min' />
</div>
</>,
<>
<div style={{ width: 100 }}>
<div style={{ width: 25, height: 4, background: 'blue' }} />
<div style={{ width: 50, height: 4, background: 'red' }} />
{/* Without a size container, of the viewport. */}
<div style={{ width: 16, height: 4, background: 'red' }} />
</div>
<div style={{ width: 80, height: 40 }}>
<div style={{ width: 4, height: 4, background: 'red' }} />
<div style={{ width: 4, height: 4, background: 'red' }} />
</div>
</>
)
})

it('should check container units in linear time', async () => {
// A regular expression backtracked for seconds on long numbers, e.g. in
// quoted font names.
const digits = '1'.repeat(4000)
const errors: string[] = []
const start = performance.now()
await satori(
page(
<>
<div className='a'>A</div>
<div className='b'>B</div>
<div className='c'>C</div>
</>,
`.a { font-family: "${digits}", sans-serif }
.b { margin: ${digits}cqx 1cqw }
.c { font-family: "${digits}1cqw", sans-serif; width: 10cqw }`
),
{
width: 200,
height: 160,
fonts,
onStyleError: (error) => errors.push(error.message),
}
)
expect(performance.now() - start).toBeLessThan(1000)
// The invalid margin is reported with its value.
expect(errors.some((error) => error.includes(`${digits}cqx`))).toBe(true)
})

it('should order cascade layers', async () => {
await expectSame(
`@layer base, theme;
Expand Down
Loading
Loading