|
| 1 | +/** |
| 2 | + * Amend extracted CSS data to add/update syntaxes that could not be extracted |
| 3 | + * from the specifications, typically because the syntax is defined in non |
| 4 | + * machine-readable prose. To run the script: |
| 5 | + * |
| 6 | + * node tools/amend-css-syntaxes.js [folder] |
| 7 | + * |
| 8 | + * ... where: |
| 9 | + * - [folder] is the name of the folder that contains the data to parse and |
| 10 | + * update (default is "curated") |
| 11 | + */ |
| 12 | + |
| 13 | +/****************************************************************************** |
| 14 | + * List of CSS syntax patches to apply. |
| 15 | + * |
| 16 | + * The list is an indexed object where keys are the shortnames of the |
| 17 | + * specifications to patch and where values are themselves an indexed object |
| 18 | + * where: |
| 19 | + * - keys are the name of the CSS constructs to patch, for example: |
| 20 | + * `<system-color>` |
| 21 | + * - values define the patch to apply |
| 22 | + * |
| 23 | + * If the CSS construct to target is nested (for example, the descriptor of an |
| 24 | + * at-rule), start with the name of the root constructor, add a `/`, and then |
| 25 | + * the name of the actual construct. For example: `@page/page/<page-size>` to |
| 26 | + * target the `<page-size>` type of the `page` descriptor of the `@page` |
| 27 | + * at-rule. |
| 28 | + * |
| 29 | + * The patch value can either be: |
| 30 | + * - A string, which gets interpreted as the CSS syntax to set. For example: |
| 31 | + * '<custom-ident>' |
| 32 | + * - An object with one or more of the following keys: |
| 33 | + * - `useValues`: a boolean flag. When set, the code builds the syntax of the |
| 34 | + * CSS construct from the list of CSS values that are defined for it in the |
| 35 | + * spec (and that could be extracted). |
| 36 | + * - `newValues`: a string that contains some additional syntax to add on top |
| 37 | + * of existing values. The `useValues` flag must also be set (there is no |
| 38 | + * way to set the syntax of an extended definition for now) |
| 39 | + * - `sameAs`: a reference to another CSS construct in the same spec that |
| 40 | + * provides the syntax to use. Cannot be used with any of the other keys. |
| 41 | + * |
| 42 | + * Trailing spaces in syntaxes are trimmed, and so are extra spaces. That's |
| 43 | + * to ease patch readability. For example: ` |
| 44 | + * <basic-shape-rect> | |
| 45 | + * <circle()> | <ellipse()> |
| 46 | + * ` |
| 47 | + * ... becomes '<basic-shape-rect> | <circle()> | <ellipse()>' |
| 48 | + * |
| 49 | + * It is good practice to start a patch with a comment that links to the |
| 50 | + * the construct definition in the spec. |
| 51 | + * |
| 52 | + * Patches should only be used for syntaxes that cannot be correctly extracted |
| 53 | + * from specs. |
| 54 | + * |
| 55 | + * Note: the logic could be extended over time as needed to: |
| 56 | + * - Allow overriding an existing syntax. That would allow replacing most |
| 57 | + * patches in "csspatches". The underlying PR should be reported in a |
| 58 | + * `pending` key and/or the incorrect syntax should be recorded to make |
| 59 | + * sure we do not miss updates. |
| 60 | + * - Allow dropping an existing syntax. That has never been needed until now. |
| 61 | + * - Allow adding/overriding new values of an extended definition. That could |
| 62 | + * also prove useful. |
| 63 | + *****************************************************************************/ |
| 64 | +const patches = { |
| 65 | + // https://drafts.csswg.org/css-cascade-6/#scope-syntax |
| 66 | + 'css-cascade-6': { |
| 67 | + '<scope-start>': '<selector-list>', |
| 68 | + '<scope-end>': '<selector-list>' |
| 69 | + }, |
| 70 | + |
| 71 | + // https://drafts.csswg.org/css-color-4/#typedef-system-color |
| 72 | + // https://drafts.csswg.org/css-color-4/#typedef-deprecated-color |
| 73 | + // https://drafts.csswg.org/css-color-4/#typedef-named-color |
| 74 | + 'css-color-4': { |
| 75 | + '<system-color>': { |
| 76 | + useValues: true, |
| 77 | + newValues: '<deprecated-color>' |
| 78 | + }, |
| 79 | + '<deprecated-color>': { |
| 80 | + useValues: true |
| 81 | + }, |
| 82 | + '<named-color>': { |
| 83 | + useValues: true, |
| 84 | + newValues: 'transparent' |
| 85 | + } |
| 86 | + }, |
| 87 | + |
| 88 | + // https://drafts.csswg.org/css-counter-styles-3/#typedef-counter-style-name |
| 89 | + 'css-counter-styles-3': { |
| 90 | + '<counter-style-name>': '<custom-ident>' |
| 91 | + }, |
| 92 | + |
| 93 | + // https://drafts.csswg.org/css-fonts-4/#typedef-font-palette-palette-identifier |
| 94 | + 'css-fonts-4': { |
| 95 | + 'font-palette/<palette-identifier>': '<dashed-ident>' |
| 96 | + }, |
| 97 | + |
| 98 | + // https://drafts.csswg.org/css-lists-3/#typedef-counter-name |
| 99 | + 'css-lists-3': { |
| 100 | + '<counter-name>': '<custom-ident>' |
| 101 | + }, |
| 102 | + |
| 103 | + // https://drafts.csswg.org/css-overflow-5/#typedef-scroll-button-direction |
| 104 | + 'css-overflow-5': { |
| 105 | + '<scroll-button-direction>': { |
| 106 | + useValues: true |
| 107 | + } |
| 108 | + }, |
| 109 | + |
| 110 | + // https://drafts.csswg.org/css-page-3/#typedef-page-size-page-size |
| 111 | + 'css-page-3': { |
| 112 | + '@page/size/<page-size>': { |
| 113 | + useValues: true |
| 114 | + } |
| 115 | + }, |
| 116 | + |
| 117 | + // https://drafts.csswg.org/css-shapes-1/#supported-basic-shapes |
| 118 | + 'css-shapes-1': { |
| 119 | + '<basic-shape>': ` |
| 120 | + <basic-shape-rect> | |
| 121 | + <circle()> | <ellipse()> | <polygon()> | |
| 122 | + <path()> | <shape()> |
| 123 | + ` |
| 124 | + }, |
| 125 | + |
| 126 | + // https://drafts.csswg.org/css-syntax-3/#the-anb-type |
| 127 | + 'css-syntax-3': { |
| 128 | + '<n-dimension>': '<dimension-token>', |
| 129 | + '<ndash-dimension>': '<dimension-token>', |
| 130 | + '<ndashdigit-dimension>': '<dimension-token>', |
| 131 | + '<ndashdigit-ident>': '<ident-token>', |
| 132 | + '<dashndashdigit-ident>': '<ident-token>', |
| 133 | + '<signed-integer>': '<number-token>', |
| 134 | + '<signless-integer>': '<number-token>' |
| 135 | + }, |
| 136 | + |
| 137 | + // https://drafts.csswg.org/css-transforms-2/#transform-functions |
| 138 | + 'css-transforms-2': { |
| 139 | + '<transform-function>': ` |
| 140 | + <scale()> | <scaleX()> | <scaleY()> | <scaleZ()> | |
| 141 | + <translate3d()> | <translate()> | <translateX()> | <translateY()> | <translateZ()> | |
| 142 | + <rotate3d()> <rotate()> | <rotateX()> | <rotateY()> | <rotateZ()> | |
| 143 | + <skew()> | <skewX()> | <skewY()> | |
| 144 | + <matrix3d()> | <matrix()> | <perspective()> |
| 145 | + `, |
| 146 | + }, |
| 147 | + |
| 148 | + // https://drafts.csswg.org/css-ui-4/#propdef--webkit-user-select |
| 149 | + // https://drafts.csswg.org/css-ui-4/#typedef-outline-line-style |
| 150 | + 'css-ui-4': { |
| 151 | + '-webkit-user-select': { |
| 152 | + sameAs: 'user-select' |
| 153 | + }, |
| 154 | + // Same as <line-style> but 'auto' replaces 'hidden' |
| 155 | + '<outline-line-style>': ` |
| 156 | + none | auto | dotted | dashed | solid | double | |
| 157 | + groove | ridge | inset | outset |
| 158 | + ` |
| 159 | + }, |
| 160 | + |
| 161 | + // https://drafts.csswg.org/css-values-4/#integers |
| 162 | + 'css-values-4': { |
| 163 | + '<integer>': '<number-token>' |
| 164 | + }, |
| 165 | + |
| 166 | + // https://drafts.csswg.org/css2/#value-def-absolute-size |
| 167 | + // https://drafts.csswg.org/css2/#value-def-relative-size |
| 168 | + // https://drafts.csswg.org/css2/#value-def-shape |
| 169 | + 'CSS2': { |
| 170 | + '<absolute-size>': ` |
| 171 | + xx-small | x-small | small | medium | |
| 172 | + large | x-large | xx-large |
| 173 | + `, |
| 174 | + '<relative-size>': 'larger | smaller', |
| 175 | + '<shape>': 'rect(<top>, <right>, <bottom>, <left>)' |
| 176 | + }, |
| 177 | + |
| 178 | + // https://svgwg.org/svg2-draft/pservers.html#StopColorProperty |
| 179 | + // https://svgwg.org/svg2-draft/pservers.html#StopOpacityProperty |
| 180 | + 'SVG2': { |
| 181 | + 'stop-color': `<'color'>`, |
| 182 | + 'stop-opacity': `<'opacity'>` |
| 183 | + } |
| 184 | +}; |
| 185 | + |
| 186 | + |
| 187 | +/****************************************************************************** |
| 188 | + * Patching logic |
| 189 | + *****************************************************************************/ |
| 190 | +import fs from 'node:fs/promises'; |
| 191 | +import path from 'node:path'; |
| 192 | +import { fileURLToPath } from 'node:url'; |
| 193 | +import { loadJSON } from './utils.js'; |
| 194 | +import { expandCrawlResult, isLatestLevelThatPasses } from 'reffy'; |
| 195 | + |
| 196 | +/** |
| 197 | + * Trim a patch syntax |
| 198 | + */ |
| 199 | +function trimSyntax(syntax) { |
| 200 | + return syntax.trim().replace(/\s+\|\s+/g, ' | '); |
| 201 | +} |
| 202 | + |
| 203 | +/** |
| 204 | + * Recursive function to look for a CSS construct within an extract. |
| 205 | + * |
| 206 | + * Levels are separated with '/'. When looking at a spec, pass `spec.css` as |
| 207 | + * second parameter. For example: |
| 208 | + * const construct = findCssConstruct('@page/page/<page-size>', spec.css); |
| 209 | + */ |
| 210 | +function findCssConstruct(key, currentRoot) { |
| 211 | + if (!key) { |
| 212 | + return currentRoot; |
| 213 | + } |
| 214 | + const keys = key.split('/'); |
| 215 | + const types = ['properties', 'atrules', 'selectors', 'values', 'descriptors']; |
| 216 | + for (const key of keys) { |
| 217 | + for (const type of types) { |
| 218 | + for (const entry of (currentRoot[type] ?? [])) { |
| 219 | + if (entry.name === keys[0]) { |
| 220 | + return findCssConstruct(keys.slice(1).join('/'), entry); |
| 221 | + } |
| 222 | + } |
| 223 | + } |
| 224 | + } |
| 225 | + return null; |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Apply patches that apply to the given spec. |
| 230 | + * |
| 231 | + * The function returns a list of errors, an empty array if all went fine. |
| 232 | + */ |
| 233 | +function applyCssSyntaxPatches(spec) { |
| 234 | + let errors = []; |
| 235 | + console.log(`- applying CSS syntax patches for ${spec.shortname}`); |
| 236 | + for (let [key, patch] of Object.entries(patches[spec.shortname])) { |
| 237 | + // Expand the patch as needed, and check it against current patching |
| 238 | + // constraints (note the logic could be extended over time to support more |
| 239 | + // scenarios if that proves needed). |
| 240 | + if (typeof patch === 'string') { |
| 241 | + patch = { value: patch }; |
| 242 | + } |
| 243 | + if (!patch.value && !patch.useValues && !patch.newValues && !patch.sameAs) { |
| 244 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} does not define any syntax`); |
| 245 | + continue; |
| 246 | + } |
| 247 | + if (patch.sameAs && (patch.value || patch.useValues || patch.newValues)) { |
| 248 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} is invalid, cannot have "sameAs" and another syntax at the same time`); |
| 249 | + continue; |
| 250 | + } |
| 251 | + if (patch.value && patch.useValues) { |
| 252 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} is invalid, cannot have "value" and "useValues" at the same time`); |
| 253 | + continue; |
| 254 | + } |
| 255 | + if (patch.value && patch.newValues) { |
| 256 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} is invalid, cannot have "value" and "newValues" at the same time`); |
| 257 | + continue; |
| 258 | + } |
| 259 | + if (patch.newValues && !patch.useValues) { |
| 260 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} is invalid, cannot have "newValues" without "useValues"`); |
| 261 | + continue; |
| 262 | + } |
| 263 | + |
| 264 | + // Locate the underlying CSS construct in the spec extract |
| 265 | + const construct = findCssConstruct(key, spec.css); |
| 266 | + if (!construct) { |
| 267 | + errors.push(`Could not find a CSS construct with name ${key} in ${spec.shortname} for syntax patching`); |
| 268 | + continue; |
| 269 | + } |
| 270 | + |
| 271 | + // Patching mechanism is for adding new syntaxes for now. |
| 272 | + // (It could be extended later on to override existing syntaxes) |
| 273 | + if (construct.value || construct.newValues) { |
| 274 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} cannot be applied: construct already has a syntax`); |
| 275 | + continue; |
| 276 | + } |
| 277 | + |
| 278 | + if (patch.useValues) { |
| 279 | + if (!construct.values || construct.values.length === 0) { |
| 280 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} cannot be applied: "useValues" is set but no values are associated with the construct`); |
| 281 | + continue; |
| 282 | + } |
| 283 | + construct.value = construct.values.map(v => v.value).join(' | '); |
| 284 | + } |
| 285 | + else if (patch.sameAs) { |
| 286 | + const sameConstruct = findCssConstruct(patch.sameAs, spec.css); |
| 287 | + if (!sameConstruct) { |
| 288 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} cannot be applied: could not find "sameAs" target ${patch.sameAs}`); |
| 289 | + continue; |
| 290 | + } |
| 291 | + if (!sameConstruct.value) { |
| 292 | + errors.push(`The CSS syntax patch for ${key} in ${spec.shortname} cannot be applied: "sameAs" target ${patch.sameAs} has no syntax`); |
| 293 | + continue; |
| 294 | + } |
| 295 | + } |
| 296 | + else { |
| 297 | + construct.value = trimSyntax(patch.value); |
| 298 | + } |
| 299 | + if (patch.newValues) { |
| 300 | + construct.value += ' | ' + trimSyntax(patch.newValues); |
| 301 | + } |
| 302 | + } |
| 303 | + if (!errors.length) { |
| 304 | + spec.needsSaving = true; |
| 305 | + } |
| 306 | + return errors; |
| 307 | +} |
| 308 | + |
| 309 | +/** |
| 310 | + * Save the updated CSS extract of the spec |
| 311 | + * |
| 312 | + * Note: CSS extracts are named after the series shortname by default. |
| 313 | + */ |
| 314 | +async function saveCss(spec, folder, list) { |
| 315 | + let filename = spec.series.shortname; |
| 316 | + if ((spec.seriesComposition === 'delta') || |
| 317 | + !isLatestLevelThatPasses(spec, list, spec => spec.css)) { |
| 318 | + filename = spec.shortname; |
| 319 | + } |
| 320 | + const pathname = path.join(folder, 'css', filename + '.json'); |
| 321 | + const css = Object.assign({ |
| 322 | + spec: { |
| 323 | + title: spec.title, |
| 324 | + url: spec.crawled |
| 325 | + } |
| 326 | + }, spec.css); |
| 327 | + const json = JSON.stringify(css, null, 2) + '\n'; |
| 328 | + await fs.writeFile(pathname, json); |
| 329 | +}; |
| 330 | + |
| 331 | + |
| 332 | +/** |
| 333 | + * Apply CSS syntax patch to the crawl results in the given folder. |
| 334 | + * |
| 335 | + * The function expects to find an `index.json` file in that folder that links |
| 336 | + * to individual CSS extracts. |
| 337 | + * |
| 338 | + * The function throws with a list of errors when patches could not all be |
| 339 | + * applied. |
| 340 | + */ |
| 341 | +async function amendCssSyntaxes(folder) { |
| 342 | + const rawIndex = await loadJSON(path.join(folder, 'index.json')); |
| 343 | + const index = JSON.parse(JSON.stringify(rawIndex)); |
| 344 | + await expandCrawlResult(index, folder, ['css']); |
| 345 | + |
| 346 | + let errors = []; |
| 347 | + for (const specShortname of Object.keys(patches)) { |
| 348 | + const spec = index.results.find(s => s.shortname === specShortname); |
| 349 | + if (!spec) { |
| 350 | + errors.push(`Could not find spec with shortname ${specShortname} for CSS syntax patching`); |
| 351 | + continue; |
| 352 | + } |
| 353 | + if (!spec.css) { |
| 354 | + errors.push(`Could not find any CSS in spec with shortname ${specShortname} for CSS syntax patching`); |
| 355 | + continue; |
| 356 | + } |
| 357 | + errors = errors.concat(applyCssSyntaxPatches(spec)); |
| 358 | + } |
| 359 | + |
| 360 | + for (const spec of index.results) { |
| 361 | + if (spec.needsSaving) { |
| 362 | + await saveCss(spec, folder, index.results); |
| 363 | + delete spec.needsSaving; |
| 364 | + } |
| 365 | + } |
| 366 | + |
| 367 | + if (errors.length) { |
| 368 | + throw new Error("\n- " + errors.join("\n- ")); |
| 369 | + } |
| 370 | +} |
| 371 | + |
| 372 | + |
| 373 | +/****************************************************************************** |
| 374 | + * Export methods for use as module |
| 375 | + *****************************************************************************/ |
| 376 | +export { amendCssSyntaxes }; |
| 377 | + |
| 378 | +/****************************************************************************** |
| 379 | + * Code run if the code is run as a stand-alone module |
| 380 | + *****************************************************************************/ |
| 381 | +if (process.argv[1] === fileURLToPath(import.meta.url)) { |
| 382 | + const folder = process.argv[2] ?? 'curated'; |
| 383 | + |
| 384 | + amendCssSyntaxes(folder).catch(e => { |
| 385 | + console.error(e); |
| 386 | + process.exit(1); |
| 387 | + }); |
| 388 | +} |
0 commit comments