/* ========================================================================= * DSSL - resolver.js * Walks the parsed tree and produces flat, engine-ready rules: * * rule = { * id, sel, // full structural selector (may contain native pseudo-classes) * media: [query], // all must match * signals: [{name, op, value}], // JS-driven conditions * containers: [query], // container-query conditions * layer, order, // cascade: (layer, order) - no specificity * decls: [{prop, value} | special], * file, line * } * * Also resolves tokens (→ custom properties on ), semantic properties, * behaviors (`use`), motions (→ Web Animations keyframes) and pseudo-element * rules (→ generated "shadow" CSS, the only thing inline styles can't reach). * ========================================================================= */ import { levenshtein, didYouMean, composeDeform } from './utils.js'; // 0.9.0: the three new styling families. Each is a pure module, so the compiler // can validate their words (with did-you-mean) without a DOM. import { parseTexture } from './texture.js'; import { parseAura } from './aura.js'; import { parseSlider, sliderCss } from './slider.js'; import { lintRules } from './lint.js'; import { parseFonts } from './fonts.js'; import { NAMED as NAMED_COLORS } from './color.js'; import { glassDecls, parseTilt } from './effects.js'; // v0.5.0 - the resolver validates the `::name` half of full selectors so a // typo like .card::befor is a LOUD error, never a silently dead rule. import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js'; import { CSS_VALUE_WORDS, CSS_PROPERTY_NAMES } from './css-keywords.js'; /* ------------------------------------------------------------------ * * Vocabulary * ------------------------------------------------------------------ */ // exported for the editor extension: completions ride the same list the // compiler validates against, so the two can never drift apart export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,none,auto,currentcolor,transparent,' + 'from,stroke,markers,normal,' + // 0.14.0: conic-gradient(from 0deg...), paint-order: stroke fill 'black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' + 'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' + 'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' + 'ghostwhite,ivory,beige,linen,seashell,snow,honeydew,mintcream,azure,aliceblue,lavender,' + 'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' + 'solid,dashed,dotted,double,groove,ridge,inset,outset,hidden,visible,' + 'block,inline,inline-block,flex,inline-flex,grid,inline-grid,flow-root,contents,table,table-row,table-cell,list-item,' + 'relative,absolute,fixed,sticky,static,sticky,' + 'start,end,center,left,right,between,around,evenly,baseline,stretch,normal,space-between,space-around,space-evenly,' + 'wrap,nowrap,reverse,row,column,row-reverse,column-reverse,' + 'bold,bolder,lighter,italic,oblique,small-caps,uppercase,lowercase,capitalize,underline,overline,line-through,blink,' + 'ellipsis,clip,break-word,anywhere,pre,pre-wrap,pre-line,subpixel-antialiased,antialiased,' + 'pointer,progress,default,move,text,wait,help,grab,grabbing,not-allowed,zoom-in,zoom-out,copy,alias,crosshair,' + 'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' + 'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' + 'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' + // v0.8.0 - the background repeat/attachment words the url() fix exposed: // `background: url(a.png) no-repeat` was a false error because "no-repeat" // was missing while "repeat" existed 'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' + 'contain,cover,fill,fit-content,min-content,max-content,minmax,repeat,clamp,calc,var,url,env,attr,' + 'auto-fill,auto-fit,subgrid,dense,stretch,span,' + 'scaleX,scaleY,translateX,translateY,translate,rotate,skew,skewX,skewY,matrix,' + 'ease,ease-in,ease-out,ease-in-out,linear,step-start,step-end,steps,cubic-bezier,spring,bounce,smooth,snap,' + 'forwards,backwards,both,infinite,alternate,alternate-reverse,running,paused,reverse,' + 'safe,unsafe,legacy,loose,strict,spell-out,letters,isolate,isolation,blend,multiply,screen,overlay,darken,lighten,color-dodge,color-burn,hard-light,soft-light,difference,exclusion,hue,saturation,color,luminosity,plus-lighter,' + 'blur,brightness,contrast,grayscale,invert,opacity,sepia,saturate,drop-shadow,' + 'pointer coarse fine hover interactive,first,last,odd,even,all,portrait,landscape,print,screen,and,not,only,' + 'inline-size,size,scroll-state,normal,snapped,physical,logical,' + 'in,oklab,oklch,srgb,srgb-linear,display-p3,a98-rgb,prophoto-rgb,rec2020,xyz,xyz-d50,xyz-d65,shorter,longer,deg,grad,rad,turn,' + 'to,top,bottom,left,right,above,below,closest-side,farthest-side,ellipse,circle,at,padding-box,border-box,' + 'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' + '-webkit-box,-webkit-inline-box,' + 'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' + // v0.5.0 - hyphens: manual used to be rejected ("unknown value") even though // the property existed; plus the rendering keywords people actually type. 'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' + 'flex-start,flex-end,self-start,self-end,snap-block,snap-inline,' + // @font-face's font-display, which no property's grammar lists 'block,swap,fallback,optional').split(',').concat(CSS_VALUE_WORDS.split(','))); // 0.16.0: plus every keyword CSS defines (generated from the MDN data) export const UNITLESS_PROPS = new Set([ 'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink', 'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio', 'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity', 'shape-image-threshold', 'counter-reset', 'counter-increment', 'counter-set', // grid line placement takes integers, not lengths: grid-column: span 2 'grid-row', 'grid-column', 'grid-area', // 0.16.0: these take plain numbers, so `font-feature-settings: "tnum" 1` is not "tnum" 1px 'font-feature-settings', 'font-variation-settings', 'tab-size', '-webkit-line-clamp', 'line-clamp', 'stroke-miterlimit', 'stroke-opacity', 'initial-letter', 'font-size-adjust', ]); // v0.7.2 - time properties: a bare number is milliseconds, never px, and // never unitless (both used to emit values the browser silently rejected). // transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is // why `transition-delay: 200` shipped as invalid unitless 200. const TIME_PROPS = new Set([ 'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration', // v0.8.0 - the animation shorthand takes bare numbers as ms too: // `animation: spin 1s infinite` is fine, but `animation: ghost 400` used // to become "ghost 400px" (invalid). 400 → 400ms, matching animate:. 'animation', ]); // A practical whitelist of standard CSS properties (loud errors beat silent ones) export const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' + 'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' + 'block-size,border,border-block,border-block-color,border-block-end,border-block-end-color,border-block-end-style,border-block-end-width,border-block-start,border-block-start-color,border-block-start-style,border-block-start-width,border-block-style,border-block-width,' + 'border-bottom,border-bottom-color,border-bottom-left-radius,border-bottom-right-radius,border-bottom-style,border-bottom-width,' + 'border-collapse,border-color,border-end-end-radius,border-end-start-radius,border-image,border-image-outset,border-image-repeat,border-image-slice,border-image-source,border-image-width,' + 'border-inline,border-inline-color,border-inline-end,border-inline-end-color,border-inline-end-style,border-inline-end-width,border-inline-start,border-inline-start-color,border-inline-start-style,border-inline-start-width,border-inline-style,border-inline-width,' + 'border-left,border-left-color,border-left-style,border-left-width,border-radius,border-right,border-right-color,border-right-style,border-right-width,' + 'border-spacing,border-start-end-radius,border-start-start-radius,border-style,border-top,border-top-color,border-top-left-radius,border-top-right-radius,border-top-style,border-top-width,border-width,' + 'bottom,counter-reset,counter-increment,counter-set,box-decoration-break,box-shadow,box-sizing,break-after,break-before,break-inside,caret-color,caret-shape,clear,clip,clip-path,color,color-scheme,column-count,column-fill,column-gap,column-rule,column-rule-color,column-rule-style,column-rule-width,column-span,column-width,columns,contain,contain-intrinsic-size,container,container-name,container-type,content,content-visibility,cursor,caret,display,empty-cells,filter,flex,flex-basis,flex-direction,flex-flow,flex-grow,flex-shrink,flex-wrap,float,fill,fill-rule,fill-opacity,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-opacity,stroke-width,font,font-family,font-feature-settings,font-kerning,font-language-override,font-optical-sizing,font-palette,font-size,font-size-adjust,font-stretch,font-style,font-synthesis,font-variant,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,font-variant-position,font-variation-settings,font-weight,forced-color-adjust,gap,grid,grid-area,grid-auto-columns,grid-auto-flow,grid-auto-rows,grid-column,grid-column-end,grid-column-gap,grid-column-start,grid-gap,grid-row,grid-row-end,grid-row-gap,grid-row-start,grid-template,grid-template-areas,grid-template-columns,grid-template-rows,' + 'hanging-punctuation,height,hyphenate-character,hyphenate-limit-chars,hyphens,image-orientation,image-rendering,inline-size,inset,inset-block,inset-block-end,inset-block-start,inset-inline,inset-inline-end,inset-inline-start,isolation,justify-content,justify-items,justify-self,justify-tracks,left,letter-spacing,line-break,line-height,list-style,list-style-image,list-style-position,list-style-type,margin,margin-block,margin-block-end,margin-block-start,margin-bottom,margin-inline,margin-inline-end,margin-inline-start,margin-left,margin-right,margin-top,margin-trim,mask,mask-border,mask-border-mode,mask-border-outset,mask-border-repeat,mask-border-slice,mask-border-source,mask-border-width,mask-clip,mask-composite,mask-image,mask-mode,mask-origin,mask-position,mask-repeat,mask-size,max-height,max-inline-size,max-width,min-block-size,min-height,min-inline-size,min-width,mix-blend-mode,object-fit,object-position,offset,offset-anchor,offset-distance,offset-path,offset-rotate,opacity,order,outline,outline-color,outline-offset,outline-style,outline-width,overflow,overflow-anchor,overflow-clip-margin,overflow-wrap,overflow-x,overflow-y,overscroll-behavior,overscroll-behavior-block,overscroll-behavior-inline,overscroll-behavior-x,overscroll-behavior-y,padding,padding-block,padding-block-end,padding-block-start,padding-bottom,padding-inline,padding-inline-end,padding-inline-start,padding-left,padding-right,padding-top,page-break-after,page-break-before,page-break-inside,paint-order,perspective,perspective-origin,place-content,place-items,place-self,pointer-events,position,print-color-adjust,quotes,resize,right,rotate,row-gap,ruby-align,ruby-merge,scale,scroll-behavior,scroll-margin,scroll-margin-block,scroll-margin-block-end,scroll-margin-block-start,scroll-margin-bottom,scroll-margin-inline,scroll-margin-inline-end,scroll-margin-inline-start,scroll-margin-left,scroll-margin-right,scroll-margin-top,scroll-padding,scroll-padding-block,scroll-padding-bottom,scroll-padding-inline,scroll-padding-left,scroll-padding-right,scroll-padding-top,scroll-snap-align,scroll-snap-stop,scroll-snap-type,scrollbar-color,scrollbar-gutter,scrollbar-width,shape-image-threshold,shape-margin,shape-outside,tab-size,table-layout,text-align,text-align-last,text-combine-upright,text-decoration,text-decoration-color,text-decoration-line,text-decoration-skip-ink,text-decoration-style,text-decoration-thickness,text-emphasis,text-emphasis-color,text-emphasis-position,text-emphasis-style,text-indent,text-justify,text-orientation,text-overflow,text-rendering,text-shadow,text-transform,text-underline-offset,text-underline-position,text-wrap,top,touch-action,transform,transform-box,transform-origin,transform-style,transition,transition-behavior,transition-delay,transition-duration,transition-property,transition-timing-function,translate,unicode-bidi,user-select,vertical-align,visibility,white-space,white-space-collapse,width,will-change,word-break,word-spacing,word-wrap,writing-mode,z-index,zoom,' + 'animation,animation-composition,animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-range,animation-timeline,animation-timing-function,' + 'view-transition-name,interpolate-size,text-box,text-box-edge,text-box-trim,dynamic-range-limit,field-sizing,anchor-name,position-anchor,position-area,scroll-timeline,scroll-timeline-axis,scroll-timeline-name,timeline-scope,view-timeline,view-timeline-axis,view-timeline-name,' + '-webkit-background-clip,-webkit-text-fill-color,-webkit-box-orient,-webkit-line-clamp,-webkit-tap-highlight-color,-webkit-font-smoothing,-moz-osx-font-smoothing,-webkit-box-shadow,-webkit-text-stroke,' + '-webkit-user-select,-webkit-touch-callout,-webkit-overflow-scrolling,-webkit-appearance,-moz-appearance').split(',').concat(CSS_PROPERTY_NAMES.split(','))); // 0.16.0: plus every property CSS has (generated from the MDN data) /* ------------------------------------------------------------------ * * Built-in token defaults (overridable via `tokens { }`) * ------------------------------------------------------------------ */ export const DEFAULT_TOKENS = { 'color.ink': '#16161d', 'color.paper': '#faf7f2', 'color.card': '#ffffff', 'color.muted': '#6f6a7a', 'color.line': '#e5ded2', 'color.accent': 'oklch(62% 0.19 35)', 'color.accent-ink': '#fff6ee', 'color.ok': '#2e7d4f', 'color.err': '#b3372e', 'space.1': '4', 'space.2': '8', 'space.3': '12', 'space.4': '16', 'space.5': '24', 'space.6': '32', 'space.7': '48', 'space.8': '64', 'radius.sm': '8', 'radius.md': '14', 'radius.lg': '22', 'radius.full': '999', 'shadow.sm': '0 1 2 color.ink/8, 0 1 3 color.ink/6', 'shadow.md': '0 2 4 color.ink/10, 0 4 12 color.ink/8', 'shadow.lg': '0 4 8 color.ink/12, 0 12 32 color.ink/12', 'shadow.xl': '0 8 16 color.ink/14, 0 24 64 color.ink/16', 'font.sans': 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 'font.mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', }; export const EASINGS = { snap: 'cubic-bezier(0.2, 0.9, 0.3, 1)', smooth: 'cubic-bezier(0.4, 0, 0.2, 1)', bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)', linear: 'linear', ease: 'ease', 'ease-in': 'ease-in', 'ease-out': 'ease-out', 'ease-in-out': 'ease-in-out', }; const PLACE_WORDS = { center: ['center', 'center'], start: ['start', 'flex-start'], end: ['end', 'flex-end'], between: ['center', 'space-between'], around: ['center', 'space-around'], evenly: ['center', 'space-evenly'], stretch: ['stretch', 'flex-start'], baseline: ['baseline', 'flex-start'], }; /* ------------------------------------------------------------------ * * Value resolution * ------------------------------------------------------------------ */ const IDENT_RE = /^-?(?:[A-Za-z_]|--[A-Za-z_])[A-Za-z0-9_.\\-]*/; // functions whose numeric arguments are NOT lengths - numbers stay bare const UNITLESS_FN = new Set(('oklch,oklab,lab,lch,hsl,hsla,rgb,rgba,hwb,color,color-mix,light-dark,' + 'brightness,saturate,contrast,grayscale,sepia,invert,opacity,' + 'scale,scalex,scaley,scalez,scale3d,steps,cubic-bezier,linear,radial,conic').split(',')); // functions whose bare numbers ARE lengths (px) const LENGTH_FN = new Set('calc,min,max,clamp,round,mod,rem,abs,sign,hypot,pow,sqrt,log,exp,blur,drop-shadow'.split(',')); /** * Resolve a raw value string into its final form: * - bare numbers get `px` (unless ctx.unitless) * - token references become var(--dssl-…), `token/NN` becomes color-mix * - unknown identifiers are errors (with did-you-mean) */ /** split a selector list at top-level commas (not inside :is(...) and friends) */ function splitTopLevel(sel) { const out = []; let depth = 0, cur = ''; for (const ch of String(sel)) { if (ch === '(' || ch === '[') depth++; if (ch === ')' || ch === ']') depth--; if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; } cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; } function resolveValue(raw, ctx) { let out = ''; let i = 0; const n = raw.length; const err = (msg, hint) => ctx.errors.push({ file: ctx.file, line: ctx.line, message: msg, hint }); while (i < n) { const c = raw[i]; if (/\s/.test(c)) { out += c; i++; continue; } if (c === '"' || c === "'") { let j = i + 1; while (j < n && raw[j] !== c) j++; out += raw.slice(i, j + 1); i = j + 1; continue; } if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(raw[i + 1] || '')) || ((c === '-' || c === '+') && /[0-9.]/.test(raw[i + 1] || ''))) { let j = i + 1; while (j < n && /[0-9.]/.test(raw[j])) j++; const num = raw.slice(i, j); // optional unit let unit = ''; if (j < n && /[a-z%]/i.test(raw[j])) { let k = j; while (k < n && /[a-z%]/i.test(raw[k])) k++; unit = raw.slice(j, k).toLowerCase(); j = k; } out += num + (unit ? unit : (ctx.unitless ? '' : TIME_PROPS.has(ctx.prop) ? 'ms' : 'px')); i = j; continue; } const idm = raw.slice(i).match(IDENT_RE); if (idm) { let ident = idm[0]; i += ident.length; if (ident.startsWith('--')) { out += ident; continue; } // custom property // function call - resolve the inside if (raw[i] === '(') { let depth = 1, j = i + 1; while (j < n && depth > 0) { if (raw[j] === '(') depth++; if (raw[j] === ')') depth--; if (depth > 0 && (raw[j] === '"' || raw[j] === "'")) { const q = raw[j]; j++; while (j < n && raw[j] !== q) j++; } j++; } const inner = raw.slice(i + 1, j - 1); const fname = ident.toLowerCase(); // v0.8.0 - url() is atomic. The validator used to tokenize INSIDE it, // so `url(a.png)` raised "unknown value a.png" (quoted urls worked // only because strings were already consumed whole). CSS allows the // unquoted form; DSSL passes it through untouched now. if (fname === 'url') { out += 'url(' + inner.trim() + ')'; i = j; continue; } // v0.3.2 fix: repeat(COUNT, tracks) - the leading count is an integer, // never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`, // which the browser rejected and the whole grid silently collapsed. if (fname === 'repeat') { const rm = inner.match(/^\s*(\d+(?:\.\d+)?)\s*,([\s\S]*)$/); if (rm) { out += ident + '(' + rm[1] + ',' + resolveValue(rm[2], { ...ctx, unitless: ctx.unitless }) + ')'; i = j; continue; } // auto-fill / auto-fit / nested weirdness - fall through to generic } // color-ish functions keep bare numbers bare; calc-family gets px const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless; // counter(step), counters(step, "."), attr(data-x): the names inside are the author's own, not tokens const nameFn = fname === 'counter' || fname === 'counters' || fname === 'attr' || fname === 'env' || fname === 'format' || fname === 'local' || fname === 'paint' || fname === 'element'; // env(safe-area-inset-top): the name is CSS's own const resolvedInner = resolveValue(inner, { ...ctx, unitless: innerUnitless, bareIdents: nameFn ? true : ctx.bareIdents }); out += ident + '(' + resolvedInner + ')'; i = j; continue; } // token reference? const tokenKey = findToken(ctx.tokens, ident); if (tokenKey) { const varRef = 'var(--dssl-' + tokenKey.replace(/\./g, '-') + ')'; // alpha modifier: ink/20 let rest = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/); if (rest) { out += `color-mix(in oklab, ${varRef} ${rest[1]}%, transparent)`; i += rest[0].length; continue; } out += varRef; continue; } // known keyword? if (CSS_KEYWORDS.has(ident) || CSS_KEYWORDS.has(ident.toLowerCase())) { // 0.10.0 - alpha on a named colour: white/12 used to become white/12px, // which the browser drops without a word. Same treatment as tokens and hex. const low = ident.toLowerCase(); if (NAMED_COLORS[low] || low === 'currentcolor') { const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/); if (am) { out += `color-mix(in oklab, ${ident} ${am[1]}%, transparent)`; i += am[0].length; continue; } } out += ident; continue; } // v0.5.0 - font names are proper nouns: JetBrains Mono, IBM Plex Serif. // In font-family values and font.* tokens, bare multi-word names pass // through instead of demanding quotes. Everywhere else stays loud. if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) { out += ident; continue; } err(`unknown value "${ident}" for property "${ctx.prop}"`, didYouMean(ident, [...ctx.tokenNames, ...CSS_KEYWORDS])); out += ident; // keep going so the rest still resolves continue; } // punctuation: copy (handles / , + * # etc.) if (c === '#') { // hex color let j = i + 1; while (j < n && /[0-9a-fA-F]/.test(raw[j])) j++; const hex = raw.slice(i, j); out += hex; i = j; // v0.7.2 - alpha on a raw hex color: #ff0000/50 used to become // #ff0000/50px (the bare number got the generic px treatment) - invalid // CSS, silently dropped by the browser. Tokens always had /NN via // color-mix; raw hex now gets the same treatment. const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/); if (am) { out = out.slice(0, out.length - hex.length) + `color-mix(in oklab, ${hex} ${am[1]}%, transparent)`; i += am[0].length; } continue; } out += c; i++; } return out; } /** paren-aware split on the `->` arrow - gradient stops use it as a separator */ function splitArrow(s) { const out = []; let cur = '', depth = 0, q = null; for (let i = 0; i < s.length; i++) { const c = s[i]; if (q) { cur += c; if (c === q && s[i - 1] !== '\\') q = null; continue; } if (c === '"' || c === "'") { q = c; cur += c; continue; } if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth = Math.max(0, depth - 1); if (depth === 0 && c === '-' && s[i + 1] === '>') { out.push(cur); cur = ''; i++; continue; } cur += c; } out.push(cur); return out; } /** split on a top-level separator (parens-aware) - commas inside fn() survive */ function topSplit(str, sep) { const out = []; let cur = '', depth = 0; for (const ch of str) { if (ch === '(') depth++; else if (ch === ')') depth = Math.max(0, depth - 1); if (ch === sep && depth === 0) { out.push(cur); cur = ''; continue; } cur += ch; } out.push(cur); return out; } function findToken(tokens, ident) { if (tokens.has(ident)) return ident; // try progressive dot paths (a.b.c defined, user typed a.b.c - exact only, plus flat fallback) if (ident.includes('.')) { const parts = ident.split('.'); for (let k = parts.length - 1; k > 0; k--) { const key = parts.slice(0, k).join('.'); if (tokens.has(key)) return key; // hmm - partial match would change meaning; require exact } } return null; } /* ------------------------------------------------------------------ * * Semantic properties * ------------------------------------------------------------------ */ const len = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: false }); const num = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: true }); // v0.6.0 - the deform family speaks degrees, not lengths: bare numbers are // angles and `20deg` is accepted for people with CSS muscle memory. const degNum = (raw, what, ctx) => { const t = String(raw).trim(); const m = t.match(/^(-?\d*\.?\d+)(deg)?$/i); if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `${what} takes a bare number of degrees`, hint: `try ${what}: 20 or ${what}: -12 - bare numbers are degrees here`, }); return null; } return parseFloat(m[1]); }; /** v0.6.0 - shared grammar for gradient / gradient-text: kind · prefix · stops. * Stops may be arrow-joined (`a -> b -> c`) or comma-joined (`linear 135, a, b`); * the prefix is whatever the grammar calls direction/shape - everything else is a stop. */ // Stops are colors by construction, so a bare word that is not a color name is // a typo the browser would only mutter about at apply time. Say it NOW instead. const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' + 'orange,gold,pink,brown,violet,cyan,magenta,indigo,crimson,coral,tomato,goldenrod,khaki,' + 'slategray,slategrey,dimgray,dimgrey,lightgray,lightgrey,darkgray,darkgrey,gainsboro,whitesmoke,' + 'ghostwhite,ivory,beige,linen,seashell,snow,honeydew,mintcream,azure,aliceblue,lavender,' + 'canvas,canvastext,linktext,visitedtext,activetext,buttonface,buttontext,buttonborder,field,fieldtext,highlight,highlighttext,' + 'currentcolor,transparent').split(',')); /** split on a top-level " + " (layers), never inside parentheses or quotes */ function splitLayers(s) { const out = []; let depth = 0, q = null, cur = ''; for (let i = 0; i < s.length; i++) { const c = s[i]; if (q) { cur += c; if (c === q) q = null; continue; } if (c === '"' || c === "'") { q = c; cur += c; continue; } if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth = Math.max(0, depth - 1); if (depth === 0 && c === '+' && /\s/.test(s[i - 1] || '') && /\s/.test(s[i + 1] || '')) { out.push(cur.trim()); cur = ''; continue; } cur += c; } if (cur.trim()) out.push(cur.trim()); return out; } const INTERP_RE = /\s*\bin\s+(srgb-linear|srgb|display-p3|lab|oklab|xyz-d50|xyz-d65|xyz|hsl|hwb|lch|oklch)(\s+(?:longer|shorter|increasing|decreasing)\s+hue)?\s*$/i; /** * gradient: one or several layers. * linear 135, a -> b the original sentence * repeating linear 45, red 0 10, blue 10 20 stripes: positions take %, px, em, rem (deg on conic) * linear 90 in oklch, #f00 -> #00f pick the colour space the blend happens in * linear 90, a -> 30% -> b a lone position between two stops moves the midpoint * mesh #fff4e6, #ff7a1a at 15% 20%, #c2185b at 80% 30% size 60% soft blobs of colour on a base * radial at top left, #fff/40 -> transparent + linear 135, #123 -> #456 layers, first on top */ function buildGradient(v, ctx) { const parts = buildGradientParts(v, ctx); return parts ? parts.image : null; } /** * The same, with each layer's own size, position and repeat. After a layer, in any order: * tile 24 background-size 24px (tile 24 12 for width and height) * offset 4 4 background-position * no-repeat, repeat-x, repeat-y * and a layer can be a plain CSS function (radial-gradient(...), url(...)) when the sentence has no word for it. * Returns { image, size, position, repeat } where size, position and repeat are null unless some layer set one. */ function buildGradientParts(v, ctx) { const layers = splitLayers(String(v).trim()); const out = { images: [], sizes: [], positions: [], repeats: [] }; const px = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + 'px' : x); for (let layer of layers) { let size = null, pos = null, rep = null; for (;;) { let m; if ((m = layer.match(/\s+tile\s+(\S+)(?:\s+(\S+))?$/i)) && !/^(tile|offset)$/i.test(m[1])) { size = px(m[1]) + ' ' + px(m[2] || m[1]); layer = layer.slice(0, m.index); continue; } if ((m = layer.match(/\s+offset\s+(\S+)\s+(\S+)$/i))) { pos = px(m[1]) + ' ' + px(m[2]); layer = layer.slice(0, m.index); continue; } if ((m = layer.match(/\s+(no-repeat|repeat-x|repeat-y)$/i))) { rep = m[1].toLowerCase(); layer = layer.slice(0, m.index); continue; } break; } const isCssFn = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(|^url\(|^image-set\(|^-webkit-/i.test(layer.trim()); const img = isCssFn ? resolveValue(layer.trim(), { ...ctx, unitless: false, bareIdents: false }) : buildGradientLayer(layer, ctx); if (img === null) return null; out.images.push(img); out.sizes.push(size); out.positions.push(pos); out.repeats.push(rep); } const listOf = (arr, dflt) => (arr.some((x) => x !== null) ? arr.map((x) => x || dflt).join(', ') : null); return { image: out.images.join(', '), size: listOf(out.sizes, 'auto'), position: listOf(out.positions, '0 0'), repeat: listOf(out.repeats, 'repeat') }; } function buildMesh(rest, ctx) { const items = topSplit(rest, ',').map((x) => x.trim()).filter(Boolean); const bad = (message, hint) => { ctx.errors.push({ file: ctx.file, line: ctx.line, message, hint }); return null; }; if (items.length < 2) return bad('mesh needs a base colour and at least one blob', 'try gradient: mesh #fff4e6, #ff7a1a at 15% 20%, #3949ab at 80% 70%'); let base = null; const blobs = []; for (const it of items) { const m = it.match(/^(.*?)\s+at\s+(\S+)\s+(\S+)(?:\s+size\s+(\S+))?$/i); if (!m) { if (base !== null) return bad(`mesh item "${it}" needs a position`, 'write color at 20% 30% (and optionally size 60%)'); base = resolveValue(it, { ...ctx, unitless: false, bareIdents: false }); continue; } const pos = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + 'px' : x); const size = m[4] ? pos(m[4]) : '60%'; blobs.push(`radial-gradient(circle farthest-side at ${pos(m[2])} ${pos(m[3])}, ${resolveValue(m[1].trim(), { ...ctx, unitless: false, bareIdents: false })}, transparent ${size})`); } if (base === null) return bad('mesh needs a base colour first (an item without "at")', 'gradient: mesh #fff4e6, #ff7a1a at 15% 20%'); return [...blobs, `linear-gradient(${base}, ${base})`].join(', '); } function buildGradientLayer(v, ctx) { const t = String(v).trim(); const mm = t.match(/^mesh\b([\s\S]*)$/i); if (mm) return buildMesh(mm[1], ctx); const m = t.match(/^(repeating\s+)?(linear|radial|conic)\b([\s\S]*)$/i); if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `gradient needs a kind: "linear", "radial", "conic" or "mesh" (any of them can be "repeating")`, hint: 'try gradient: linear 135, color.accent -> color.ok', }); return null; } const repeating = !!m[1]; const kind = m[2].toLowerCase(); const rest = m[3].trim().replace(/^,\s*/, ''); const DIR_RE = /^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i; const isPrefixShaped = (seg0) => { const s = seg0.toLowerCase().replace(INTERP_RE, '').trim(); if (s === '' && INTERP_RE.test(seg0)) return true; if (kind === 'linear') return /^-?\d+(\.\d+)?(deg)?$/.test(s) || DIR_RE.test(s); if (kind === 'conic') return /^from\s+-?\d+(\.\d+)?(deg)?(\s+at\s+.+)?$/.test(s) || /^at\s+.+$/.test(s); // radial: every atom is shape/size/position vocabulary (possibly around `at`) return s.split(/\s+/).every(a => a === 'at' || /^(circle|ellipse|center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(\.\d+)?(%|px|em|rem)?)$/.test(a)); }; let head = ''; let stopStrs = []; const arrowParts = splitArrow(rest).map(s => s.trim()).filter(Boolean); if (arrowParts.length >= 2) { head = arrowParts[0]; stopStrs = arrowParts.slice(1); // mixed styles like `linear 135, a -> b`: the comma belongs to the stop list const hc = topSplit(head, ',').map(s => s.trim()).filter(Boolean); if (hc.length > 1) { head = hc[0]; stopStrs = [...hc.slice(1), ...stopStrs]; } // no direction at all: `linear #f00 -> #00f`, the first part is a stop if (head && !isPrefixShaped(head) && /^(#|[a-z-]+\(|[a-z]+\.[a-z0-9-]+)/i.test(head) || (head && COLOR_WORDS.has(head.split(/\s+/)[0].toLowerCase()) && !isPrefixShaped(head))) { stopStrs.unshift(head); head = ''; } } else { const segs = topSplit(rest, ',').map(s => s.trim()).filter(Boolean); while (segs.length > 1 && isPrefixShaped(segs[0])) head = head ? head + ' ' + segs.shift() : segs.shift(); stopStrs = segs; } if (stopStrs.length < 2) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `gradient needs at least two stops`, hint: 'try gradient: linear 135, #ff0055 -> #00d4ff - commas work too: linear 135, #ff0055, #00d4ff', }); return null; } // the colour space of the blend, if one was written: `in oklch`, `in hsl longer hue` let interp = ''; { const im = head.match(INTERP_RE); if (im) { interp = im[0].trim().toLowerCase(); head = head.slice(0, im.index).trim(); } } let lead = ''; const POS_ATOM = /^(center|top|bottom|left|right|closest-side|farthest-side|closest-corner|farthest-corner|-?\d+(?:\.\d+)?(%|px|em|rem)?)$/i; if (kind === 'linear') { if (!head) lead = '180deg'; else if (/^-?\d+(\.\d+)?$/.test(head)) lead = head + 'deg'; else if (/^-?\d+(\.\d+)?deg$/i.test(head)) lead = head.toLowerCase(); else if (DIR_RE.test(head)) lead = head.toLowerCase(); else { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `"${head}" is not a linear gradient direction`, hint: 'use an angle (linear 135, …) or words (linear to bottom right, …)', }); return null; } } else if (kind === 'radial') { if (head) { for (const a of head.split(/\s+/)) { if (a.toLowerCase() === 'at' || a.toLowerCase() === 'circle' || a.toLowerCase() === 'ellipse' || POS_ATOM.test(a)) continue; ctx.errors.push({ file: ctx.file, line: ctx.line, message: `"${a}" is not part of a radial gradient shape`, hint: 'try gradient: radial circle at center, #fff -> #000', }); return null; } lead = head; } } else { // conic if (head) { const fm = head.match(/^from\s+(-?\d+(?:\.\d+)?)(deg)?(?:\s+(at\s+[\s\S]+))?$/i); if (fm) lead = 'from ' + fm[1] + 'deg' + (fm[3] ? ' ' + fm[3].trim() : ''); else if (/^at\s+[\s\S]+$/i.test(head)) lead = head; else { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `"${head}" is not a conic gradient prefix`, hint: 'use from and/or at - try conic from 45 at center, …', }); return null; } } } const stops = []; const posUnit = kind === 'conic' ? 'deg' : 'px'; const asPos = (x) => (/^-?\d+(\.\d+)?$/.test(x) ? x + (x === '0' ? '' : posUnit) : x); const POS = String.raw`-?\d+(?:\.\d+)?(?:%|px|em|rem|deg|turn)?`; for (let si = 0; si < stopStrs.length; si++) { const s = stopStrs[si]; // a lone position between two colours is a midpoint hint if (new RegExp('^' + POS + '$').test(s)) { if (si === 0 || si === stopStrs.length - 1) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `"${s}" is a midpoint, so it has to sit between two colours`, hint: 'try gradient: linear 90, #f00 -> 30% -> #00f' }); return null; } stops.push(asPos(s)); continue; } let colorPart = s, pos = ''; // trailing position, or two of them (a hard-edged band: red 0 30%) const pm = s.match(new RegExp('\\s+(' + POS + ')(?:\\s+(' + POS + '))?$')); if (pm) { pos = pm[2] ? asPos(pm[1]) + ' ' + asPos(pm[2]) : asPos(pm[1]); colorPart = s.slice(0, pm.index).trim(); } if (!colorPart) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `a gradient stop is missing its color (got "${s}")` }); return null; } const val = resolveValue(colorPart, ctx); if (/^[a-z-]+$/i.test(val) && !COLOR_WORDS.has(val.toLowerCase())) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `gradient stop "${s}" is not a color`, hint: (didYouMean(val, [...COLOR_WORDS]) || 'colors, tokens (color.ink) and alpha mixes (ink/40) all work'), }); return null; } stops.push(pos ? `${val} ${pos}` : val); } const fn = (repeating ? 'repeating-' : '') + (kind === 'linear' ? 'linear-gradient' : kind === 'radial' ? 'radial-gradient' : 'conic-gradient'); const first = [lead, interp].filter(Boolean).join(' '); return `${fn}(${[first, ...stops].filter(Boolean).join(', ')})`; } function parseFlexWords(value, ctx, dir) { const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }]; // v0.8.0 - axis roles. Every place word used to write BOTH axes, so // `row: gap 8 between center` (the guide's own example) compiled to // justify-content: space-between followed by justify-content: center - // and center won, bunching everything in the middle. Words now declare // the axis they are actually about: distribution words (between/around/ // evenly) own the main axis, stretch/baseline own the cross axis, and // center/start/end fill whichever axes are still free. A word whose axes // are all taken is a loud warning instead of a silent overwrite. const FLEX_AXIS = { between: 'main', around: 'main', evenly: 'main', stretch: 'cross', baseline: 'cross', center: 'both', start: 'both', end: 'both', }; const atoms = value.split(/\s+/).filter(Boolean); let mainSet = null, crossSet = null; for (let k = 0; k < atoms.length; k++) { const a = atoms[k]; if (a === 'gap') { const v = atoms[++k]; if (v === undefined) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'gap needs a value' }); break; } out.push({ prop: 'gap', value: len(v, ctx) }); continue; } if (a === 'wrap') { out.push({ prop: 'flex-wrap', value: 'wrap' }); continue; } if (a === 'nowrap') { out.push({ prop: 'flex-wrap', value: 'nowrap' }); continue; } if (a === 'reverse') { out[1].value = dir + '-reverse'; continue; } if (a === 'inline') { out[0].value = 'inline-flex'; continue; } if (FLEX_AXIS[a]) { const role = FLEX_AXIS[a]; const pc = PLACE_WORDS[a]; // [cross, main] const axisWarn = (prop, prev) => { ctx.warnings = ctx.warnings || []; ctx.warnings.push(prev === a ? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} - the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` } : { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win - drop one, or reorder so the distribution word comes first` }); }; let wrote = false; if (role === 'main' || (role === 'both' && !mainSet)) { if (mainSet) axisWarn('justify-content', mainSet); out.push({ prop: 'justify-content', value: pc[1] }); mainSet = a; wrote = true; } if (role === 'cross' || (role === 'both' && !crossSet)) { if (crossSet) axisWarn('align-items', crossSet); out.push({ prop: 'align-items', value: pc[0] }); crossSet = a; wrote = true; } if (!wrote) { ctx.warnings = ctx.warnings || []; ctx.warnings.push({ file: ctx.file, line: ctx.line, message: mainSet === a || crossSet === a ? `"${a}" is written twice in ${dir} - both axes already came from it` : `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`, hint: 'one distribution word + one alignment word is the whole grammar - the extra word is dropped', }); } continue; } ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown word "${a}" in ${dir}`, hint: didYouMean(a, ['gap', 'center', 'start', 'end', 'between', 'around', 'evenly', 'stretch', 'baseline', 'wrap', 'nowrap', 'reverse', 'inline']) }); } return out; } // v0.6.0 - deform members all land in the transform channel, so asking for // `transition: 200 snap bend` should just work and mean transform. const DEFORM_TO_TRANSFORM = { bend: 'transform', 'bend-y': 'transform', 'bend-up': 'transform', fold: 'transform', stretch: 'transform', }; /** one motion of an animate: line: `bob 1200 infinite alternate delay 100 jitter 600 essential` */ function animateOne(t, ctx) { const atoms = t.split(/\s+/); const spec = { motion: atoms[0], duration: 400, easing: EASINGS.smooth, fill: 'both', delay: 0 }; const toMs = (a) => (a.endsWith('s') && !a.endsWith('ms') ? parseFloat(a) * 1000 : parseFloat(a)); for (let k = 1; k < atoms.length; k++) { const a = atoms[k]; // 0.14.0: the words that used to be CSS longhands which did nothing here if (a === 'delay' || a === 'jitter' || a === 'iterations') { const v = atoms[k + 1]; if (v === undefined || !/^\d+(\.\d+)?(ms|s)?$/.test(v)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `${a} needs a number`, hint: a === 'iterations' ? 'animate: boing 300 iterations 3' : `animate: bob 1200 infinite ${a} 600` }); continue; } if (a === 'iterations') spec.iterations = parseFloat(v); else spec[a] = toMs(v); k++; continue; } if (a === 'essential') { spec.essentialHere = true; continue; } if (/^\d+(ms|s)?$/.test(a)) { const ms = toMs(a); if (k === 1) spec.duration = ms; else spec.delay = ms; continue; } if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; } if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; } // v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs. // animate: pulse 1600 infinite alternate - was impossible before. if (a === 'infinite') { spec.iterations = Infinity; continue; } if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; } ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate', 'delay', 'jitter', 'iterations', 'essential'])) }); } return [{ prop: '@animate', value: spec }]; } const SEMANTIC = { size(v, ctx) { const parts = v.split(/\s+/).filter(Boolean); const w = len(parts[0] || 'auto', ctx); const h = parts[1] !== undefined ? len(parts[1], ctx) : w; return [{ prop: 'width', value: w }, { prop: 'height', value: h }]; }, row: (v, ctx) => parseFlexWords(v, ctx, 'row'), column: (v, ctx) => parseFlexWords(v, ctx, 'column'), grid(v, ctx) { const atoms = v.split(/\s+/).filter(Boolean); // v0.7.2 - `grid: 3 gap 12` (the documented form) used to die: the gap // atoms were fed into grid-template-columns → "3px gap 12px", invalid. // gap and rows are now first-class words of the shorthand. let gap = null, rows = null; const rest = []; for (let i = 0; i < atoms.length; i++) { if (atoms[i] === 'gap' && atoms[i + 1]) { gap = atoms[++i]; continue; } if (atoms[i] === 'rows' && atoms[i + 1]) { rows = atoms[++i]; continue; } rest.push(atoms[i]); } let cols; if (!rest.length) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 · grid: auto-fill 200 · grid: 200px 1fr - optionally with gap N and rows N' }); cols = 'none'; } else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) { cols = `repeat(${rest[0]}, 1fr)`; } else if ((rest[0] === 'auto-fill' || rest[0] === 'auto-fit') && rest[1]) { cols = `repeat(${rest[0]}, minmax(${len(rest[1], ctx)}, 1fr))`; } else { cols = rest.map(a => len(a, ctx)).join(' '); } const out = [{ prop: 'display', value: 'grid' }, { prop: 'grid-template-columns', value: cols }]; if (gap) out.push({ prop: 'gap', value: len(gap, ctx) }); if (rows) out.push({ prop: 'grid-template-rows', value: /^[0-9]+$/.test(rows) ? `repeat(${rows}, 1fr)` : len(rows, ctx) }); return out; }, stack(v, ctx) { const word = (v || 'center').trim(); const p = PLACE_WORDS[word]; if (!p) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown place "${word}" in stack`, hint: didYouMean(word, Object.keys(PLACE_WORDS)) }); return [{ prop: '@stack', value: '1' }]; } return [{ prop: 'display', value: 'grid' }, { prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: '@stack', value: '1' }]; }, place(v, ctx) { const p = PLACE_WORDS[v.trim()]; if (!p) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown place "${v}"`, hint: didYouMean(v, Object.keys(PLACE_WORDS)) }); return []; } return [{ prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: 'place-content', value: `${p[0]} ${p[1]}` }]; }, // v0.3.4 - the one-word centering primitive, because "how do I center this // thing" is the first question everyone asks. Emits the alignment pair only; // the ENGINE guarantees a formatting context for alignment declarations // (inline elements → inline-grid, blocks → grid), so `center:` can never // silently no-op the way raw place-items did on spans and buttons. // center: both axes · center: x horizontal only · center: y vertical only center(v, ctx) { const mode = (v || '').trim().toLowerCase() || 'both'; if (mode === 'x') { return [{ prop: 'justify-items', value: 'center' }, { prop: 'justify-content', value: 'center' }]; } if (mode === 'y') { return [{ prop: 'align-items', value: 'center' }, { prop: 'align-content', value: 'center' }]; } if (mode !== 'both') { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown center axis "${v}"`, hint: didYouMean(mode, ['both', 'x', 'y']) }); return []; } return [{ prop: 'place-items', value: 'center center' }, { prop: 'place-content', value: 'center center' }]; }, cover(v, ctx) { const pos = (v || 'absolute').trim(); return [{ prop: 'position', value: pos }, { prop: 'inset', value: '0' }]; }, sticky(v, ctx) { return [{ prop: 'position', value: 'sticky' }, { prop: 'top', value: len(v || '0', ctx) }]; }, font(v, ctx) { const out = []; const slash = v.split('/'); const head = slash[0].trim().split(/\s+/).filter(Boolean); // 0.15.0: DSSL's font: is size/line-height and a colour, NOT the CSS shorthand (weight, style, family). A CSS-looking value used to // become a 700px font; say what it is instead. const sized = (x) => /^\d*\.?\d+(px|em|rem|%|pt|vw|vh|ch)$/.test(x); if (/["',]/.test(v) || /^(italic|oblique|bold|bolder|lighter|normal|small-caps|condensed|expanded|caption|icon|menu|message-box|small-caption|status-bar|system-ui|serif|sans-serif|monospace)$/.test(head[0] || '') || (/^[1-9]00$/.test(head[0] || '') && head[1] !== undefined && sized(head[1]))) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `font: "${v.trim()}" looks like the CSS font shorthand, but DSSL's font: means size, line height and colour`, hint: 'font: 16/1.5 color.ink sets size, line-height and colour. Weight and family are their own lines: font-weight: 700 and font-family: "Fraunces", serif' }); return []; } out.push({ prop: 'font-size', value: len(head[0] || '16', ctx) }); if (slash[1]) { const tail = slash[1].trim().split(/\s+/).filter(Boolean); out.push({ prop: 'line-height', value: num(tail[0] || '1.5', ctx) }); if (tail.length > 1) out.push({ prop: 'color', value: resolveValue(tail.slice(1).join(' '), ctx) }); } else if (head.length > 1) { out.push({ prop: 'color', value: resolveValue(head.slice(1).join(' '), ctx) }); } return out; }, fluid(v, ctx) { return buildFluidParts(v.split(',').map(s => s.trim()).filter(Boolean), ctx); }, shadow(v, ctx) { const t = v.trim(); if (['sm', 'md', 'lg', 'xl', 'none'].includes(t)) { return [{ prop: 'box-shadow', value: t === 'none' ? 'none' : `var(--dssl-shadow-${t})` }]; } // 0.14.0: your own shadow token works by its bare name, like the built-in ones: tokens { shadow { pop: ... } } then shadow: pop if (/^[A-Za-z][\w-]*$/.test(t) && ctx.tokens && ctx.tokens.has('shadow.' + t)) return [{ prop: 'box-shadow', value: `var(--dssl-shadow-${t})` }]; return [{ prop: 'box-shadow', value: resolveValue(v, ctx) }]; }, radius(v, ctx) { const atoms = v.trim().split(/\s+/).filter(Boolean); const out = atoms.map(a => { if (['sm', 'md', 'lg', 'full', 'xl'].includes(a)) return `var(--dssl-radius-${a})`; if (/^[A-Za-z][\w-]*$/.test(a) && ctx.tokens && ctx.tokens.has('radius.' + a)) return `var(--dssl-radius-${a})`; // 0.14.0: radius { pill: 99 } then radius: pill return resolveValue(a, ctx); }); return [{ prop: 'border-radius', value: out.join(' ') }]; }, circle(v, ctx) { const s = len(v, ctx); return [ { prop: 'width', value: s }, { prop: 'height', value: s }, { prop: 'border-radius', value: '50%' }, ]; }, align(v, ctx) { const map = { center: 'center', start: 'start', end: 'end', stretch: 'stretch', baseline: 'baseline' }; const val = map[v.trim()]; if (!val) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown align "${v}"`, hint: didYouMean(v, Object.keys(map)) }); return []; } return [{ prop: 'align-items', value: val }]; }, z(v, ctx) { return [{ prop: 'z-index', value: num(v, ctx) }]; }, grow(v, ctx) { return [{ prop: 'flex-grow', value: num(v || '1', ctx) }]; }, shrink(v, ctx) { return [{ prop: 'flex-shrink', value: num(v || '1', ctx) }]; }, basis(v, ctx) { return [{ prop: 'flex-basis', value: len(v, ctx) }]; }, truncate() { return [ { prop: 'overflow', value: 'hidden' }, { prop: 'text-overflow', value: 'ellipsis' }, { prop: 'white-space', value: 'nowrap' }, ]; }, // v0.4.0 - the accessibility classic: visually hidden, still announced by // screen readers. One word instead of nine lines of clip folklore. 'visually-hidden'() { return [ { prop: 'position', value: 'absolute' }, { prop: 'width', value: '1px' }, { prop: 'height', value: '1px' }, { prop: 'padding', value: '0' }, { prop: 'margin', value: '-1px' }, { prop: 'overflow', value: 'hidden' }, { prop: 'clip-path', value: 'inset(50%)' }, { prop: 'white-space', value: 'nowrap' }, { prop: 'border', value: '0' }, ]; }, // v0.4.0 - transition grew per-property specs. Each comma part is // duration [easing] [property] (property omitted → all) // The one-value form keeps working exactly as before: // transition: 200 snap // transition: 150 snap background-color, 300 transform transition(v, ctx) { const t = v.trim(); if (t === 'none') return [{ prop: 'transition', value: 'none' }]; const parts = topSplit(t, ',').map(s => s.trim()).filter(Boolean); const out = []; for (const part of parts) { const atoms = part.split(/\s+/); const dur = atoms[0]; if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) { // v0.8.0 - the CSS habit is property-first (`transition: color .2s // ease`). That used to be a generic "not a time" error and the part // silently dropped. The error now REWRITES the value into the DSSL // order (time · easing · property) so the fix is a copy-paste. const timeAtom = atoms.find(a => /^\d*\.?\d+(ms|s)?$/.test(a)); const easeAtom = atoms.find(a => EASINGS[a]); const propAtom = atoms.find(a => a !== timeAtom && a !== easeAtom && (KNOWN_PROPS.has(a) || DEFORM_TO_TRANSFORM[a])); if (timeAtom) { const msOut = /^\d+$/.test(timeAtom) ? timeAtom : /ms$/.test(timeAtom) ? timeAtom.replace(/ms$/, '') : String(parseFloat(timeAtom) * 1000); ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition: the CSS form (property first) is not DSSL`, hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} - time first, easing, property last`, }); continue; } ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration · easing · property - try 200 or 0.2s' }); continue; } const ms = /^\d+$/.test(dur) ? dur + 'ms' : dur; let k = 1; let easing = 'ease'; if (atoms[k] && EASINGS[atoms[k]]) { easing = EASINGS[atoms[k]]; k++; } else if (atoms[k] && /^(cubic-bezier|steps)\(/.test(atoms[k])) { easing = resolveValue(atoms[k], { ...ctx, unitless: true }); k++; } let prop = 'all'; if (atoms.length > k) { const raw = atoms.slice(k).join(' ').toLowerCase(); const p = DEFORM_TO_TRANSFORM[raw] || raw; if (p !== 'all' && !KNOWN_PROPS.has(p)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown transition property "${raw}"`, hint: didYouMean(raw, [...KNOWN_PROPS, ...Object.keys(DEFORM_TO_TRANSFORM)]) }); continue; } prop = p; } out.push(`${prop} ${ms} ${easing}`); } if (!out.length) return []; return [{ prop: 'transition', value: out.join(', ') }]; }, animate(v, ctx) { const t = v.trim(); if (t === 'none') return [{ prop: '@animate', value: null }]; // 0.15.0: several motions on one element: animate: blink 300 infinite, waddle 900 infinite alternate const parts = topSplit(t, ',').map((x) => x.trim()).filter(Boolean); if (parts.length > 1) { const out = []; for (const part of parts) for (const d of animateOne(part, ctx)) out.push(d); return out; } return animateOne(t, ctx); }, // v0.4.0 - choreography: a parent with `stagger: 60` offsets each element // child's animation delay by its DOM position. Pairs with `seen` + `animate` // on the children: grids and lists cascade in as they scroll into view. stagger(v, ctx) { const t = (v || '').trim(); if (t === 'none') return [{ prop: '@stagger', value: null }]; if (!/^\d+(?:\.\d+)?(?:ms|s)?$/.test(t)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `stagger needs a duration like "60" or "120ms", or "none"`, hint: 'stagger offsets each child\'s animation delay by its position' }); return []; } const ms = t.endsWith('s') && !t.endsWith('ms') ? parseFloat(t) * 1000 : parseFloat(t); return [{ prop: '@stagger', value: ms }]; }, // v0.5.0 - overlap policy. CSS can stack transparent things all day and say // nothing; DSSL refuses. `deny` makes the element always yield out of any // overlap; `auto` yields only while the element sits below 100% opacity. // Both inherit down the tree, so `body { overlap: auto }` is the site-wide // "nothing transparent may overlap anything" switch. Intentional overlays // (scrims, veils, translucent navs) opt out with `overlap: allow`. overlap(v, ctx) { const t = (v || '').trim().toLowerCase(); if (t === 'allow' || t === 'none') return [{ prop: '@overlap', value: 'allow' }]; if (t === 'deny') return [{ prop: '@overlap', value: 'deny' }]; if (t === 'auto') return [{ prop: '@overlap', value: 'auto' }]; ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown overlap policy "${(v || '').trim()}"`, hint: 'overlap is allow (default) · deny (always yield out of overlaps) · auto (yield only while below 100% opacity)', }); return []; }, /* ---------------------------------------------------------------- * * v0.6.0 - the deform family: bend, bend-y, bend-up, fold, stretch, * bend-around. All are engine-native (@-props) that compose into the * transform channel, so `transition: 200 snap transform` tweens them * and `:active { bend: 14 }` makes a button visibly flex under the * pointer. bend-around is the odd one out: it curves a CONTAINER's * children along an arc instead of deforming the element itself. * ---------------------------------------------------------------- */ bend(v, ctx) { const n = degNum(v, 'bend', ctx); return n === null ? [] : [{ prop: '@bend', value: n }]; }, 'bend-y'(v, ctx) { const n = degNum(v, 'bend-y', ctx); return n === null ? [] : [{ prop: '@bend-y', value: n }]; }, 'bend-up'(v, ctx) { const n = degNum(v, 'bend-up', ctx); return n === null ? [] : [{ prop: '@bend-up', value: n }]; }, fold(v, ctx) { const n = degNum(v, 'fold', ctx); return n === null ? [] : [{ prop: '@fold', value: n }]; }, 'bend-around'(v, ctx) { const n = degNum(v, 'bend-around', ctx); if (n === null) return []; if (Math.abs(n) > 170) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `bend-around caps at ±170° - past that the arc folds back on itself`, hint: 'try 30..120 for a visible arc', }); return []; } return [{ prop: '@bend-around', value: n }]; }, stretch(v, ctx) { const t = String(v).trim(); const m = t.match(/^(\d*\.?\d+)(?:\s+(horizontal|vertical|x|y))?$/i); if (!m) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `stretch needs a positive factor, like "stretch: 1.2" or "stretch: 1.4 vertical"`, hint: '1 is neutral · the other axis shrinks so volume stays constant', }); return []; } const f = parseFloat(m[1]); if (f <= 0) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `stretch: ${m[1]} would flatten the element to nothing`, hint: 'use a factor above 0 - 1.2 is a gentle pull, 2 is cartoonish', }); return []; } const axisRaw = (m[2] || 'horizontal').toLowerCase(); const axis = axisRaw === 'y' ? 'vertical' : axisRaw === 'x' ? 'horizontal' : axisRaw; return [{ prop: '@stretch', value: { factor: f, axis } }]; }, /* v0.6.0 - gradient tools. `gradient` replaces the CSS function soup with * kind · prefix · stop -> stop; stops may be colors, tokens, alpha mixes * (ink/40) and carry a trailing % position. gradient-text clips the same * gradient into the glyphs. gradient-flow loops background-position so a * gradient drifts forever - one line instead of keyframe folklore. */ gradient(v, ctx) { const g = buildGradientParts(v, ctx); if (!g) return []; const out = [{ prop: 'background-image', value: g.image, fromGradient: true }]; if (g.size) out.push({ prop: 'background-size', value: g.size }); if (g.position) out.push({ prop: 'background-position', value: g.position }); if (g.repeat) out.push({ prop: 'background-repeat', value: g.repeat }); return out; }, // 0.14.0: an outline round text, in one line. The stroke is drawn first and the fill over it, so the outline sits OUTSIDE // the letters and does not eat them (the stroke-plus-paint-order trick, written once). `inside` keeps the default centred stroke. // text-stroke: 6 color.ink text-stroke: 2 #fff inside 'text-stroke'(v, ctx) { const atoms = (v || '').trim().split(/\s+/).filter(Boolean); if (atoms.length === 1 && atoms[0] === 'none') return [{ prop: '-webkit-text-stroke', value: '0' }, { prop: 'paint-order', value: 'normal' }]; const inside = atoms.includes('inside'); const rest = atoms.filter((a) => a !== 'inside'); if (!rest.length || !/^\d+(\.\d+)?(px|em|rem)?$/.test(rest[0])) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'text-stroke needs a width first', hint: 'text-stroke: 6 color.ink' }); return []; } const width = /^\d+(\.\d+)?$/.test(rest[0]) ? rest[0] + 'px' : rest[0]; const color = rest[1] ? resolveValue(rest.slice(1).join(' '), { ...ctx, unitless: false, bareIdents: false }) : 'currentcolor'; const out = [{ prop: '-webkit-text-stroke', value: `${width} ${color}` }]; if (!inside) out.push({ prop: 'paint-order', value: 'stroke fill' }); return out; }, // 0.16.0: the engine writes its styles inline, so a plain CSS rule for the same property loses to them (short of !important). // `yield:` hands properties back: the engine stops writing them on the elements this rule matches, and plain CSS (a stylesheet, env(), // a media query in another file) decides. yield: padding yields padding-top and the rest too; yield: none clears it. // .app { yield: padding-top padding-bottom } then, in a normal stylesheet: .app { padding-top: env(safe-area-inset-top) } yield(v, ctx) { const t = (v || '').trim(); if (t === 'none') return [{ prop: '@yield', value: null }]; const names = t.split(/[\s,]+/).filter(Boolean).map((x) => x.toLowerCase()); if (!names.length) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'yield needs the properties to hand back', hint: 'yield: padding-top padding-bottom' }); return []; } for (const n of names) { if (!KNOWN_PROPS.has(n) && !Object.prototype.hasOwnProperty.call(SEMANTIC, n)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `yield: "${n}" is not a CSS property`, hint: didYouMean(n, [...KNOWN_PROPS]) }); return []; } } return [{ prop: '@yield', value: names }]; }, // 0.15.0: an outline that follows the SHAPE's alpha (a sprite, an icon, a cut-out), not its box. It is four drop-shadows with no blur, // one in each direction, which is what people write by hand. The corners come out square. It uses `filter`, so it replaces another filter on the same rule. // outline-shape: 3 color.ink outline-shape: none 'outline-shape'(v, ctx) { const atoms = (v || '').trim().split(/\s+/).filter(Boolean); if (atoms.length === 1 && atoms[0] === 'none') return [{ prop: 'filter', value: 'none' }]; if (!atoms.length || !/^\d+(\.\d+)?(px)?$/.test(atoms[0])) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'outline-shape needs a width first', hint: 'outline-shape: 3 color.ink' }); return []; } const w = parseFloat(atoms[0]); const color = atoms[1] ? resolveValue(atoms.slice(1).join(' '), { ...ctx, unitless: false, bareIdents: false }) : 'currentcolor'; const at = (x, y) => `drop-shadow(${x}px ${y}px 0 ${color})`; return [{ prop: 'filter', value: [at(w, 0), at(-w, 0), at(0, w), at(0, -w)].join(' ') }]; }, // 0.15.0: an angle is a bare number like every other length: rotate: 5 is 5deg (the other forms, 5deg, 0.1turn, x 30, pass through) rotate(v, ctx) { const t = (v || '').trim(); const num = '-?\\d*\\.?\\d+'; let out; if (new RegExp(`^${num}$`).test(t)) out = t + 'deg'; else if (new RegExp(`^[xyz]\\s+${num}$`).test(t)) out = t + 'deg'; else if (new RegExp(`^${num}\\s+${num}\\s+${num}\\s+${num}$`).test(t)) out = t + 'deg'; else out = resolveValue(t, { ...ctx, unitless: true }); return [{ prop: 'rotate', value: out }]; }, 'gradient-text'(v, ctx) { const img = buildGradient(v, ctx); if (!img) return []; return [ { prop: 'background-image', value: img }, { prop: 'background-clip', value: 'text' }, { prop: '-webkit-background-clip', value: 'text' }, { prop: 'color', value: 'transparent' }, { prop: '-webkit-text-fill-color', value: 'transparent' }, ]; }, 'gradient-flow'(v, ctx) { const t = (v || '').trim(); if (!t || t === 'none') return [{ prop: '@gradient-flow', value: null }]; const atoms = t.split(/\s+/); const dur = atoms[0]; if (!/^\d+(\.\d+)?(ms|s)?$/.test(dur)) { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `gradient-flow takes a duration like "6s" or "6000ms", or "none"`, hint: 'gradient-flow loops the element\'s gradient sideways forever', }); return []; } const ms = dur.endsWith('s') && !dur.endsWith('ms') ? parseFloat(dur) * 1000 : parseFloat(dur); const spec = { duration: ms, easing: 'linear', direction: 'normal' }; for (let k = 1; k < atoms.length; k++) { const a = atoms[k]; if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; } if (a === 'alternate' || a === 'alternate-reverse') { spec.direction = a; continue; } ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown gradient-flow part "${a}"`, hint: didYouMean(a, [...Object.keys(EASINGS), 'alternate', 'alternate-reverse']), }); } return [{ prop: '@gradient-flow', value: spec }]; }, }; /* v0.8.0 - fluid sizing sugar. `fluid: 16, 24` keeps working (font-size), * and now any length-ish property takes it inline: `font-size: fluid 16 24`. * Both compile to a clamp() that scales linearly between the viewport bounds * (360px and 1200px by default) - the formula everyone writes by hand and * gets wrong. */ const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' + 'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left,' + // 0.8.2: the longhands. The site's own hero wanted a fluid padding-top and // there was no way to say it, because `padding: fluid ...` is all four sides. 'padding-top,padding-right,padding-bottom,padding-left,padding-inline,padding-block,' + 'margin-top,margin-right,margin-bottom,margin-left,margin-inline,margin-block,' + 'letter-spacing,border-radius').split(',')); function buildFluidParts(parts, ctx, prop = 'font-size') { const bad = (s, why) => { ctx.errors.push({ file: ctx.file, line: ctx.line, message: `fluid: "${s}" ${why}`, hint: 'fluid takes two sizes and optionally the viewport bounds, like: fluid 16 24, or fluid 16 24 360 1200' }); }; // sizes may be px (bare numbers are px), rem or em. Percentages cannot be // interpolated against the viewport, so they are refused instead of // silently producing a clamp() that does not do what it says. const size = (s) => { const m = String(s).match(/^(-?[0-9.]+)\s*(px|rem|em|%)?$/); if (!m) { bad(s, 'is not a size'); return null; } if (m[2] === '%') { bad(s, 'cannot be a percentage, use px, rem or em'); return null; } return { n: parseFloat(m[1]), unit: m[2] || 'px' }; }; const a = size(parts[0]); const b = size(parts[1]); const v0 = size(parts[2] || '360'); const v1 = size(parts[3] || '1200'); if (!a || !b || !v0 || !v1) return [{ prop, value: '0px' }]; if (a.unit !== b.unit) { bad(`${parts[0]} ${parts[1]}`, 'mixes units, give both sizes the same unit'); return [{ prop, value: '0px' }]; } if (v0.unit !== 'px' || v1.unit !== 'px') { bad(`${parts[2]} ${parts[3]}`, 'viewport bounds are px'); return [{ prop, value: '0px' }]; } if (v1.n <= v0.n) { bad(`${parts[2]} ${parts[3]}`, 'needs the larger viewport second'); return [{ prop, value: '0px' }]; } // a straight line through (v0, a) and (v1, b): // size(viewport) = intercept + slope * viewport // The vw term is slope * 100, and the intercept is what is left at a // viewport of 0. v0.8.0 forgot the intercept, so the size ran ahead of the // line and hit its maximum long before the upper viewport. rem and em are // worked out against a 16px root for the slope only; the intercept stays // in the unit you wrote. const f = a.unit === 'px' ? 1 : 16; const slopePxPerPx = ((b.n - a.n) * f) / (v1.n - v0.n); const vw = slopePxPerPx * 100; const intercept = a.n - (slopePxPerPx * v0.n) / f; const num = (x) => String(+x.toFixed(3)); const lo = Math.min(a.n, b.n), hi = Math.max(a.n, b.n); // clamp() needs lower first even for shrinking sizes return [{ prop, value: `clamp(${num(lo)}${a.unit}, calc(${num(intercept)}${a.unit} + ${num(vw)}vw), ${num(hi)}${a.unit})` }]; } /* ------------------------------------------------------------------ * * 0.9.0: texture, aura and slider * * All three validate their own vocabulary in their own module. Here they only * get hooks: how to report an error on this line, how to turn a colour word * into CSS (tokens, alpha and all), and how to suggest a correction. * ------------------------------------------------------------------ */ function styleHooks(ctx) { return { error: (message, hint) => ctx.errors.push({ file: ctx.file, line: ctx.line, message, hint: hint || undefined }), color: (word) => { const before = ctx.errors.length; const css = resolveValue(word, { ...ctx, unitless: false, bareIdents: false }); return ctx.errors.length > before ? null : css; }, suggest: (word, list) => { const h = didYouMean(word, list); return h ? `did you mean "${h}"?` : null; }, }; } SEMANTIC.texture = function texture(v, ctx) { const layers = parseTexture(v, styleHooks(ctx)); if (layers === null) return []; return [{ prop: '@texture', value: layers.length ? layers : null }]; }; function auraDecls(prop, v, ctx) { const r = parseAura(prop, v, styleHooks(ctx)); if (!r) return []; const out = []; // an empty list or none clears the slot; undefined means this property does not touch it if (r.glow !== undefined) out.push({ prop: '@aura-glow', value: r.glow && r.glow.length ? r.glow : null }); if (r.particles !== undefined) out.push({ prop: '@aura-particles', value: r.particles && r.particles.length ? r.particles : null }); return out; } for (const name of ['aura', 'aura-glow', 'aura-particle', 'aura-particles', 'glow-particle', 'glow-particles', 'aura-glow-particle', 'aura-glow-particles']) { SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx); } SEMANTIC.glass = function glass(v, ctx) { return glassDecls(v, { ...styleHooks(ctx), looksLikeColor: (w) => /^(#|rgb|hsl|oklch|oklab|color)|\//.test(w) || /^[a-z]+\.[a-z0-9-]+/.test(w) || /^(white|black|transparent)$/.test(w) }) || []; }; SEMANTIC.tilt = function tilt(v, ctx) { const spec = parseTilt(v, styleHooks(ctx)); if (spec === undefined) return []; return [{ prop: '@tilt', value: spec }]; }; SEMANTIC.slider = function slider(v, ctx) { const spec = parseSlider(v, styleHooks(ctx)); if (!spec) return []; return [{ prop: '@slider', value: spec }]; }; /* ------------------------------------------------------------------ * * resolveDecl - one declaration → zero or more engine decls * ------------------------------------------------------------------ */ function resolveDecl(prop, value, ctx) { const errors = ctx.errors; const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings }; // v0.8.0 - !important does not exist in DSSL. It used to fall through as a // value: the compiler said "unknown value important" AND still emitted // `red !important`, which the browser then silently dropped as an invalid // inline value. Now: one loud error, nothing emitted, and the pin { } // answer to the actual question. if (hasImportant(value)) { errors.push({ file: ctx.file, line: ctx.line, message: `!important does not exist in DSSL - "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`, hint: 'the cascade is (layer, order) and later wins - put the rule in pin { } when it genuinely must win', }); return []; } if (prop.startsWith('--')) { // v0.4.0 - scoped tokens. Dotted names are token re-declarations: // .danger { --color.accent: color.err } // sets --dssl-color-accent ON the element, and custom properties inherit, // so every var() reference in the subtree re-resolves. Plain names // (--brand) stay free-form custom properties, passed through unvalidated. const outProp = prop.includes('.') ? '--dssl-' + prop.slice(2).replace(/\./g, '-') : prop; return [{ prop: outProp, value: resolveValue(value, { ...ctx2, unitless: false, bareIdents: true }) }]; } const sem = SEMANTIC[prop]; if (sem) return sem(value, ctx2); // v0.8.0 - the animation shorthand passes through (it is legal CSS), but // bare motion names must not pay the generic unknown-value tax: "spin" // used to error with the surreal suggestion "did you mean span?". Motion // names pass silently here and a dedicated warning in the final loop says // the DSSL way (motion + animate) when the name matches nothing. if (prop === 'animation' || prop === 'animation-name') { return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }]; } // v0.8.0 - inline fluid sugar on any length-ish property: // font-size: fluid 16 24 → clamp(16px, calc(16px + 0.952vw), 24px) const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim()); if (fluidM && FLUID_PROPS.has(prop)) { return buildFluidParts(fluidM[1].split(/[\s,]+/).filter(Boolean), ctx2, prop); } if (!KNOWN_PROPS.has(prop)) { errors.push({ file: ctx.file, line: ctx.line, message: `unknown property "${prop}"`, hint: didYouMean(prop, [...KNOWN_PROPS, ...Object.keys(SEMANTIC)]), }); return []; } return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' || prop.startsWith('counter-') }) }]; } /** string-aware !important scan - quoted content: "!important" must not trip it */ function hasImportant(v) { const s = String(v); let q = null; for (let i = 0; i < s.length; i++) { const c = s[i]; if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; } if (c === '"' || c === "'") { q = c; continue; } if (c === '!' && /\s*important/i.test(s.slice(i + 1))) return true; } return false; } /* ------------------------------------------------------------------ * * Media / container / signal normalization * ------------------------------------------------------------------ */ function normalizeMedia(query, ctx, line) { let q = query.trim(); if (!q) { ctx.errors.push({ file: ctx.file, line, message: '@media needs a condition', hint: 'e.g. @media 768 or @media dark or @media (width >= 768)' }); return '(min-width: 0)'; } // ---- v0.3 one-word sugar (the conditions everyone writes) ---- if (/^dark$/i.test(q)) return '(prefers-color-scheme: dark)'; if (/^light$/i.test(q)) return '(prefers-color-scheme: light)'; if (/^reduced-motion$/i.test(q)) return '(prefers-reduced-motion: reduce)'; // ---- bare-width sugar ---- // @media 768 → (min-width: 768px) "this wide or wider" // @media <= 768 → (max-width: 768px) "narrower than this" // @media 480..960 → min + max range const m = q.match(/^(<=|>=)?\s*(\d+(?:\.\d+)?)\s*(?:\.\.\s*(\d+(?:\.\d+)?))?$/); if (m) { const a = m[2] + 'px'; if (m[3] !== undefined) return `(min-width: ${a}) and (max-width: ${m[3]}px)`; if (m[1] === '<=') return `(max-width: ${a})`; return `(min-width: ${a})`; } // bare numbers become px: (width >= 768) → (width >= 768px) q = q.replace(/([>=<]\s*)(\d+(?:\.\d+)?)\b(?!\s*(?:px|%|em|rem|vh|vw|vmin|vmax|s|ms|deg|dppx|x))/g, '$1$2px'); return q; } function parseSignal(expr, ctx, line) { // 0.14.0: a set of values: @signal screen in (menu, shop, settings) and @signal screen not in (playing) const list = expr.match(/^([A-Za-z_][\w-]*)\s+(not\s+in|in)\s*\(([^)]*)\)\s*$/); if (list) { const values = list[3].split(',').map((x) => x.trim().replace(/^(["'])(.*)\1$/, '$2')).filter((x) => x !== ''); if (!values.length) { ctx.errors.push({ file: ctx.file, line, message: `@signal ${list[1]} ${list[2]} ( ) has no values`, hint: '@signal screen in (menu, shop)' }); } return { name: list[1], op: /^not/.test(list[2]) ? 'notin' : 'in', value: values.join(','), values }; } const m = expr.match(/^([A-Za-z_][\w-]*)\s*(==|!=)\s*(.+)$/); if (m) { let v = m[3].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); return { name: m[1], op: m[2], value: v }; } if (/^[A-Za-z_][\w-]*$/.test(expr.trim())) return { name: expr.trim(), op: 'truthy', value: null }; ctx.errors.push({ file: ctx.file, line, message: `bad @signal expression "${expr}"`, hint: 'try: @signal theme == dark or @signal compact' }); return { name: expr.trim(), op: 'truthy', value: null }; } /* ------------------------------------------------------------------ * * The flattener * ------------------------------------------------------------------ */ export function resolve(ast, file = 'inline.dssl', baseTokens = null, baseMotions = null) { const errors = []; const rules = []; const shadowRules = []; const fontLinks = []; // 0.12.0 - stylesheet links from fonts { } const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks const motions = {}; const behaviors = {}; // v0.7.0 - compile-time warnings: things that compile and run but are // probably not what the author meant. Separate from errors - ok stays ok - // but the engine prints them loudly so nobody debugs cascade order by hand // ever again. const warnings = []; let tokenMap = new Map(Object.entries(baseTokens || DEFAULT_TOKENS)); const tokenLines = new Map(); // for docs/debug let order = 0; let ruleId = 0; /* ---- pass 1: collect tokens / behaviors / motions ---- */ const collectTokens = (node, path, acc) => { for (const child of node.children) { if (child.kind === 'decl') { const key = path ? path + '.' + child.prop : child.prop; tokenMap.set(key, child.value); tokenLines.set(key, child.line); if (acc) acc.push(key); } else if (child.kind === 'selector' || child.kind === 'tokens') { // nested groups written as bare words: `color { ink: ... }` collectTokens(child, path ? path + '.' + child.sel : child.sel, acc); } else { errors.push({ file, line: child.line, message: `unexpected "${child.kind}" inside tokens` }); } } }; const prepass = (nodes) => { for (const node of nodes) { if (node.kind === 'tokens') collectTokens(node, ''); else if (node.kind === 'behavior') { if (behaviors[node.name]) errors.push({ file, line: node.line, message: `behavior "${node.name}" is defined twice` }); behaviors[node.name] = node; } else if (node.kind === 'motion') { motions[node.name] = node; } else { // v0.8.0 - defensive `|| []`: a malformed tree can throw from here // and take the whole stylesheet down (the @font-face crash report). // The parser now skips unknown at-rules whole, but the guard stays. for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]); } } }; prepass(ast.children); /* ---- motions → keyframes ---- */ const parseMotion = (name) => { const node = motions[name]; // 0.14.0: a motion defined in another stylesheet counts, like a token does. `baseMotions` holds the ones the files loaded // before this one defined, already turned into keyframes. if (!node) return (baseMotions && baseMotions[name]) || null; if (node._parsed) return node._parsed; node._parsed = { essential: false, frames: [] }; // from, to, at 40%, and since 0.14.0 plain CSS habits too: 0%, 40%, and a list like 0%, 100% const frameOf = (label) => { const t = label.trim(); if (t === 'from') return 0; if (t === 'to') return 100; const m = t.match(/^(?:at\s+)?([0-9.]+)\s*%?$/); return m ? parseFloat(m[1]) : null; }; const framesOf = (label) => { const parts = label.split(',').map((x) => x.trim()).filter(Boolean); const offs = parts.map(frameOf); return offs.some((o) => o === null || o < 0 || o > 100) ? null : offs; }; const ctxBase = { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: [...tokenMap.keys()], prop: 'motion', unitless: false }; for (const child of node.children) { if (child.kind === 'decl' && child.prop === 'essential') { node._parsed.essential = child.value === '' || child.value === 'true'; continue; } if (child.kind === 'decl') { errors.push({ file, line: child.line, message: `"${child.prop}" is not allowed inside motion`, hint: 'motions take from/to/at blocks and `essential`' }); continue; } if (child.kind === 'selector') { const offsets = framesOf(child.sel); if (offsets === null) { errors.push({ file, line: child.line, message: `bad keyframe selector "${child.sel}"`, hint: 'use from, to, at 40%, or just 40% (a list works too: 0%, 100%). A keyframe sits between 0 and 100' }); continue; } const props = {}; const deform = {}; // v0.6.0 - bend/stretch/fold in keyframes compose into transform const DEFORM_KEY = { '@bend': 'bend', '@bend-y': 'bendY', '@fold': 'fold', '@bend-up': 'bendUp' }; for (const d of child.children) { if (d.kind !== 'decl') continue; const resolved = resolveDecl(d.prop, d.value, { ...ctxBase, line: d.line }); for (const r of resolved) { if (r.prop.startsWith('--')) continue; if (r.prop.startsWith('@')) { if (r.prop === '@stretch' && r.value) { deform.stretch = typeof r.value === 'object' ? Number(r.value.factor) : parseFloat(r.value); deform.stretchAxis = (r.value && r.value.axis) || 'horizontal'; } else if (DEFORM_KEY[r.prop]) deform[DEFORM_KEY[r.prop]] = Number(r.value) || 0; continue; } const camel = r.prop.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase()); props[camel] = r.value; } } if (deform.bend !== undefined || deform.bendY !== undefined || deform.stretch !== undefined || deform.fold !== undefined || deform.bendUp !== undefined) { // no box exists at resolve time, so edge-pinned members ride a // transformOrigin keyframe property instead of baked translates. // Presence (not truthiness) - `bend: 0` in a frame is a real reset. const c = composeDeform(deform, props.transform || '', null); props.transform = c.transform; if (c.origin) props.transformOrigin = c.origin; } for (const offset of offsets) node._parsed.frames.push({ offset, props: { ...props } }); } } node._parsed.frames.sort((a, b) => a.offset - b.offset); return node._parsed; }; /* ---- token emission ---- */ const tokenNames = () => [...tokenMap.keys()]; const resolveTokenDecls = (keys, conds) => { const decls = []; const ctxBase = { file, line: 0, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: 'token', unitless: false }; for (const key of keys) { const raw = tokenMap.get(key); const prop = '--dssl-' + key.replace(/\./g, '-'); // font.* tokens hold font stacks - proper nouns, not keywords (v0.5.0) // 0.16.0: an error in a token points at the token's own line (it used to say line 0), and only the file that defines the token reports it const own = tokenLines.has(key); decls.push({ prop, value: resolveValue(raw, { ...ctxBase, line: tokenLines.get(key) || 0, errors: own ? errors : [], prop: key, bareIdents: /^font/.test(key) }) }); } if (!decls.length) return null; return { id: ruleId++, sel: ':root', media: conds.media, signals: conds.signals, containers: conds.containers, layer: 'tokens', order: order++, decls, file, line: 0, pseudo: null, states: new Set(), }; }; /** used by index.js to install the shared registry as a base bundle */ // Built immediately, before the walk, so its cascade order sorts below every // author rule (engine cascade: layer first, then source order). const registryRule = resolveTokenDecls([...tokenMap.keys()], { media: [], signals: [], containers: [] }); /* ---- main recursive walk ---- */ const walk = (children, ctx, ctxRule) => { // v0.7.2 - parent-first flush. Two real bugs lived here. (1) A nested // block (hover { }, @media, a nested selector) was emitted BEFORE the // parent's own declarations got their cascade order, so `background: red` // on the parent always beat `hover { background: red }` nested inside it // - the exact pattern the docs taught, and the external review's headline // finding. (2) media/container/signal/layer blocks flushed with the // PARENT context, dropping the condition or layer entirely: a nested // `@media dark` was silently unconditional AND sorted before base. // Nested blocks now flush everything the parent has said so far first, // and every block flushes with the context it was actually walked with. const flushPending = (line) => { if (ctxRule && ctxRule.decls.length && ctx.sel) { flush(ctxRule, ctx, line); ctxRule.decls = []; } }; for (const node of children) { switch (node.kind) { case 'decl': { if (node.prop === 'use') { const name = node.value.trim(); const bh = behaviors[name]; if (!bh) { errors.push({ file, line: node.line, message: `unknown behavior "${name}"`, hint: didYouMean(name, Object.keys(behaviors)) }); break; } if (bh._active) { errors.push({ file, line: node.line, message: `behavior cycle involving "${name}"` }); break; } bh._active = true; walk(bh.children, ctx, ctxRule); bh._active = false; break; } const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false }); for (const r of resolved) { // v0.7.0 - remember WHICH source property wrote a display value, // so the ordering linter can tell an explicit "display:" from a // shorthand that expanded into one (column: → display: flex). if (r.prop === 'display' && node.prop !== 'display') r.via = node.prop; ctxRule.decls.push({ ...r, line: node.line }); } break; } case 'selector': { if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) { const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]); if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}" - this block currently styles descendants called <${node.sel}>` }); } flushPending(node.line); const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel); const childCtx = { ...ctx, sel }; const sub = { decls: [] }; walk(node.children, childCtx, sub); flush(sub, childCtx, node.line); break; } case 'state': { if (!ctx.sel) { errors.push({ file, line: node.line, message: `"${node.name}" needs a selector around it` }); break; } flushPending(node.line); const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name }; // v0.7.2 - the arrow is motion/gradient-stop syntax; inside a seen // block it compiled into an inline style the browser rejects, so the // element just sat at its base style forever. Say so, loudly, and // point at the two real patterns (final value, or a motion). if (node.name === 'seen') { for (const d of node.children) { if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) { errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}" - the -> arrow is motion syntax and does nothing inside seen { }`, hint: 'write the final style (seen { opacity: 1 }) or attach a motion (seen { animate: rise-in 400 })' }); } } } const sub = { decls: [] }; walk(node.children, childCtx, sub); flush(sub, childCtx, node.line); break; } case 'media': { const q = normalizeMedia(node.query, { errors, file, line: node.line }, node.line); flushPending(node.line); const childCtx = { ...ctx, media: [...ctx.media, q] }; const sub = { decls: [] }; walk(node.children, childCtx, sub); flush(sub, childCtx, node.line); break; } case 'container': { const q = normalizeMedia(node.query || '(min-width: 0)', { errors, file, line: node.line }, node.line); flushPending(node.line); const childCtx = { ...ctx, containers: [...ctx.containers, q] }; const sub = { decls: [] }; walk(node.children, childCtx, sub); flush(sub, childCtx, node.line); break; } case 'signal': { const sig = parseSignal(node.expr, { errors, file, line: node.line }, node.line); flushPending(node.line); const childCtx = { ...ctx, signals: [...ctx.signals, sig] }; const sub = { decls: [] }; walk(node.children, childCtx, sub); flush(sub, childCtx, node.line); break; } case 'layer': case 'pin': { const layer = node.kind === 'pin' ? 'pin' : (['tokens', 'base', 'components', 'utilities', 'pin'].includes(node.name) ? node.name : 'components'); flushPending(node.line); const childCtx = { ...ctx, layer }; const sub = { decls: [] }; walk(node.children, childCtx, sub); flush(sub, childCtx, node.line); break; } case 'pseudo': { const base = ctx.sel || '*'; // top-level ::selection etc. means every element const srule = { id: ruleId++, sel: base, pseudo: node.name, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls: [], file, line: node.line, }; // 0.15.0: a state inside the pseudo-element block styles that pseudo-element in that state: // .btn { ::after { content: ""; hover { opacity: 1 } } } is .btn:hover::after const fill = (target, kids) => { for (const d of kids) { if (d.kind === 'state' && target === srule) { if (d.name === 'seen') { errors.push({ file, line: d.line, message: '"seen" is engine-native and has no meaning on a pseudo-element', hint: 'put seen { } on the element itself' }); continue; } const st = { id: ruleId++, sel: base + ':' + d.name, pseudo: node.name, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls: [], file, line: d.line }; fill(st, d.children); stateRules.push(st); continue; } if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations and states (hover { }) may appear inside a pseudo-element block' }); continue; } if (d.prop === 'content' && d.value === '') { target.decls.push({ prop: 'content', value: '""', line: d.line }); continue; } const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false }); // v0.5.0 - engine-native properties have no pseudo-element story: // there is no DOM handle to nudge, animate, stack or stagger. They // used to leak into the shadow sheet as junk CSS the browser // silently dropped - exactly the silence this language refuses. for (const r of resolved) { if (r.prop.startsWith('@') && r.prop !== '@animate') { // 0.14.0: animate: becomes a CSS animation on a pseudo-element errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' }); continue; } target.decls.push({ ...r, line: d.line }); } } }; const stateRules = []; fill(srule, node.children); if (srule.decls.length) shadowRules.push(srule); for (const st of stateRules) if (st.decls.length) shadowRules.push(st); break; } case 'tokens': { if (ctx.sel) { errors.push({ file, line: node.line, message: 'tokens must be top-level or inside @media / @signal' }); break; } // structural tracking: only keys THIS block declares are emitted, // so conditional palettes don't shadow unrelated tokens const ownKeys = []; collectTokens(node, '', ownKeys); const tr = ownKeys.length ? resolveTokenDecls(ownKeys, ctx) : null; if (tr) rules.push(tr); break; } case 'fonts': { // 0.12.0 - a font is a stylesheet link or an @font-face, not a rule. Links ride on the document // (the engine adds them to , `dssl build` writes @import); faces are plain CSS and ride with raw. if (ctx.sel) break; const pf = parseFonts(node.css || '', node.line, { error: (line, message, hint) => errors.push({ file, line, message, hint }), suggest: (w, list) => { const h = didYouMean(w, list); return h ? `did you mean "${h}"?` : null; }, }); for (const l of pf.links) fontLinks.push({ ...l, file }); for (const css of pf.faces) raws.push({ css, file, line: node.line, order: order++ }); break; } case 'raw': { // v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes, // @supports, @import, @property…). The parser captured the body // untouched; it rides the same document stylesheet as the // pseudo-element rules and survives `dssl build` verbatim. if ((node.css || '').trim()) raws.push({ css: node.css.trim(), file, line: node.line, order: order++ }); // raw is real CSS, so DSSL's no-semicolon habit does not carry over. // A declaration line that is followed by another declaration or a // closing brace, with no ";" between, is almost always that slip. { const rl = (node.css || '').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).split('\n'); const decl = /^[a-zA-Z-]+\s*:\s*\S/; const nextReal = (i) => { for (let j = i + 1; j < rl.length; j++) { const t = rl[j].trim(); if (t) return t; } return '}'; }; for (let i = 0; i < rl.length; i++) { const t = rl[i].trim(); if (!decl.test(t) || /[;{},]$/.test(t)) continue; const nx = nextReal(i); if (nx.startsWith('}') || decl.test(nx)) { warnings.push({ file, line: node.line + i, message: `raw { } is verbatim CSS, so this line needs a ";" at the end ("${t.slice(0, 40)}")`, hint: 'unlike the rest of DSSL, semicolons are required inside raw { }' }); } } } break; } case 'behavior': case 'motion': if (ctx.depth) errors.push({ file, line: node.line, message: `${node.kind} "${node.name}" must be top-level` }); break; default: break; } } }; function flush(sub, ctx, line) { if (!sub.decls.length) return; if (!ctx.sel) { errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media… - or a selector like .card' }); return; } // v0.5.0 - the pseudo-element trap fix. A full selector like `.card::before` // used to compile ok and then match NOTHING: inline styles can never reach // pseudo-elements. Now the selector is split into element + pseudo halves // and the rule rides the shadow-CSS pipeline - exactly what the nested // form (`.card { ::before { } }`) always did. Both forms are now one thing. const ps = analyzePseudo(ctx.sel); if (ps.mode === 'all') { // parity with the nested path: bare `content:` becomes content: "" // v0.5.0 - and engine-native properties fail loud here too (see the // nested pseudo path): no DOM handle means no nudge, animate or stack. const decls = []; for (const d of sub.decls) { if (d.prop && d.prop.startsWith('@') && d.prop !== '@animate') { errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' }); continue; } decls.push(d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d); } for (const { el, pseudo } of ps.parts) { const name = pseudo.split(/[\s:(]/)[0]; if (!PSEUDO_ELEMENTS.has(name) && !VENDOR_PSEUDO.test(name)) { errors.push({ file, line, message: `unknown pseudo-element "::${pseudo}"`, hint: 'try ::before, ::after, ::selection, ::part(name), or a vendor one like ::-webkit-scrollbar-thumb', }); continue; } if (name === 'slotted') { errors.push({ file, line, message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM', hint: 'style the slotted element itself with a normal selector - it IS reachable from the page', }); continue; } if (/:seen\b/.test(el)) { errors.push({ file, line, message: `the engine-native "seen" state can't combine with "::${name}" in shadow CSS`, hint: 'style the element itself in a seen { } block instead', }); continue; } // v0.5.0 - parity with the nested path: if every declaration was // rejected (engine-native props, bad values), emit NOTHING rather // than an empty ghost rule that matches and styles zero things. if (!decls.length) continue; shadowRules.push({ id: ruleId++, sel: el, pseudo, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls, file, line, }); } return; } if (ps.mode === 'mixed') { errors.push({ file, line, message: `selector list mixes pseudo-element and plain selectors: "${ctx.sel}"`, hint: 'split it into two rules - one for the pseudo-element, one for the rest' }); return; } // 0.9.0: slider. One description becomes the base decls on the input (put FIRST so the // author's own declarations in the same rule win) and one shadow rule per vendor pseudo. const sliderDecl = sub.decls.find((d) => d.prop === '@slider'); if (sliderDecl && sliderDecl.value) { const css = sliderCss(sliderDecl.value); sub.decls.unshift(...css.base.map((d) => ({ ...d, line }))); for (const part of splitTopLevel(ctx.sel)) { for (const sh of css.shadow) { shadowRules.push({ id: ruleId++, sel: part, pseudo: sh.pseudo, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls: sh.decls.map((d) => ({ ...d, line })), file, line, }); } } } warnDisplayClash(sub.decls); rules.push({ id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals, containers: ctx.containers, layer: ctx.layer, order: order++, decls: sub.decls, file, line, pseudo: null, states: scanStates(ctx.sel), }); } /* v0.7.0 - the ordering linter. Within one rule, later wins; that is the * whole cascade model. But "column: gap 2" quietly expanding into * display: flex after an explicit "display: none" reads like a menu * toggle and behaves like a bug report - a real reviewer lost an evening * to exactly this ("their own source comments warn about this, but it * should be a compiler warning, not a comment"). Agreed. When a * shorthand's expansion clobbers an explicit display written earlier in * the SAME rule, say so at compile time. Cross-rule overrides are the * documented cascade at work, not an accident - no warning there. */ function warnDisplayClash(decls) { let explicit = null; for (const d of decls) { if (d.prop !== 'display') continue; if (!d.via) { explicit = d; continue; } if (explicit && explicit.value !== d.value && warnings.length < 400) { warnings.push({ file, line: d.line, message: `"${d.via}:" expands to display: ${d.value}, which overrides the earlier "display: ${explicit.value}" (line ${explicit.line}) in the same rule - later wins, so the earlier one is dead`, hint: `if the override is intended, delete the dead line; otherwise put "${d.via}:" first, or move the display you want to win AFTER it`, }); } } } /* ---- go ---- */ const topCtx = { sel: '', media: [], signals: [], containers: [], layer: 'components', depth: 0 }; const rootRule = { decls: [] }; walk(ast.children, topCtx, rootRule); flush(rootRule, topCtx, 0); // parse all motions that were referenced for (const r of [...rules, ...shadowRules]) { // 0.14.0: gradient: writes background-image, so a second background-image (or a background: with an image in it) in the same // rule replaces it and the gradient is gone, with no sign of it. Say so, and say how to layer. { const g = r.decls.find((x) => x.fromGradient); if (g) { const other = r.decls.find((x) => x !== g && !x.fromGradient && (x.prop === 'background-image' || (x.prop === 'background' && /url\(|gradient\(/.test(String(x.value))))); if (other) warnings.push({ file, line: Math.max(g.line || 0, other.line || 0) || g.line, message: `"${r.sel}" sets the background image twice (gradient: and ${other.prop}): the later one replaces the earlier, so one of them never shows`, hint: 'layer them in one gradient: sentence, joined with +, first on top: gradient: radial circle, #fff4 -> transparent tile 24 + linear 180, #e33 -> #a11 (a CSS radial-gradient(...) or url(...) works as a layer too)', }); } } // 0.14.0: animation-delay, -duration, -iteration-count... only tune a CSS animation, and a rule that runs its motion through // animate: (the Web Animations API) has none, so they compiled, were written inline, and did nothing, with no word about it. { const hasCssAnim = r.decls.some((x) => x.prop === 'animation' || x.prop === 'animation-name'); const usesAnimate = r.decls.some((x) => x.prop === '@animate' && x.value); for (const x of r.decls) { const m = /^animation-(delay|duration|iteration-count|direction|fill-mode|timing-function|play-state)$/.exec(x.prop); if (m && !hasCssAnim) { const word = { delay: 'delay', duration: 'a number after the name', 'iteration-count': 'infinite, or iterations 3', direction: 'alternate', 'fill-mode': 'both, forwards', 'timing-function': 'an easing name', 'play-state': '' }[m[1]]; warnings.push({ file, line: x.line, message: `${x.prop} does nothing here: this rule has no CSS animation to tune${usesAnimate ? ', it runs its motion through animate:' : ''}`, hint: usesAnimate || !hasCssAnim ? `put it in the animate: line, e.g. animate: ${(r.decls.find((y) => y.prop === '@animate' && y.value) || { value: { motion: 'name' } }).value.motion} 400 ${m[1] === 'delay' ? 'delay 200' : word || ''} (words: delay N, jitter N, iterations N, infinite, alternate, an easing, essential). Or write the whole animation: name 400ms ... shorthand` : '', }); } } } for (const d of r.decls) { if (d.prop === '@animate' && d.value && d.value.motion) { const m = parseMotion(d.value.motion); if (!m) { errors.push({ file, line: d.line, message: `unknown motion "${d.value.motion}"`, hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } - then "animate: ${d.value.motion} 400" runs it`, }); d.value.broken = true; } else { d.value.frames = m.frames; d.value.essential = m.essential || !!d.value.essentialHere; } } // v0.8.0 - the animation shorthand is CSS passthrough, so a motion // name that matches nothing used to be a SILENT no-op (no @keyframes // can exist in DSSL). Warn with the whole story; if the keyframes come // from raw { } or another stylesheet, the warning says it can be ignored. if ((d.prop === 'animation' || d.prop === 'animation-name') && typeof d.value === 'string') { const words = d.value.split(/[\s,]+/).filter(w => /^[A-Za-z][\w-]*$/.test(w)); const named = words.find(w => motions[w] || (baseMotions && baseMotions[w])); if (words.length && !named) { warnings.push({ file, line: d.line, message: `animation "${d.value}" references no motion defined in this sheet - it will not run`, hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 - @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`, }); } } } } // v0.8.0 - pre-parse EVERY motion (not just referenced ones) so the static // emitter can turn the whole motion vocabulary into real @keyframes. const motionFrames = {}; for (const name of Object.keys(motions)) { const m = parseMotion(name); if (m && m.frames.length) motionFrames[name] = m; } // 0.9.0: the lint pass. Warnings only, so ok stays ok; `dssl check --strict` makes them fatal. try { warnings.push(...lintRules({ rules, tokenMap, file })); } catch { /* a lint bug must never break a compile */ } const mediaQueries = new Set(); for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q)); // 0.16.0: the same problem at the same line is said once (a token is checked by the registry pass and by its own block) { const seen = new Set(); for (let i = errors.length - 1; i >= 0; i--) { const k = `${errors[i].file}:${errors[i].line}:${errors[i].message}`; if (seen.has(k)) errors.splice(i, 1); else seen.add(k); } } return { rules, shadowRules, raws, fontLinks, motions, motionFrames, errors, warnings, mediaQueries: [...mediaQueries], tokens: Object.fromEntries(tokenMap), registryRule }; } /** naive `&` substitution + descendant join */ function combine(parent, child) { if (child.includes('&')) return child.split('&').join(parent); return parent + ' ' + child; } /* v0.5.0 - where does a selector carry a pseudo-element? * 'none' - plain selector, inline-style path * 'all' - every comma-part has one: { parts: [{ el, pseudo }] } → shadow CSS * 'mixed' - some parts have one, some don't → loud error (split the list) * The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */ function analyzePseudo(sel) { // top-level comma split const parts = []; let depth = 0, q = null, cur = ''; for (const ch of sel) { if (q) { cur += ch; if (ch === q) q = null; continue; } if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; } if (ch === '(' || ch === '[') depth++; else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1); if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue; } cur += ch; } parts.push(cur); const splits = []; let withPseudo = 0; for (const part of parts) { let idx = -1; depth = 0; q = null; for (let i = 0; i < part.length - 1; i++) { const ch = part[i]; if (q) { if (ch === q && part[i - 1] !== '\\') q = null; continue; } if (ch === '"' || ch === "'") { q = ch; continue; } if (ch === '(' || ch === '[') depth++; else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1); else if (depth === 0 && ch === ':' && part[i + 1] === ':') { idx = i; break; } } if (idx === -1) { splits.push(null); continue; } withPseudo++; const el = part.slice(0, idx).trim(); const pseudo = part.slice(idx + 2).trim(); splits.push(el && pseudo ? { el, pseudo } : null); } if (withPseudo === 0) return { mode: 'none' }; if (withPseudo !== parts.length || splits.some(s => !s)) return { mode: 'mixed', parts: splits }; return { mode: 'all', parts: splits }; } const STATE_SCAN = /:(hover|focus-visible|focus|active|disabled|checked|empty|target|invalid|valid|required|open|closed|read-only|read-write|placeholder-shown|first-child|last-child|only-child|seen)\b/g; function scanStates(sel) { const s = new Set(); if (!sel) return s; let m; const re = new RegExp(STATE_SCAN.source, 'g'); while ((m = re.exec(sel))) s.add(m[1]); return s; }