dssl / dssl-vscode /lib /engine /resolver.js
EHS-Studio's picture
Claude Sonnet 5.5
v0.16.0: round three
10656db
Raw History Blame Contribute Delete
111 kB
/* =========================================================================
* 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 <html>), 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 <angle> and/or at <position> - 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 <head>, `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;
}