mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780)
Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render real semibold and bold faces instead of a synthetic bold, keep <html lang> in step with the UI language, and move every font weight onto the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard LIVE badge now uses the interface font at 700). Add the `styles:font-weights:validate` ratchet guard and its CI step. It reads stylesheets much as Sass and the browser do (cascade, layers, mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`, keyframes) and lists what it deliberately does not trace in its header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab8460338a
commit
e8b181fcea
52 files changed
+11216
-97
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,14 @@ import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const STYLESHEET_RULE = /@(use|forward|import)\s+([^;{}]*)/g;
|
||||
const STYLESHEET_RULE = /@(use|forward|import)\s+/g;
|
||||
const OPEN_URL = /url\(\s*[^\s)'"]*$/i;
|
||||
|
||||
/** Whether `index` sits in an unquoted `url(…)` on its line. */
|
||||
function inUrl(source, index) {
|
||||
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
|
||||
return OPEN_URL.test(source.slice(lineStart, index));
|
||||
}
|
||||
const QUOTED_TARGET = /(['"])([^'"]+)\1/g;
|
||||
const CSS_URL = /url\([^)]*\)/g;
|
||||
|
||||
@@ -27,32 +34,123 @@ function targetsOfRule(rule, clause) {
|
||||
/**
|
||||
* Sass documents relative `@use` examples inside comments. Those paths do not
|
||||
* resolve from the file that documents them, so scanning raw source reports
|
||||
* them as broken imports.
|
||||
* them as broken imports. A comment marker inside a string is text
|
||||
* (`with ($asset: '//cdn/x')`), and so is a `//` in an unquoted
|
||||
* `url(https://…)`; anywhere else, even right after `:` or `(`, `//` opens
|
||||
* a comment.
|
||||
*/
|
||||
export function stripScssComments(source) {
|
||||
const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
||||
return withoutBlocks
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const commentStart = line.search(/(^|[^:])\/\//);
|
||||
if (commentStart === -1) return line;
|
||||
return line.slice(
|
||||
0,
|
||||
line[commentStart] === '/' ? commentStart : commentStart + 1
|
||||
);
|
||||
})
|
||||
.join('\n');
|
||||
// Blank rather than cut, so every offset still points into `source`.
|
||||
const out = source.split('');
|
||||
const blank = (from, to) => {
|
||||
for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' ';
|
||||
return to - 1;
|
||||
};
|
||||
let quote = '';
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quote) {
|
||||
if (char === '\\') i += 1;
|
||||
else if (char === quote || char === '\n') quote = '';
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (source.startsWith('/*', i)) {
|
||||
const end = source.indexOf('*/', i + 2);
|
||||
i = blank(i, end === -1 ? source.length : end + 2);
|
||||
} else if (source.startsWith('//', i) && !inUrl(source, i)) {
|
||||
const end = source.indexOf('\n', i);
|
||||
i = blank(i, end === -1 ? source.length : end);
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a rule's clause ends: at a `;`, `{` or `}` outside a string and a
|
||||
* Sass interpolation, so a quoted `;` in a configuration
|
||||
* (`'data:image/svg+xml;utf8,…'`) and `#{600}` are values.
|
||||
*/
|
||||
function clauseEnd(text, start) {
|
||||
let quote = '';
|
||||
let interpolation = 0;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (quote) {
|
||||
if (char === '\\') i += 1;
|
||||
else if (char === quote || char === '\n') quote = '';
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (text.startsWith('#{', i)) {
|
||||
interpolation += 1;
|
||||
i += 1;
|
||||
} else if (char === '}' && interpolation > 0) {
|
||||
interpolation -= 1;
|
||||
} else if (';{}'.includes(char)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `@use`/`@forward`/`@import` target, with the rule that loads it, any
|
||||
* `as` clause (`as t`, `as *`, or a `@forward … as btn-*` prefix), a
|
||||
* `@forward`'s `show`/`hide` member list (`filter`, or `null`), where the
|
||||
* rule starts (`index`) and where its `with (…)` configuration sits in
|
||||
* `source` (`[start, end)`, or `null`).
|
||||
*/
|
||||
export function extractStylesheetLoads(source) {
|
||||
const loads = [];
|
||||
const stripped = stripScssComments(source);
|
||||
for (const match of stripped.matchAll(STYLESHEET_RULE)) {
|
||||
const rule = match[1];
|
||||
const clauseStart = match.index + match[0].length;
|
||||
const clause = stripped.slice(
|
||||
clauseStart,
|
||||
clauseEnd(stripped, clauseStart)
|
||||
);
|
||||
const rest = clause.replace(QUOTED_TARGET, (quoted) =>
|
||||
' '.repeat(quoted.length)
|
||||
);
|
||||
const as =
|
||||
rule === 'import'
|
||||
? null
|
||||
: (/\bas\s+(\*|[\w-]+\*?)/.exec(rest)?.[1] ?? null);
|
||||
const opening = /\bwith\s*\(/.exec(rest);
|
||||
const configuration = opening
|
||||
? [
|
||||
clauseStart + opening.index + opening[0].length,
|
||||
clauseStart + rest.lastIndexOf(')'),
|
||||
]
|
||||
: null;
|
||||
// The list ends where a `with (…)` starts; its values are not names.
|
||||
const listed = /\b(show|hide)\s+([\s\S]*)/.exec(
|
||||
rest.slice(0, opening?.index ?? rest.length)
|
||||
);
|
||||
const filter =
|
||||
rule === 'forward' && listed
|
||||
? {
|
||||
kind: listed[1],
|
||||
names: listed[2]
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
: null;
|
||||
for (const target of targetsOfRule(rule, clause)) {
|
||||
loads.push({
|
||||
...{ rule, target, as, configuration, filter },
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
return loads;
|
||||
}
|
||||
|
||||
export function extractRelativeImports(source) {
|
||||
const specifiers = [];
|
||||
const stripped = stripScssComments(source);
|
||||
for (const [, rule, clause] of stripped.matchAll(STYLESHEET_RULE)) {
|
||||
for (const target of targetsOfRule(rule, clause)) {
|
||||
if (target.startsWith('.')) specifiers.push(target);
|
||||
}
|
||||
}
|
||||
return specifiers;
|
||||
return extractStylesheetLoads(source)
|
||||
.map(({ target }) => target)
|
||||
.filter((target) => target.startsWith('.'));
|
||||
}
|
||||
|
||||
/** Mirrors Sass partial resolution for a relative specifier. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
extractRelativeImports,
|
||||
extractStylesheetLoads,
|
||||
resolveStylesheet,
|
||||
stripScssComments,
|
||||
validateScanCoverage,
|
||||
@@ -159,6 +160,30 @@ test('treats a @use configuration value as a value, not a second import', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('reads the show or hide list of a @forward', () => {
|
||||
const source = [
|
||||
"@forward 'a' as p-* hide $p-w, mixin-x;",
|
||||
"@forward 'b' show $w with ($w: 600);",
|
||||
"@forward 'd' with ($mode: hide auto);",
|
||||
"@forward 'show-tokens' as show-*;",
|
||||
"@use 'c' as show;",
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
extractStylesheetLoads(source).map(({ target, filter }) => [
|
||||
target,
|
||||
filter,
|
||||
]),
|
||||
[
|
||||
['a', { kind: 'hide', names: ['$p-w', 'mixin-x'] }],
|
||||
['b', { kind: 'show', names: ['$w'] }],
|
||||
['d', null],
|
||||
['show-tokens', null],
|
||||
['c', null],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores a url() import the browser resolves at runtime', () => {
|
||||
assert.deepEqual(extractRelativeImports('@import url("./plain.css");'), []);
|
||||
});
|
||||
@@ -172,6 +197,48 @@ test('keeps protocol slashes intact when stripping line comments', () => {
|
||||
assert.doesNotMatch(stripped, /trailing note/);
|
||||
});
|
||||
|
||||
test('keeps comment markers inside strings', () => {
|
||||
const source = [
|
||||
"@use 'tokens' with ($asset: '//cdn/x', $w: 600); // note",
|
||||
'/* a */ $b: "/* kept */";',
|
||||
].join('\n');
|
||||
const stripped = stripScssComments(source);
|
||||
|
||||
assert.equal(stripped.length, source.length);
|
||||
assert.match(stripped, /'\/\/cdn\/x', \$w: 600\);/);
|
||||
assert.match(stripped, /"\/\* kept \*\/"/);
|
||||
assert.doesNotMatch(stripped, /note|\/\* a/);
|
||||
const [{ configuration }] = extractStylesheetLoads(source);
|
||||
assert.equal(source.slice(...configuration), "$asset: '//cdn/x', $w: 600");
|
||||
// A quoted `;` is a value, not the rule's end.
|
||||
const dataUri =
|
||||
"@use 'tokens' with ($asset: 'data:image/svg+xml;utf8,x', $w: 600);";
|
||||
const [{ configuration: range }] = extractStylesheetLoads(dataUri);
|
||||
assert.equal(
|
||||
dataUri.slice(...range),
|
||||
"$asset: 'data:image/svg+xml;utf8,x', $w: 600"
|
||||
);
|
||||
// So is a Sass interpolation's `}`.
|
||||
const interpolated = "@use 'tokens' with ($w: #{600}, $h: #{$w});";
|
||||
const [{ configuration: span }] = extractStylesheetLoads(interpolated);
|
||||
assert.equal(interpolated.slice(...span), '$w: #{600}, $h: #{$w}');
|
||||
// An escaped quote stays inside the string, an unclosed one ends at the
|
||||
// line break, and an unquoted URL keeps its slashes.
|
||||
for (const [text, kept, dropped] of [
|
||||
["$a: 'it\\'s // kept'; // gone", /\/\/ kept/, /gone/],
|
||||
["$a: 'open\n// gone", /open/, /gone/],
|
||||
['$a: url(//cdn/x.css); // gone', /url\(\/\/cdn/, /gone/],
|
||||
['$a: url(https://cdn/x.css); // gone', /https:\/\/cdn/, /gone/],
|
||||
['$a:// gone', /\$a:/, /gone/],
|
||||
['// a note on url(\n// gone', /\n/, /gone/],
|
||||
['$a: (// gone\n1);', /\$a: \(/, /gone/],
|
||||
]) {
|
||||
const result = stripScssComments(text);
|
||||
assert.match(result, kept);
|
||||
assert.doesNotMatch(result, dropped);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolves a specifier to its Sass partial file', () => {
|
||||
const existing = new Set([
|
||||
path.resolve('/repo/libs/ui/styles/_detail-view.scss'),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,859 @@
|
||||
/**
|
||||
* Just enough lexing for `check-font-weights.mjs`: comments, the string that
|
||||
* encloses each position, and where a declaration's value ends.
|
||||
*/
|
||||
|
||||
const QUOTES = new Set(["'", '"', '`']);
|
||||
const VALUE_END = new Set([';', '{', '}', ']']);
|
||||
|
||||
/**
|
||||
* Plain CSS has block comments only; SCSS and TypeScript add `//`. In SCSS
|
||||
* a `//` is a comment anywhere outside a string (`font-weight:// old`, `(//
|
||||
* note`) except in an unquoted `url(//cdn…)` or `url(https://…)`. In
|
||||
* TypeScript a URL is always inside a string, so every `//` there is a
|
||||
* comment.
|
||||
*/
|
||||
function syntaxOf(file) {
|
||||
// SVG is markup like HTML: attributes, `<!-- -->` comments, `<style>`.
|
||||
if (/\.(?:html|svg)$/.test(file)) return { html: true, quotes: ['"', "'"] };
|
||||
const typescript = file.endsWith('.ts');
|
||||
return {
|
||||
block: true,
|
||||
line: !file.endsWith('.css'),
|
||||
protocol: !typescript,
|
||||
quotes: typescript ? ['"', "'", '`'] : ['"', "'"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `index` sits in an unquoted `url(…)` on its line, whose `//` is a
|
||||
* URL; a `url(` that a comment ends with leaves the next line alone.
|
||||
*/
|
||||
function inUrl(source, index) {
|
||||
const line = source.slice(source.lastIndexOf('\n', index - 1) + 1, index);
|
||||
return /url\(\s*[^\s)'"]*$/i.test(line);
|
||||
}
|
||||
|
||||
const CONDITION_PRELUDE = /^@(?:supports|media|container)\b/i;
|
||||
|
||||
/**
|
||||
* Whether `index` sits in a conditional at-rule's prelude, such as the test
|
||||
* of `@supports (font-weight: 650) {…}`, which is a condition rather than a
|
||||
* declaration. A mixin call's arguments (`@include m($w: 650)`) do count.
|
||||
*/
|
||||
export function inConditionPrelude({ text, quoteAt }, index) {
|
||||
const start = preludeStart(text, quoteAt, index);
|
||||
return CONDITION_PRELUDE.test(text.slice(start, index).trimStart());
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the statement or prelude that `index` sits in starts: just after the
|
||||
* `;`, `{` or `}` before it, skipping strings and Sass interpolations
|
||||
* (`@supports (min-width: #{10}px) and …`).
|
||||
*/
|
||||
function preludeStart(text, quoteAt, index) {
|
||||
let k = index - 1;
|
||||
while (k >= 0) {
|
||||
if (quoteAt[k]) {
|
||||
k -= 1;
|
||||
} else if (text[k] === '}') {
|
||||
const open = openingBrace(text, quoteAt, k);
|
||||
if (open <= 0 || text[open - 1] !== '#') break;
|
||||
k = open - 2;
|
||||
} else if (text[k] === ';' || text[k] === '{') {
|
||||
break;
|
||||
} else {
|
||||
k -= 1;
|
||||
}
|
||||
}
|
||||
return k + 1;
|
||||
}
|
||||
|
||||
/** The `{` that the `}` at `close` closes, skipping strings, or -1. */
|
||||
function openingBrace(text, quoteAt, close) {
|
||||
let depth = 0;
|
||||
for (let i = close; i >= 0; i -= 1) {
|
||||
if (quoteAt[i]) continue;
|
||||
if (text[i] === '}') depth += 1;
|
||||
else if (text[i] === '{' && (depth -= 1) === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The `}` that closes the `{` at `open`, skipping strings. */
|
||||
export function closingBrace({ text, quoteAt }, open) {
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i += 1) {
|
||||
if (quoteAt[i]) continue;
|
||||
if (text[i] === '{') depth += 1;
|
||||
else if (text[i] === '}' && --depth === 0) return i;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `index` sits in an unquoted `style=font-weight:650` attribute
|
||||
* value, which runs to a space or `>`.
|
||||
*/
|
||||
export function inUnquotedStyle(lexed, index) {
|
||||
const attribute = /(?:^|[\s<])style\s*=\s*[^\s"'=<>`]*$/i;
|
||||
return (
|
||||
attribute.test(lexed.text.slice(0, index)) &&
|
||||
insideTag(lexed, index, { html: true })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `index` sits where markup holds CSS: a `<style>` element, a
|
||||
* `style` attribute, or an Angular style binding (`[style]`, `[style.x]`,
|
||||
* `[ngStyle]`, `[attr.style]`, whose strings are CSS). Text content and
|
||||
* other attributes or bindings (`[title]="'font-weight: 650'"`) are not.
|
||||
*/
|
||||
export function inMarkupCss({ text, quoteAt }, index) {
|
||||
const before = text.slice(0, index).toLowerCase();
|
||||
const open = before.lastIndexOf('<style');
|
||||
if (open !== -1 && !before.includes('</style', open)) {
|
||||
if (/^<style[\s>]/.test(before.slice(open, open + 7))) return true;
|
||||
}
|
||||
const quote = quoteAt[index];
|
||||
if (!quote) return inUnquotedStyle({ text, quoteAt }, index);
|
||||
let start = index;
|
||||
while (start > 0 && quoteAt[start - 1] === quote) start -= 1;
|
||||
const name = /([^\s<>="']+)\s*=\s*$/.exec(text.slice(0, start - 1))?.[1];
|
||||
return /^(?:style|\[(?:style(?:\.[^\]]+)?|ngStyle|attr\.style)\])$/i.test(
|
||||
name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
const BINDING_VALUE = /\[[^\]\s="'<>]+\]\s*=\s*(?:"([^"]*)|'([^']*))$/;
|
||||
|
||||
/**
|
||||
* Where `index` sits in an Angular binding's value: `quote` is the string
|
||||
* literal open there (`''` in code), `attribute` the quote that ends the
|
||||
* value; `null` outside a binding. An escaped quote (`'it\'s'`) is text.
|
||||
*/
|
||||
function bindingAt(text, index) {
|
||||
const before = text.slice(Math.max(0, index - 4096), index);
|
||||
const binding = BINDING_VALUE.exec(before);
|
||||
if (!binding) return null;
|
||||
const value = binding[1] ?? binding[2];
|
||||
let quote = '';
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (quote && value[i] === '\\') i += 1;
|
||||
else if (quote) quote = value[i] === quote ? '' : quote;
|
||||
else if (QUOTES.has(value[i])) quote = value[i];
|
||||
}
|
||||
return { quote, attribute: binding[1] === undefined ? "'" : '"' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `index` sits in an Angular binding's value (`[ngStyle]="{…}"`),
|
||||
* which is code, and not in a string literal inside it, which is CSS text.
|
||||
*/
|
||||
export function inBinding(text, index) {
|
||||
return bindingAt(text, index)?.quote === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Blanks comments, keeping every newline so line numbers hold, and records
|
||||
* the quote enclosing each position: a comment marker inside a string is
|
||||
* text. In a stylesheet, `//` right after `:` or `(` is a URL, not a
|
||||
* comment. HTML strings are attribute values, so they open only inside tags.
|
||||
*/
|
||||
export function lex(file, source) {
|
||||
const syntax = syntaxOf(file);
|
||||
const text = source.split('');
|
||||
const quoteAt = new Array(source.length).fill('');
|
||||
const blank = (from, to) => {
|
||||
for (let k = from; k < to; k += 1) if (text[k] !== '\n') text[k] = ' ';
|
||||
return to - 1;
|
||||
};
|
||||
const until = (marker, from) => {
|
||||
const at = source.indexOf(marker, from);
|
||||
return at === -1 ? source.length : at + marker.length;
|
||||
};
|
||||
let quote = '';
|
||||
let inTag = false;
|
||||
// Markup `<style>` content is CSS: block comments and strings there.
|
||||
let styleTag = false;
|
||||
let inStyle = false;
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const char = source[i];
|
||||
if (quote) {
|
||||
quoteAt[i] = quote;
|
||||
if (char === '\\' && i + 1 < source.length) {
|
||||
quoteAt[i + 1] = quote;
|
||||
i += 1;
|
||||
} else if (char === quote) {
|
||||
quote = '';
|
||||
} else if (char === '\n' && quote !== '`' && !syntax.html) {
|
||||
quote = '';
|
||||
}
|
||||
} else if (syntax.html && source.startsWith('<!--', i)) {
|
||||
i = blank(i, until('-->', i + 4));
|
||||
} else if (syntax.html && inStyle && source.startsWith('/*', i)) {
|
||||
i = blank(i, until('*/', i + 2));
|
||||
} else if (syntax.html) {
|
||||
if (char === '<') {
|
||||
inTag = true;
|
||||
const tag = source.slice(i, i + 8).toLowerCase();
|
||||
if (/^<style[\s>]/.test(tag)) styleTag = true;
|
||||
if (/^<\/style[\s>]/.test(tag)) inStyle = false;
|
||||
}
|
||||
if (char === '>') {
|
||||
inTag = false;
|
||||
if (styleTag) inStyle = true;
|
||||
styleTag = false;
|
||||
}
|
||||
const css = inStyle && !inTag;
|
||||
if ((inTag || css) && syntax.quotes.includes(char)) quote = char;
|
||||
} else if (syntax.block && source.startsWith('/*', i)) {
|
||||
i = blank(i, until('*/', i + 2));
|
||||
} else if (
|
||||
syntax.line &&
|
||||
source.startsWith('//', i) &&
|
||||
!(syntax.protocol && inUrl(source, i))
|
||||
) {
|
||||
const lineEnd = source.indexOf('\n', i);
|
||||
i = blank(i, lineEnd === -1 ? source.length : lineEnd);
|
||||
} else if (syntax.quotes.includes(char)) {
|
||||
quote = char;
|
||||
}
|
||||
}
|
||||
return { text: text.join(''), quoteAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration's value, across line breaks, so a wrapped
|
||||
* `var(--x,\n 650)` keeps its fallback. It stays inside the string that
|
||||
* encloses the declaration (an inline style) and skips the CSS strings within
|
||||
* it, so `font: 650 12px 'DM Sans'` keeps its family. Sass `#{…}` and template
|
||||
* `${…}` interpolations are part of the value. It ends at `;`, a brace, or,
|
||||
* outside parentheses, at a comma or the `)` that closes a Sass map or
|
||||
* argument list. A `{` first means the match was a selector such as
|
||||
* `.x-weight:hover`.
|
||||
*/
|
||||
export function valueAfter({ text, quoteAt }, start) {
|
||||
const enclosing = quoteAt[start] ?? '';
|
||||
let depth = 0;
|
||||
let interpolation = 0;
|
||||
let end = start;
|
||||
for (; end < text.length; end += 1) {
|
||||
const char = text[end];
|
||||
if (quoteAt[end] !== enclosing) {
|
||||
if (enclosing) break;
|
||||
continue;
|
||||
}
|
||||
if (enclosing && char === enclosing) break;
|
||||
if (QUOTES.has(char)) {
|
||||
if (!enclosing) continue;
|
||||
const close = text.indexOf(char, end + 1);
|
||||
if (close === -1 || quoteAt[close] !== enclosing) break;
|
||||
end = close;
|
||||
} else if ((char === '#' || char === '$') && text[end + 1] === '{') {
|
||||
interpolation += 1;
|
||||
end += 1;
|
||||
} else if (char === '}' && interpolation > 0) {
|
||||
interpolation -= 1;
|
||||
} else if (char === '(') {
|
||||
depth += 1;
|
||||
} else if (char === ')') {
|
||||
if (depth === 0) break;
|
||||
depth -= 1;
|
||||
} else if (
|
||||
VALUE_END.has(char) ||
|
||||
(char === ',' && depth === 0 && interpolation === 0)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { value: text.slice(start, end), selector: text[end] === '{' };
|
||||
}
|
||||
|
||||
/** A binary operator (or a member access) that carries an expression on. */
|
||||
const CONTINUES = /[-+*/%=(,?:&|!<>.]$/;
|
||||
const CONTINUED = /^[-+*/%?:.,&|]/;
|
||||
|
||||
/**
|
||||
* A JavaScript expression from `start` to its end, across line breaks: a
|
||||
* `;`, a closing bracket it did not open, or (with `argument`) a top-level
|
||||
* comma. A line break ends it only where the statement is complete, so
|
||||
* `600 +\n50` and `600\n+ 50` both read as one expression.
|
||||
*/
|
||||
export function codeExpression(text, start, { argument = false } = {}) {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let end = start;
|
||||
for (; end < text.length; end += 1) {
|
||||
const char = text[end];
|
||||
if (quote) {
|
||||
if (char === '\\') end += 1;
|
||||
else if (char === quote) quote = '';
|
||||
} else if (QUOTES.has(char)) {
|
||||
quote = char;
|
||||
} else if ('([{'.includes(char)) {
|
||||
depth += 1;
|
||||
} else if (')]}'.includes(char)) {
|
||||
if (depth === 0) break;
|
||||
depth -= 1;
|
||||
} else if (
|
||||
depth === 0 &&
|
||||
(char === ';' || (argument && char === ','))
|
||||
) {
|
||||
break;
|
||||
} else if (depth === 0 && char === '\n') {
|
||||
const before = text.slice(start, end).trim();
|
||||
const after = text.slice(end + 1).trimStart();
|
||||
if (before && !CONTINUES.test(before) && !CONTINUED.test(after)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* The string literal of code that encloses `index` (a TypeScript string, or
|
||||
* one inside an Angular binding's value): its closing quote's position
|
||||
* (`close`) and where the code around it ends (`limit`: the file's end, or
|
||||
* the binding attribute's closing quote). `null` outside one.
|
||||
*/
|
||||
export function codeStringAt({ text, quoteAt }, file, index) {
|
||||
if (file.endsWith('.ts')) {
|
||||
const quote = quoteAt[index];
|
||||
let close = index;
|
||||
while (close + 1 < text.length && quoteAt[close + 1] === quote) {
|
||||
close += 1;
|
||||
}
|
||||
return quote ? { close, limit: text.length } : null;
|
||||
}
|
||||
const binding = bindingAt(text, index);
|
||||
if (!binding?.quote) return null;
|
||||
const close = text.indexOf(binding.quote, index);
|
||||
const limit = text.indexOf(binding.attribute, close + 1);
|
||||
return close !== -1 && limit !== -1 ? { close, limit } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The code a string's CSS text continues with: the first operand after a
|
||||
* `+` right after the string that adds more than whitespace
|
||||
* (`'font-weight:' + ' ' + 650 + ';'` gives `650`), or `null` when no `+`
|
||||
* follows.
|
||||
*/
|
||||
export function concatenatedAfter(text, { close, limit }) {
|
||||
const code = text.slice(0, limit);
|
||||
const plus = /^\s*\+\s*/.exec(code.slice(close + 1));
|
||||
if (!plus) return null;
|
||||
const start = close + 1 + plus[0].length;
|
||||
const expression = codeExpression(code, start, { argument: true });
|
||||
const operands = splitAt(expression, ['+']);
|
||||
const blank = /^\s*(['"`])\s*\1\s*$/;
|
||||
return operands.find((operand) => !blank.test(operand)) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A template literal's body split into text and `${…}` code, in order
|
||||
* (`65${0}` is `[{ text: '65' }, { code: '0' }]`). An escaped character is
|
||||
* text.
|
||||
*/
|
||||
export function templateParts(body) {
|
||||
const parts = [];
|
||||
let text = '';
|
||||
for (let i = 0; i < body.length; i += 1) {
|
||||
if (body[i] === '\\') {
|
||||
text += body[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const code = body.startsWith('${', i)
|
||||
? codeExpression(body, i + 2)
|
||||
: null;
|
||||
if (code === null) {
|
||||
text += body[i];
|
||||
continue;
|
||||
}
|
||||
if (text) parts.push({ text });
|
||||
text = '';
|
||||
parts.push({ code });
|
||||
i += 2 + code.length;
|
||||
}
|
||||
if (text) parts.push({ text });
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The bracket depth at each position of code, or -1 inside a string. */
|
||||
function depthsOf(text) {
|
||||
const depths = new Array(text.length).fill(-1);
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (quote) {
|
||||
if (char === '\\') i += 1;
|
||||
else if (char === quote) quote = '';
|
||||
} else if (QUOTES.has(char)) {
|
||||
quote = char;
|
||||
} else {
|
||||
if (')]}'.includes(char)) depth -= 1;
|
||||
depths[i] = depth;
|
||||
if ('([{'.includes(char)) depth += 1;
|
||||
}
|
||||
}
|
||||
return depths;
|
||||
}
|
||||
|
||||
/** Where `text` splits at a top-level operator from `operators`. */
|
||||
function splitAt(text, operators) {
|
||||
const depths = depthsOf(text);
|
||||
const parts = [];
|
||||
let from = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (depths[i] !== 0 || i < from) continue;
|
||||
const operator = operators.find((op) => text.startsWith(op, i));
|
||||
// `||=` and kin assign; `=>` and `<<`/`>>` are not comparisons.
|
||||
if (!operator || /^[=<>]/.test(text[i + operator.length] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
if (/[=<>!]/.test(text[i - 1] ?? '')) continue;
|
||||
parts.push(text.slice(from, i));
|
||||
from = i + operator.length;
|
||||
}
|
||||
return [...parts, text.slice(from)];
|
||||
}
|
||||
|
||||
/** The branches of a top-level `c ? a : b`, or `null`. */
|
||||
function branchesOf(text) {
|
||||
const depths = depthsOf(text);
|
||||
let question = -1;
|
||||
let nested = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (depths[i] !== 0) continue;
|
||||
if (text[i] === '?') {
|
||||
// `??` is nullish; `?.` chains, unless a digit follows (`?.5`).
|
||||
const pair = text[i + 1] === '?' || text[i - 1] === '?';
|
||||
const chain = text[i + 1] === '.' && !/\d/.test(text[i + 2] ?? '');
|
||||
if (pair || chain) continue;
|
||||
if (question === -1) question = i;
|
||||
else nested += 1;
|
||||
} else if (text[i] === ':' && question !== -1) {
|
||||
if (nested === 0) {
|
||||
return [text.slice(question + 1, i), text.slice(i + 1)];
|
||||
}
|
||||
nested -= 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const COMPARISONS = ['===', '!==', '==', '!=', '<=', '>=', '<', '>'];
|
||||
|
||||
/**
|
||||
* What a JavaScript expression can evaluate to, as sub-expressions: both
|
||||
* branches of `c ? a : b`, every operand of `||`, `??` and `&&`, and nothing
|
||||
* for a comparison, which is a boolean. A condition never becomes the value,
|
||||
* so `width >= 768 ? 700 : 600` is 700 or 600.
|
||||
*/
|
||||
export function resultsOf(expression) {
|
||||
let text = expression.trim();
|
||||
// `(…)` around the whole expression.
|
||||
while (
|
||||
text.startsWith('(') &&
|
||||
text.endsWith(')') &&
|
||||
depthsOf(text)
|
||||
.slice(1, -1)
|
||||
.every((depth) => depth !== 0)
|
||||
) {
|
||||
text = text.slice(1, -1).trim();
|
||||
}
|
||||
const branches = branchesOf(text);
|
||||
if (branches) return branches.flatMap(resultsOf);
|
||||
const operands = splitAt(text, ['||', '??', '&&']);
|
||||
if (operands.length > 1) return operands.flatMap(resultsOf);
|
||||
if (splitAt(text, COMPARISONS).length > 1) return [];
|
||||
return [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace-separated tokens, keeping `var(--x, 650)`, a quoted family
|
||||
* such as `"DM Sans"` and an escaped space in one piece.
|
||||
*/
|
||||
export function tokensOf(value) {
|
||||
const tokens = [];
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let current = '';
|
||||
const text = value.trim();
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
// An escaped character (`JetBrains\ Mono`) is text.
|
||||
if (char === '\\') {
|
||||
current += text.slice(i, i + 2);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = '';
|
||||
} else if (QUOTES.has(char)) {
|
||||
quote = char;
|
||||
} else if (char === '(') {
|
||||
depth += 1;
|
||||
} else if (char === ')') {
|
||||
depth -= 1;
|
||||
}
|
||||
if (!quote && depth === 0 && /\s/.test(char)) {
|
||||
if (current) tokens.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `index` sits in a tag's attribute list: scanning the markup before
|
||||
* it (the whole file for HTML, the enclosing string for TypeScript), a `<`
|
||||
* followed by a letter, `/` or `!` opens a tag, a `>` outside an attribute
|
||||
* value closes it, and quoted values are skipped (an escaped `\"` is still a
|
||||
* quote to this scan). So
|
||||
* `<text aria-label="x > y" font-weight=…>` is inside a tag, while text such
|
||||
* as `<p>a < b font-weight=…</p>` or a `title="font-weight=…"` value is not.
|
||||
*/
|
||||
export function insideTag({ text, quoteAt }, index, { html = false } = {}) {
|
||||
let start = 0;
|
||||
if (!html) {
|
||||
start = index;
|
||||
while (start > 0 && quoteAt[start - 1] === quoteAt[index]) start -= 1;
|
||||
}
|
||||
const markup = text.slice(start, index);
|
||||
let inTag = false;
|
||||
let quote = '';
|
||||
for (let i = 0; i < markup.length; i += 1) {
|
||||
const char = markup[i];
|
||||
if (quote) {
|
||||
if (char === quote) quote = '';
|
||||
} else if (char === '<' && /[a-z/!]/i.test(markup[i + 1] ?? '')) {
|
||||
inTag = true;
|
||||
} else if (inTag && char === '>') {
|
||||
inTag = false;
|
||||
} else if (inTag && (char === '"' || char === "'")) {
|
||||
quote = char;
|
||||
}
|
||||
}
|
||||
return inTag && !quote;
|
||||
}
|
||||
|
||||
const FLOW = /^@(?:if|else|each|for|while)\b/i;
|
||||
const CALLABLE = /^@(?:mixin|function)\b/i;
|
||||
|
||||
/** Sass reads `-` and `_` in a name alike; callable names keep `-`. */
|
||||
const callableName = (name) => name?.replace(/_/g, '-') ?? null;
|
||||
|
||||
/**
|
||||
* Whether `index` follows `@mixin` or `@function` and whitespace (blanked
|
||||
* comments included), so the name there opens a signature, not a call.
|
||||
*/
|
||||
export function namesCallable(text, index) {
|
||||
let k = index;
|
||||
while (k > 0 && /\s/.test(text[k - 1])) k -= 1;
|
||||
return (
|
||||
k < index &&
|
||||
/@(?:mixin|function)$/i.test(text.slice(Math.max(0, k - 9), k))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What opens the block at `brace`: flow control, a callable (with its name,
|
||||
* `_` read as `-`) or a rule, with its `prelude` (the selector or at-rule).
|
||||
*/
|
||||
function kindOf(text, quoteAt, brace) {
|
||||
const prelude = text
|
||||
.slice(preludeStart(text, quoteAt, brace), brace)
|
||||
.trim();
|
||||
if (FLOW.test(prelude)) return { kind: 'flow', prelude };
|
||||
const callable = CALLABLE.exec(prelude);
|
||||
if (callable) {
|
||||
const name = /^@\w+\s+([\w-]+)/.exec(prelude)?.[1];
|
||||
return { kind: 'callable', name: callableName(name), prelude };
|
||||
}
|
||||
return { kind: 'rule', prelude };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{…}` blocks of a stylesheet as `{ start, end, kind, prelude }` (offsets of the
|
||||
* braces), skipping braces inside strings. An interpolation `#{…}` is a block
|
||||
* too, which is harmless: no declaration sits inside one.
|
||||
*/
|
||||
export function blocksOf({ text, quoteAt }) {
|
||||
const blocks = [];
|
||||
const open = [];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (quoteAt[i]) continue;
|
||||
if (text[i] === '{') open.push(i);
|
||||
else if (text[i] === '}' && open.length > 0) {
|
||||
const start = open.pop();
|
||||
blocks.push({ start, end: i, ...kindOf(text, quoteAt, start) });
|
||||
}
|
||||
}
|
||||
return blocks.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `index` sits in the Sass scope tree. `scopes` lists the scopes a name
|
||||
* there resolves through, innermost first and ending in `null` (the module).
|
||||
* Flow-control blocks (`@if`, `@each`, …) are not scopes of their own: Sass
|
||||
* assigns to the enclosing scope's variable. `scope` is the innermost scope,
|
||||
* `conditional` says a flow-control block lies in between, `flow` that one
|
||||
* encloses `index` at any depth, `inCallable`
|
||||
* whether a `@mixin`/`@function` body encloses `index`, and `callable` the
|
||||
* name of the innermost one.
|
||||
*/
|
||||
export function placeOf(blocks, index) {
|
||||
const around = blocks
|
||||
.filter((block) => block.start < index && index < block.end)
|
||||
.reverse();
|
||||
const scopes = around
|
||||
.filter((block) => block.kind !== 'flow')
|
||||
.map((block) => block.start);
|
||||
const firstScope = around.findIndex((block) => block.kind !== 'flow');
|
||||
return {
|
||||
scopes: [...scopes, null],
|
||||
scope: scopes[0] ?? null,
|
||||
conditional: firstScope === -1 ? around.length > 0 : firstScope > 0,
|
||||
flow: around.some((block) => block.kind === 'flow'),
|
||||
inCallable: around.some((block) => block.kind === 'callable'),
|
||||
callable:
|
||||
around.find((block) => block.kind === 'callable')?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** `ns.name` as `{ name, namespace }`, with `_` read as `-`. */
|
||||
function calleeNamed(full) {
|
||||
const parts = full.split('.');
|
||||
const name = parts.pop();
|
||||
return { name: callableName(name), namespace: parts.pop() ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The mixin or function an argument at `index` is passed to, as
|
||||
* `{ name, namespace, paren, signature }`: the name before the `(` that
|
||||
* encloses it (`ns.name(` gives both; `_` reads as `-`), or `with` for a
|
||||
* `@use … with (…)`. `paren` is where that `(` sits; `signature` says it
|
||||
* opens a `@mixin`/`@function` parameter list, so the argument is a default.
|
||||
*/
|
||||
export function calleeOf(text, index) {
|
||||
let depth = 0;
|
||||
for (let k = index - 1; k >= 0; k -= 1) {
|
||||
if (text[k] === ')') depth += 1;
|
||||
else if (text[k] === '(') {
|
||||
if (depth === 0) {
|
||||
const named = /([\w.-]+)\s*$/.exec(text.slice(0, k));
|
||||
if (!named) return null;
|
||||
return {
|
||||
...calleeNamed(named[1]),
|
||||
paren: k,
|
||||
signature: namesCallable(text, named.index),
|
||||
};
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const INVOCATION =
|
||||
/@include\s+([\w.-]+)|(?<![\w$.@#-])([\w-]+(?:\.[\w-]+)?)(?=\s*\()/gi;
|
||||
|
||||
/**
|
||||
* Every mixin include and function call in a stylesheet, as
|
||||
* `{ index, paren, callee }`: `paren` is where its argument list opens, or
|
||||
* `null` for an `@include name;` without one. Strings and the names in
|
||||
* `@mixin`/`@function` signatures are skipped.
|
||||
*/
|
||||
export function invocationsOf({ text, quoteAt }) {
|
||||
const calls = [];
|
||||
for (const match of text.matchAll(INVOCATION)) {
|
||||
if (quoteAt[match.index] || namesCallable(text, match.index)) continue;
|
||||
const end = match.index + match[0].length;
|
||||
const open = /^\s*\(/.exec(text.slice(end));
|
||||
calls.push({
|
||||
index: match.index,
|
||||
paren: open ? end + open[0].length - 1 : null,
|
||||
callee: calleeNamed(match[1] ?? match[2]),
|
||||
});
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** A CSS escape: up to six hex digits (one whitespace after them ends it). */
|
||||
const ESCAPE = /\\(?:([\da-f]{1,6})(?:\r\n|[ \t\r\n\f])?|([^\n\r\f]))/iy;
|
||||
|
||||
/** The name characters `text` ends with (`font-w` in `.x { font-w`). */
|
||||
function trailingName(text) {
|
||||
let start = text.length;
|
||||
while (start > 0 && /[\w-]/.test(text[start - 1])) start -= 1;
|
||||
return text.slice(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name character an escape decodes to after `name` (the name characters
|
||||
* before it), or `''` where it stays an escape: past ASCII, outside a name,
|
||||
* or a digit or `-` that would start one.
|
||||
*/
|
||||
function escapedName(escape, name) {
|
||||
const code = escape[1] ? Number.parseInt(escape[1], 16) : null;
|
||||
const char = code === null ? escape[2] : String.fromCharCode(code);
|
||||
if ((code !== null && code >= 0x80) || !/^[\w-]$/.test(char)) return '';
|
||||
if (/^(?:-?[a-z_]|--)/i.test(name)) return char;
|
||||
return /^-?$/.test(name) && /^[a-z_]$/i.test(char) ? char : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* `source` with the CSS escapes that Sass and the browser read as plain name
|
||||
* characters decoded (`font-w\65 ight` is `font-weight`, `b\6f ld` is
|
||||
* `bold`, `--w\65 ight` is `--weight`), and `origin`, the offset in `source`
|
||||
* of each position (`null` when nothing changed). An escape stays where its
|
||||
* character would change the token, as Sass leaves it: a digit or `-`
|
||||
* starting a name (`\36 50` is a name, not 650), anything after a number
|
||||
* (`6\35 0`, `6\65 2`), and a character no name has (`\:`, `\20`, `\'`).
|
||||
* Comments and TypeScript are left as written (TypeScript strings use
|
||||
* JavaScript escapes).
|
||||
*/
|
||||
export function decodeEscapes(file, source) {
|
||||
if (file.endsWith('.ts') || !source.includes('\\')) {
|
||||
return { text: source, origin: null };
|
||||
}
|
||||
// A comment is no CSS: `// note \65` must not take the next line.
|
||||
const plain = lex(file, source).text;
|
||||
let text = '';
|
||||
const origin = [];
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
ESCAPE.lastIndex = i;
|
||||
const escape = plain[i] === '\\' ? ESCAPE.exec(source) : null;
|
||||
const length = escape ? escape[0].length : 1;
|
||||
const char = escape ? escapedName(escape, trailingName(text)) : '';
|
||||
if (char) {
|
||||
text += char;
|
||||
origin.push(i);
|
||||
} else {
|
||||
text += source.slice(i, i + length);
|
||||
for (let k = i; k < i + length; k += 1) origin.push(k);
|
||||
}
|
||||
i += length - 1;
|
||||
}
|
||||
origin.push(source.length);
|
||||
return { text, origin };
|
||||
}
|
||||
|
||||
/**
|
||||
* A character reference markup decodes: numeric (`6`, `6`, the `;`
|
||||
* optional) or named, and the named ones CSS text can use.
|
||||
*/
|
||||
const REFERENCE = /&(?:#(\d+);?|#x([\da-f]+);?|([a-z]+);)/iy;
|
||||
const NAMED = Object.freeze({
|
||||
...{ quot: '"', QUOT: '"', apos: "'", colon: ':', semi: ';', excl: '!' },
|
||||
...{ lpar: '(', rpar: ')', comma: ',', period: '.', plus: '+', sol: '/' },
|
||||
...{ bsol: '\\', percnt: '%', lowbar: '_', num: '#', Tab: '\t' },
|
||||
...{ NewLine: '\n', nbsp: '\u00a0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* The text a reference stands for inside `quote` (the quoted attribute
|
||||
* value's quote, or `''`), or `''` where it stays as written: `<` and `>`
|
||||
* would change the markup, so they never decode, and the value's own quote
|
||||
* decodes as the other one (CSS reads both alike).
|
||||
*/
|
||||
function referenced(reference, quote) {
|
||||
const [, decimal, hex, name] = reference;
|
||||
const code = decimal ?? hex;
|
||||
let char = Object.hasOwn(NAMED, name ?? '') ? NAMED[name] : '';
|
||||
if (code !== undefined) {
|
||||
// Past the last code point it is U+FFFD (`fromCodePoint` throws).
|
||||
const point = Number.parseInt(code, decimal ? 10 : 16);
|
||||
char = point > 0x10ffff ? '\uFFFD' : String.fromCodePoint(point);
|
||||
}
|
||||
if ('<>'.includes(char)) return '';
|
||||
if (char !== quote) return char;
|
||||
return quote === '"' ? "'" : '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Where markup keeps its references as written: HTML's `<style>` and
|
||||
* `<script>` text, and an SVG's CDATA sections.
|
||||
*/
|
||||
function rawRanges(file, source) {
|
||||
const raw = file.endsWith('.svg')
|
||||
? /<!\[CDATA\[[\s\S]*?(?:\]\]>|$)/g
|
||||
: /<(style|script)\b[^>]*>[\s\S]*?(?:<\/\1|$)/gi;
|
||||
return [...source.matchAll(raw)].map((m) => [
|
||||
m.index,
|
||||
m.index + m[0].length,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markup with its character references decoded as the browser decodes them
|
||||
* (`style="font-weight: 650"` is 650), outside comments and raw text,
|
||||
* and `origin` as in `decodeEscapes`.
|
||||
*/
|
||||
export function decodeReferences(file, source) {
|
||||
if (!/\.(?:html|svg)$/.test(file) || !source.includes('&')) {
|
||||
return { text: source, origin: null };
|
||||
}
|
||||
const { text: plain, quoteAt } = lex(file, source);
|
||||
const raw = rawRanges(file, source);
|
||||
let text = '';
|
||||
const origin = [];
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
REFERENCE.lastIndex = i;
|
||||
const kept = plain[i] !== '&' || raw.some(([a, b]) => a <= i && i < b);
|
||||
const reference = kept ? null : REFERENCE.exec(source);
|
||||
const length = reference ? reference[0].length : 1;
|
||||
const char = reference ? referenced(reference, quoteAt[i]) : '';
|
||||
if (char) {
|
||||
text += char;
|
||||
for (let k = 0; k < char.length; k += 1) origin.push(i);
|
||||
} else {
|
||||
text += source.slice(i, i + length);
|
||||
for (let k = i; k < i + length; k += 1) origin.push(k);
|
||||
}
|
||||
i += length - 1;
|
||||
}
|
||||
origin.push(source.length);
|
||||
return { text, origin };
|
||||
}
|
||||
|
||||
/**
|
||||
* A file as the browser reads it: markup references, then CSS escapes,
|
||||
* decoded; `origin` maps each position back to `written` (`null` when
|
||||
* nothing changed).
|
||||
*/
|
||||
export function decodeSource(file, written) {
|
||||
const references = decodeReferences(file, written);
|
||||
const escapes = decodeEscapes(file, references.text);
|
||||
if (!escapes.origin) return references;
|
||||
const { origin } = references;
|
||||
return {
|
||||
text: escapes.text,
|
||||
origin: origin ? escapes.origin.map((i) => origin[i]) : escapes.origin,
|
||||
};
|
||||
}
|
||||
|
||||
/** 1-based line of every index, computed once per file. */
|
||||
export function lineIndex(text) {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '\n') starts.push(i + 1);
|
||||
}
|
||||
return (index) => {
|
||||
let low = 0;
|
||||
let high = starts.length - 1;
|
||||
while (low < high) {
|
||||
const middle = (low + high + 1) >> 1;
|
||||
if (starts[middle] <= index) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return low + 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import path from 'node:path';
|
||||
|
||||
/** How a module reads its own members: unprefixed, nothing hidden. */
|
||||
const DIRECT = Object.freeze({ prefix: '', filters: Object.freeze([]) });
|
||||
|
||||
const BOTH = Object.freeze({
|
||||
declarations: true,
|
||||
arguments: true,
|
||||
ranges: [],
|
||||
before: Infinity,
|
||||
exposures: [DIRECT],
|
||||
});
|
||||
const DECLARATIONS = Object.freeze({ ...BOTH, arguments: false });
|
||||
const NOTHING = Object.freeze({
|
||||
...BOTH,
|
||||
declarations: false,
|
||||
arguments: false,
|
||||
exposures: [],
|
||||
});
|
||||
|
||||
/** Sass reads `-` and `_` in a name as the same character. */
|
||||
export function sassName(name) {
|
||||
return name.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
/** `$w` (or a mixin or function `m`) with `prefix` after its `$`. */
|
||||
function prefixed(prefix, name) {
|
||||
return sassName(
|
||||
name.startsWith('$') ? `$${prefix}${name.slice(1)}` : prefix + name
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a loader reads a member `name` by through `exposure`: under its
|
||||
* prefix, or `null` where a `show`/`hide` on the way leaves it out.
|
||||
*/
|
||||
export function exposedName({ prefix, filters }, name) {
|
||||
const exposed = prefixed(prefix, name);
|
||||
const kept = filters.every(
|
||||
({ kind, names }) => names.has(exposed) === (kind === 'show')
|
||||
);
|
||||
return kept ? exposed : null;
|
||||
}
|
||||
|
||||
const keyOf = ({ prefix, filters }) =>
|
||||
[prefix, ...filters.map((filter) => filter.key)].join(' ');
|
||||
|
||||
/**
|
||||
* A forwarding cycle with prefixes would grow without end; without them, a
|
||||
* list already on the way is not added again, so the cycle repeats a key.
|
||||
*/
|
||||
const tooLong = ({ prefix }) => prefix.length > 200;
|
||||
|
||||
/**
|
||||
* One more `@forward` below `exposure`: its `as p-*` prefix goes after the
|
||||
* ones above, and its `show`/`hide` names, written as that `@forward`
|
||||
* exposes them, read under the prefixes above.
|
||||
*/
|
||||
function deeper(exposure, { prefix, filter }) {
|
||||
const filters = [...exposure.filters];
|
||||
if (filter) {
|
||||
const names = new Set(
|
||||
filter.names.map((name) => prefixed(exposure.prefix, name))
|
||||
);
|
||||
const key = `${filter.kind}:${[...names].sort().join(',')}`;
|
||||
if (!filters.some((known) => known.key === key)) {
|
||||
filters.push({ kind: filter.kind, names, key });
|
||||
}
|
||||
}
|
||||
return { prefix: exposure.prefix + prefix, filters };
|
||||
}
|
||||
|
||||
/**
|
||||
* The files a Sass load can name, in Sass's resolution order. Bare targets
|
||||
* (`@use 'tokens'`) resolve next to the loading file first; package and
|
||||
* built-in modules (`@angular/material`, `sass:math`) are not workspace files
|
||||
* and simply match nothing.
|
||||
*/
|
||||
function candidates(from, specifier) {
|
||||
const target = path.posix.join(path.posix.dirname(from), specifier);
|
||||
const dir = path.posix.dirname(target);
|
||||
const base = path.posix.basename(target);
|
||||
return [
|
||||
target,
|
||||
`${target}.scss`,
|
||||
`${dir}/_${base}.scss`,
|
||||
`${target}/_index.scss`,
|
||||
`${target}/index.scss`,
|
||||
];
|
||||
}
|
||||
|
||||
/** `@use '../x/_tokens'` is namespaced `tokens` unless `as` renames it. */
|
||||
function namespaceOf({ target, as }) {
|
||||
if (as) return as;
|
||||
return path.posix
|
||||
.basename(target)
|
||||
.replace(/\.s?css$/, '')
|
||||
.replace(/^_/, '');
|
||||
}
|
||||
|
||||
function push(map, key, value) {
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(value);
|
||||
}
|
||||
|
||||
function merge(scope, file, access) {
|
||||
const known = scope.get(file) ?? {
|
||||
...NOTHING,
|
||||
ranges: [],
|
||||
before: -Infinity,
|
||||
};
|
||||
const exposures = new Map(
|
||||
[...known.exposures, ...access.exposures].map((e) => [keyOf(e), e])
|
||||
);
|
||||
scope.set(file, {
|
||||
declarations: known.declarations || access.declarations,
|
||||
arguments: known.arguments || access.arguments,
|
||||
ranges: [...known.ranges, ...access.ranges],
|
||||
// A file can count only up to a position (an `@import` of it).
|
||||
before: Math.max(known.before, access.before),
|
||||
exposures: [...exposures.values()],
|
||||
});
|
||||
}
|
||||
|
||||
/** `with (…)` ranges whose names read as `exposure` turns them. */
|
||||
const rangesOf = (ranges, exposure, outward = false) =>
|
||||
ranges.map(([start, end]) => ({ start, end, exposure, outward }));
|
||||
|
||||
/**
|
||||
* Which definitions a Sass variable can resolve to, following the module
|
||||
* system. `declarations` are `$x: 1;` statements; `arguments` are `$x: 1`
|
||||
* inside a mixin call or a `with (…)` configuration.
|
||||
*
|
||||
* `qualified(file, ns)` — `ns.$x`: the members of the module the file loads
|
||||
* as `ns` (its declarations and, transitively, what it `@forward`s), plus the
|
||||
* arguments written inside that `@use`'s own `with (…)` (or a `@forward …
|
||||
* with (…)` in the chain), by position, so a same-named argument elsewhere,
|
||||
* or another module's configuration, never counts.
|
||||
*
|
||||
* `unqualified(file)` — `$x`, per file:
|
||||
* - the file itself: both;
|
||||
* - members of modules it loads `as *`, and files it `@import`s (textual
|
||||
* inclusion, transitively) with what they `@forward`: their declarations;
|
||||
* - files that load it: only what they pass in, since `@use` never injects
|
||||
* the loader's own variables. That is the `with (…)` configuration of the
|
||||
* load and arguments, which the caller then matches to the callable they
|
||||
* are passed to. A chain of `@import`s is textual, so there the loader's
|
||||
* declarations count too.
|
||||
*
|
||||
* A namespaced `@use` adds nothing unqualified, and two files that only share
|
||||
* a partial are never connected. Each file carries the `exposures` it is read
|
||||
* through (see `exposedName`): a `@forward … as p-*` chain prefixes members,
|
||||
* and its `show`/`hide` lists leave some out. Each `with (…)` range carries
|
||||
* the exposure that turns the names written in it into the names read
|
||||
* (`outward` when the reader is the configured module, read before the
|
||||
* prefix). Sass lets a loader configure a hidden member, so only prefixes
|
||||
* apply there. A textual importer counts only `before` its `@import`.
|
||||
* `scans` carry `file` and `loads` (`{ rule, target, as, configuration,
|
||||
* filter }`).
|
||||
*/
|
||||
export function sassScopes(scans) {
|
||||
const known = new Set(scans.map((scan) => scan.file));
|
||||
const edges = new Map();
|
||||
const loadedBy = new Map();
|
||||
for (const { file, loads = [] } of scans) {
|
||||
for (const load of loads) {
|
||||
const loaded = candidates(file, load.target).find((c) =>
|
||||
known.has(c)
|
||||
);
|
||||
if (!loaded) continue;
|
||||
const namespace = load.rule === 'use' ? namespaceOf(load) : null;
|
||||
const ranges = load.configuration ? [load.configuration] : [];
|
||||
const forward = load.rule === 'forward';
|
||||
// `@forward 'x' as btn-*` exposes `$v` as `$btn-v`.
|
||||
const prefix =
|
||||
forward && load.as?.endsWith('*') ? load.as.slice(0, -1) : '';
|
||||
const filter = forward ? load.filter : null;
|
||||
push(edges, file, {
|
||||
...{ rule: load.rule, loaded, namespace, ranges, prefix },
|
||||
...{ filter, index: load.index },
|
||||
});
|
||||
push(loadedBy, loaded, {
|
||||
...{ file, textual: load.rule === 'import', ranges, prefix },
|
||||
forward,
|
||||
index: load.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A module's members: how each file is exposed, and where a forwarding
|
||||
// `with (…)` sits.
|
||||
const membersCache = new Map();
|
||||
const members = (module) => {
|
||||
if (membersCache.has(module)) return membersCache.get(module);
|
||||
const found = new Map();
|
||||
const reach = (file, exposure) => {
|
||||
if (!found.has(file))
|
||||
found.set(file, { ranges: [], exposures: [] });
|
||||
const { exposures } = found.get(file);
|
||||
if (!exposures.some((e) => keyOf(e) === keyOf(exposure))) {
|
||||
exposures.push(exposure);
|
||||
}
|
||||
};
|
||||
reach(module, DIRECT);
|
||||
membersCache.set(module, found);
|
||||
const stack = [{ file: module, exposure: DIRECT }];
|
||||
const seen = new Set([`${module} ${keyOf(DIRECT)}`]);
|
||||
while (stack.length > 0) {
|
||||
const { file: current, exposure } = stack.pop();
|
||||
for (const edge of edges.get(current) ?? []) {
|
||||
if (edge.rule !== 'forward') continue;
|
||||
const next = deeper(exposure, edge);
|
||||
// Its `with (…)` names the forwarded module's own members,
|
||||
// which a reader of this module sees through `next`.
|
||||
found.get(current).ranges.push(...rangesOf(edge.ranges, next));
|
||||
reach(edge.loaded, next);
|
||||
const key = `${edge.loaded} ${keyOf(next)}`;
|
||||
if (seen.has(key) || tooLong(next)) continue;
|
||||
seen.add(key);
|
||||
stack.push({ file: edge.loaded, exposure: next });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const bringIn = (scope, module) => {
|
||||
for (const [member, { ranges, exposures }] of members(module)) {
|
||||
merge(scope, member, {
|
||||
...DECLARATIONS,
|
||||
arguments: ranges.length > 0,
|
||||
...{ ranges, exposures },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const qualified = (file, namespace) => {
|
||||
const scope = new Map();
|
||||
for (const edge of edges.get(file) ?? []) {
|
||||
if (edge.rule !== 'use' || edge.namespace !== namespace) continue;
|
||||
merge(scope, file, {
|
||||
...NOTHING,
|
||||
ranges: rangesOf(edge.ranges, DIRECT),
|
||||
});
|
||||
for (const [member, { ranges, exposures }] of members(
|
||||
edge.loaded
|
||||
)) {
|
||||
merge(scope, member, { ...DECLARATIONS, ranges, exposures });
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
const unqualified = (file) => {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const scope = new Map([[file, BOTH]]);
|
||||
const down = [file];
|
||||
const imported = new Set(down);
|
||||
while (down.length > 0) {
|
||||
const current = down.pop();
|
||||
for (const edge of edges.get(current) ?? []) {
|
||||
if (edge.rule === 'use' && edge.namespace === '*') {
|
||||
// Its `with (…)` configures what the loading file reads.
|
||||
merge(scope, current, {
|
||||
...BOTH,
|
||||
ranges: rangesOf(edge.ranges, DIRECT),
|
||||
});
|
||||
bringIn(scope, edge.loaded);
|
||||
} else if (edge.rule === 'import') {
|
||||
// An imported file's `@forward`s reach the importer too.
|
||||
bringIn(scope, edge.loaded);
|
||||
if (!imported.has(edge.loaded)) {
|
||||
imported.add(edge.loaded);
|
||||
down.push(edge.loaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `exposure` turns this file's names into those the loader writes:
|
||||
// a loader of a `@forward … as p-*` configures `$w` as `$p-w`.
|
||||
const up = [{ file, textual: true, exposure: DIRECT }];
|
||||
const visitedUp = new Set([`${file} true ${keyOf(DIRECT)}`]);
|
||||
while (up.length > 0) {
|
||||
const current = up.pop();
|
||||
for (const loader of loadedBy.get(current.file) ?? []) {
|
||||
const textual = current.textual && loader.textual;
|
||||
// A textual importer's code before the `@import` has run when
|
||||
// the imported file's rules render; what follows has not.
|
||||
merge(scope, loader.file, {
|
||||
...{ declarations: textual, arguments: true },
|
||||
ranges: rangesOf(loader.ranges, current.exposure, true),
|
||||
before: textual ? loader.index : Infinity,
|
||||
exposures: [DIRECT],
|
||||
});
|
||||
const exposure = {
|
||||
prefix: loader.prefix + current.exposure.prefix,
|
||||
filters: [],
|
||||
};
|
||||
const key = `${loader.file} ${textual} ${keyOf(exposure)}`;
|
||||
if (visitedUp.has(key) || tooLong(exposure)) continue;
|
||||
visitedUp.add(key);
|
||||
up.push({ file: loader.file, textual, exposure });
|
||||
}
|
||||
}
|
||||
cache.set(file, scope);
|
||||
return scope;
|
||||
};
|
||||
|
||||
/**
|
||||
* The loads of `file`: who loads it, the `with (…)` ranges (`[start,
|
||||
* end)`) on each, and for a `@forward`, the prefix it adds.
|
||||
*/
|
||||
const loadsOf = (file) => loadedBy.get(file) ?? [];
|
||||
|
||||
/** Files `@import`ed by `file`, with where each `@import` sits. */
|
||||
const imports = (file) =>
|
||||
(edges.get(file) ?? [])
|
||||
.filter((edge) => edge.rule === 'import')
|
||||
.map(({ loaded, index }) => ({ loaded, index }));
|
||||
|
||||
return { qualified, unqualified, imports, loadsOf };
|
||||
}
|
||||
|
||||
/**
|
||||
* The declarations of one name in the reference's own file that can be in
|
||||
* effect at the reference, as Sass runs them. Scopes are tried innermost
|
||||
* first; in the first that declares the name before the reference, the last
|
||||
* unconditional assignment counts, plus any conditional (flow-control) ones
|
||||
* after it, and an inner declaration shadows outer ones. A scope with only
|
||||
* conditional assignments falls through to the next. Inside a `@mixin` or
|
||||
* `@function` body, module variables are read at call time: the top level is
|
||||
* resolved at each call site in the file (`callSites`) and at the end of the
|
||||
* module, for callers elsewhere. A `!default` assignment counts only where
|
||||
* no unconditional value precedes it, and never settles the value. Imported
|
||||
* declarations keep the text order of their inclusion. `settled` means an
|
||||
* unconditional declaration decided the value.
|
||||
*/
|
||||
export function effectiveDeclarations(reference, candidates, callSites = []) {
|
||||
const picked = new Set();
|
||||
// Text order; an imported declaration carries its path (`order`).
|
||||
const orderOf = (d) => d.order ?? [d.index];
|
||||
const compare = (a, b) => {
|
||||
const [x, y] = [orderOf(a), orderOf(b)];
|
||||
for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
|
||||
if (x[i] !== y[i]) return x[i] - y[i];
|
||||
}
|
||||
return x.length - y.length;
|
||||
};
|
||||
const firm = (d) => !d.conditional && !d.fallback;
|
||||
// `!default` assigns only while the name is unset, and `null` counts as
|
||||
// unset: the latest unconditional assignment before it, in its scope or
|
||||
// an outer one, wins unless it is `null`.
|
||||
const rank = new Map(reference.scopes.map((scope, i) => [scope, i]));
|
||||
const live = candidates.filter((d) => {
|
||||
if (!d.fallback) return true;
|
||||
const earlier = candidates
|
||||
.filter((f) => firm(f) && compare(f, d) < 0)
|
||||
.filter(
|
||||
(f) => (rank.get(f.scope) ?? -1) >= (rank.get(d.scope) ?? -1)
|
||||
)
|
||||
.sort(compare)
|
||||
.pop();
|
||||
return !earlier || /^null\b/i.test(earlier.value.trim());
|
||||
});
|
||||
const inEffect = (here) => {
|
||||
const last = here.map(firm).lastIndexOf(true);
|
||||
for (const d of last === -1 ? here : here.slice(last)) picked.add(d);
|
||||
return last !== -1;
|
||||
};
|
||||
const at = (scope, position) =>
|
||||
live
|
||||
.filter((d) => d.scope === scope && d.index < position)
|
||||
.sort(compare);
|
||||
for (const scope of reference.scopes) {
|
||||
if (scope === null && reference.inCallable) {
|
||||
const positions = [...callSites, Infinity];
|
||||
const settled = positions
|
||||
.map((position) => inEffect(at(null, position)))
|
||||
.every(Boolean);
|
||||
return { picked: [...picked], settled };
|
||||
}
|
||||
const here = at(scope, reference.index);
|
||||
if (here.length > 0 && inEffect(here)) {
|
||||
return { picked: [...picked], settled: true };
|
||||
}
|
||||
}
|
||||
return { picked: [...picked], settled: false };
|
||||
}
|
||||
Reference in new issue
Block a user