CSS
The same word takes a different colour in a selector, a property and a value, so CSS is read
one statement at a time. Inside a block, a statement that reaches ; or } before any { is
a declaration, and anything else is a nested rule's selector. An @ starts an at-rule.
The colours follow shiki's CSS grammar. In a selector, a tag is coloured as a string, and a class, an id or a pseudo-class as a function. A property is a constant, and so is most of a value, except its functions, strings and commas.
import { type Span, rules, scan } from "./scan.js";
const COMMENT = { re: /\/\*[\s\S]*?(?:\*\/|$)/, colour: "comment" } as const;
const STRING = { re: /"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?/, colour: "string" } as const;
const SELECTOR = rules([
COMMENT,
STRING,
{
re: /\[([\w-]+)(?:([~|^$*]?=)("[^"\n]*"|'[^'\n]*'|[^\]\s]*))?\]?/,
groups: ["function", "keyword", "string"],
},
{ re: /[.#][\w-]+|::?[\w-]+/, colour: "function" },
{ re: /[>+~]/, colour: "keyword" },
{ re: /,/, colour: "punctuation" },
{ re: /[a-zA-Z][\w-]*|\*|&/, colour: "string" },
]);
const VALUE = rules([
COMMENT,
STRING,
{ re: /!important\b/, colour: "keyword" },
{ re: /\b(?:and|not|only|or)\b/, colour: "keyword" },
{ re: /[\w-]+(?=\()/, colour: "function" },
{ re: /,/, colour: "punctuation" },
// A number's unit is a keyword, as shiki colours it.
{ re: /([-+]?(?:\d*\.)?\d+)(%|[a-zA-Z]+)?/, groups: ["constant", "keyword"] },
{ re: /(?<=\s)[-+*](?=\s)/, colour: "keyword" },
{ re: /[()/]/, colour: "constant" },
{ re: /[^\s,;:{}()/"']+/, colour: "constant" },
]);
/** An at-rule's prelude, like `(max-width: 62rem)`, colours its features and numbers. */
const PRELUDE = rules([
COMMENT,
STRING,
{ re: /\b(?:and|not|only|or)\b/, colour: "keyword" },
{ re: /([-+]?(?:\d*\.)?\d+)(%|[a-zA-Z]+)?/, groups: ["constant", "keyword"] },
{ re: /([\w-]+)(\s*:)/, groups: ["constant", "keyword"] },
{ re: /[\w-]+/, colour: null },
]);
/** Where the statement at `from` ends: the index of its `{`, `;` or `}`, skipping strings,
* comments and parentheses. */
function statementEnd(source: string, from: number, to: number): number {
let depth = 0;
for (let i = from; i < to; i++) {
const c = source[i];
if (c === '"' || c === "'") {
i++;
while (i < to && source[i] !== c && source[i] !== "\n") i += source[i] === "\\" ? 2 : 1;
} else if (c === "/" && source[i + 1] === "*") {
const close = source.indexOf("*/", i + 2);
i = close === -1 ? to : close + 1;
} else if (c === "(") depth++;
else if (c === ")") depth--;
else if (depth <= 0 && (c === "{" || c === ";" || c === "}")) return i;
}
return to;
}
export function css(source: string, from = 0, to = source.length): Span[] {
const spans: Span[] = [];
let depth = 0;
let i = from;
while (i < to) {
const c = source[i]!;
if (/\s/.test(c)) {
i++;
} else if (c === "/" && source[i + 1] === "*") {
const close = source.indexOf("*/", i + 2);
const end = close === -1 || close + 2 > to ? to : close + 2;
spans.push({ colour: "comment", start: i, end });
i = end;
} else if (c === "{") {
depth++;
i++;
} else if (c === "}") {
depth = Math.max(0, depth - 1);
i++;
} else if (c === ";") {
i++;
} else {
const end = statementEnd(source, i, to);
const declaration = depth > 0 && source[end] !== "{" && c !== "@";
if (c === "@") {
const name = /^@[\w-]+/.exec(source.slice(i, end))?.[0] ?? "@";
spans.push({ colour: "keyword", start: i, end: i + name.length });
spans.push(...scan(source, PRELUDE, i + name.length, end));
} else if (declaration) {
const colon = source.indexOf(":", i);
if (colon === -1 || colon > end) {
spans.push(...scan(source, VALUE, i, end));
} else {
// A custom property's name stays plain, as shiki leaves it.
if (!source.startsWith("--", i)) spans.push({ colour: "constant", start: i, end: colon });
spans.push({ colour: "keyword", start: colon, end: colon + 1 });
spans.push(...scan(source, VALUE, colon + 1, end));
}
} else {
spans.push(...scan(source, SELECTOR, i, end));
}
i = end;
}
}
return spans;
}