Attributes
The {…} block, markz's one extension syntax, wherever syntax.md allows one: #id, .class,
key=value, with key="a quoted value" for spaces, and a bare key for a boolean attribute,
on one line. An element's block starts with @name, and a block element's may end in /.
Items are kept verbatim and in source order, each with its range; merging (classes accumulate,
a later value wins) is the renderer's job. Anything that doesn't parse returns null, and the
caller keeps the braces as text (syntax.md: Attributes).
import { type Attribute, type Attributes } from "./ast";
import { isSpace, unescape } from "./chars";
import { scanExpression, type Memo } from "./expression";
/**
* `source[at]` is a `{`. Returns the attributes, whose `end` is just past the `}`, or `null`.
* `multiline` reads a line ending as a space, only to tell whether lines would parse if joined.
* `memo` is `scanExpression`'s, for `source` and `end`.
*/
export function parseAttributes(
source: string,
at: number,
end: number,
multiline = false,
memo?: Memo,
): Attributes | null {
const space = multiline ? (c: number) => isSpace(c) || c === 10 || c === 13 : isSpace;
return scan(source, at, at + 1, end, space, memo, false)?.attributes ?? null;
}
/** An element's `{@name …}`, whose `attributes` are the items after the name. */
export interface ElementHead {
name: string;
attributes: Attributes;
/** It ends in `/}`: a block element closed on its line. */
slash: boolean;
}
/** `source[at]` is a `{` followed by `@`. Returns the element's head, or `null`. */
export function parseElement(
source: string,
at: number,
end: number,
memo?: Memo,
): ElementHead | null {
NAME.lastIndex = at + 2;
const m = NAME.exec(source);
if (!m || at + 2 + m[0].length > end) return null;
const i = at + 2 + m[0].length;
const c = source.charCodeAt(i);
if (i < end && !isSpace(c) && c !== 125 && c !== 47) return null;
const out = scan(source, at, i, end, isSpace, memo, true);
return out && { name: m[0], attributes: out.attributes, slash: out.slash };
}
/** A name as written: `element()` decides whether it is one. */
const NAME = /[A-Za-z][\w-]*/y;
/** The items from `i` to the `}`, and whether a `/` came just before it, where `slash` allows one. */
function scan(
source: string,
at: number,
i: number,
end: number,
space: (c: number) => boolean,
memo: Memo | undefined,
slash: boolean,
): { attributes: Attributes; slash: boolean } | null {
const items: Attribute[] = [];
for (;;) {
while (i < end && space(source.charCodeAt(i))) i++;
if (i >= end) return null;
const c = source[i];
if (c === "}") return { attributes: { start: at, end: i + 1, items }, slash: false };
if (slash && c === "/" && source[i + 1] === "}") {
return { attributes: { start: at, end: i + 2, items }, slash: true };
}
const start = i;
if (c === "#" || c === ".") {
i = name(source, i + 1, end, space, slash);
if (i === start + 1) return null;
items.push({
key: c === "#" ? "id" : "class",
value: source.slice(start + 1, i),
start,
end: i,
});
} else {
const keyEnd = key(source, i, end);
if (keyEnd === i) return null;
const valueStart = keyEnd + 1;
let value = "";
if (source[keyEnd] !== "=") {
// A bare key is a boolean attribute (`open`, `controls`), and starts with a letter.
if (!/[A-Za-z]/.test(c!)) return null;
i = keyEnd;
} else if (source[valueStart] === '"') {
i = quoted(source, valueStart, end, memo);
if (i < 0) return null;
value = unescape(source.slice(valueStart + 1, i - 1));
} else {
i = bare(source, valueStart, end, space, memo, slash);
if (i < 0 || i === valueStart) return null;
value = source.slice(valueStart, i);
}
items.push({ key: source.slice(start, keyEnd), value, start, end: i });
}
// Items are separated by whitespace, or end at the `}`.
if (i < end && !space(source.charCodeAt(i)) && source[i] !== "}") {
if (!(slash && source[i] === "/")) return null;
}
}
}
/**
* Whether every item is a bare key. Such a block counts only where attributes attach to a link,
* image, span or element: on a line of its own or after a word, `{year}` is an MDX expression or
* a placeholder, and stays text.
*/
export function bareOnly(source: string, a: Attributes): boolean {
return a.items.length > 0 && a.items.every((i) => source.slice(i.start, i.end) === i.key);
}
/** Where a `{…}` that didn't parse ends, just past its `}` on the same line, or -1. */
export function braceEnd(source: string, at: number, end: number): number {
for (let i = at; i < end; i++) if (source.charCodeAt(i) === 125) return i + 1;
return -1;
}
/**
* An id or class name: anything up to whitespace or one of the characters that delimit items.
* Where `slash` allows a closing `/}`, a `/` just before the `}` is the leaf's, not the name's.
*/
function name(source: string, at: number, end: number, space = isSpace, slash = false): number {
let i = at;
while (
i < end &&
!space(source.charCodeAt(i)) &&
!"{}#.\"'=".includes(source[i]!) &&
!(slash && closes(source, i))
)
i++;
return i;
}
/** A `/` just before the `}`. */
const closes = (source: string, i: number) => source[i] === "/" && source[i + 1] === "}";
function key(source: string, at: number, end: number): number {
let i = at;
while (i < end && /[\w:-]/.test(source[i]!)) i++;
return i;
}
/** A `"…"` value, with backslash escapes and `${…}` inside. Returns the offset past the `"`. */
function quoted(source: string, at: number, end: number, memo?: Memo): number {
for (let i = at + 1; i < end;) {
const c = source[i];
if (c === "\\") i += 2;
else if (c === '"') return i + 1;
else if (c === "$" && source[i + 1] === "{") {
i = scanExpression(source, i, end, memo);
if (i < 0) return -1;
} else i++;
}
return -1;
}
/** An unquoted value runs to whitespace or `}`, or a closing `/}`, with `${…}` skipped whole. */
function bare(
source: string,
at: number,
end: number,
space: (c: number) => boolean,
memo?: Memo,
slash = false,
): number {
let i = at;
while (i < end) {
const c = source[i]!;
if (c === "$" && source[i + 1] === "{") {
i = scanExpression(source, i, end, memo);
if (i < 0) return -1;
} else if (space(c.charCodeAt(0)) || "{}\"'=".includes(c) || (slash && closes(source, i)))
break;
else i++;
}
return i;
}