Document application modes (development/debug/production) Add global file drop handler, order column normalization, SPA hash fix Serve CDN assets via /_vendor/ URLs instead of merging into bundles Add production minification with license preservation Improve JSON formatting for debugging and production optimization Add CDN asset caching with CSS URL inlining for production builds Add three-mode system (development, debug, production) Update Manifest CLAUDE.md to reflect helper class architecture Refactor Manifest.php into helper classes for better organization Pre-manifest-refactor checkpoint: Add app_mode documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
553 lines
13 KiB
JavaScript
Executable File
553 lines
13 KiB
JavaScript
Executable File
import { Scanner } from './scanner.js';
|
|
|
|
const TAB = 9;
|
|
const N = 10;
|
|
const F = 12;
|
|
const R = 13;
|
|
const SPACE = 32;
|
|
const EXCLAMATIONMARK = 33; // !
|
|
const NUMBERSIGN = 35; // #
|
|
const AMPERSAND = 38; // &
|
|
const APOSTROPHE = 39; // '
|
|
const LEFTPARENTHESIS = 40; // (
|
|
const RIGHTPARENTHESIS = 41; // )
|
|
const ASTERISK = 42; // *
|
|
const PLUSSIGN = 43; // +
|
|
const COMMA = 44; // ,
|
|
const HYPERMINUS = 45; // -
|
|
const LESSTHANSIGN = 60; // <
|
|
const GREATERTHANSIGN = 62; // >
|
|
const QUESTIONMARK = 63; // ?
|
|
const COMMERCIALAT = 64; // @
|
|
const LEFTSQUAREBRACKET = 91; // [
|
|
const RIGHTSQUAREBRACKET = 93; // ]
|
|
const LEFTCURLYBRACKET = 123; // {
|
|
const VERTICALLINE = 124; // |
|
|
const RIGHTCURLYBRACKET = 125; // }
|
|
const INFINITY = 8734; // ∞
|
|
const COMBINATOR_PRECEDENCE = {
|
|
' ': 1,
|
|
'&&': 2,
|
|
'||': 3,
|
|
'|': 4
|
|
};
|
|
|
|
function readMultiplierRange(scanner) {
|
|
let min = null;
|
|
let max = null;
|
|
|
|
scanner.eat(LEFTCURLYBRACKET);
|
|
scanner.skipWs();
|
|
|
|
min = scanner.scanNumber(scanner);
|
|
scanner.skipWs();
|
|
|
|
if (scanner.charCode() === COMMA) {
|
|
scanner.pos++;
|
|
scanner.skipWs();
|
|
|
|
if (scanner.charCode() !== RIGHTCURLYBRACKET) {
|
|
max = scanner.scanNumber(scanner);
|
|
scanner.skipWs();
|
|
}
|
|
} else {
|
|
max = min;
|
|
}
|
|
|
|
scanner.eat(RIGHTCURLYBRACKET);
|
|
|
|
return {
|
|
min: Number(min),
|
|
max: max ? Number(max) : 0
|
|
};
|
|
}
|
|
|
|
function readMultiplier(scanner) {
|
|
let range = null;
|
|
let comma = false;
|
|
|
|
switch (scanner.charCode()) {
|
|
case ASTERISK:
|
|
scanner.pos++;
|
|
|
|
range = {
|
|
min: 0,
|
|
max: 0
|
|
};
|
|
|
|
break;
|
|
|
|
case PLUSSIGN:
|
|
scanner.pos++;
|
|
|
|
range = {
|
|
min: 1,
|
|
max: 0
|
|
};
|
|
|
|
break;
|
|
|
|
case QUESTIONMARK:
|
|
scanner.pos++;
|
|
|
|
range = {
|
|
min: 0,
|
|
max: 1
|
|
};
|
|
|
|
break;
|
|
|
|
case NUMBERSIGN:
|
|
scanner.pos++;
|
|
|
|
comma = true;
|
|
|
|
if (scanner.charCode() === LEFTCURLYBRACKET) {
|
|
range = readMultiplierRange(scanner);
|
|
} else if (scanner.charCode() === QUESTIONMARK) {
|
|
// https://www.w3.org/TR/css-values-4/#component-multipliers
|
|
// > the # and ? multipliers may be stacked as #?
|
|
// In this case just treat "#?" as a single multiplier
|
|
// { min: 0, max: 0, comma: true }
|
|
scanner.pos++;
|
|
range = {
|
|
min: 0,
|
|
max: 0
|
|
};
|
|
} else {
|
|
range = {
|
|
min: 1,
|
|
max: 0
|
|
};
|
|
}
|
|
|
|
break;
|
|
|
|
case LEFTCURLYBRACKET:
|
|
range = readMultiplierRange(scanner);
|
|
break;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: 'Multiplier',
|
|
comma,
|
|
min: range.min,
|
|
max: range.max,
|
|
term: null
|
|
};
|
|
}
|
|
|
|
function maybeMultiplied(scanner, node) {
|
|
const multiplier = readMultiplier(scanner);
|
|
|
|
if (multiplier !== null) {
|
|
multiplier.term = node;
|
|
|
|
// https://www.w3.org/TR/css-values-4/#component-multipliers
|
|
// > The + and # multipliers may be stacked as +#;
|
|
// Represent "+#" as nested multipliers:
|
|
// { ...<multiplier #>,
|
|
// term: {
|
|
// ...<multipler +>,
|
|
// term: node
|
|
// }
|
|
// }
|
|
if (scanner.charCode() === NUMBERSIGN &&
|
|
scanner.charCodeAt(scanner.pos - 1) === PLUSSIGN) {
|
|
return maybeMultiplied(scanner, multiplier);
|
|
}
|
|
|
|
return multiplier;
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
function maybeToken(scanner) {
|
|
const ch = scanner.peek();
|
|
|
|
if (ch === '') {
|
|
return null;
|
|
}
|
|
|
|
return maybeMultiplied(scanner, {
|
|
type: 'Token',
|
|
value: ch
|
|
});
|
|
}
|
|
|
|
function readProperty(scanner) {
|
|
let name;
|
|
|
|
scanner.eat(LESSTHANSIGN);
|
|
scanner.eat(APOSTROPHE);
|
|
|
|
name = scanner.scanWord();
|
|
|
|
scanner.eat(APOSTROPHE);
|
|
scanner.eat(GREATERTHANSIGN);
|
|
|
|
return maybeMultiplied(scanner, {
|
|
type: 'Property',
|
|
name
|
|
});
|
|
}
|
|
|
|
// https://drafts.csswg.org/css-values-3/#numeric-ranges
|
|
// 4.1. Range Restrictions and Range Definition Notation
|
|
//
|
|
// Range restrictions can be annotated in the numeric type notation using CSS bracketed
|
|
// range notation—[min,max]—within the angle brackets, after the identifying keyword,
|
|
// indicating a closed range between (and including) min and max.
|
|
// For example, <integer [0, 10]> indicates an integer between 0 and 10, inclusive.
|
|
function readTypeRange(scanner) {
|
|
// use null for Infinity to make AST format JSON serializable/deserializable
|
|
let min = null; // -Infinity
|
|
let max = null; // Infinity
|
|
let sign = 1;
|
|
|
|
scanner.eat(LEFTSQUAREBRACKET);
|
|
|
|
if (scanner.charCode() === HYPERMINUS) {
|
|
scanner.peek();
|
|
sign = -1;
|
|
}
|
|
|
|
if (sign == -1 && scanner.charCode() === INFINITY) {
|
|
scanner.peek();
|
|
} else {
|
|
min = sign * Number(scanner.scanNumber(scanner));
|
|
|
|
if (scanner.isNameCharCode()) {
|
|
min += scanner.scanWord();
|
|
}
|
|
}
|
|
|
|
scanner.skipWs();
|
|
scanner.eat(COMMA);
|
|
scanner.skipWs();
|
|
|
|
if (scanner.charCode() === INFINITY) {
|
|
scanner.peek();
|
|
} else {
|
|
sign = 1;
|
|
|
|
if (scanner.charCode() === HYPERMINUS) {
|
|
scanner.peek();
|
|
sign = -1;
|
|
}
|
|
|
|
max = sign * Number(scanner.scanNumber(scanner));
|
|
|
|
if (scanner.isNameCharCode()) {
|
|
max += scanner.scanWord();
|
|
}
|
|
}
|
|
|
|
scanner.eat(RIGHTSQUAREBRACKET);
|
|
|
|
return {
|
|
type: 'Range',
|
|
min,
|
|
max
|
|
};
|
|
}
|
|
|
|
function readType(scanner) {
|
|
let name;
|
|
let opts = null;
|
|
|
|
scanner.eat(LESSTHANSIGN);
|
|
name = scanner.scanWord();
|
|
|
|
// https://drafts.csswg.org/css-values-5/#boolean
|
|
if (name === 'boolean-expr') {
|
|
scanner.eat(LEFTSQUAREBRACKET);
|
|
|
|
const implicitGroup = readImplicitGroup(scanner, RIGHTSQUAREBRACKET);
|
|
|
|
scanner.eat(RIGHTSQUAREBRACKET);
|
|
scanner.eat(GREATERTHANSIGN);
|
|
|
|
return maybeMultiplied(scanner, {
|
|
type: 'Boolean',
|
|
term: implicitGroup.terms.length === 1
|
|
? implicitGroup.terms[0]
|
|
: implicitGroup
|
|
});
|
|
}
|
|
|
|
if (scanner.charCode() === LEFTPARENTHESIS &&
|
|
scanner.nextCharCode() === RIGHTPARENTHESIS) {
|
|
scanner.pos += 2;
|
|
name += '()';
|
|
}
|
|
|
|
if (scanner.charCodeAt(scanner.findWsEnd(scanner.pos)) === LEFTSQUAREBRACKET) {
|
|
scanner.skipWs();
|
|
opts = readTypeRange(scanner);
|
|
}
|
|
|
|
scanner.eat(GREATERTHANSIGN);
|
|
|
|
return maybeMultiplied(scanner, {
|
|
type: 'Type',
|
|
name,
|
|
opts
|
|
});
|
|
}
|
|
|
|
function readKeywordOrFunction(scanner) {
|
|
const name = scanner.scanWord();
|
|
|
|
if (scanner.charCode() === LEFTPARENTHESIS) {
|
|
scanner.pos++;
|
|
|
|
return {
|
|
type: 'Function',
|
|
name
|
|
};
|
|
}
|
|
|
|
return maybeMultiplied(scanner, {
|
|
type: 'Keyword',
|
|
name
|
|
});
|
|
}
|
|
|
|
function regroupTerms(terms, combinators) {
|
|
function createGroup(terms, combinator) {
|
|
return {
|
|
type: 'Group',
|
|
terms,
|
|
combinator,
|
|
disallowEmpty: false,
|
|
explicit: false
|
|
};
|
|
}
|
|
|
|
let combinator;
|
|
|
|
combinators = Object.keys(combinators)
|
|
.sort((a, b) => COMBINATOR_PRECEDENCE[a] - COMBINATOR_PRECEDENCE[b]);
|
|
|
|
while (combinators.length > 0) {
|
|
combinator = combinators.shift();
|
|
|
|
let i = 0;
|
|
let subgroupStart = 0;
|
|
|
|
for (; i < terms.length; i++) {
|
|
const term = terms[i];
|
|
|
|
if (term.type === 'Combinator') {
|
|
if (term.value === combinator) {
|
|
if (subgroupStart === -1) {
|
|
subgroupStart = i - 1;
|
|
}
|
|
terms.splice(i, 1);
|
|
i--;
|
|
} else {
|
|
if (subgroupStart !== -1 && i - subgroupStart > 1) {
|
|
terms.splice(
|
|
subgroupStart,
|
|
i - subgroupStart,
|
|
createGroup(terms.slice(subgroupStart, i), combinator)
|
|
);
|
|
i = subgroupStart + 1;
|
|
}
|
|
subgroupStart = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (subgroupStart !== -1 && combinators.length) {
|
|
terms.splice(
|
|
subgroupStart,
|
|
i - subgroupStart,
|
|
createGroup(terms.slice(subgroupStart, i), combinator)
|
|
);
|
|
}
|
|
}
|
|
|
|
return combinator;
|
|
}
|
|
|
|
function readImplicitGroup(scanner, stopCharCode) {
|
|
const combinators = Object.create(null);
|
|
const terms = [];
|
|
let token;
|
|
let prevToken = null;
|
|
let prevTokenPos = scanner.pos;
|
|
|
|
while (scanner.charCode() !== stopCharCode && (token = peek(scanner, stopCharCode))) {
|
|
if (token.type !== 'Spaces') {
|
|
if (token.type === 'Combinator') {
|
|
// check for combinator in group beginning and double combinator sequence
|
|
if (prevToken === null || prevToken.type === 'Combinator') {
|
|
scanner.pos = prevTokenPos;
|
|
scanner.error('Unexpected combinator');
|
|
}
|
|
|
|
combinators[token.value] = true;
|
|
} else if (prevToken !== null && prevToken.type !== 'Combinator') {
|
|
combinators[' '] = true; // a b
|
|
terms.push({
|
|
type: 'Combinator',
|
|
value: ' '
|
|
});
|
|
}
|
|
|
|
terms.push(token);
|
|
prevToken = token;
|
|
prevTokenPos = scanner.pos;
|
|
}
|
|
}
|
|
|
|
// check for combinator in group ending
|
|
if (prevToken !== null && prevToken.type === 'Combinator') {
|
|
scanner.pos -= prevTokenPos;
|
|
scanner.error('Unexpected combinator');
|
|
}
|
|
|
|
return {
|
|
type: 'Group',
|
|
terms,
|
|
combinator: regroupTerms(terms, combinators) || ' ',
|
|
disallowEmpty: false,
|
|
explicit: false
|
|
};
|
|
}
|
|
|
|
function readGroup(scanner, stopCharCode) {
|
|
let result;
|
|
|
|
scanner.eat(LEFTSQUAREBRACKET);
|
|
result = readImplicitGroup(scanner, stopCharCode);
|
|
scanner.eat(RIGHTSQUAREBRACKET);
|
|
|
|
result.explicit = true;
|
|
|
|
if (scanner.charCode() === EXCLAMATIONMARK) {
|
|
scanner.pos++;
|
|
result.disallowEmpty = true;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function peek(scanner, stopCharCode) {
|
|
let code = scanner.charCode();
|
|
|
|
switch (code) {
|
|
case RIGHTSQUAREBRACKET:
|
|
// don't eat, stop scan a group
|
|
break;
|
|
|
|
case LEFTSQUAREBRACKET:
|
|
return maybeMultiplied(scanner, readGroup(scanner, stopCharCode));
|
|
|
|
case LESSTHANSIGN:
|
|
return scanner.nextCharCode() === APOSTROPHE
|
|
? readProperty(scanner)
|
|
: readType(scanner);
|
|
|
|
case VERTICALLINE:
|
|
return {
|
|
type: 'Combinator',
|
|
value: scanner.substringToPos(
|
|
scanner.pos + (scanner.nextCharCode() === VERTICALLINE ? 2 : 1)
|
|
)
|
|
};
|
|
|
|
case AMPERSAND:
|
|
scanner.pos++;
|
|
scanner.eat(AMPERSAND);
|
|
|
|
return {
|
|
type: 'Combinator',
|
|
value: '&&'
|
|
};
|
|
|
|
case COMMA:
|
|
scanner.pos++;
|
|
return {
|
|
type: 'Comma'
|
|
};
|
|
|
|
case APOSTROPHE:
|
|
return maybeMultiplied(scanner, {
|
|
type: 'String',
|
|
value: scanner.scanString()
|
|
});
|
|
|
|
case SPACE:
|
|
case TAB:
|
|
case N:
|
|
case R:
|
|
case F:
|
|
return {
|
|
type: 'Spaces',
|
|
value: scanner.scanSpaces()
|
|
};
|
|
|
|
case COMMERCIALAT:
|
|
code = scanner.nextCharCode();
|
|
|
|
if (scanner.isNameCharCode(code)) {
|
|
scanner.pos++;
|
|
return {
|
|
type: 'AtKeyword',
|
|
name: scanner.scanWord()
|
|
};
|
|
}
|
|
|
|
return maybeToken(scanner);
|
|
|
|
case ASTERISK:
|
|
case PLUSSIGN:
|
|
case QUESTIONMARK:
|
|
case NUMBERSIGN:
|
|
case EXCLAMATIONMARK:
|
|
// prohibited tokens (used as a multiplier start)
|
|
break;
|
|
|
|
case LEFTCURLYBRACKET:
|
|
// LEFTCURLYBRACKET is allowed since mdn/data uses it w/o quoting
|
|
// check next char isn't a number, because it's likely a disjoined multiplier
|
|
code = scanner.nextCharCode();
|
|
|
|
if (code < 48 || code > 57) {
|
|
return maybeToken(scanner);
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
if (scanner.isNameCharCode(code)) {
|
|
return readKeywordOrFunction(scanner);
|
|
}
|
|
|
|
return maybeToken(scanner);
|
|
}
|
|
}
|
|
|
|
export function parse(source) {
|
|
const scanner = new Scanner(source);
|
|
const result = readImplicitGroup(scanner);
|
|
|
|
if (scanner.pos !== source.length) {
|
|
scanner.error('Unexpected input');
|
|
}
|
|
|
|
// reduce redundant groups with single group term
|
|
if (result.terms.length === 1 && result.terms[0].type === 'Group') {
|
|
return result.terms[0];
|
|
}
|
|
|
|
return result;
|
|
};
|