Skip to content

About

CSS Parser Extensions Explainer

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

22 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

CSS Parser Extensions

Write robust, smaller, and more efficient CSS polyfills.

Author(s): Bramus

Table of Contents

Status of this document

⚠️ THIS IS IDEA IS STILL VERY ROUGH. AS A RESULT, THIS DOCUMENT IS STILL VERY MUCH WORK IN PROGRESS

This document is intended as a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization. As the solutions to problems described in this document progress along the standards-track, we will retain this document as an archive and use this section to keep the community up-to-date with the most current standards venue and content location of future work and discussions.

  • This document status: Inception, Active
  • Expected venue: CSSWG
  • Current version: this document

Introduction

A common type of feedback heard from authors on new CSS features is that they won’t use it because said feature does not have cross-browser support. This feedback is heard for all sorts of features, including those that can be used as a Progressive Enhancement. Within their organization there are often still expectations that websites need to look exactly the same in every browser. As a result, the adoption of new CSS features – PE-capable or not – is blocked until the feature is Baseline Widely available. Assuming an average time-to-interop of ±1.5 years, this means a CSS feature is only ready for adoption after 4 years.


Figure: Screenshot of a typical comment on a new CSS feature, sourced from a video by Theo on the CSS if() function.

To aid in the adoption of new CSS features, polyfills can be created. The polyfill for container queries for example has proven its worth. However, this polyfill – like any other CSS polyfill – is not perfect and comes with limitations because polyfilling CSS features comes with a lot of challenges and pitfalls. Above that, ±65% of the code is dedicated to purely parsing the CSS file and extracting the necessary information (property values and container at-rules) from it.

CSS Parser Extensions aims to remove these limitations and to ease this information gathering by allowing authors to extend the CSS Parser with new syntaxes, properties, keywords, etc. for it to support. By tapping directly into the CSS parser, CSS polyfills become easier to author, have a reduced size & performance footprint, and become more robust.

How to (try to) polyfill CSS today

The problem is clearly stated in the talk The Dark Side of Polyfilling CSS by Philip Walton. It is recommended to watch this presentation to get a good understanding of the problem. Below is an abbreviated and less-detailed version of the problem statement.

When authors create a polyfill for a CSS feature, they can’t rely on the CSS parser giving them the information about, for example, the declarations they want to polyfill. This is because the CSS Parser throws away rules and declarations it couldn’t successfully parse. Therefore, polyfills need to gather and re-process the stylesheets themselves in order to get the tokens for the feature that they want to polyfill.

While this looks as simple as performing these 3 steps, it’s more complicated than it looks.

  1. Gathering all styles
  2. Parsing the styles
  3. Applying the styles

Each step has its own challenges and limitations, detailed below, and nicely summarized by this quote by Philip Walton from 2016:

“If you’ve never tried writing a CSS polyfill yourself, then you’ve probably never experienced the pain”

– Philip Walton

1. Gathering all styles

Collecting all styles in itself already is challenging, as authors need to gather these from a various sources:

  1. document.styleSheets
  2. document.adoptedStyleSheets
  3. Element attached styles

After collecting all references to these stylesheets it’s not done, as authors also need to keep an eye out for mutations in any of those sources.

2. Parsing the styles

With all style sheets in hand, authors can then continue to parse the contents of the style sheets. This sounds like a no-brainer but it already comes with challenges as in many cases they can’t access contents of stylesheet being served from a CORS-protected origin.

In case they do have access to the style sheet’s contents, authors need to manually tokenize and parse the contents, duplicating work that was already done by the UA.

The custom CSS parser they let loose on the source code must also work with the entire CSS Syntax. For example, when a UA ships a feature like CSS nesting, the polyfill’s CSS parser also needs to support it. As a result, CSS parsers used in CSS polyfills constantly need to play catch-up to support the latest syntax.

3. Applying the styles

With the styles parsed authors must then figure out which elements they need to apply things to. For declarations for example, this basically means that they need to write their own cascade. They also need to implement CSS features such as Media Queries and take those into account. And oh, there’s also the Shadow DOM which complicates things.

Proposed Solution

Solution

What if, instead of having polyfill authors to write their own parser and cascade, they could teach the parser some new tricks?

As in: give authors access to the CSS Parser so that they can extend it with new syntaxes, properties, keywords, and functions to support. Authors can use it to register various CSS aspects:

  • CSS Properties
  • CSS Syntaxes
  • CSS Functions
  • CSS Keywords
  • @TODO: CSS At-Rules
  • @TODO: more?

After registering one of these features using CSS Parser Extensions, the parser won’t discard the tokens associated with it and authors can use the feature as if the parser never discarded it.

For example, when registering an unsupported CSS Property + Syntax, the parser will keep the declaration and the property will show up in calls like window.getComputedStyle(). Feature checks using CSS.supports() / @supports() will then also pass.

Furthermore some utility functions are made available to authors to, for example, compute lengths of any type to the pixel value they represent.

@TODO: Explain that this doesn’t do custom function or custom selectors or custom properties registrations, which are already handled by the platform.

@TODO: Excluded are custom selectors. Explain why.

Benefits

By allowing authors to extend the CSS Parser that ships with the UA, they no longer need to gather all styles, parse stylesheets themselves, or figure out when to apply styles to an element. The resulting polyfills will be easier to author, smaller in size, perform faster, and be more robust and efficient.

With robust CSS polyfills powered by CSS Parser Extensions available, the adoption of CSS features is no longer blocked on Baseline widely available cross-browser support, leading to an increased adoption rate.

API Shape

In the UA, the CSSParser is exposed to authors as CSS.parser

Registering a new feature

  • CSS Functions: CSS.parser.registerFunction(…)
  • CSS Syntaxes: CSS.parser.registerSyntax(…)
  • CSS Declarations: CSS.parser.registerProperty(…)
  • CSS Keywords: CSS.parser.registerKeyword(…)
  • @TODO: At-Rules (@container, @keyframes with range info, …)
  • @TODO: more?

Handling a Registration

  • .substibuteAs(match)
  • .onElementMatch(match)
  • .onElementUnmatch(match)
  • .computeTo()

Detecting if a feature is supported

  • CSS Functions: CSS.parser.supportsFunction(…)
  • CSS Syntaxes: CSS.parser.supportsSyntax(…)
  • CSS Declarations: CSS.parser.supportsProperty(…)
  • CSS Keywords: CSS.parser.supportsKeyword(…)
  • CSS At-Rules: CSS.parser.supportsAtRule(…)

Note: These methods should typically not be used as you most likely want registrations made with CSS Parser Extensions to override UA-defined things for compatibility reasons. That way, UAs can ship new features without breaking websites that use the polyfill.

Utility Functions

(incomplete list)

  • (w3c/csswg-drafts#10002) CSS.parser.getSpecifiedStyle(HTMLElement elt, CSSOMString? pseudoElt)
    • To get to know the author specified 90vh instead of the resolved pixel value.
  • CSS.utils.computeLength(CSSUnitValue value, HTMLElement? elt, string? context = 'width')
    • Context: to resolve percentages (e.g. padding is resolved against the width)
  • CSS.utils.isAbsoluteLength and friends.

Constants

  • CSS.parser.animation_types defines if a registered property is animatable or not
    • DISCRETE = …
    • NOT_ANIMATABLE = …
    • BY_COMPUTED_VALUE = …
    • REPEATABLE_LIST = …
    • CUSTOM = …
  • CSS.parser.caching_modes defines when a computation should happen
    • NONE = The value is not cached and needs to be recomputed every time an invalidation happens
    • PER_MATCH (default) = The computed value is cached for the remainder of the lifecycle of the page.
    • PER_RULE = The computed value is cached for the rule that matched the element. I.e. two occurrences in the same rule have the same value.
    • PER_ELEMENT = The computed value is cached for the element. I.e. multiple rules that use the same keyword will have the same value.
    • GLOBAL = The computed value is computed once and the result is cached globally. All other or successive computations yield the same result
  • CSS.parser.invalidation_types (@TODO: refine this)
    • NONE = never gets invalidated
    • PROPERTIES = list of other properties (~ dependencies)
      • E.g. color-scheme invalidates the computation of light-dark()
    • STATE = State changes (IDL Attribute change) – e.g.
    • DOM_MUTATION = A DOM Mutation (Content Attribute Change)
    • …

Registrations

@TODO: Add some API surface to figure out if something was polyfilled or not. This is a vague idea … needs work

  • E.g. CSS.parser.registrations.properties.has('animation-range');
  • E.g. CSS.parser.registrations.functions.has('animation-range');

But: makes it too easy to conditionally register a polyfill … So should we drop it? But then again: authors can already easily conditionally load something through CSS.supports() … and this call could specifically know if something was polyfilled instead of supported (which can be native or polyfilled).

Interfaces

@TODO: Define all the interfaces (CSSElementMatchCollection, CSSElementMatch, CSSParserMatch, …)

Considerations

@TODO: Timing of the registration: must be done upfront, or the parser will already have thrown away things. Not possible in a ServiceWorker, as you need access to the DOM and other things. Registrations need to happen using blocking JavaScript in the head, before any CSS is referenced.

@TODO: Timing of the execution: parsed value time? But: if you use a var() in a polyfilled property, that var() is only known at computed value time. Robert Flack suggest to model the timing of all this after ResizeObserver, so that it can schedule a new style+layout cycle.

@TODO: Double opt-in (and even scoped per stylesheet)? Tab Atkins-Bittner suggested this. But: authors want to just drop in the polyfill and be done with it.

Practical

Registering CSS Functions

Example: the light-dark() function

CSS.parser
  .registerFunction(
    'light-dark(light:<color>, dark:<color>):<color>',
    {
        invalidation: ['color-scheme'],
    }
  )
  .computeTo((match, args) => {
    const { element,  property, propertyValue } = match;
    const colorScheme = CSS.parser.getSpecifiedStyle(element).getPropertyValue('color-scheme');

    if (colorScheme == 'light') return args.light;
    return args.dark;
  })
);

Example: the at-rule() function

This polyfills the at-rule() function which authors can use to feature detect support for certain at-rules such as @property, @view-transition, @starting-style, etc. Only the simple form that checks for @keyword support is implemented.

CSS.parser
    .registerFunction(
        'at-rule(keyword:<string>):<boolean>',
        { 
            caching_mode: CSS.parser.computation_modes.GLOBAL, // The values may be cached; as at-rule is global
        }
    )
    .computeTo((match, args) => {
        const { keyword } = args; 

        switch (keyword) {
            case '@property':
                return ("registerProperty" in CSS);

            case '@view-transition':
                return ("CSSViewTransitionRule" in window);

            case '@starting-style':
                return ("CSSStartingStyleRule" in window);

            case '@layer':
                return ("CSSLayerStatementRule" in self);

            case '@page':
                const stylesheet = new CSSStyleSheet();
                stylesheet.replaceSync("@page {margin: 1cm;}");
                const instance = stylesheet.cssRules.item(0);
                return !!instance;

            case '@container':
                return ("CSSContainerRule" in self && "containerQuery" in CSSContainerRule.prototype);

            // @TODO: Add more at-rules …

            default:
                return false;
        }
    })
;

Example: a randomBetween() function

CSS.parser
  .registerFunction(�    'randomBetween(min:<number> = 0, max:<number> = 1):<number>',
    {
      computation: CSS.parser.computation_modes.ONCE_PER_MATCH,
    }
  )
  .computeTo((match, args) => {
    const { element, property, specifiedValue } = match;
    const { min, max } = args;�
    return Math.floor(min + Math.random() * max);
  })
;

@TODO: What about allowing the value to change when a MQ matches?

Example: the sibling-index() and sibling-count() functions

@TODO: Write this. Don’t forget to add a MutationObserver invalidation.

Registering a keyword

Example: the random keyword

CSS.parser
  .registerKeyword('random:<number>', {
    caching_mode: CSS.parser.caching_modes.PER_MATCH,
    invalidation: CSS.parser.invalidation.NONE,
  })
  .computeTo((match) => {
    return Math.random();
  });
;

Registering a syntax

Using the CSS Syntax syntax

CSS.parser.registerSyntax('<scroll-timeline-name>', '[ none | <dashed-ident> ]#');

Using a function

@TODO: This needs work…

CSS.parser.registerSyntax('<foo-bar>', function(str) {
    const { value1, value2 } = parseFooBarThing(str);

    return {
        prop1: value1,
        prop2: value2,
    }
});

Registering CSS Properties

Registration

CSS.parser.registerSyntax('<position-arg>', 'layout | visual | visual-unzoomed');

CSS.parser.registerProperty('position', {
    extends: 'position', // Take the existing `position` property as the base
    syntax: '<position> [/ arg:<position-arg>]',
});
CSS.parser.registerProperty('scroll-timeline-name', {
    syntax: '<scroll-timeline-name>', // References the registered syntax from earlier
    initialValue: 'none',
    inherits: false,
    animatable: CSS.parser.ANIMATABLE_DISCRETE,
});

Computation

Example: the size property

CSS.parser
  .registerProperty('size', {
      syntax: '[<length-percentage [0,∞]> | auto]{1,2}',
      initialValue: 'auto',
      inherits: false,
      percentages: 'inline-size'
      animatable: CSS.parser.animation_types.BY_COMPUTED_VALUE,
  })
  .computeTo(match => {
    const { element, specifiedValue } = match;

    const compute = (value, element) => {
      if (value == 'auto') {
        // @TODO: Manual value computation for auto keyword
      }
      if (CSS.utils.isLength(value) || CSS.utils.isPercentage(value)) {
        return CSS.utils.computeLength(value, element);
      }
      throw new Error('Cannot resolve length'); // = IACVT
    }

    if (specifiedValue[1]) {
      return `${compute(specifiedValue[0], element)} ${compute(specifiedValue[1], element)}`;
    }
    
    return `${compute(specifiedValue[0], element)}`;
  })
  .onMatch((match, computedValue) => {
    const { element, specifiedValue } = match;

    return {
      'width': computedValue[0],
      'height': computedValue[1] ?? computedValue[0],
    };
  })
;

Example: position: fixed / visual

This implements position: fixed / visual, as discussed in w3c/csswg-drafts#7475

@TODO: is .with() the correct way to prefilter matches?

// Register syntaxes used by the polyfill.
// No .computeTo … the computed value is the specified value
CSS.parser.registerSyntax('<position>', 'static | relative | absolute | sticky | fixed');
CSS.parser.registerSyntax('<position-arg>', 'layout | visual | visual-unzoomed');

// Extend the existing `position` property registration, only overriding certain parts.
// The non-overriden parts remain untouched
const positionWithArgRegistration = CSS.parser
  .registerProperty('position', {
    extends: 'position',
    syntax: '<position> [/ arg:<position-arg>]?',
  })
  // No .computeTo … so the syntax will compute individually
;

const cssPositionFixed =
    positionWithArgRegistration
      .with('position', 'fixed') // Only `position: fixed`
      .with('arg') // Any arg value
    .onMatch((match) => {
        const { element, specifiedValue } = match;
        const { position, arg } = specifiedValue;

        const styles = CSS.parser.getSpecifiedStyle(element);
        const visualViewport = determineVisualViewport();

        switch (arg) {
            case 'layout':
                return {
                    position: 'fixed',
                };

            case 'visual':                    
                return {
                    position: 'fixed',
                    bottom: (() => {
                        if (styles.bottom.toString() != 'auto') {
                            return styles.bottom.add(CSS.px(visualViewport.height));
                        }
                    })(),
                };

            case 'visual-unzoomed':
                return {
                    position: 'fixed',
                    // @TODO: change all other properties
                };
        }
    })
;

window.visualViewport.addEventListener('resize', () => {
    cssPositionFixed.triggerMatch();
});

Example: Scroll-Driven Animations

@TODO: This example is unfinished

@TODO: Replace .matchProperty with the result of the registerProperty call (?)

CSS.parser.registerProperty('scroll-timeline-name', {
    syntax: '[ none | <dashed-ident> ]#',
    initialValue: 'none',
    inherits: false,
    animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('scroll-timeline-axis', {
    syntax: '[ block | inline | x | y ]#',
    initialValue: 'block',
    inherits: false,
    animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('scroll-timeline', {
    syntax: '[ name:<scroll-timeline-name> axis:<scroll-timeline-axis>? ]#',
    shorthand: true,
});

const scrollTimelineMatches = CSS.parser
    .matchProperty('scroll-timeline')
    // No .computeTo … so it would just return the declared value
    .onMatch(parserMatch => {
        const resizeObserver = new ResizeObserver((entries) => {
            // Change total size
        });
        resizeObserver.observe(parserMatch.element);
        parserMatch.data.set('ro', resizeObserver);
    })
    .onElementUnmatch(parserMatch => {
        const resizeObserver = parserMatch.data.get('ro');
        resizeObserver.disconnect();
    });

CSS.parser.registerSyntax('<single-animation-timeline>', 'auto | none | <dashed-ident> | <scroll()> | <view()>');
CSS.parser
  .registerProperty('animation-timeline', {
    syntax: '<single-animation-timeline>#',
    initialValue: 'auto',
    inherits: false,
    animatable: CSS.parser.ANIMATABLE_NO,
  })
  // No .computeTo … so it would just return the declared value
  .onMatch(match => {
        switch(match.specifiedValue.toString()) {
            case 'auto':
                // …
                break;
            case 'none':
                // …
                break;
            case 'scroll()': // @TODO: Accept params
                // …
                break;
            case 'view()': // @TODO: Accept params
                // …
                break;
            default: // Catches <dashed-ident>
                // …

                // @TODO: What about the order of things?
                // animation-timeline relies on scroll-timeline 
                break;
        }
  })
  .onElementUnmatch(elementMatch => {
    // @TODO: Get all animations and set their timeline to DocumentTimeline?
  })
;


CSS.parser.registerSyntax(
  '<single-animation-range>',
  '[ normal | <length-percentage> | <timeline-range-name> <length-percentage>? ]#'
);
CSS.parser.registerProperty('animation-range-start', {
    syntax: '<single-animation-range>',
    initialValue: 'none',
    inherits: false,
    percentages: customPercentageComputationFunction,
    animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('animation-range-end', {
    syntax: '<single-animation-range>',
    initialValue: 'none',
    inherits: false,
    percentages: customPercentageComputationFunction,
    animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('animation-range', {
    syntax: "[ <'animation-range-start'> <'animation-range-end'>? ]#",
    shorthand: true,
});

CSS.parser
  .matchProperty(['animation-range-start', 'animation-range-end'])
  .computeTo(match => {
    const { element, specifiedValue } = match;
    const animationTimeline = CSS.parser.getSpecifiedStyle(element).getPropertyValue('animation-timeline');

    // Parse the range to pixel values based on the the timeline
    const toReturn = parseRange(specifiedValue, animationTimeline);

    // The computed value is the declared one
    return toReturn;
  })
;

Case Studies

Taking a look at some CSS Polyfills and how CSS Parser Extensions would influence these.

The css-anchor-positioning polyfill package

Metadata

Impact on source/bundle size

tl;dr projected savings = 85%

One thing that immediately stands out is that css-anchor-positioning relies on css-tree to parse all the CSS. As a result, of the 221.5kB this polyfill weighs, ±79% is taken up by css-tree@3.0.0 which weighs 174.8kB. With CSS Parser Extensions this can immediately be removed.

Taking a closer look at the raw source (which weighs 120kB in total), CSS Parser Extensions allows removal of the following files (weighing ±34kB combined):

  • cascade.ts (2.2kB, 78 LOC) which replaces the original properties with custom properties to allow future retrieval through getComputedStyle and friends
  • fetch.ts (3.9kB, 119 LOC) which is responsible for gathering the inline and external style sheets
  • parse.ts (25kB, 728 LOC) which is responsible for parsing all properties, functions, and keywords related to anchor positioning.
  • transform.ts (2.3kB, 71 LOC)

Furthermore some util functions can also be replaced by the util functions CSS Parser Extensions offers.

All combined, a similar polyfill powered by CSS Parser Extensions would be ±15% of the current package’s size.
(15% = (1 - 174/221)* (1 - 34 / 120)).

The container-query-polyfill package

Metadata

Impact on source/bundle size

tl;dr projected savings = 64%

Taking a look at the raw source, the following can be removed thanks to CSS Parser Extensions:

  • The entire utils folder that includes a full CSS Parser can be removed. This immediately scrapes off 60kB (±38%) of the source code.
    • ast.ts (44 LOC)
    • css.ts (1577 LOC)
    • parse-media-feature.ts (246 LOC)
    • parse-media-query.ts (198 LOC)
  • evaluate.ts (11kB, 476 LOC) which converts units and more
  • parser.ts (11kB, 445 LOC) which parses container query syntax
  • transform.ts (18kB, 723 LOC) which transforms the collected stylesheets using the parser

Combined, that’s 100kB (64%) that can be shaved off the total of 156kB.

Finally the engine.ts can be adjusted to remove functions such as registerStyleSheet which no longer needs to happen.

Impact on correctness

CSS Parser Extensions would remove the biggest limitation the polyfill currently has:

CSS first: The polyfill currently only supports <style> and same-origin <link> elements. Inline styles via the style attribute or CSSOM methods are not polyfilled. Likewise, JavaScript APIs like CSSContainerRule are not polyfilled, and APIs like CSS.supports() are not monkey-patched.

Of that limitation, only the lack of CSSContainerRule would remain.

The scroll-timeline-polyfill package

Metadata

Impact on source/bundle size

tl;dr projected savings = 48%

A big part of the source code is dedicated to parsing CSS and CSSOM calculations. With CSS Parser Extensions, the polyfill could be rewritten to be a pure JS polyfill with some additional hooks to make the CSS part work. The following files can be removed in that case:

  • numeric-values.js (31kB, 844 LOC) – (Partial) JS implementation of the https://www.w3.org/TR/css-typed-om-1/ spec
  • proxy-cssom.js (6.3kB, 261 LOC)
  • scroll-timeline-css-parser.js (25kB, 808 LOC) – Parses CSS Property values
  • scroll-timeline-css.js (6.7kB, 190 LOC) – Initializes the CSS polyfill
  • simplify-calculation.js (13kB, 318 LOC) – To support calc() expressions in the values (https://www.w3.org/TR/css-values-4/ spec)
  • tokenizer.js (28kB, 799 LOC) – CSS Tokenizer
  • utils.js (2.2kB, 80 LOC) – Can partially be replaced

Combined, that’s 112.2kB that can immediately be removed from the source.

Impact on correctness

Currently there are a bunch of open bugs and shortcomings that still need to be addressed. With CSS Parser Extensions, these bugs would not have existed in the first place:

(This list excludes a bunch of already closed duplicates)

About

CSS Parser Extensions Explainer

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors