better formatting

This commit is contained in:
Hellings 2026-10-23 09:12:49 -04:00
parent f18a8fd409
commit 8d0d645487
188 changed files with 11503 additions and 6286 deletions

View file

@ -24,10 +24,21 @@ export interface WranglerSetupOptions {
} }
/** Creates a minimal Wrangler config when a project has not supplied one. */ /** Creates a minimal Wrangler config when a project has not supplied one. */
export async function setupWrangler(root: string, options: WranglerSetupOptions = {}): Promise<string> { export async function setupWrangler(
root: string,
options: WranglerSetupOptions = {},
): Promise<string> {
const path = join(resolve(root), "wrangler.jsonc"); const path = join(resolve(root), "wrangler.jsonc");
if (existsSync(path)) return path; if (existsSync(path)) return path;
const source = JSON.stringify({ name: options.name ?? "muddle-app", main: options.main ?? "src/worker.ts", compatibility_date: options.compatibilityDate ?? "2026-10-22" }, null, 2).concat("\n"); const source = JSON.stringify(
{
name: options.name ?? "muddle-app",
main: options.main ?? "src/worker.ts",
compatibility_date: options.compatibilityDate ?? "2026-10-22",
},
null,
2,
).concat("\n");
await writeFile(path, source, "utf8"); await writeFile(path, source, "utf8");
return path; return path;
} }

View file

@ -54,7 +54,9 @@ export interface ReflectiveAnchorGroup {
const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => { const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
const { remeasure, selectors = [] } = input; const { remeasure, selectors = [] } = input;
const document = (globalThis as unknown as { document: any }).document; const document = (globalThis as unknown as { document: any }).document;
const getComputedStyle = (globalThis as unknown as { getComputedStyle: (element: any) => any }).getComputedStyle; const getComputedStyle = (
globalThis as unknown as { getComputedStyle: (element: any) => any }
).getComputedStyle;
type Entry = { type Entry = {
selector: string; selector: string;
index: number; index: number;
@ -89,15 +91,26 @@ const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
"transition", "transition",
]); ]);
const propertyValue = (element: any, style: any, property: string): string => { const propertyValue = (
element: any,
style: any,
property: string,
): string => {
switch (property) { switch (property) {
case "scroll-height": return `${element.scrollHeight}px`; case "scroll-height":
case "scroll-width": return `${element.scrollWidth}px`; return `${element.scrollHeight}px`;
case "client-height": return `${element.clientHeight}px`; case "scroll-width":
case "client-width": return `${element.clientWidth}px`; return `${element.scrollWidth}px`;
case "offset-height": return `${element.offsetHeight}px`; case "client-height":
case "offset-width": return `${element.offsetWidth}px`; return `${element.clientHeight}px`;
default: return style.getPropertyValue(property) ?? ""; case "client-width":
return `${element.clientWidth}px`;
case "offset-height":
return `${element.offsetHeight}px`;
case "offset-width":
return `${element.offsetWidth}px`;
default:
return style.getPropertyValue(property) ?? "";
} }
}; };
@ -120,13 +133,17 @@ const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
} }
} }
} }
const anchors = [...document.querySelectorAll("[data-muddle-anchor]")] as any[]; const anchors = [
...document.querySelectorAll("[data-muddle-anchor]"),
] as any[];
if (!remeasure) { if (!remeasure) {
for (const element of anchors) { for (const element of anchors) {
const name = element.getAttribute("data-muddle-anchor") as string | null; const name = element.getAttribute("data-muddle-anchor") as string | null;
if (!name?.includes(".")) continue; if (!name?.includes(".")) continue;
const parts = name.split("."); const parts = name.split(".");
const groups = parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join(".")); const groups = parts
.slice(0, -1)
.map((_part, index) => parts.slice(0, index + 1).join("."));
element.setAttribute("data-muddle-group", groups.join(" ")); element.setAttribute("data-muddle-group", groups.join(" "));
} }
} }
@ -139,11 +156,16 @@ const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
const needle = `--muddle-relate-${key}-`; const needle = `--muddle-relate-${key}-`;
let index = text.indexOf(needle); let index = text.indexOf(needle);
while (index !== -1) { while (index !== -1) {
const property = /^[a-zA-Z][\w-]*/.exec(text.slice(index + needle.length))?.[0]; const property = /^[a-zA-Z][\w-]*/.exec(
text.slice(index + needle.length),
)?.[0];
if (property && !requests.get(key)?.includes(property)) { if (property && !requests.get(key)?.includes(property)) {
requests.set(key, [...(requests.get(key) ?? []), property]); requests.set(key, [...(requests.get(key) ?? []), property]);
} }
index = text.indexOf(needle, index + needle.length + (property?.length ?? 0)); index = text.indexOf(
needle,
index + needle.length + (property?.length ?? 0),
);
} }
} }
} }
@ -152,7 +174,8 @@ const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
if (!remeasure) { if (!remeasure) {
const selected = new Set<any>(); const selected = new Set<any>();
for (const selector of selectors) { for (const selector of selectors) {
for (const element of document.querySelectorAll(selector)) selected.add(element); for (const element of document.querySelectorAll(selector))
selected.add(element);
} }
for (const [index, element] of [...selected].entries()) { for (const [index, element] of [...selected].entries()) {
element.setAttribute("data-muddle-predicted", `predict-${index}`); element.setAttribute("data-muddle-predicted", `predict-${index}`);
@ -160,7 +183,9 @@ const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
} }
const entries: Entry[] = []; const entries: Entry[] = [];
for (const [index, element] of ([...document.querySelectorAll("[data-muddle-predicted]")] as any[]).entries()) { for (const [index, element] of (
[...document.querySelectorAll("[data-muddle-predicted]")] as any[]
).entries()) {
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();
const style = getComputedStyle(element); const style = getComputedStyle(element);
const entry: Entry = { const entry: Entry = {
@ -197,7 +222,10 @@ const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
return entries; return entries;
}; };
function predictionVariables(prediction: LayoutPrediction, variant: string): string { function predictionVariables(
prediction: LayoutPrediction,
variant: string,
): string {
return [ return [
`${prediction.selector} {`, `${prediction.selector} {`,
`--muddle-predicted-${variant}-width: ${prediction.width}px;`, `--muddle-predicted-${variant}-width: ${prediction.width}px;`,
@ -218,7 +246,10 @@ function predictionVariables(prediction: LayoutPrediction, variant: string): str
* The suffixed variables hold the raw measurement of the variant, the plain * The suffixed variables hold the raw measurement of the variant, the plain
* alias resolves to whichever variant applies at the current viewport. * alias resolves to whichever variant applies at the current viewport.
*/ */
function relationVariables(prediction: LayoutPrediction, variant: string): string { function relationVariables(
prediction: LayoutPrediction,
variant: string,
): string {
if (!prediction.anchor) return ""; if (!prediction.anchor) return "";
const key = prediction.anchor.replace(/[^a-zA-Z0-9_-]/g, "_"); const key = prediction.anchor.replace(/[^a-zA-Z0-9_-]/g, "_");
const prefix = `--muddle-relate-${key}-${variant}`; const prefix = `--muddle-relate-${key}-${variant}`;
@ -227,21 +258,33 @@ function relationVariables(prediction: LayoutPrediction, variant: string): strin
`${prefix}-y: ${prediction.y}px`, `${prefix}-y: ${prediction.y}px`,
`${prefix}-width: ${prediction.width}px`, `${prefix}-width: ${prediction.width}px`,
`${prefix}-height: ${prediction.height}px`, `${prefix}-height: ${prediction.height}px`,
prediction.backgroundColor ? `${prefix}-background-color: ${prediction.backgroundColor}` : "", prediction.backgroundColor
? `${prefix}-background-color: ${prediction.backgroundColor}`
: "",
prediction.color ? `${prefix}-color: ${prediction.color}` : "", prediction.color ? `${prefix}-color: ${prediction.color}` : "",
prediction.transition ? `${prefix}-transition: ${prediction.transition}` : "", prediction.transition
? `${prefix}-transition: ${prediction.transition}`
: "",
`--muddle-relate-${key}-x: var(${prefix}-x)`, `--muddle-relate-${key}-x: var(${prefix}-x)`,
`--muddle-relate-${key}-y: var(${prefix}-y)`, `--muddle-relate-${key}-y: var(${prefix}-y)`,
`--muddle-relate-${key}-width: var(${prefix}-width)`, `--muddle-relate-${key}-width: var(${prefix}-width)`,
`--muddle-relate-${key}-height: var(${prefix}-height)`, `--muddle-relate-${key}-height: var(${prefix}-height)`,
prediction.backgroundColor ? `--muddle-relate-${key}-background-color: var(${prefix}-background-color)` : "", prediction.backgroundColor
prediction.color ? `--muddle-relate-${key}-color: var(${prefix}-color)` : "", ? `--muddle-relate-${key}-background-color: var(${prefix}-background-color)`
prediction.transition ? `--muddle-relate-${key}-transition: var(${prefix}-transition)` : "", : "",
prediction.color
? `--muddle-relate-${key}-color: var(${prefix}-color)`
: "",
prediction.transition
? `--muddle-relate-${key}-transition: var(${prefix}-transition)`
: "",
] ]
.concat(Object.entries(prediction.relate ?? {}).flatMap(([property, value]) => [ .concat(
Object.entries(prediction.relate ?? {}).flatMap(([property, value]) => [
`${prefix}-${property}: ${value}`, `${prefix}-${property}: ${value}`,
`--muddle-relate-${key}-${property}: var(${prefix}-${property})`, `--muddle-relate-${key}-${property}: var(${prefix}-${property})`,
])) ]),
)
.filter(Boolean); .filter(Boolean);
return `:root {\n\t${values.join(";\n")};\n}`; return `:root {\n\t${values.join(";\n")};\n}`;
} }
@ -256,7 +299,9 @@ function selfVariables(prediction: LayoutPrediction): string {
if (!prediction.anchor) return ""; if (!prediction.anchor) return "";
const entries = Object.entries(prediction.self ?? {}); const entries = Object.entries(prediction.self ?? {});
if (entries.length === 0) return ""; if (entries.length === 0) return "";
const values = entries.map(([property, value]) => `--muddle-self-${property}: ${value}`); const values = entries.map(
([property, value]) => `--muddle-self-${property}: ${value}`,
);
return `[data-muddle-anchor="${prediction.anchor}"] {\n\t${values.join(";\n")};\n}`; return `[data-muddle-anchor="${prediction.anchor}"] {\n\t${values.join(";\n")};\n}`;
} }
@ -267,7 +312,9 @@ interface PredictionLayer {
predictions: LayoutPrediction[]; predictions: LayoutPrediction[];
} }
function reflectiveAnchorGroups(predictions: LayoutPrediction[]): ReflectiveAnchorGroup[] { function reflectiveAnchorGroups(
predictions: LayoutPrediction[],
): ReflectiveAnchorGroup[] {
const groups = new Map<string, string[]>(); const groups = new Map<string, string[]>();
for (const prediction of predictions) { for (const prediction of predictions) {
const anchor = prediction.anchor; const anchor = prediction.anchor;
@ -288,13 +335,21 @@ function reflectionCss(groups: ReflectiveAnchorGroup[]): string[] {
const key = group.name.replace(/[^a-zA-Z0-9_-]/g, "_"); const key = group.name.replace(/[^a-zA-Z0-9_-]/g, "_");
return [ return [
`[data-muddle-group~="${group.name}"] { --muddle-reflect-${key}-count: ${group.anchors.length}; }`, `[data-muddle-group~="${group.name}"] { --muddle-reflect-${key}-count: ${group.anchors.length}; }`,
...group.anchors.map((anchor, index) => `[data-muddle-anchor="${anchor}"] { --muddle-reflect-${key}-index: ${index}; }`), ...group.anchors.map(
(anchor, index) =>
`[data-muddle-anchor="${anchor}"] { --muddle-reflect-${key}-index: ${index}; }`,
),
]; ];
}); });
} }
function predictionCss(desktop: LayoutPrediction[], layers: PredictionLayer[], groups: ReflectiveAnchorGroup[]): string { function predictionCss(
const rules = (predictions: LayoutPrediction[], variant: string) => predictions.flatMap((entry) => [ desktop: LayoutPrediction[],
layers: PredictionLayer[],
groups: ReflectiveAnchorGroup[],
): string {
const rules = (predictions: LayoutPrediction[], variant: string) =>
predictions.flatMap((entry) => [
predictionVariables(entry, variant), predictionVariables(entry, variant),
relationVariables(entry, variant), relationVariables(entry, variant),
selfVariables(entry), selfVariables(entry),
@ -304,15 +359,24 @@ function predictionCss(desktop: LayoutPrediction[], layers: PredictionLayer[], g
"@media (min-width: 0) {", "@media (min-width: 0) {",
...rules(desktop, "desktop"), ...rules(desktop, "desktop"),
"}", "}",
...layers.flatMap((layer) => [`@media ${layer.media} {`, ...rules(layer.predictions, layer.name), "}"]), ...layers.flatMap((layer) => [
`@media ${layer.media} {`,
...rules(layer.predictions, layer.name),
"}",
]),
] ]
.filter((line) => line.trim().length > 0) .filter((line) => line.trim().length > 0)
.join("\n"); .join("\n");
} }
function mobileMediaQuery(html: string, explicit?: string): string { function mobileMediaQuery(html: string, explicit?: string): string {
if (explicit) return explicit.trim().startsWith("(") ? explicit.trim() : `(${explicit.trim()})`; if (explicit)
const candidates = [...html.matchAll(/@media\s*\(\s*max-width\s*:\s*([^)]+)\)/gi)] return explicit.trim().startsWith("(")
? explicit.trim()
: `(${explicit.trim()})`;
const candidates = [
...html.matchAll(/@media\s*\(\s*max-width\s*:\s*([^)]+)\)/gi),
]
.map((match) => match[1]!.trim()) .map((match) => match[1]!.trim())
.sort((left, right) => mediaWidth(right) - mediaWidth(left)); .sort((left, right) => mediaWidth(right) - mediaWidth(left));
return candidates[0] ? `(max-width: ${candidates[0]})` : "(max-width: 48rem)"; return candidates[0] ? `(max-width: ${candidates[0]})` : "(max-width: 48rem)";
@ -325,10 +389,18 @@ function mediaWidth(value: string): number {
return amount * (match[2]?.toLowerCase() === "px" ? 1 : 16); return amount * (match[2]?.toLowerCase() === "px" ? 1 : 16);
} }
function viewportForMobileMedia(query: string, fallback: { width: number; height: number }): { width: number; height: number } { function viewportForMobileMedia(
query: string,
fallback: { width: number; height: number },
): { width: number; height: number } {
const match = /max-width\s*:\s*([^)\s]+)/i.exec(query); const match = /max-width\s*:\s*([^)\s]+)/i.exec(query);
const width = match ? mediaWidth(match[1]!) : 0; const width = match ? mediaWidth(match[1]!) : 0;
return width > 0 ? { ...fallback, width: Math.min(fallback.width, Math.max(1, Math.floor(width - 1))) } : fallback; return width > 0
? {
...fallback,
width: Math.min(fallback.width, Math.max(1, Math.floor(width - 1))),
}
: fallback;
} }
const BREAKPOINTS: Record<BreakpointAcc, { width: number; media: string }> = { const BREAKPOINTS: Record<BreakpointAcc, { width: number; media: string }> = {
@ -338,9 +410,18 @@ const BREAKPOINTS: Record<BreakpointAcc, { width: number; media: string }> = {
SM: { width: 640, media: "(max-width: 40rem)" }, SM: { width: 640, media: "(max-width: 40rem)" },
}; };
function predictionLayers(options: LayoutPredictionOptions, html: string): Omit<PredictionLayer, "predictions">[] { function predictionLayers(
options: LayoutPredictionOptions,
html: string,
): Omit<PredictionLayer, "predictions">[] {
const requested = options.breakpointAcc const requested = options.breakpointAcc
? [...new Set(Array.isArray(options.breakpointAcc) ? options.breakpointAcc : [options.breakpointAcc])] ? [
...new Set(
Array.isArray(options.breakpointAcc)
? options.breakpointAcc
: [options.breakpointAcc],
),
]
: []; : [];
if (requested.length) { if (requested.length) {
return requested return requested
@ -352,14 +433,28 @@ function predictionLayers(options: LayoutPredictionOptions, html: string): Omit<
})); }));
} }
const media = mobileMediaQuery(html, options.mobileMediaQuery); const media = mobileMediaQuery(html, options.mobileMediaQuery);
return [{ name: "mobile", media, viewport: options.mobileViewport ?? viewportForMobileMedia(media, { width: 390, height: 844 }) }]; return [
{
name: "mobile",
media,
viewport:
options.mobileViewport ??
viewportForMobileMedia(media, { width: 390, height: 844 }),
},
];
} }
/** Reads the `@measure` targets emitted into stylesheets by the STCSS compiler. */ /** Reads the `@measure` targets emitted into stylesheets by the STCSS compiler. */
export function extractPredictSelectors(html: string): string[] { export function extractPredictSelectors(html: string): string[] {
return [...html.matchAll(/\/\*\s*muddle-predict\s*:([\s\S]*?)\*\//g)].flatMap((match) => (match[1] return [...html.matchAll(/\/\*\s*muddle-predict\s*:([\s\S]*?)\*\//g)].flatMap(
? match[1].split(/[|,]/).map((selector) => selector.trim()).filter(Boolean) (match) =>
: [])); match[1]
? match[1]
.split(/[|,]/)
.map((selector) => selector.trim())
.filter(Boolean)
: [],
);
} }
/** /**
@ -370,30 +465,69 @@ export function extractPredictSelectors(html: string): string[] {
* `<style>` block in the `<head>` of the returned page, so the shipped bundle * `<style>` block in the `<head>` of the returned page, so the shipped bundle
* contains no scripting at all. * contains no scripting at all.
*/ */
export async function predictLayout(html: string, options: LayoutPredictionOptions): Promise<{ html: string; predictions: LayoutPrediction[]; groups: ReflectiveAnchorGroup[] }> { export async function predictLayout(
html: string,
options: LayoutPredictionOptions,
): Promise<{
html: string;
predictions: LayoutPrediction[];
groups: ReflectiveAnchorGroup[];
}> {
let browser: import("playwright").Browser | undefined; let browser: import("playwright").Browser | undefined;
try { try {
const { chromium } = await import("playwright"); const { chromium } = await import("playwright");
browser = await chromium.launch({ headless: true }); browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } }); const page = await browser.newPage({
viewport: options.viewport ?? { width: 1440, height: 900 },
});
await page.setContent(html, { waitUntil: "networkidle" }); await page.setContent(html, { waitUntil: "networkidle" });
const layers = predictionLayers(options, html); const layers = predictionLayers(options, html);
// Disclosure bodies are measured in their open state. // Disclosure bodies are measured in their open state.
const openStates = await page.locator("details").evaluateAll((elements) => elements.map((element) => (element as unknown as { hasAttribute(name: string): boolean }).hasAttribute("open"))); const openStates = await page
.locator("details")
.evaluateAll((elements) =>
elements.map((element) =>
(
element as unknown as { hasAttribute(name: string): boolean }
).hasAttribute("open"),
),
);
await page.locator("details").evaluateAll((elements) => { await page.locator("details").evaluateAll((elements) => {
for (const element of elements) (element as unknown as { setAttribute(name: string, value: string): void }).setAttribute("open", ""); for (const element of elements)
(
element as unknown as {
setAttribute(name: string, value: string): void;
}
).setAttribute("open", "");
}); });
const selected = [...new Set([...(options.selectors ?? []), ...extractPredictSelectors(html), "[data-muddle-anchor]"])]; const selected = [
...new Set([
...(options.selectors ?? []),
...extractPredictSelectors(html),
"[data-muddle-anchor]",
]),
];
// First collect the independent anchor geometry. Relation-driven targets // First collect the independent anchor geometry. Relation-driven targets
// cannot have a meaningful size until those values exist in the page. // cannot have a meaningful size until those values exist in the page.
const initialDesktop = await page.evaluate(measureScript, { remeasure: false, selectors: selected }) as LayoutPrediction[]; const initialDesktop = (await page.evaluate(measureScript, {
const groups = options.reflective === false ? [] : reflectiveAnchorGroups(initialDesktop); remeasure: false,
selectors: selected,
})) as LayoutPrediction[];
const groups =
options.reflective === false
? []
: reflectiveAnchorGroups(initialDesktop);
const initialLayers: PredictionLayer[] = []; const initialLayers: PredictionLayer[] = [];
for (const layer of layers) { for (const layer of layers) {
await page.setViewportSize(layer.viewport); await page.setViewportSize(layer.viewport);
initialLayers.push({ ...layer, predictions: await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[] }); initialLayers.push({
...layer,
predictions: (await page.evaluate(measureScript, {
remeasure: true,
})) as LayoutPrediction[],
});
} }
// Propagate the measurements until the emitted relation stylesheet stops // Propagate the measurements until the emitted relation stylesheet stops
@ -405,12 +539,21 @@ export async function predictLayout(html: string, options: LayoutPredictionOptio
let settled = false; let settled = false;
for (let pass = 0; pass < (options.maxPasses ?? 8); pass++) { for (let pass = 0; pass < (options.maxPasses ?? 8); pass++) {
const provisional = await page.addStyleTag({ content: css }); const provisional = await page.addStyleTag({ content: css });
await page.setViewportSize(options.viewport ?? { width: 1440, height: 900 }); await page.setViewportSize(
const nextDesktop = await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[]; options.viewport ?? { width: 1440, height: 900 },
);
const nextDesktop = (await page.evaluate(measureScript, {
remeasure: true,
})) as LayoutPrediction[];
const nextLayers: PredictionLayer[] = []; const nextLayers: PredictionLayer[] = [];
for (const layer of measuredLayers) { for (const layer of measuredLayers) {
await page.setViewportSize(layer.viewport); await page.setViewportSize(layer.viewport);
nextLayers.push({ ...layer, predictions: await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[] }); nextLayers.push({
...layer,
predictions: (await page.evaluate(measureScript, {
remeasure: true,
})) as LayoutPrediction[],
});
} }
await provisional.evaluate((element) => element.remove()); await provisional.evaluate((element) => element.remove());
const nextCss = predictionCss(nextDesktop, nextLayers, groups); const nextCss = predictionCss(nextDesktop, nextLayers, groups);
@ -422,19 +565,34 @@ export async function predictLayout(html: string, options: LayoutPredictionOptio
} }
css = nextCss; css = nextCss;
} }
if (!settled) throw new Error(`relation prediction did not settle within ${options.maxPasses ?? 8} passes`); if (!settled)
throw new Error(
`relation prediction did not settle within ${options.maxPasses ?? 8} passes`,
);
css = predictionCss(desktop, measuredLayers, groups); css = predictionCss(desktop, measuredLayers, groups);
await page.locator("details").evaluateAll((elements, states) => { await page.locator("details").evaluateAll((elements, states) => {
elements.forEach((element, index) => { elements.forEach((element, index) => {
if (!states[index]) (element as unknown as { removeAttribute(name: string): void }).removeAttribute("open"); if (!states[index])
(
element as unknown as { removeAttribute(name: string): void }
).removeAttribute("open");
}); });
}, openStates); }, openStates);
const rendered = await page.content(); const rendered = await page.content();
return { html: rendered.replace("</head>", `<style>${css}</style></head>`), predictions: [...desktop, ...measuredLayers.flatMap((layer) => layer.predictions)], groups }; return {
html: rendered.replace("</head>", `<style>${css}</style></head>`),
predictions: [
...desktop,
...measuredLayers.flatMap((layer) => layer.predictions),
],
groups,
};
} catch (error) { } catch (error) {
if (options.strict) throw error; if (options.strict) throw error;
console.warn(`muddle prediction skipped: ${(error as Error).message.split("\n")[0]}`); console.warn(
`muddle prediction skipped: ${(error as Error).message.split("\n")[0]}`,
);
return { html, predictions: [], groups: [] }; return { html, predictions: [], groups: [] };
} finally { } finally {
await browser?.close(); await browser?.close();

View file

@ -41,7 +41,8 @@ export function serve(app: App, options: BunServerOptions = {}) {
const asset = await serveAsset(request, publicDir); const asset = await serveAsset(request, publicDir);
if (asset) return asset; if (asset) return asset;
} }
if (options.predictViews === false || request.method !== "GET") return app.fetch(request); if (options.predictViews === false || request.method !== "GET")
return app.fetch(request);
const key = request.url; const key = request.url;
let predicted = rendered.get(key); let predicted = rendered.get(key);
if (!predicted) { if (!predicted) {
@ -58,14 +59,28 @@ export function serve(app: App, options: BunServerOptions = {}) {
}); });
} }
async function renderPredictedView(app: App, request: Request): Promise<Response> { async function renderPredictedView(
app: App,
request: Request,
): Promise<Response> {
const response = await app.fetch(request); const response = await app.fetch(request);
const contentType = response.headers.get("content-type") ?? ""; const contentType = response.headers.get("content-type") ?? "";
if (!response.ok || !contentType.includes("text/html") || response.headers.has("set-cookie") || /no-store/i.test(response.headers.get("cache-control") ?? "")) return response; if (
!response.ok ||
!contentType.includes("text/html") ||
response.headers.has("set-cookie") ||
/no-store/i.test(response.headers.get("cache-control") ?? "")
)
return response;
const html = await response.text(); const html = await response.text();
const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); const hasRelations =
/data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html);
if (!hasRelations) return responseFromText(html, response); if (!hasRelations) return responseFromText(html, response);
const assetHtml = await inlinePredictionStyles(app, html, new URL(request.url).origin); const assetHtml = await inlinePredictionStyles(
app,
html,
new URL(request.url).origin,
);
const predicted = await predictLayout(assetHtml, { strict: true }); const predicted = await predictLayout(assetHtml, { strict: true });
return responseFromText(predicted.html, response); return responseFromText(predicted.html, response);
} }
@ -73,7 +88,11 @@ async function renderPredictedView(app: App, request: Request): Promise<Response
function responseFromText(html: string, source: Response): Response { function responseFromText(html: string, source: Response): Response {
const headers = new Headers(source.headers); const headers = new Headers(source.headers);
headers.delete("content-length"); headers.delete("content-length");
return new Response(html, { status: source.status, statusText: source.statusText, headers }); return new Response(html, {
status: source.status,
statusText: source.statusText,
headers,
});
} }
function cloneResponse(source: CachedResponse): Response { function cloneResponse(source: CachedResponse): Response {
@ -101,10 +120,16 @@ export async function serveAsset(
const url = new URL(request.url); const url = new URL(request.url);
if (request.method !== "GET" && request.method !== "HEAD") return undefined; if (request.method !== "GET" && request.method !== "HEAD") return undefined;
const requested = resolve(publicDir, decodeURIComponent(url.pathname).replace(/^[/\\]+/, "")); const requested = resolve(
publicDir,
decodeURIComponent(url.pathname).replace(/^[/\\]+/, ""),
);
const relativePath = relative(publicDir, requested); const relativePath = relative(publicDir, requested);
if (relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined; if (relativePath.startsWith("..") || isAbsolute(relativePath))
const path = existsSync(join(requested, "index.html")) ? join(requested, "index.html") : requested; return undefined;
const path = existsSync(join(requested, "index.html"))
? join(requested, "index.html")
: requested;
if (!existsSync(path)) return undefined; if (!existsSync(path)) return undefined;
let file: Uint8Array; let file: Uint8Array;

View file

@ -2,7 +2,10 @@ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import type { App } from "../core/app.ts"; import type { App } from "../core/app.ts";
import { getRenderAhead } from "../decorators/RenderAhead.ts"; import { getRenderAhead } from "../decorators/RenderAhead.ts";
import { getRenderRoutes, type RenderRouteSource } from "../decorators/RenderRoute.ts"; import {
getRenderRoutes,
type RenderRouteSource,
} from "../decorators/RenderRoute.ts";
import { ContentRegistry } from "../core/content.ts"; import { ContentRegistry } from "../core/content.ts";
import { predictLayout, type LayoutPredictionOptions } from "./prediction.ts"; import { predictLayout, type LayoutPredictionOptions } from "./prediction.ts";
@ -43,18 +46,33 @@ export async function prerender(
const results: PrerenderResult[] = []; const results: PrerenderResult[] = [];
const routes = new Set(options.routes ?? []); const routes = new Set(options.routes ?? []);
const content = app.resolvedConfig ? new ContentRegistry(app.resolvedConfig) : undefined; const content = app.resolvedConfig
? new ContentRegistry(app.resolvedConfig)
: undefined;
if (content) await content.load(); if (content) await content.load();
for (const route of app.router.list()) { for (const route of app.router.list()) {
if (route.method !== "GET") continue; if (route.method !== "GET") continue;
if (!route.path.includes(":")) routes.add(route.path); if (!route.path.includes(":")) routes.add(route.path);
else { else {
for (const source of getRenderRoutes(route.controller, route.methodName)) { for (const source of getRenderRoutes(
for (const path of resolveRenderRoute(source, content)) routes.add(path); route.controller,
route.methodName,
)) {
for (const path of resolveRenderRoute(source, content))
routes.add(path);
} }
const ahead = getRenderAhead(route.controller, route.methodName); const ahead = getRenderAhead(route.controller, route.methodName);
const values = ahead ? (typeof ahead === "function" ? await ahead() : ahead) : await options.discover?.(route.path); const values = ahead
for (const value of values ?? []) routes.add(value.startsWith("/") ? value : route.path.replace(/:[^/]+\??/, value)); ? typeof ahead === "function"
? await ahead()
: ahead
: await options.discover?.(route.path);
for (const value of values ?? [])
routes.add(
value.startsWith("/")
? value
: route.path.replace(/:[^/]+\??/, value),
);
} }
} }
const orderedRoutes = [...routes].sort(); const orderedRoutes = [...routes].sort();
@ -62,7 +80,11 @@ export async function prerender(
if (app.router.match("GET", route).kind === "not-found") { if (app.router.match("GET", route).kind === "not-found") {
throw new Error(`Cannot prerender unknown route "${route}"`); throw new Error(`Cannot prerender unknown route "${route}"`);
} }
options.progress?.({ current: index + 1, total: orderedRoutes.length, route }); options.progress?.({
current: index + 1,
total: orderedRoutes.length,
route,
});
const request = new Request(new URL(route, origin).toString()); const request = new Request(new URL(route, origin).toString());
const response = await app.fetch(request); const response = await app.fetch(request);
const contentType = response.headers.get("content-type") ?? ""; const contentType = response.headers.get("content-type") ?? "";
@ -83,16 +105,28 @@ export async function prerender(
// Relation anchors and STCSS prediction markers are static inputs to the // Relation anchors and STCSS prediction markers are static inputs to the
// layout pass. Run Playwright automatically so consumers never need to // layout pass. Run Playwright automatically so consumers never need to
// ship a geometry bridge merely because a page uses `$refer`. // ship a geometry bridge merely because a page uses `$refer`.
const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); const hasRelations =
/data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html);
if (options.predict || hasRelations) { if (options.predict || hasRelations) {
const assetHtml = await inlinePredictionStyles(app, html, origin); const assetHtml = await inlinePredictionStyles(app, html, origin);
html = (await predictLayout(assetHtml, options.predict ?? {})).html; html = (await predictLayout(assetHtml, options.predict ?? {})).html;
} }
const file = fileFor(stageDir, route); const file = fileFor(stageDir, route);
await mkdir(dirname(file), { recursive: true }); await mkdir(dirname(file), { recursive: true });
const asset = await app.transformAsset({ path: file, contentType, content: html }); const asset = await app.transformAsset({
const reports = await app.analyzePage({ route, html: await inlinePredictionStyles(app, html, origin) }); path: file,
if (Object.keys(reports).length) await writeFile(`${file}.analysis.json`, JSON.stringify(reports, null, 2)); contentType,
content: html,
});
const reports = await app.analyzePage({
route,
html: await inlinePredictionStyles(app, html, origin),
});
if (Object.keys(reports).length)
await writeFile(
`${file}.analysis.json`,
JSON.stringify(reports, null, 2),
);
await writeFile(file, asset.content); await writeFile(file, asset.content);
results.push({ route, file, status: response.status }); results.push({ route, file, status: response.status });
} }
@ -104,8 +138,13 @@ export async function prerender(
const response = await app.fetch(request); const response = await app.fetch(request);
const file = join(stageDir, route.path.replace(/^\//, "")); const file = join(stageDir, route.path.replace(/^\//, ""));
await mkdir(dirname(file), { recursive: true }); await mkdir(dirname(file), { recursive: true });
const contentType = response.headers.get("content-type") ?? "application/octet-stream"; const contentType =
const asset = await app.transformAsset({ path: file, contentType, content: new Uint8Array(await response.arrayBuffer()) }); response.headers.get("content-type") ?? "application/octet-stream";
const asset = await app.transformAsset({
path: file,
contentType,
content: new Uint8Array(await response.arrayBuffer()),
});
await writeFile(file, asset.content); await writeFile(file, asset.content);
results.push({ route: route.path, file, status: response.status }); results.push({ route: route.path, file, status: response.status });
} }
@ -122,20 +161,31 @@ export async function prerender(
} }
/** Inlines same-origin styles so Playwright can measure a complete document. */ /** Inlines same-origin styles so Playwright can measure a complete document. */
export async function inlinePredictionStyles(app: App, html: string, origin: string): Promise<string> { export async function inlinePredictionStyles(
app: App,
html: string,
origin: string,
): Promise<string> {
for (const match of [...html.matchAll(/<link\b[^>]*href="([^"]+)"[^>]*>/g)]) { for (const match of [...html.matchAll(/<link\b[^>]*href="([^"]+)"[^>]*>/g)]) {
const url = new URL(match[1]!, origin); const url = new URL(match[1]!, origin);
if (url.origin !== origin || !match[0].includes('rel="stylesheet"')) continue; if (url.origin !== origin || !match[0].includes('rel="stylesheet"'))
continue;
const response = await app.fetch(new Request(url.href)); const response = await app.fetch(new Request(url.href));
if (response.ok) html = html.replace(match[0], `<style>${await response.text()}</style>`); if (response.ok)
html = html.replace(match[0], `<style>${await response.text()}</style>`);
} }
return html; return html;
} }
function resolveRenderRoute(source: RenderRouteSource, content?: ContentRegistry): string[] { function resolveRenderRoute(
source: RenderRouteSource,
content?: ContentRegistry,
): string[] {
if (typeof source === "string") return [source]; if (typeof source === "string") return [source];
if (Array.isArray(source)) return source; if (Array.isArray(source)) return source;
return (content?.entries(source.collection) ?? []).map((entry) => source.path.replace(/:id\??/g, encodeURIComponent(entry.id))); return (content?.entries(source.collection) ?? []).map((entry) =>
source.path.replace(/:id\??/g, encodeURIComponent(entry.id)),
);
} }
/** Path of the html file of a route. */ /** Path of the html file of a route. */

View file

@ -13,7 +13,11 @@ export type NodeHandler = (
export async function setupVercel(root: string): Promise<string> { export async function setupVercel(root: string): Promise<string> {
const path = join(resolve(root), "vercel.json"); const path = join(resolve(root), "vercel.json");
if (existsSync(path)) return path; if (existsSync(path)) return path;
await writeFile(path, `${JSON.stringify({ cleanUrls: true }, null, 2)}\n`, "utf8"); await writeFile(
path,
`${JSON.stringify({ cleanUrls: true }, null, 2)}\n`,
"utf8",
);
return path; return path;
} }

82
cli.ts
View file

@ -9,12 +9,22 @@ import { loadConfig } from "./core/config.ts";
import { setupWrangler } from "./adapters/cloudflare-wrangler.ts"; import { setupWrangler } from "./adapters/cloudflare-wrangler.ts";
import { setupVercel } from "./adapters/vercel-vercel.ts"; import { setupVercel } from "./adapters/vercel-vercel.ts";
import { backportProject } from "./core/backport.ts"; import { backportProject } from "./core/backport.ts";
import { createTemplate, muddleTemplates, type MuddleTemplate } from "./core/templates.ts"; import {
createTemplate,
muddleTemplates,
type MuddleTemplate,
} from "./core/templates.ts";
function progress(event: { current: number; total: number; route: string }): void { function progress(event: {
current: number;
total: number;
route: string;
}): void {
const width = 20; const width = 20;
const filled = Math.round((event.current / Math.max(1, event.total)) * width); const filled = Math.round((event.current / Math.max(1, event.total)) * width);
console.log(`[${"#".repeat(filled)}${".".repeat(width - filled)}] ${event.current}/${event.total} ${event.route}`); console.log(
`[${"#".repeat(filled)}${".".repeat(width - filled)}] ${event.current}/${event.total} ${event.route}`,
);
} }
async function dev(entry: string, root: string, port: number): Promise<void> { async function dev(entry: string, root: string, port: number): Promise<void> {
@ -36,24 +46,34 @@ async function main(): Promise<void> {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const command = args[0] ?? "help"; const command = args[0] ?? "help";
const portFlag = args.indexOf("--port"); const portFlag = args.indexOf("--port");
const requestedPort = portFlag === -1 ? undefined : Number(args[portFlag + 1]); const requestedPort =
const root = args.slice(1).find((argument, index, values) => { portFlag === -1 ? undefined : Number(args[portFlag + 1]);
const root =
args.slice(1).find((argument, index, values) => {
if (argument.startsWith("--")) return false; if (argument.startsWith("--")) return false;
return values[index - 1] !== "--port"; return values[index - 1] !== "--port";
}) ?? process.cwd(); }) ?? process.cwd();
if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65_535)) { if (
requestedPort !== undefined &&
(!Number.isInteger(requestedPort) ||
requestedPort < 1 ||
requestedPort > 65_535)
) {
throw new Error("--port must be an integer from 1 through 65535"); throw new Error("--port must be an integer from 1 through 65535");
} }
if (command === "typegen") { if (command === "typegen") {
const result = await generate({ root }); const result = await generate({ root });
for (const file of result.written) console.log(`muddle generated ${relative(process.cwd(), file)}`); for (const file of result.written)
console.log(`muddle generated ${relative(process.cwd(), file)}`);
return; return;
} }
if (command === "backport") { if (command === "backport") {
const source = args[1] ? resolve(args[1]) : process.cwd(); const source = args[1] ? resolve(args[1]) : process.cwd();
const destination = args[2] ? resolve(args[2]) : resolve(source, "muddle"); const destination = args[2] ? resolve(args[2]) : resolve(source, "muddle");
const result = await backportProject(source, destination); const result = await backportProject(source, destination);
console.log(`muddle backported ${result.source} project to ${result.output} (${result.files.length} files)`); console.log(
`muddle backported ${result.source} project to ${result.output} (${result.files.length} files)`,
);
return; return;
} }
if (command === "templates") { if (command === "templates") {
@ -63,9 +83,17 @@ async function main(): Promise<void> {
console.log("Usage: muddle templates <docs|minimal> [destination]"); console.log("Usage: muddle templates <docs|minimal> [destination]");
return; return;
} }
if (!muddleTemplates.includes(name)) throw new Error(`Unknown template ${name}. Choose one of: ${muddleTemplates.join(", ")}`); if (!muddleTemplates.includes(name))
const result = await createTemplate(name, args[2] ? resolve(args[2]) : resolve(process.cwd(), name)); throw new Error(
console.log(`muddle created ${result.name} template at ${result.output} (${result.files.length} files)`); `Unknown template ${name}. Choose one of: ${muddleTemplates.join(", ")}`,
);
const result = await createTemplate(
name,
args[2] ? resolve(args[2]) : resolve(process.cwd(), name),
);
console.log(
`muddle created ${result.name} template at ${result.output} (${result.files.length} files)`,
);
return; return;
} }
if (command === "repo") { if (command === "repo") {
@ -91,8 +119,12 @@ async function main(): Promise<void> {
} }
if (command === "fmt") { if (command === "fmt") {
const check = process.argv.includes("--check"); const check = process.argv.includes("--check");
const changed = await format(resolve(root === "--check" ? process.cwd() : root), { check }); const changed = await format(
for (const file of changed) console.log(`${check ? "Unformatted" : "Formatted"} ${file}`); resolve(root === "--check" ? process.cwd() : root),
{ check },
);
for (const file of changed)
console.log(`${check ? "Unformatted" : "Formatted"} ${file}`);
if (check && changed.length) process.exitCode = 1; if (check && changed.length) process.exitCode = 1;
return; return;
} }
@ -101,23 +133,35 @@ async function main(): Promise<void> {
const config = await loadConfig(target); const config = await loadConfig(target);
const entry = resolve(target, config.entry ?? "index.ts"); const entry = resolve(target, config.entry ?? "index.ts");
if (!existsSync(entry)) throw new Error(`No index.ts found in ${root}`); if (!existsSync(entry)) throw new Error(`No index.ts found in ${root}`);
if (config.adapter?.name === "cloudflare-wrangler") await setupWrangler(target); if (config.adapter?.name === "cloudflare-wrangler")
await setupWrangler(target);
if (config.adapter?.name === "vercel-vercel") await setupVercel(target); if (config.adapter?.name === "vercel-vercel") await setupVercel(target);
const project = await import(entry) as { build?: (options?: { progress?: typeof progress }) => Promise<void>; app?: unknown }; const project = (await import(entry)) as {
build?: (options?: { progress?: typeof progress }) => Promise<void>;
app?: unknown;
};
if (command === "build") { if (command === "build") {
if (!project.build) throw new Error("Project must export build() for muddle build"); if (!project.build)
throw new Error("Project must export build() for muddle build");
await project.build({ progress }); await project.build({ progress });
return; return;
} else if (command === "dev") { } else if (command === "dev") {
if (!project.app || typeof (project.app as { fetch?: unknown }).fetch !== "function") { if (
throw new Error("Project entry must export an app with fetch(request) for muddle dev"); !project.app ||
typeof (project.app as { fetch?: unknown }).fetch !== "function"
) {
throw new Error(
"Project entry must export an app with fetch(request) for muddle dev",
);
} }
await dev(entry, target, requestedPort ?? config.adapter?.port ?? 3000); await dev(entry, target, requestedPort ?? config.adapter?.port ?? 3000);
return; return;
} }
return; return;
} }
console.log("muddle <typegen|repo|fmt|build|dev|backport|templates> [project-root] [--port number]"); console.log(
"muddle <typegen|repo|fmt|build|dev|backport|templates> [project-root] [--port number]",
);
} }
await main(); await main();

View file

@ -81,7 +81,9 @@ export interface MountableViews {
/** Registers a named view component. */ /** Registers a named view component. */
component( component(
name: string, name: string,
component: { render: (props: unknown, context: unknown) => string | Promise<string> }, component: {
render: (props: unknown, context: unknown) => string | Promise<string>;
},
): unknown; ): unknown;
} }
@ -91,7 +93,11 @@ export interface Mounts {
views?(views: MountableViews): void | Promise<void>; views?(views: MountableViews): void | Promise<void>;
/** Adds routes, e.g. assets, fonts and endpoints. */ /** Adds routes, e.g. assets, fonts and endpoints. */
routes?( routes?(
route: (method: string, path: string, handler: (request: Request) => Response | Promise<Response>) => void, route: (
method: string,
path: string,
handler: (request: Request) => Response | Promise<Response>,
) => void,
): void | Promise<void>; ): void | Promise<void>;
/** Served inline stylesheets by route. */ /** Served inline stylesheets by route. */
styles?: Record<string, string | (() => string | Promise<string>)>; styles?: Record<string, string | (() => string | Promise<string>)>;

View file

@ -323,16 +323,28 @@ export async function generate(
await mkdir(join(out, "orm"), { recursive: true }); await mkdir(join(out, "orm"), { recursive: true });
const minifyFile = join(out, "minify.ts"); const minifyFile = join(out, "minify.ts");
await writeFile(minifyFile, `export type Simplify<T> = { [K in keyof T]: T[K] } & {};\nexport type Minify<T> = Simplify<T>;\n`, "utf8"); await writeFile(
minifyFile,
`export type Simplify<T> = { [K in keyof T]: T[K] } & {};\nexport type Minify<T> = Simplify<T>;\n`,
"utf8",
);
const viewsFile = join(out, "views.ts"); const viewsFile = join(out, "views.ts");
await writeFile(viewsFile, renderViewContracts(views), "utf8"); await writeFile(viewsFile, renderViewContracts(views), "utf8");
const tablesFile = join(out, "orm", "tables.ts"); const tablesFile = join(out, "orm", "tables.ts");
await writeFile(tablesFile, renderTableContracts(tables), "utf8"); await writeFile(tablesFile, renderTableContracts(tables), "utf8");
const indexFile = join(out, "index.ts"); const indexFile = join(out, "index.ts");
await writeFile(indexFile, `export * from "./views.ts";\nexport * from "./orm/tables.ts";\nexport * from "./minify.ts";\n`, "utf8"); await writeFile(
indexFile,
`export * from "./views.ts";\nexport * from "./orm/tables.ts";\nexport * from "./minify.ts";\n`,
"utf8",
);
return { views, tables, written: [minifyFile, viewsFile, tablesFile, indexFile] }; return {
views,
tables,
written: [minifyFile, viewsFile, tablesFile, indexFile],
};
} }
/** Collects the tables of the project by importing the model files. */ /** Collects the tables of the project by importing the model files. */
@ -384,7 +396,20 @@ export async function collectTables(
const module = (await import(file)) as Record<string, unknown>; const module = (await import(file)) as Record<string, unknown>;
for (const exported of Object.values(module)) { for (const exported of Object.values(module)) {
if (typeof exported !== "function") continue; if (typeof exported !== "function") continue;
const metadata = getMetadata("muddle:orm:table", exported as object) as { name: string; fields: Map<string, { property: string; column: string; type: string; nullable: boolean }> } | undefined; const metadata = getMetadata("muddle:orm:table", exported as object) as
| {
name: string;
fields: Map<
string,
{
property: string;
column: string;
type: string;
nullable: boolean;
}
>;
}
| undefined;
if (!metadata) continue; if (!metadata) continue;
tables.push({ tables.push({
name: metadata.name, name: metadata.name,
@ -402,7 +427,9 @@ export async function collectTables(
} }
async function listFiles(dir: string, suffix: string): Promise<string[]> { async function listFiles(dir: string, suffix: string): Promise<string[]> {
const entries = existsSync(dir) ? await readdir(dir, { withFileTypes: true }) : []; const entries = existsSync(dir)
? await readdir(dir, { withFileTypes: true })
: [];
const files: string[] = []; const files: string[] = [];
for (const entry of entries) { for (const entry of entries) {
const path = join(dir, entry.name); const path = join(dir, entry.name);

View file

@ -1,6 +1,8 @@
import type { Minify } from "./minify.ts"; import type { Minify } from "./minify.ts";
/** Fallback view contracts. Project contracts are generated under `_muddle/contracts`. */ /** Fallback view contracts. Project contracts are generated under `_muddle/contracts`. */
export interface ViewProps { [path: string]: Record<string, unknown> } export interface ViewProps {
[path: string]: Record<string, unknown>;
}
export type ViewPath = string; export type ViewPath = string;
export type ViewPropsOf<P extends ViewPath> = Minify<ViewProps[P]>; export type ViewPropsOf<P extends ViewPath> = Minify<ViewProps[P]>;

View file

@ -23,7 +23,10 @@ export interface ViewRenderer {
context: RequestContext, context: RequestContext,
): Promise<string | RenderedView> | string | RenderedView; ): Promise<string | RenderedView> | string | RenderedView;
/** Registry of named components, mount targets of the integrations. */ /** Registry of named components, mount targets of the integrations. */
components?: Map<string, { name: string; component: unknown; deferred: boolean }>; components?: Map<
string,
{ name: string; component: unknown; deferred: boolean }
>;
} }
export interface AppOptions { export interface AppOptions {
@ -71,13 +74,19 @@ export class App {
} }
async transformAsset(asset: BuildAsset): Promise<BuildAsset> { async transformAsset(asset: BuildAsset): Promise<BuildAsset> {
for (const integration of this.integrations) if (integration.transform) asset = await integration.transform(asset); for (const integration of this.integrations)
if (integration.transform) asset = await integration.transform(asset);
return asset; return asset;
} }
async analyzePage(page: { route: string; html: string }): Promise<Record<string, unknown>> { async analyzePage(page: {
route: string;
html: string;
}): Promise<Record<string, unknown>> {
const reports: Record<string, unknown> = {}; const reports: Record<string, unknown> = {};
for (const integration of this.integrations) if (integration.analyze) reports[integration.name] = await integration.analyze(page); for (const integration of this.integrations)
if (integration.analyze)
reports[integration.name] = await integration.analyze(page);
return reports; return reports;
} }
@ -101,19 +110,31 @@ export class App {
await integration.setup?.(integrationContext); await integration.setup?.(integrationContext);
for (const mount of integration.mounts ?? []) { for (const mount of integration.mounts ?? []) {
if (mount.views && this.views) { if (mount.views && this.views) {
await mount.views(this.views as unknown as Parameters<NonNullable<Mounts["views"]>>[0]); await mount.views(
this.views as unknown as Parameters<
NonNullable<Mounts["views"]>
>[0],
);
} }
if (mount.routes) { if (mount.routes) {
await mount.routes((method, path, handler) => await mount.routes((method, path, handler) =>
this.router.raw(method, path, (_request, _context) => handler(_request)), this.router.raw(method, path, (_request, _context) =>
handler(_request),
),
); );
} }
if (mount.styles) { if (mount.styles) {
for (const [path, value] of Object.entries(mount.styles)) { for (const [path, value] of Object.entries(mount.styles)) {
this.router.raw("GET", path, async () => this.router.raw(
new Response(typeof value === "function" ? await value() : value, { "GET",
path,
async () =>
new Response(
typeof value === "function" ? await value() : value,
{
headers: { "content-type": "text/css; charset=utf-8" }, headers: { "content-type": "text/css; charset=utf-8" },
}), },
),
); );
} }
} }
@ -149,7 +170,8 @@ export class App {
Object.assign(context.locals, this.server.locals); Object.assign(context.locals, this.server.locals);
const response = await this.handle(request, context); const response = await this.handle(request, context);
const fetchResponse = toFetchResponse(response); const fetchResponse = toFetchResponse(response);
for (const [name, value] of Object.entries(this.server.headers)) fetchResponse.headers.set(name, value); for (const [name, value] of Object.entries(this.server.headers))
fetchResponse.headers.set(name, value);
return fetchResponse; return fetchResponse;
} }
@ -158,7 +180,8 @@ export class App {
request: Request, request: Request,
context: RequestContext, context: RequestContext,
): Promise<HttpResponse> { ): Promise<HttpResponse> {
for (const integration of this.integrations) await integration.request?.(context); for (const integration of this.integrations)
await integration.request?.(context);
context.body = await parseRequestBody(request); context.body = await parseRequestBody(request);
context.form = toFormFields( context.form = toFormFields(
context.body && context.body &&

View file

@ -31,13 +31,15 @@ const ASSET_EXTENSIONS = new Set([
export function detectBackportSource(root: string): BackportSource { export function detectBackportSource(root: string): BackportSource {
if ( if (
existsSync(join(root, "config.toml")) || existsSync(join(root, "config.toml")) ||
existsSync(join(root, "content")) && existsSync(join(root, "templates")) (existsSync(join(root, "content")) && existsSync(join(root, "templates")))
) return "zola"; )
return "zola";
if ( if (
existsSync(join(root, "astro.config.mjs")) || existsSync(join(root, "astro.config.mjs")) ||
existsSync(join(root, "astro.config.ts")) || existsSync(join(root, "astro.config.ts")) ||
existsSync(join(root, "src", "pages")) existsSync(join(root, "src", "pages"))
) return "astro"; )
return "astro";
throw new Error(`Could not identify a Zola or Astro project in ${root}`); throw new Error(`Could not identify a Zola or Astro project in ${root}`);
} }
@ -60,9 +62,20 @@ export async function backportProject(
files.push(relative(outputRoot, path).replaceAll("\\", "/")); files.push(relative(outputRoot, path).replaceAll("\\", "/"));
}; };
const contentRoot = join(inputRoot, source === "astro" ? "src/content" : "content"); const contentRoot = join(
await writeProjectShell(outputRoot, write, await collectionNames(contentRoot), source === "zola" ? await zolaSiteConfig(join(inputRoot, "config.toml")) : {}); inputRoot,
const mappings = source === "astro" source === "astro" ? "src/content" : "content",
);
await writeProjectShell(
outputRoot,
write,
await collectionNames(contentRoot),
source === "zola"
? await zolaSiteConfig(join(inputRoot, "config.toml"))
: {},
);
const mappings =
source === "astro"
? [ ? [
{ from: "src/pages", to: "modules/app/views" }, { from: "src/pages", to: "modules/app/views" },
{ from: "src/layouts", to: "modules/app/views/layouts" }, { from: "src/layouts", to: "modules/app/views/layouts" },
@ -81,7 +94,13 @@ export async function backportProject(
for (const mapping of mappings) { for (const mapping of mappings) {
const folder = join(inputRoot, mapping.from); const folder = join(inputRoot, mapping.from);
if (existsSync(folder)) { if (existsSync(folder)) {
await convertFolder(folder, join(outputRoot, mapping.to), write, source, folder); await convertFolder(
folder,
join(outputRoot, mapping.to),
write,
source,
folder,
);
} }
} }
return { source, output: outputRoot, files }; return { source, output: outputRoot, files };
@ -101,7 +120,10 @@ async function zolaSiteConfig(path: string): Promise<Record<string, string>> {
const source = await readFile(path, "utf8"); const source = await readFile(path, "utf8");
const values: Record<string, string> = {}; const values: Record<string, string> = {};
for (const key of ["title", "base_url", "description", "default_language"]) { for (const key of ["title", "base_url", "description", "default_language"]) {
const match = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']\\s*$`, "m").exec(source); const match = new RegExp(
`^\\s*${key}\\s*=\\s*["']([^"']*)["']\\s*$`,
"m",
).exec(source);
if (match?.[1] !== undefined) values[key] = match[1]; if (match?.[1] !== undefined) values[key] = match[1];
} }
return values; return values;
@ -162,8 +184,10 @@ async function convertFolder(
} }
const extension = extname(entry.name).toLowerCase(); const extension = extname(entry.name).toLowerCase();
if ( if (
extension === ".astro" || extension === ".html" || extension === ".astro" ||
extension === ".htm" || extension === ".tera" extension === ".html" ||
extension === ".htm" ||
extension === ".tera"
) { ) {
await write( await write(
to.replace(/\.(astro|html?|tera)$/i, ".spug"), to.replace(/\.(astro|html?|tera)$/i, ".spug"),
@ -172,7 +196,10 @@ async function convertFolder(
: zolaToSpug(await readFile(from, "utf8")), : zolaToSpug(await readFile(from, "utf8")),
); );
} else if (extension === ".tsx" || extension === ".jsx") { } else if (extension === ".tsx" || extension === ".jsx") {
await write(to.replace(/\.(tsx|jsx)$/i, ".spug"), reactToSpug(await readFile(from, "utf8"), from)); await write(
to.replace(/\.(tsx|jsx)$/i, ".spug"),
reactToSpug(await readFile(from, "utf8"), from),
);
} else if (extension === ".css") { } else if (extension === ".css") {
await write( await write(
to.replace(/\.css$/i, ".stcss"), to.replace(/\.css$/i, ".stcss"),
@ -184,7 +211,8 @@ async function convertFolder(
await readFile(from, "utf8"), await readFile(from, "utf8"),
); );
} else if ( } else if (
extension === ".md" || extension === ".markdown" || extension === ".md" ||
extension === ".markdown" ||
ASSET_EXTENSIONS.has(extension) ASSET_EXTENSIONS.has(extension)
) { ) {
await mkdir(dirname(to), { recursive: true }); await mkdir(dirname(to), { recursive: true });
@ -193,16 +221,36 @@ async function convertFolder(
} }
} }
export function reactToSpug(source: string, filename = "component.tsx"): string { export function reactToSpug(
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); source: string,
filename = "component.tsx",
): string {
const file = ts.createSourceFile(
filename,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const analysis = analyzeReactComponent(source, filename); const analysis = analyzeReactComponent(source, filename);
const imports = new Set<string>(); const imports = new Set<string>();
let root: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined; let root:
| ts.JsxChild
| ts.JsxElement
| ts.JsxSelfClosingElement
| ts.JsxFragment
| undefined;
const visit = (node: ts.Node): void => { const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) { if (
for (const element of node.importClause.namedBindings.elements) imports.add(element.name.text); ts.isImportDeclaration(node) &&
node.importClause?.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings)
) {
for (const element of node.importClause.namedBindings.elements)
imports.add(element.name.text);
} }
if (ts.isImportDeclaration(node) && node.importClause?.name) imports.add(node.importClause.name.text); if (ts.isImportDeclaration(node) && node.importClause?.name)
imports.add(node.importClause.name.text);
if (ts.isReturnStatement(node) && node.expression && !root) { if (ts.isReturnStatement(node) && node.expression && !root) {
const expression = unwrapJsxExpression(node.expression); const expression = unwrapJsxExpression(node.expression);
if (isJsxNode(expression)) root = expression; if (isJsxNode(expression)) root = expression;
@ -215,8 +263,11 @@ export function reactToSpug(source: string, filename = "component.tsx"): string
}; };
visit(file); visit(file);
if (!root) throw new Error(`No JSX return value found in ${filename}`); if (!root) throw new Error(`No JSX return value found in ${filename}`);
const aot = analysis.state.map((state) => ` const ${state.name} = ${state.initial};`); const aot = analysis.state.map(
const script = aot.length || analysis.hooks.length (state) => ` const ${state.name} = ${state.initial};`,
);
const script =
aot.length || analysis.hooks.length
? `javascript!\n // React hooks lowered during static component composition: ${analysis.hooks.join(", ") || "none"}\n${aot.join("\n")}\nend\n` ? `javascript!\n // React hooks lowered during static component composition: ${analysis.hooks.join(", ") || "none"}\n${aot.join("\n")}\nend\n`
: ""; : "";
return `${script}${jsxToSpug(root, imports).trim()}\n`; return `${script}${jsxToSpug(root, imports).trim()}\n`;
@ -228,19 +279,42 @@ export interface ReactHookAnalysis {
} }
/** Finds React hooks and extracts AOT-safe initial state from a component. */ /** Finds React hooks and extracts AOT-safe initial state from a component. */
export function analyzeReactComponent(source: string, filename = "component.tsx"): ReactHookAnalysis { export function analyzeReactComponent(
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); source: string,
filename = "component.tsx",
): ReactHookAnalysis {
const file = ts.createSourceFile(
filename,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const hooks = new Set<string>(); const hooks = new Set<string>();
const state: { name: string; initial: string }[] = []; const state: { name: string; initial: string }[] = [];
const visit = (node: ts.Node): void => { const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) { if (ts.isCallExpression(node)) {
const name = ts.isIdentifier(node.expression) ? node.expression.text const name = ts.isIdentifier(node.expression)
: ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ""; ? node.expression.text
: ts.isPropertyAccessExpression(node.expression)
? node.expression.name.text
: "";
if (/^use[A-Z]/.test(name)) hooks.add(name); if (/^use[A-Z]/.test(name)) hooks.add(name);
if (name === "useState" && ts.isVariableDeclaration(node.parent) && ts.isArrayBindingPattern(node.parent.name)) { if (
name === "useState" &&
ts.isVariableDeclaration(node.parent) &&
ts.isArrayBindingPattern(node.parent.name)
) {
const binding = node.parent.name.elements[0]; const binding = node.parent.name.elements[0];
if (binding && ts.isBindingElement(binding) && ts.isIdentifier(binding.name)) { if (
state.push({ name: binding.name.text, initial: node.arguments[0]?.getText() ?? "undefined" }); binding &&
ts.isBindingElement(binding) &&
ts.isIdentifier(binding.name)
) {
state.push({
name: binding.name.text,
initial: node.arguments[0]?.getText() ?? "undefined",
});
} }
} }
} }
@ -251,14 +325,26 @@ export function analyzeReactComponent(source: string, filename = "component.tsx"
} }
function unwrapJsxExpression(node: ts.Expression): ts.Expression { function unwrapJsxExpression(node: ts.Expression): ts.Expression {
return ts.isParenthesizedExpression(node) ? unwrapJsxExpression(node.expression) : node; return ts.isParenthesizedExpression(node)
? unwrapJsxExpression(node.expression)
: node;
} }
function isJsxNode(node: ts.Node): node is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment { function isJsxNode(
return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node); node: ts.Node,
): node is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment {
return (
ts.isJsxElement(node) ||
ts.isJsxSelfClosingElement(node) ||
ts.isJsxFragment(node)
);
} }
function jsxToSpug(node: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment, imports: Set<string>, depth = 0): string { function jsxToSpug(
node: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
imports: Set<string>,
depth = 0,
): string {
const pad = " ".repeat(depth); const pad = " ".repeat(depth);
if (ts.isJsxText(node)) { if (ts.isJsxText(node)) {
const text = node.getText().replace(/\s+/g, " ").trim(); const text = node.getText().replace(/\s+/g, " ").trim();
@ -268,22 +354,40 @@ function jsxToSpug(node: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement
if (!node.expression) return ""; if (!node.expression) return "";
return `${pad}= ${node.expression.getText()}`; return `${pad}= ${node.expression.getText()}`;
} }
if (ts.isJsxFragment(node)) return node.children.map((child) => jsxToSpug(child, imports, depth)).filter(Boolean).join("\n"); if (ts.isJsxFragment(node))
return node.children
.map((child) => jsxToSpug(child, imports, depth))
.filter(Boolean)
.join("\n");
const opening = ts.isJsxElement(node) ? node.openingElement : node; const opening = ts.isJsxElement(node) ? node.openingElement : node;
const rawName = opening.tagName.getText(); const rawName = opening.tagName.getText();
const name = rawName === "Comp" ? "button" : rawName; const name = rawName === "Comp" ? "button" : rawName;
const component = ( /^[A-Z]/.test(name) || imports.has(name)) && rawName !== "Comp"; const component =
(/^[A-Z]/.test(name) || imports.has(name)) && rawName !== "Comp";
const attributes = jsxAttributes(opening.attributes.properties, component); const attributes = jsxAttributes(opening.attributes.properties, component);
const line = component ? `${pad}component ${kebab(name)}` : `${pad}${name}${attributes.inline}`; const line = component
const props = component && attributes.props.length ? `\n${attributes.props.map((property) => `${pad} ${property}`).join("\n")}` : ""; ? `${pad}component ${kebab(name)}`
: `${pad}${name}${attributes.inline}`;
const props =
component && attributes.props.length
? `\n${attributes.props.map((property) => `${pad} ${property}`).join("\n")}`
: "";
if (ts.isJsxSelfClosingElement(node)) { if (ts.isJsxSelfClosingElement(node)) {
return depth === 0 ? `${line}${props}\n${pad} if content\n${pad} != await content()` : `${line}${props}`; return depth === 0
? `${line}${props}\n${pad} if content\n${pad} != await content()`
: `${line}${props}`;
} }
const children = node.children.map((child) => jsxToSpug(child, imports, depth + 1)).filter(Boolean).join("\n"); const children = node.children
.map((child) => jsxToSpug(child, imports, depth + 1))
.filter(Boolean)
.join("\n");
return [line + props, children].filter(Boolean).join("\n"); return [line + props, children].filter(Boolean).join("\n");
} }
function jsxAttributes(properties: ts.NodeArray<ts.JsxAttributeLike>, component = false): { inline: string; props: string[] } { function jsxAttributes(
properties: ts.NodeArray<ts.JsxAttributeLike>,
component = false,
): { inline: string; props: string[] } {
const inline: string[] = []; const inline: string[] = [];
const props: string[] = []; const props: string[] = [];
for (const property of properties) { for (const property of properties) {
@ -291,13 +395,22 @@ function jsxAttributes(properties: ts.NodeArray<ts.JsxAttributeLike>, component
const name = property.name.getText(); const name = property.name.getText();
if (name === "ref") continue; if (name === "ref") continue;
const initializer = property.initializer; const initializer = property.initializer;
if (!initializer) { inline.push(`${name}=true`); continue; } if (!initializer) {
const value = ts.isStringLiteral(initializer) ? JSON.stringify(initializer.text) inline.push(`${name}=true`);
: ts.isJsxExpression(initializer) && initializer.expression ? `(${initializer.expression.getText()})` continue;
}
const value = ts.isStringLiteral(initializer)
? JSON.stringify(initializer.text)
: ts.isJsxExpression(initializer) && initializer.expression
? `(${initializer.expression.getText()})`
: JSON.stringify(initializer.getText()); : JSON.stringify(initializer.getText());
const normalized = name === "className" ? "class" : name; const normalized = name === "className" ? "class" : name;
const classValue = normalized === "class" && ts.isJsxExpression(initializer) && initializer.expression const classValue =
? staticClassValue(initializer.expression) ?? JSON.stringify("muddle-react") normalized === "class" &&
ts.isJsxExpression(initializer) &&
initializer.expression
? (staticClassValue(initializer.expression) ??
JSON.stringify("muddle-react"))
: value; : value;
if (component) props.push(`${normalized}: ${classValue}`); if (component) props.push(`${normalized}: ${classValue}`);
else inline.push(`${normalized}=${classValue}`); else inline.push(`${normalized}=${classValue}`);
@ -308,12 +421,17 @@ function jsxAttributes(properties: ts.NodeArray<ts.JsxAttributeLike>, component
function staticClassValue(expression: ts.Expression): string | undefined { function staticClassValue(expression: ts.Expression): string | undefined {
if (ts.isStringLiteral(expression)) return JSON.stringify(expression.text); if (ts.isStringLiteral(expression)) return JSON.stringify(expression.text);
if (!ts.isCallExpression(expression)) return undefined; if (!ts.isCallExpression(expression)) return undefined;
const classes = expression.arguments.flatMap((argument) => ts.isStringLiteral(argument) ? [argument.text] : []); const classes = expression.arguments.flatMap((argument) =>
ts.isStringLiteral(argument) ? [argument.text] : [],
);
return classes.length ? JSON.stringify(classes.join(" ")) : undefined; return classes.length ? JSON.stringify(classes.join(" ")) : undefined;
} }
function kebab(value: string): string { function kebab(value: string): string {
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/_/g, "-")
.toLowerCase();
} }
export function cssToStcss(source: string, filename = "style.css"): string { export function cssToStcss(source: string, filename = "style.css"): string {
@ -349,10 +467,10 @@ export function cssToStcss(source: string, filename = "style.css"): string {
} }
export function htmlToSpug(source: string): string { export function htmlToSpug(source: string): string {
const body = source.replace(/^---[\s\S]*?---\s*/m, "").replace( const body = source
/^---[\s\S]*?---\s*/m, .replace(/^---[\s\S]*?---\s*/m, "")
"", .replace(/^---[\s\S]*?---\s*/m, "")
).replace(/<!--[\s\S]*?-->/g, ""); .replace(/<!--[\s\S]*?-->/g, "");
const tokens = body.match(/<[^>]+>|[^<]+/g) ?? []; const tokens = body.match(/<[^>]+>|[^<]+/g) ?? [];
const lines: string[] = []; const lines: string[] = [];
let depth = 0; let depth = 0;
@ -385,14 +503,15 @@ export function htmlToSpug(source: string): string {
"track", "track",
"wbr", "wbr",
]).has(tag) ]).has(tag)
) depth += 1; )
depth += 1;
} else { } else {
const text = token.replace(/\s+/g, " ").trim(); const text = token.replace(/\s+/g, " ").trim();
if (text) { if (text) {
lines.push( lines.push(
`${" ".repeat(depth)}| ${ `${" ".repeat(depth)}| ${text
text.replace(/\{\{\s*(.*?)\s*\}\}/g, "#{ $1 }").replace(/\{([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\}/g, "#{ $1 }") .replace(/\{\{\s*(.*?)\s*\}\}/g, "#{ $1 }")
}`, .replace(/\{([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\}/g, "#{ $1 }")}`,
); );
} }
} }
@ -401,11 +520,17 @@ export function htmlToSpug(source: string): string {
} }
/** Converts an Astro source file without evaluating its frontmatter. */ /** Converts an Astro source file without evaluating its frontmatter. */
export function astroToSpug(source: string, file = "page.astro", pagesRoot = ""): string { export function astroToSpug(
source: string,
file = "page.astro",
pagesRoot = "",
): string {
const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source)?.[1] ?? ""; const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source)?.[1] ?? "";
let body = source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ""); let body = source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
const imports = new Map<string, string>(); const imports = new Map<string, string>();
for (const entry of frontmatter.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?/g)) { for (const entry of frontmatter.matchAll(
/import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?/g,
)) {
imports.set(entry[1]!, entry[2]!); imports.set(entry[1]!, entry[2]!);
} }
const layout = /^\s*<([A-Z][\w$]*)\b([^>]*)>([\s\S]*)<\/\1>\s*$/.exec(body); const layout = /^\s*<([A-Z][\w$]*)\b([^>]*)>([\s\S]*)<\/\1>\s*$/.exec(body);
@ -415,14 +540,23 @@ export function astroToSpug(source: string, file = "page.astro", pagesRoot = "")
if (/(?:^|\/)layouts\//.test(imported)) { if (/(?:^|\/)layouts\//.test(imported)) {
const absoluteLayout = resolve(dirname(file), imported); const absoluteLayout = resolve(dirname(file), imported);
const pageRelative = relative(pagesRoot, file); const pageRelative = relative(pagesRoot, file);
const layoutRelative = relative(dirname(pagesRoot), absoluteLayout).replace(/\.astro$/, ".spug"); const layoutRelative = relative(
const target = relative(dirname(pageRelative), layoutRelative).replaceAll("\\", "/"); dirname(pagesRoot),
absoluteLayout,
).replace(/\.astro$/, ".spug");
const target = relative(dirname(pageRelative), layoutRelative).replaceAll(
"\\",
"/",
);
prefix = `extends ${target}\n\nblock content\n`; prefix = `extends ${target}\n\nblock content\n`;
body = layout[3]!; body = layout[3]!;
} }
} }
body = body body = body
.replace(/<slot\s+name=["']([\w-]+)["']\s*\/?\s*>/g, "<muddle-slot data-name=\"$1\"></muddle-slot>") .replace(
/<slot\s+name=["']([\w-]+)["']\s*\/?\s*>/g,
'<muddle-slot data-name="$1"></muddle-slot>',
)
.replace(/<slot\s*\/?\s*>/g, "<muddle-slot></muddle-slot>"); .replace(/<slot\s*\/?\s*>/g, "<muddle-slot></muddle-slot>");
const converted = htmlToSpug(body) const converted = htmlToSpug(body)
.replace(/^(\s*)muddle-slot\(data-name="([\w-]+)"\)\s*$/gm, "$1block $2") .replace(/^(\s*)muddle-slot\(data-name="([\w-]+)"\)\s*$/gm, "$1block $2")
@ -435,8 +569,16 @@ export function astroToSpug(source: string, file = "page.astro", pagesRoot = "")
export function zolaToSpug(source: string): string { export function zolaToSpug(source: string): string {
let result = source let result = source
.replace(/\{#([\s\S]*?)#\}/g, "") .replace(/\{#([\s\S]*?)#\}/g, "")
.replace(/\{%\s*extends\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `extends ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`) .replace(
.replace(/\{%\s*include\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `include ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`) /\{%\s*extends\s+["']([^"']+)["']\s*%\}/g,
(_all, path: string) =>
`extends ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`,
)
.replace(
/\{%\s*include\s+["']([^"']+)["']\s*%\}/g,
(_all, path: string) =>
`include ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`,
)
.replace(/\{%\s*block\s+([\w-]+)\s*%\}/g, "block $1") .replace(/\{%\s*block\s+([\w-]+)\s*%\}/g, "block $1")
.replace(/\{%\s*endblock\s*%\}/g, "") .replace(/\{%\s*endblock\s*%\}/g, "")
.replace(/\{%\s*if\s+(.+?)\s*%\}/g, "if $1") .replace(/\{%\s*if\s+(.+?)\s*%\}/g, "if $1")
@ -452,33 +594,50 @@ function componentCalls(source: string, imports: Map<string, string>): string {
const output: string[] = []; const output: string[] = [];
for (const line of source.split("\n")) { for (const line of source.split("\n")) {
const match = /^(\s*)([A-Z][\w$]*)(.*)$/.exec(line); const match = /^(\s*)([A-Z][\w$]*)(.*)$/.exec(line);
if (!match || !imports.has(match[2]!)) { output.push(line); continue; } if (!match || !imports.has(match[2]!)) {
output.push(line);
continue;
}
const imported = imports.get(match[2]!)!; const imported = imports.get(match[2]!)!;
const name = imported.replace(/^.*\//, "").replace(/\.astro$/, "").replace(/[A-Z]/g, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`); const name = imported
.replace(/^.*\//, "")
.replace(/\.astro$/, "")
.replace(
/[A-Z]/g,
(letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`,
);
const attributeText = /\((.*)\)$/.exec(match[3]!)?.[1]; const attributeText = /\((.*)\)$/.exec(match[3]!)?.[1];
output.push(`${match[1]}component ${name}`); output.push(`${match[1]}component ${name}`);
for (const pair of attributeText?.split(/\s*,\s*/) ?? []) { for (const pair of attributeText?.split(/\s*,\s*/) ?? []) {
const [key = "", raw = "true"] = pair.split(/=(.+)/); const [key = "", raw = "true"] = pair.split(/=(.+)/);
const expression = /^\((.*)\)$/.exec(raw.trim())?.[1] ?? raw.trim().replace(/^\{\s*|\s*\}$/g, ""); const expression =
/^\((.*)\)$/.exec(raw.trim())?.[1] ??
raw.trim().replace(/^\{\s*|\s*\}$/g, "");
const dynamic = raw.trim().startsWith("{") || raw.trim().startsWith("("); const dynamic = raw.trim().startsWith("{") || raw.trim().startsWith("(");
output.push(`${match[1]} ${key.trim()}: ${dynamic ? expression : expression === "true" ? "true" : JSON.stringify(expression)}`); output.push(
`${match[1]} ${key.trim()}: ${dynamic ? expression : expression === "true" ? "true" : JSON.stringify(expression)}`,
);
} }
} }
return `${output.filter(Boolean).join("\n").trim()}\n`; return `${output.filter(Boolean).join("\n").trim()}\n`;
} }
function indent(source: string): string { function indent(source: string): string {
return source.split("\n").filter(Boolean).map((line) => ` ${line}`).join("\n") + "\n"; return (
source
.split("\n")
.filter(Boolean)
.map((line) => ` ${line}`)
.join("\n") + "\n"
);
} }
function attributesToSpug(raw: string): string { function attributesToSpug(raw: string): string {
const classes: string[] = []; const classes: string[] = [];
const attributes: string[] = []; const attributes: string[] = [];
for ( for (const match of raw.matchAll(
const match of raw.matchAll(
/([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g, /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g,
) )) {
) {
const name = match[1]!; const name = match[1]!;
const value = match[2] ?? match[3] ?? match[4]; const value = match[2] ?? match[3] ?? match[4];
if (name === "class" && value) { if (name === "class" && value) {
@ -490,7 +649,8 @@ function attributesToSpug(raw: string): string {
continue; continue;
} }
if (value === undefined) attributes.push(`${name}=true`); if (value === undefined) attributes.push(`${name}=true`);
else if (/^\{[\s\S]*\}$/.test(value)) attributes.push(`${name}=(${value.slice(1, -1).trim()})`); else if (/^\{[\s\S]*\}$/.test(value))
attributes.push(`${name}=(${value.slice(1, -1).trim()})`);
else attributes.push(`${name}=${JSON.stringify(value)}`); else attributes.push(`${name}=${JSON.stringify(value)}`);
} }
return `${classes.map((value) => `.${value}`).join("")}${attributes.length ? `(${attributes.join(", ")})` : ""}`; return `${classes.map((value) => `.${value}`).join("")}${attributes.length ? `(${attributes.join(", ")})` : ""}`;

View file

@ -4,7 +4,11 @@ import { join } from "node:path";
export interface Cache { export interface Cache {
get(key: string): Promise<Uint8Array | undefined>; get(key: string): Promise<Uint8Array | undefined>;
put(key: string, value: Uint8Array, options?: { ttl?: number }): Promise<void>; put(
key: string,
value: Uint8Array,
options?: { ttl?: number },
): Promise<void>;
delete(key: string): Promise<void>; delete(key: string): Promise<void>;
} }
@ -39,17 +43,27 @@ export function filesystemCache(dir: string): Cache {
}; };
} }
export function cloudflareCache(cache: CloudflareCacheLike, origin = "https://muddle.cache"): Cache { export function cloudflareCache(
const request = (key: string) => new Request(new URL(`/${encodeURIComponent(key)}`, origin).toString()); cache: CloudflareCacheLike,
origin = "https://muddle.cache",
): Cache {
const request = (key: string) =>
new Request(new URL(`/${encodeURIComponent(key)}`, origin).toString());
return { return {
async get(key) { async get(key) {
const response = await cache.match(request(key)); const response = await cache.match(request(key));
return response ? new Uint8Array(await response.arrayBuffer()) : undefined; return response
? new Uint8Array(await response.arrayBuffer())
: undefined;
}, },
async put(key, value, options) { async put(key, value, options) {
const headers = options?.ttl ? { "cache-control": `max-age=${options.ttl}` } : undefined; const headers = options?.ttl
? { "cache-control": `max-age=${options.ttl}` }
: undefined;
await cache.put(request(key), new Response(value, { headers })); await cache.put(request(key), new Response(value, { headers }));
}, },
async delete(key) { await cache.delete(request(key)); }, async delete(key) {
await cache.delete(request(key));
},
}; };
} }

View file

@ -131,12 +131,24 @@ export class ContentRegistry {
} }
} }
async function listCollectionFiles(dir: string, extensions: string[], prefix = ""): Promise<string[]> { async function listCollectionFiles(
dir: string,
extensions: string[],
prefix = "",
): Promise<string[]> {
const files: string[] = []; const files: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) { for (const entry of await readdir(dir, { withFileTypes: true })) {
const relativePath = join(prefix, entry.name); const relativePath = join(prefix, entry.name);
if (entry.isDirectory()) files.push(...(await listCollectionFiles(join(dir, entry.name), extensions, relativePath))); if (entry.isDirectory())
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(relativePath); files.push(
...(await listCollectionFiles(
join(dir, entry.name),
extensions,
relativePath,
)),
);
else if (extensions.some((extension) => entry.name.endsWith(extension)))
files.push(relativePath);
} }
return files.sort(); return files.sort();
} }

View file

@ -1,24 +1,87 @@
import type { Headers } from "../contracts/typegen/http/headers.ts"; import type { Headers } from "../contracts/typegen/http/headers.ts";
export class ServerBuildContext { export class ServerBuildContext {
private readonly values: { headers: Headers; locals: Record<string, unknown> } = { headers: {}, locals: {} }; private readonly values: {
headers: Headers;
locals: Record<string, unknown>;
} = { headers: {}, locals: {} };
header(name: string, value: string): this { this.values.headers[name.toLowerCase()] = value; return this; } header(name: string, value: string): this {
headers(values: Headers): this { Object.assign(this.values.headers, values); return this; } this.values.headers[name.toLowerCase()] = value;
local(name: string, value: unknown): this { this.values.locals[name] = value; return this; } return this;
build(): Readonly<{ headers: Headers; locals: Record<string, unknown> }> { return { headers: { ...this.values.headers }, locals: { ...this.values.locals } }; } }
headers(values: Headers): this {
Object.assign(this.values.headers, values);
return this;
}
local(name: string, value: unknown): this {
this.values.locals[name] = value;
return this;
}
build(): Readonly<{ headers: Headers; locals: Record<string, unknown> }> {
return {
headers: { ...this.values.headers },
locals: { ...this.values.locals },
};
}
} }
export class TemplateContext { export class TemplateContext {
readonly imports = new Map<string, { render(props: Record<string, unknown>, context: { locals: Record<string, unknown>; params: Record<string, string> }): Promise<string> | string }>(); readonly imports = new Map<
import(name: string, component: { render(props: Record<string, unknown>, context: { locals: Record<string, unknown>; params: Record<string, string> }): Promise<string> | string }): this { string,
if (!/^[A-Za-z][\w-]*$/.test(name)) throw new Error("Invalid component import name"); {
render(
props: Record<string, unknown>,
context: {
locals: Record<string, unknown>;
params: Record<string, string>;
},
): Promise<string> | string;
}
>();
import(
name: string,
component: {
render(
props: Record<string, unknown>,
context: {
locals: Record<string, unknown>;
params: Record<string, string>;
},
): Promise<string> | string;
},
): this {
if (!/^[A-Za-z][\w-]*$/.test(name))
throw new Error("Invalid component import name");
this.imports.set(name, component); this.imports.set(name, component);
return this; return this;
} }
private readonly values: { templates: string[]; inputs: Record<string, unknown>; contracts: Record<string, string> } = { templates: [], inputs: {}, contracts: {} }; private readonly values: {
template(path: string): this { this.values.templates.push(path); return this; } templates: string[];
input(name: string, value: unknown): this { this.values.inputs[name] = value; return this; } inputs: Record<string, unknown>;
contract(name: string, type: string): this { this.values.contracts[name] = type; return this; } contracts: Record<string, string>;
build(): Readonly<{ templates: string[]; inputs: Record<string, unknown>; contracts: Record<string, string> }> { return { templates: [...this.values.templates], inputs: { ...this.values.inputs }, contracts: { ...this.values.contracts } }; } } = { templates: [], inputs: {}, contracts: {} };
template(path: string): this {
this.values.templates.push(path);
return this;
}
input(name: string, value: unknown): this {
this.values.inputs[name] = value;
return this;
}
contract(name: string, type: string): this {
this.values.contracts[name] = type;
return this;
}
build(): Readonly<{
templates: string[];
inputs: Record<string, unknown>;
contracts: Record<string, string>;
}> {
return {
templates: [...this.values.templates],
inputs: { ...this.values.inputs },
contracts: { ...this.values.contracts },
};
}
} }

View file

@ -1,30 +1,58 @@
import { foldLines } from "../views/engine/lexer.ts"; import { foldLines } from "../views/engine/lexer.ts";
export interface FormatOptions { tabWidth?: number; useTabs?: boolean; printWidth?: number } export interface FormatOptions {
tabWidth?: number;
useTabs?: boolean;
printWidth?: number;
}
export type FormatLanguage = "spug" | "stpug" | "stug" | "stcss"; export type FormatLanguage = "spug" | "stpug" | "stug" | "stcss";
/** Shared by the CLI and Prettier plugin, with no subprocesses or write side effects. */ /** Shared by the CLI and Prettier plugin, with no subprocesses or write side effects. */
export async function formatSource(source: string, language: FormatLanguage, options: FormatOptions = {}): Promise<string> { export async function formatSource(
source: string,
language: FormatLanguage,
options: FormatOptions = {},
): Promise<string> {
const prettier = await import("prettier"); const prettier = await import("prettier");
const scripts: string[] = []; const scripts: string[] = [];
let protectedSource = source.replace(/(^[\t ]*)(javascript!|typescript!|---)\s*\n([\s\S]*?)^\s*(end|---)\s*$/gm, (_match, indentation: string, directive: string, code: string, ending: string) => { let protectedSource = source.replace(
/(^[\t ]*)(javascript!|typescript!|---)\s*\n([\s\S]*?)^\s*(end|---)\s*$/gm,
(
_match,
indentation: string,
directive: string,
code: string,
ending: string,
) => {
const index = scripts.length; const index = scripts.length;
scripts.push(JSON.stringify({ directive, code, ending })); scripts.push(JSON.stringify({ directive, code, ending }));
return language === "stcss" ? `/* MUDDLE_SCRIPT_${index} */` : `${indentation}MUDDLE_SCRIPT_${index}`; return language === "stcss"
}); ? `/* MUDDLE_SCRIPT_${index} */`
: `${indentation}MUDDLE_SCRIPT_${index}`;
},
);
const rawBlocks: { marker: string; body: string[]; prefix: string }[] = []; const rawBlocks: { marker: string; body: string[]; prefix: string }[] = [];
if (language !== "stcss") { if (language !== "stcss") {
const sourceLines = protectedSource.split("\n"); const sourceLines = protectedSource.split("\n");
const output: string[] = []; const output: string[] = [];
for (let index = 0; index < sourceLines.length; index++) { for (let index = 0; index < sourceLines.length; index++) {
const line = sourceLines[index]!; const line = sourceLines[index]!;
const match = /^(\s*)(?:[\w.#-]+(?:\([^]*\))?\.|\/\/-?|\$\$\$)\s*$/.exec(line); const match = /^(\s*)(?:[\w.#-]+(?:\([^]*\))?\.|\/\/-?|\$\$\$)\s*$/.exec(
if (!match) { output.push(line); continue; } line,
);
if (!match) {
output.push(line);
continue;
}
const prefix = match[1]!; const prefix = match[1]!;
const body: string[] = [line.slice(prefix.length)]; const body: string[] = [line.slice(prefix.length)];
while (index + 1 < sourceLines.length) { while (index + 1 < sourceLines.length) {
const next = sourceLines[index + 1]!; const next = sourceLines[index + 1]!;
if (next.trim() && (next.match(/^\s*/)?.[0].length ?? 0) <= prefix.length) break; if (
next.trim() &&
(next.match(/^\s*/)?.[0].length ?? 0) <= prefix.length
)
break;
body.push(next.startsWith(prefix) ? next.slice(prefix.length) : next); body.push(next.startsWith(prefix) ? next.slice(prefix.length) : next);
index++; index++;
} }
@ -37,12 +65,19 @@ export async function formatSource(source: string, language: FormatLanguage, opt
const unit = options.useTabs ? "\t" : " ".repeat(options.tabWidth ?? 2); const unit = options.useTabs ? "\t" : " ".repeat(options.tabWidth ?? 2);
let formatted: string; let formatted: string;
if (language === "stcss") { if (language === "stcss") {
formatted = await prettier.format(protectedSource, { parser: "scss", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false, printWidth: options.printWidth ?? 80 }); formatted = await prettier.format(protectedSource, {
parser: "scss",
plugins: [],
tabWidth: options.tabWidth ?? 2,
useTabs: options.useTabs ?? false,
printWidth: options.printWidth ?? 80,
});
} else { } else {
const stack = [0]; const stack = [0];
const lines: string[] = []; const lines: string[] = [];
for (const line of foldLines(protectedSource)) { for (const line of foldLines(protectedSource)) {
while (stack.length > 1 && line.indent < stack[stack.length - 1]!) stack.pop(); while (stack.length > 1 && line.indent < stack[stack.length - 1]!)
stack.pop();
if (line.indent > stack[stack.length - 1]!) stack.push(line.indent); if (line.indent > stack[stack.length - 1]!) stack.push(line.indent);
const indent = unit.repeat(stack.length - 1); const indent = unit.repeat(stack.length - 1);
lines.push(`${indent}${line.text.trimEnd()}`); lines.push(`${indent}${line.text.trimEnd()}`);
@ -50,14 +85,35 @@ export async function formatSource(source: string, language: FormatLanguage, opt
formatted = `${lines.join("\n")}\n`; formatted = `${lines.join("\n")}\n`;
} }
for (let index = 0; index < scripts.length; index++) { for (let index = 0; index < scripts.length; index++) {
const script = JSON.parse(scripts[index]!) as { directive: string; code: string; ending: string }; const script = JSON.parse(scripts[index]!) as {
const code = await prettier.format(script.code, { parser: "typescript", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false }); directive: string;
const marker = language === "stcss" ? new RegExp(`^[\\t ]*/\\* MUDDLE_SCRIPT_${index} \\*/`, "m") : new RegExp(`^([\\t ]*)MUDDLE_SCRIPT_${index}`, "m"); code: string;
ending: string;
};
const code = await prettier.format(script.code, {
parser: "typescript",
plugins: [],
tabWidth: options.tabWidth ?? 2,
useTabs: options.useTabs ?? false,
});
const marker =
language === "stcss"
? new RegExp(`^[\\t ]*/\\* MUDDLE_SCRIPT_${index} \\*/`, "m")
: new RegExp(`^([\\t ]*)MUDDLE_SCRIPT_${index}`, "m");
formatted = formatted.replace(marker, (match) => { formatted = formatted.replace(marker, (match) => {
const indentation = match.match(/^[\t ]*/)?.[0] ?? ""; const indentation = match.match(/^[\t ]*/)?.[0] ?? "";
return `${indentation}${script.directive}\n${code.trimEnd().split("\n").map((line) => `${indentation}${line}`).join("\n")}\n${indentation}${script.ending}`; return `${indentation}${script.directive}\n${code
.trimEnd()
.split("\n")
.map((line) => `${indentation}${line}`)
.join("\n")}\n${indentation}${script.ending}`;
}); });
} }
for (const block of rawBlocks) formatted = formatted.replace(new RegExp(`^([\\t ]*)${block.marker}$`, "m"), (_match, prefix: string) => block.body.map((line) => prefix + line).join("\n")); for (const block of rawBlocks)
formatted = formatted.replace(
new RegExp(`^([\\t ]*)${block.marker}$`, "m"),
(_match, prefix: string) =>
block.body.map((line) => prefix + line).join("\n"),
);
return formatted; return formatted;
} }

View file

@ -1,22 +1,42 @@
import { readdir, readFile, writeFile, stat } from "node:fs/promises"; import { readdir, readFile, writeFile, stat } from "node:fs/promises";
import { join, extname } from "node:path"; import { join, extname } from "node:path";
import { formatSource, type FormatLanguage, type FormatOptions } from "./format-source.ts"; import {
formatSource,
type FormatLanguage,
type FormatOptions,
} from "./format-source.ts";
export async function format(root: string, options: FormatOptions & { check?: boolean } = {}): Promise<string[]> { export async function format(
root: string,
options: FormatOptions & { check?: boolean } = {},
): Promise<string[]> {
const changed: string[] = []; const changed: string[] = [];
if ((await stat(root)).isFile()) { if ((await stat(root)).isFile()) {
const language = extname(root).slice(1) as FormatLanguage; const language = extname(root).slice(1) as FormatLanguage;
if (!["spug", "stpug", "stug", "stcss"].includes(language)) return changed; if (!["spug", "stpug", "stug", "stcss"].includes(language)) return changed;
const source = await readFile(root, "utf8"); const source = await readFile(root, "utf8");
const next = await formatSource(source, language, options); const next = await formatSource(source, language, options);
if (next !== source) { changed.push(root); if (!options.check) await writeFile(root, next, "utf8"); } if (next !== source) {
changed.push(root);
if (!options.check) await writeFile(root, next, "utf8");
}
return changed; return changed;
} }
for (const entry of await readdir(root, { withFileTypes: true })) { for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue; if (entry.isSymbolicLink()) continue;
if (entry.name.startsWith(".") || ["node_modules", "dist", "dist.previous", "shadcn-svelte", "_muddle"].includes(entry.name)) continue; if (
entry.name.startsWith(".") ||
[
"node_modules",
"dist",
"dist.previous",
"shadcn-svelte",
"_muddle",
].includes(entry.name)
)
continue;
const path = join(root, entry.name); const path = join(root, entry.name);
changed.push(...await format(path, options)); changed.push(...(await format(path, options)));
} }
return changed; return changed;
} }

View file

@ -202,7 +202,10 @@ export async function serveStaticFile(
path: string, path: string,
): Promise<HttpResponse | undefined> { ): Promise<HttpResponse | undefined> {
const relative = decodeURIComponent(path.split("?")[0] ?? ""); const relative = decodeURIComponent(path.split("?")[0] ?? "");
const target = resolve(root, `.${relative.startsWith("/") ? relative : `/${relative}`}`); const target = resolve(
root,
`.${relative.startsWith("/") ? relative : `/${relative}`}`,
);
if (!target.startsWith(resolve(root))) return undefined; if (!target.startsWith(resolve(root))) return undefined;
if (!existsSync(target)) return undefined; if (!existsSync(target)) return undefined;
if (!statSync(target).isFile()) return undefined; if (!statSync(target).isFile()) return undefined;

View file

@ -2,13 +2,25 @@ import type { HttpResponse } from "../contracts/typegen/http/response.ts";
import type { RequestContext } from "../contracts/typegen/http/request.ts"; import type { RequestContext } from "../contracts/typegen/http/request.ts";
import type { RouteDefinition } from "./router.ts"; import type { RouteDefinition } from "./router.ts";
export type RouteGuard = (context: RequestContext, route: RouteDefinition) => Promise<void | HttpResponse> | void | HttpResponse; export type RouteGuard = (
context: RequestContext,
route: RouteDefinition,
) => Promise<void | HttpResponse> | void | HttpResponse;
export class RoutePipeline { export class RoutePipeline {
private readonly guards: RouteGuard[] = []; private readonly guards: RouteGuard[] = [];
use(guard: RouteGuard): this { this.guards.push(guard); return this; } use(guard: RouteGuard): this {
async run(context: RequestContext, route: RouteDefinition): Promise<HttpResponse | undefined> { this.guards.push(guard);
for (const guard of this.guards) { const result = await guard(context, route); if (result) return result; } return this;
}
async run(
context: RequestContext,
route: RouteDefinition,
): Promise<HttpResponse | undefined> {
for (const guard of this.guards) {
const result = await guard(context, route);
if (result) return result;
}
return undefined; return undefined;
} }
} }

View file

@ -1,4 +1,7 @@
import { getControllerOptions, getControllerPrefix } from "../decorators/Controller.ts"; import {
getControllerOptions,
getControllerPrefix,
} from "../decorators/Controller.ts";
import { getRoot, resolveViewRoot } from "../decorators/Root.ts"; import { getRoot, resolveViewRoot } from "../decorators/Root.ts";
import { getParamMetadata, type ParamMetadata } from "../decorators/Params.ts"; import { getParamMetadata, type ParamMetadata } from "../decorators/Params.ts";
import { getRoutes, type RouteOptions } from "../decorators/Route.ts"; import { getRoutes, type RouteOptions } from "../decorators/Route.ts";
@ -139,7 +142,10 @@ export interface ResolvedView {
/** /**
* Resolves the template of a view. * Resolves the template of a view.
*/ */
export function resolveView(view: ViewMetadata, viewRoot: string): ResolvedView { export function resolveView(
view: ViewMetadata,
viewRoot: string,
): ResolvedView {
const absolute = isAbsolute(view.path) const absolute = isAbsolute(view.path)
? view.path ? view.path
: resolve(viewRoot, view.path); : resolve(viewRoot, view.path);
@ -151,7 +157,7 @@ export function resolveView(view: ViewMetadata, viewRoot: string): ResolvedView
let relative = path.startsWith(`${viewRoot}/`) let relative = path.startsWith(`${viewRoot}/`)
? path.slice(viewRoot.length + 1) ? path.slice(viewRoot.length + 1)
: path.split("/").pop() ?? path; : (path.split("/").pop() ?? path);
relative = relative.replace(/\.[^./]+$/, ""); relative = relative.replace(/\.[^./]+$/, "");
const segments = relative.split("/").filter((segment) => segment.length > 0); const segments = relative.split("/").filter((segment) => segment.length > 0);
@ -181,11 +187,7 @@ export class Router {
/** /**
* Adds a route that is not bound to a controller. * Adds a route that is not bound to a controller.
*/ */
raw( raw(method: string, path: string, handler: RawRoute["handler"]): void {
method: string,
path: string,
handler: RawRoute["handler"],
): void {
this.rawRoutes.push({ method: method.toUpperCase(), path, handler }); this.rawRoutes.push({ method: method.toUpperCase(), path, handler });
} }
@ -259,14 +261,33 @@ export class Router {
} }
/** Public route metadata for client navigation and route-aware components. */ /** Public route metadata for client navigation and route-aware components. */
clientRoutes(): { name: string; description?: string; path: string; method: HttpMethod; params: Record<string, string> }[] { clientRoutes(): {
name: string;
description?: string;
path: string;
method: HttpMethod;
params: Record<string, string>;
}[] {
return this.routes.map((route) => { return this.routes.map((route) => {
const view = route.view; const view = route.view;
const params = Object.fromEntries(pathSegments(route.path).filter((part) => part.startsWith(":")).map((part) => { const params = Object.fromEntries(
pathSegments(route.path)
.filter((part) => part.startsWith(":"))
.map((part) => {
const key = part.slice(1).replace(/[?*]$/, ""); const key = part.slice(1).replace(/[?*]$/, "");
return [key, route.options.params?.[key] ?? view?.params?.[key] ?? ""]; return [
})); key,
return { name: route.options.name ?? view?.name ?? route.methodName, description: route.options.description ?? view?.description, path: route.path, method: route.method, params }; route.options.params?.[key] ?? view?.params?.[key] ?? "",
];
}),
);
return {
name: route.options.name ?? view?.name ?? route.methodName,
description: route.options.description ?? view?.description,
path: route.path,
method: route.method,
params,
};
}); });
} }
@ -292,11 +313,13 @@ export class Router {
async handle(context: RequestContext): Promise<HttpResponse> { async handle(context: RequestContext): Promise<HttpResponse> {
for (const route of this.rawRoutes) { for (const route of this.rawRoutes) {
if (route.method !== context.method) continue; if (route.method !== context.method) continue;
if (route.path.endsWith("*") ? !context.path.startsWith(route.path.slice(0, -1)) : route.path !== context.path) continue; if (
const response = await route.handler( route.path.endsWith("*")
context.raw as Request, ? !context.path.startsWith(route.path.slice(0, -1))
context, : route.path !== context.path
); )
continue;
const response = await route.handler(context.raw as Request, context);
return { return {
status: response.status as StatusCode, status: response.status as StatusCode,
headers: Object.fromEntries(response.headers.entries()), headers: Object.fromEntries(response.headers.entries()),
@ -332,8 +355,17 @@ export class Router {
const auth = getAuth(match.route.controller, match.route.methodName); const auth = getAuth(match.route.controller, match.route.methodName);
if (auth) { if (auth) {
const identity = context.locals.auth as { roles?: string[] } | undefined; const identity = context.locals.auth as { roles?: string[] } | undefined;
if (!identity || (auth.roles?.some((role) => !identity.roles?.includes(role)) ?? false)) { if (
return { status: auth.redirect ? 302 : identity ? 403 : 401, headers: auth.redirect ? { location: auth.redirect } : { "content-type": "text/plain; charset=utf-8" }, body: auth.redirect ? undefined : "Unauthorized" }; !identity ||
(auth.roles?.some((role) => !identity.roles?.includes(role)) ?? false)
) {
return {
status: auth.redirect ? 302 : identity ? 403 : 401,
headers: auth.redirect
? { location: auth.redirect }
: { "content-type": "text/plain; charset=utf-8" },
body: auth.redirect ? undefined : "Unauthorized",
};
} }
} }
const result = await match.route.invoke(context); const result = await match.route.invoke(context);

View file

@ -15,14 +15,23 @@ export class MemorySessionStore implements SessionStore {
private readonly records = new Map<string, SessionRecord>(); private readonly records = new Map<string, SessionRecord>();
async get(key: string): Promise<SessionRecord | undefined> { async get(key: string): Promise<SessionRecord | undefined> {
const record = this.records.get(key); const record = this.records.get(key);
if (!record || record.expires <= Date.now()) { this.records.delete(key); return undefined; } if (!record || record.expires <= Date.now()) {
this.records.delete(key);
return undefined;
}
return structuredClone(record); return structuredClone(record);
} }
async set(key: string, record: SessionRecord): Promise<void> { this.records.set(key, structuredClone(record)); } async set(key: string, record: SessionRecord): Promise<void> {
async delete(key: string): Promise<void> { this.records.delete(key); } this.records.set(key, structuredClone(record));
}
async delete(key: string): Promise<void> {
this.records.delete(key);
}
async consume(key: string): Promise<SessionRecord | undefined> { async consume(key: string): Promise<SessionRecord | undefined> {
const record = this.records.get(key); const record = this.records.get(key);
this.records.delete(key); this.records.delete(key);
return record && record.expires > Date.now() ? structuredClone(record) : undefined; return record && record.expires > Date.now()
? structuredClone(record)
: undefined;
} }
} }

View file

@ -11,16 +11,32 @@ export interface TemplateResult {
} }
const sharedFiles: Record<string, string> = { const sharedFiles: Record<string, string> = {
"package.json": `${JSON.stringify({ "package.json": `${JSON.stringify(
{
name: "my-muddle-site", name: "my-muddle-site",
private: true, private: true,
type: "module", type: "module",
scripts: { dev: "muddle dev .", build: "muddle build ." }, scripts: { dev: "muddle dev .", build: "muddle build ." },
dependencies: { muddle: "latest" }, dependencies: { muddle: "latest" },
}, null, 2)}\n`, },
"tsconfig.json": `${JSON.stringify({ null,
compilerOptions: { target: "ESNext", module: "Preserve", moduleResolution: "bundler", strict: true, noEmit: true, experimentalDecorators: true, allowImportingTsExtensions: true }, 2,
}, null, 2)}\n`, )}\n`,
"tsconfig.json": `${JSON.stringify(
{
compilerOptions: {
target: "ESNext",
module: "Preserve",
moduleResolution: "bundler",
strict: true,
noEmit: true,
experimentalDecorators: true,
allowImportingTsExtensions: true,
},
},
null,
2,
)}\n`,
"config.ts": `import { resolveConfig } from "muddle";\nimport config from "./ct.config.ts";\n\nexport const resolved = resolveConfig(config);\n`, "config.ts": `import { resolveConfig } from "muddle";\nimport config from "./ct.config.ts";\n\nexport const resolved = resolveConfig(config);\n`,
"modules/app/app.module.ts": `import { Module } from "muddle";\nimport { AppController } from "./app.controller.ts";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`, "modules/app/app.module.ts": `import { Module } from "muddle";\nimport { AppController } from "./app.controller.ts";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`,
}; };
@ -45,12 +61,18 @@ const docsFiles: Record<string, string> = {
export const muddleTemplates: readonly MuddleTemplate[] = ["docs", "minimal"]; export const muddleTemplates: readonly MuddleTemplate[] = ["docs", "minimal"];
export async function createTemplate(name: MuddleTemplate, destination: string): Promise<TemplateResult> { export async function createTemplate(
name: MuddleTemplate,
destination: string,
): Promise<TemplateResult> {
const output = resolve(destination); const output = resolve(destination);
if (existsSync(output) && (await readdir(output)).length > 0) { if (existsSync(output) && (await readdir(output)).length > 0) {
throw new Error(`Refusing to overwrite non-empty directory ${output}`); throw new Error(`Refusing to overwrite non-empty directory ${output}`);
} }
const files = { ...sharedFiles, ...(name === "docs" ? docsFiles : minimalFiles) }; const files = {
...sharedFiles,
...(name === "docs" ? docsFiles : minimalFiles),
};
const written: string[] = []; const written: string[] = [];
for (const [relative, source] of Object.entries(files)) { for (const [relative, source] of Object.entries(files)) {
const file = join(output, relative); const file = join(output, relative);

View file

@ -9,13 +9,10 @@ export default defineConfig({
components: "samples/hello-world/modules/app/views/components", components: "samples/hello-world/modules/app/views/components",
viewRoots: ["samples/hello-world/modules/app/views"], viewRoots: ["samples/hello-world/modules/app/views"],
integrations: [ integrations: [stcssIntegration({ dir: "samples/hello-world/styles" })],
stcssIntegration({ dir: "samples/hello-world/styles" }),
],
prerender: { prerender: {
routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"], routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"],
outDir: "samples/hello-world/dist", outDir: "samples/hello-world/dist",
}, },
}); });

View file

@ -1,8 +1,17 @@
import { Route, type RouteOptions } from "./Route.ts"; import { Route, type RouteOptions } from "./Route.ts";
export interface AstroOptions extends RouteOptions { partial?: boolean; action?: boolean } export interface AstroOptions extends RouteOptions {
partial?: boolean;
action?: boolean;
}
/** Declares an Astro-compatible endpoint. */ /** Declares an Astro-compatible endpoint. */
export function Astro(path: string, options: AstroOptions = {}): MethodDecorator { export function Astro(
return Route(path, { ...options, method: options.action ? "POST" : options.method ?? "GET" }); path: string,
options: AstroOptions = {},
): MethodDecorator {
return Route(path, {
...options,
method: options.action ? "POST" : (options.method ?? "GET"),
});
} }

View file

@ -1,12 +1,21 @@
import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts"; import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts";
const KEY = "muddle:auth"; const KEY = "muddle:auth";
export interface AuthOptions { roles?: string[]; redirect?: string } export interface AuthOptions {
roles?: string[];
redirect?: string;
}
export function Auth(options: AuthOptions = {}): MethodDecorator { export function Auth(options: AuthOptions = {}): MethodDecorator {
return (target, propertyKey) => defineMemberMetadata(KEY, options, target as object, propertyKey as string); return (target, propertyKey) =>
defineMemberMetadata(KEY, options, target as object, propertyKey as string);
} }
export function getAuth(target: object, propertyKey: string): AuthOptions | undefined { export function getAuth(
return getMemberMetadata(KEY, target, propertyKey)[0] as AuthOptions | undefined; target: object,
propertyKey: string,
): AuthOptions | undefined {
return getMemberMetadata(KEY, target, propertyKey)[0] as
| AuthOptions
| undefined;
} }

View file

@ -10,6 +10,9 @@ export function RenderAhead(routes: RenderAheadSource): MethodDecorator {
}; };
} }
export function getRenderAhead(target: object, propertyKey: string): RenderAheadSource | undefined { export function getRenderAhead(
target: object,
propertyKey: string,
): RenderAheadSource | undefined {
return getMetadata(KEY, target, propertyKey) as RenderAheadSource | undefined; return getMetadata(KEY, target, propertyKey) as RenderAheadSource | undefined;
} }

View file

@ -10,17 +10,31 @@ export interface CollectionRenderRoute {
export type RenderRouteSource = string | string[] | CollectionRenderRoute; export type RenderRouteSource = string | string[] | CollectionRenderRoute;
export function routeFolder(...segments: string[]): string { export function routeFolder(...segments: string[]): string {
return `/${segments.filter(Boolean).map((segment) => segment.replace(/^\/+|\/+$/g, "")).join("/")}`; return `/${segments
.filter(Boolean)
.map((segment) => segment.replace(/^\/+|\/+$/g, ""))
.join("/")}`;
} }
export function collectionRoute(collection: string, path: string): CollectionRenderRoute { export function collectionRoute(
collection: string,
path: string,
): CollectionRenderRoute {
return { collection, path }; return { collection, path };
} }
export function RenderRoute(routes: RenderRouteSource[]): MethodDecorator { export function RenderRoute(routes: RenderRouteSource[]): MethodDecorator {
return (target, propertyKey) => defineMetadata(KEY, routes, target, propertyKey); return (target, propertyKey) =>
defineMetadata(KEY, routes, target, propertyKey);
} }
export function getRenderRoutes(target: object, propertyKey: string): RenderRouteSource[] { export function getRenderRoutes(
return (getMetadata(KEY, target, propertyKey) as RenderRouteSource[] | undefined) ?? []; target: object,
propertyKey: string,
): RenderRouteSource[] {
return (
(getMetadata(KEY, target, propertyKey) as
| RenderRouteSource[]
| undefined) ?? []
);
} }

View file

@ -1,5 +1,8 @@
import "../core/metadata.ts"; import "../core/metadata.ts";
import { defineMemberMetadata, getAllMemberMetadata } from "../core/metadata.ts"; import {
defineMemberMetadata,
getAllMemberMetadata,
} from "../core/metadata.ts";
import { callerFile } from "../core/paths.ts"; import { callerFile } from "../core/paths.ts";
import { dirname } from "node:path"; import { dirname } from "node:path";
import type { HttpMethod } from "../contracts/typegen/http/method.ts"; import type { HttpMethod } from "../contracts/typegen/http/method.ts";
@ -35,7 +38,10 @@ export interface ViewMetadata {
} }
/** Declares the template rendered by a controller method. */ /** Declares the template rendered by a controller method. */
export function View(path?: string, options: ViewOptions = {}): MethodDecorator { export function View(
path?: string,
options: ViewOptions = {},
): MethodDecorator {
return (target, propertyKey, descriptor) => { return (target, propertyKey, descriptor) => {
const caller = callerFile(); const caller = callerFile();
const method = String(propertyKey); const method = String(propertyKey);
@ -71,10 +77,7 @@ export function View(path?: string, options: ViewOptions = {}): MethodDecorator
* *
* When a property key is given, only the views of that method are returned. * When a property key is given, only the views of that method are returned.
*/ */
export function getViews( export function getViews(target: object, propertyKey?: string): ViewMetadata[] {
target: object,
propertyKey?: string,
): ViewMetadata[] {
return getAllMemberMetadata(VIEW_METADATA_KEY, target) return getAllMemberMetadata(VIEW_METADATA_KEY, target)
.filter( .filter(
(entry) => propertyKey === undefined || entry.propertyKey === propertyKey, (entry) => propertyKey === undefined || entry.propertyKey === propertyKey,

View file

@ -10,7 +10,11 @@
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] } { "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
], ],
"surroundingPairs": [ "surroundingPairs": [
["{", "}"], ["(", ")"], ["[", "]"], ["'", "'"], ["\"", "\""] ["{", "}"],
["(", ")"],
["[", "]"],
["'", "'"],
["\"", "\""]
], ],
"indentationRules": { "indentationRules": {
"increaseIndentPattern": "^\\s*(?:@\\w+[^;{]*)?[^{};]*\\{\\s*$|^(?:mixin|if|each|for|component)\\s+.*$", "increaseIndentPattern": "^\\s*(?:@\\w+[^;{]*)?[^{};]*\\{\\s*$|^(?:mixin|if|each|for|component)\\s+.*$",

View file

@ -27,12 +27,18 @@
"languages": [ "languages": [
{ {
"id": "spug", "id": "spug",
"extensions": [".spug", ".stpug", ".stug"], "extensions": [
".spug",
".stpug",
".stug"
],
"configuration": "./language-configuration.json" "configuration": "./language-configuration.json"
}, },
{ {
"id": "stcss", "id": "stcss",
"extensions": [".stcss"], "extensions": [
".stcss"
],
"configuration": "./language-configuration.json" "configuration": "./language-configuration.json"
} }
], ],

View file

@ -1,18 +1,88 @@
{ {
"Typed props": { "prefix": "props", "body": ["props", " ${1:name}: ${2:string}"], "description": "Declare typed SPUG props" }, "Typed props": {
"Optional prop": { "prefix": "props?", "body": ["props", " ${1:name}: ${2:string}?", ""], "description": "Declare an optional SPUG prop" }, "prefix": "props",
"Relation anchor": { "prefix": "anchor", "body": ["${1:a}.class@${2:name}(${3:id})(${0})"], "description": "Spawn a relation anchor consumed by $refer / :with / :when in STCSS" }, "body": ["props", " ${1:name}: ${2:string}"],
"Dynamic anchor": { "prefix": "anchor+", "body": ["li@row(item.id)(${0})"], "description": "Dynamic relation anchor from an each-loop item" }, "description": "Declare typed SPUG props"
"TypeScript block": { "prefix": "javascript", "body": ["javascript!", " ${1:const value = true;}", "end"], "description": "Embed a TypeScript block" }, },
"HTML block": { "prefix": "html-block", "body": ["html!\n ${1:<p>raw</p>}\nend"], "description": "Embed a raw HTML block" }, "Optional prop": {
"Component render": { "prefix": "component", "body": ["component ${1:name}", " ${2:prop}: ${3:value}", " render ${4:slot}", " ${0}", " end? (see docs)"], "description": "Render a component with props and a slot" }, "prefix": "props?",
"Extend layout": { "prefix": "extends", "body": ["extends ./layout.spug", "", "block content", " ${0}"], "description": "Extend a layout and fill the content block" }, "body": ["props", " ${1:name}: ${2:string}?", ""],
"Render slot": { "prefix": "render", "body": ["render ${1:content}", " ${0}"], "description": "Pass a slot into a component" }, "description": "Declare an optional SPUG prop"
"Frontmatter": { "prefix": "fm", "body": ["---", "${0}", "---"], "description": "Front matter block with TypeScript imports" }, },
"Escape expression": { "prefix": "expr", "body": ["#{${1:expression}}"], "description": "Inline interpolated expression" }, "Relation anchor": {
"Raw expression": { "prefix": "raw", "body": ["!{${1:expression}}"], "description": "Inline raw unescaped expression" }, "prefix": "anchor",
"Iterate": { "prefix": "each", "body": ["each ${1:item} in ${2:items}", " ${0}"], "description": "Iterate over a list" }, "body": ["${1:a}.class@${2:name}(${3:id})(${0})"],
"Condition": { "prefix": "if", "body": ["if ${1:condition}", " ${0}"], "description": "Conditional block" }, "description": "Spawn a relation anchor consumed by $refer / :with / :when in STCSS"
"Iso if-choice": { "prefix": "unless", "body": ["unless ${1:condition}", " ${0}"], "description": "Inverse conditional block" }, },
"Configuration block": { "prefix": "config", "body": ["$$$", " ${1:block}", "notes", " ${0}"], "description": "Template test configuration and notes" } "Dynamic anchor": {
"prefix": "anchor+",
"body": ["li@row(item.id)(${0})"],
"description": "Dynamic relation anchor from an each-loop item"
},
"TypeScript block": {
"prefix": "javascript",
"body": ["javascript!", " ${1:const value = true;}", "end"],
"description": "Embed a TypeScript block"
},
"HTML block": {
"prefix": "html-block",
"body": ["html!\n ${1:<p>raw</p>}\nend"],
"description": "Embed a raw HTML block"
},
"Component render": {
"prefix": "component",
"body": [
"component ${1:name}",
" ${2:prop}: ${3:value}",
" render ${4:slot}",
" ${0}",
" end? (see docs)"
],
"description": "Render a component with props and a slot"
},
"Extend layout": {
"prefix": "extends",
"body": ["extends ./layout.spug", "", "block content", " ${0}"],
"description": "Extend a layout and fill the content block"
},
"Render slot": {
"prefix": "render",
"body": ["render ${1:content}", " ${0}"],
"description": "Pass a slot into a component"
},
"Frontmatter": {
"prefix": "fm",
"body": ["---", "${0}", "---"],
"description": "Front matter block with TypeScript imports"
},
"Escape expression": {
"prefix": "expr",
"body": ["#{${1:expression}}"],
"description": "Inline interpolated expression"
},
"Raw expression": {
"prefix": "raw",
"body": ["!{${1:expression}}"],
"description": "Inline raw unescaped expression"
},
"Iterate": {
"prefix": "each",
"body": ["each ${1:item} in ${2:items}", " ${0}"],
"description": "Iterate over a list"
},
"Condition": {
"prefix": "if",
"body": ["if ${1:condition}", " ${0}"],
"description": "Conditional block"
},
"Iso if-choice": {
"prefix": "unless",
"body": ["unless ${1:condition}", " ${0}"],
"description": "Inverse conditional block"
},
"Configuration block": {
"prefix": "config",
"body": ["$$$", " ${1:block}", "notes", " ${0}"],
"description": "Template test configuration and notes"
}
} }

View file

@ -1,25 +1,121 @@
{ {
"Predicted geometry": { "prefix": "predict", "body": ["@predict max-height max-width;"], "description": "Transition prerendered geometry" }, "Predicted geometry": {
"Predicted transitions": { "prefix": "predict-full", "body": ["@predict left top width height opacity transform;"], "description": "Full prediction transition list (pill/ribbon style)" }, "prefix": "predict",
"Measure prediction target": { "prefix": "measure", "body": ["@measure;"], "description": "Mark this selector for prerender geometry prefill (inline prediction source)" }, "body": ["@predict max-height max-width;"],
"Measure anchors": { "prefix": "measure-anchor", "body": ["@measure @${1:anchor}, ${0:.selector};"], "description": "Measure elements by relation anchor or class" }, "description": "Transition prerendered geometry"
"Responsive rule": { "prefix": "responsive", "body": ["@responsive ${1:md} {", " ${2}", "}"], "description": "Add a responsive STCSS rule" }, },
"Media rule": { "prefix": "media", "body": ["@media ${1:(max-width: 48rem)} {", " ${2}", "}"], "description": "Add a media query" }, "Predicted transitions": {
"Each loop": { "prefix": "each", "body": ["@each $${1:item} in ${2:items} {", " ${0}", "}"], "description": "Repeat a block over a value list" }, "prefix": "predict-full",
"For loop": { "prefix": "for", "body": ["@for $${1:i} from ${2:1} through ${3:5} {", " ${0}", "}"], "description": "Count a range" }, "body": ["@predict left top width height opacity transform;"],
"Mixin declaration": { "prefix": "mixin", "body": ["@mixin ${1:name}($${2:arg}) {", " ${0}", "}"], "description": "Declare an STCSS mixin" }, "description": "Full prediction transition list (pill/ribbon style)"
"Include mixin": { "prefix": "include", "body": ["@include ${1:name}(${2:arg});"], "description": "Use an STCSS mixin" }, },
"Interpolation": { "prefix": "interp", "body": ["#{$${1:scale} * 2}rem"], "description": "Compile-time value interpolation" }, "Measure prediction target": {
"d-string": { "prefix": "d", "body": ["d\"${1:calc(#{} )}\""], "description": "String with brace expressions evaluated at compile time" }, "prefix": "measure",
"$refer geometry": { "prefix": "refer", "body": ["$refer[${1:left}](@${2:anchor})"], "description": "Read pre-computed anchor geometry (left/top/width/height)" }, "body": ["@measure;"],
"$refer interpolated location": { "prefix": "refer-between", "body": ["calc($refer[${1:left}](@${2:anchor}) - $refer[${3:left}](@${4:relative}))"], "description": "Compute location between two anchors" }, "description": "Mark this selector for prerender geometry prefill (inline prediction source)"
"$relate metrics": { "prefix": "relate", "body": ["$relate(${1:@anchor})"], "description": "Legacy relation value of an anchor" }, },
"Computed mirror": { "prefix": "computed", "body": ["@${1:anchor}.computed.${2:backgroundColor}"], "description": "Mirror a live computed style of an anchor" }, "Measure anchors": {
"Relation state selector": { "prefix": "with-state", "body": [":with(@${1:anchor}:${2|active,clicked,focus|})"], "description": "Anchor state selection (:has under the hood)" }, "prefix": "measure-anchor",
"When selector": { "prefix": "when-state", "body": [":when(${1:root} @${2:anchor}:${3|active,clicked,focus|})"], "description": "Scoped anchor state selection" }, "body": ["@measure @${1:anchor}, ${0:.selector};"],
"Concentric radius": { "prefix": "concentric", "body": ["border-radius: concentric ${1:0.75rem};"], "description": "Concentric nested corner radii" }, "description": "Measure elements by relation anchor or class"
"Freeform gradient": { "prefix": "freeform-gradient", "body": ["background-image: freeform-gradient(${1:#7c3aed} ${2:10%} ${3:50%}, ${4:#f00} ${5:70%} ${6:40%});"], "description": "Spawn layered freeform radial gradients" }, },
"Concentric block": { "prefix": "concentric-block", "body": ["@concentric ${1:1rem} {", " ${0}", "}"], "description": "Concentric radius at-rule" }, "Responsive rule": {
"View transition": { "prefix": "view-transition", "body": ["@view-transition ${1:name};"], "description": "Emit a css view-transition-name" }, "prefix": "responsive",
"Compile-time values": { "prefix": "javascript", "body": ["javascript!", " const ${1:space} = ${2:1};", "end"], "description": "Add compile-time values" } "body": ["@responsive ${1:md} {", " ${2}", "}"],
"description": "Add a responsive STCSS rule"
},
"Media rule": {
"prefix": "media",
"body": ["@media ${1:(max-width: 48rem)} {", " ${2}", "}"],
"description": "Add a media query"
},
"Each loop": {
"prefix": "each",
"body": ["@each $${1:item} in ${2:items} {", " ${0}", "}"],
"description": "Repeat a block over a value list"
},
"For loop": {
"prefix": "for",
"body": ["@for $${1:i} from ${2:1} through ${3:5} {", " ${0}", "}"],
"description": "Count a range"
},
"Mixin declaration": {
"prefix": "mixin",
"body": ["@mixin ${1:name}($${2:arg}) {", " ${0}", "}"],
"description": "Declare an STCSS mixin"
},
"Include mixin": {
"prefix": "include",
"body": ["@include ${1:name}(${2:arg});"],
"description": "Use an STCSS mixin"
},
"Interpolation": {
"prefix": "interp",
"body": ["#{$${1:scale} * 2}rem"],
"description": "Compile-time value interpolation"
},
"d-string": {
"prefix": "d",
"body": ["d\"${1:calc(#{} )}\""],
"description": "String with brace expressions evaluated at compile time"
},
"$refer geometry": {
"prefix": "refer",
"body": ["$refer[${1:left}](@${2:anchor})"],
"description": "Read pre-computed anchor geometry (left/top/width/height)"
},
"$refer interpolated location": {
"prefix": "refer-between",
"body": [
"calc($refer[${1:left}](@${2:anchor}) - $refer[${3:left}](@${4:relative}))"
],
"description": "Compute location between two anchors"
},
"$relate metrics": {
"prefix": "relate",
"body": ["$relate(${1:@anchor})"],
"description": "Legacy relation value of an anchor"
},
"Computed mirror": {
"prefix": "computed",
"body": ["@${1:anchor}.computed.${2:backgroundColor}"],
"description": "Mirror a live computed style of an anchor"
},
"Relation state selector": {
"prefix": "with-state",
"body": [":with(@${1:anchor}:${2|active,clicked,focus|})"],
"description": "Anchor state selection (:has under the hood)"
},
"When selector": {
"prefix": "when-state",
"body": [":when(${1:root} @${2:anchor}:${3|active,clicked,focus|})"],
"description": "Scoped anchor state selection"
},
"Concentric radius": {
"prefix": "concentric",
"body": ["border-radius: concentric ${1:0.75rem};"],
"description": "Concentric nested corner radii"
},
"Freeform gradient": {
"prefix": "freeform-gradient",
"body": [
"background-image: freeform-gradient(${1:#7c3aed} ${2:10%} ${3:50%}, ${4:#f00} ${5:70%} ${6:40%});"
],
"description": "Spawn layered freeform radial gradients"
},
"Concentric block": {
"prefix": "concentric-block",
"body": ["@concentric ${1:1rem} {", " ${0}", "}"],
"description": "Concentric radius at-rule"
},
"View transition": {
"prefix": "view-transition",
"body": ["@view-transition ${1:name};"],
"description": "Emit a css view-transition-name"
},
"Compile-time values": {
"prefix": "javascript",
"body": ["javascript!", " const ${1:space} = ${2:1};", "end"],
"description": "Add compile-time values"
}
} }

View file

@ -1,13 +1,46 @@
const vscode = require("vscode"); const vscode = require("vscode");
const AT_RULES = new Set([ const AT_RULES = new Set([
"each", "for", "from", "through", "include", "responsive", "media", "supports", "each",
"concentric", "view-transition", "predict", "measure", "property", "mixin", "for",
"stcss", "import", "charset", "document", "font-face", "keyframes", "layer", "from",
"namespace", "page", "scope", "starting-style", "container", "through",
"include",
"responsive",
"media",
"supports",
"concentric",
"view-transition",
"predict",
"measure",
"property",
"mixin",
"stcss",
"import",
"charset",
"document",
"font-face",
"keyframes",
"layer",
"namespace",
"page",
"scope",
"starting-style",
"container",
]); ]);
const STATE_WORDS = new Set(["active", "clicked", "focus", "hover", "open", "closed", "selected", "loading", "visible", "hidden"]); const STATE_WORDS = new Set([
"active",
"clicked",
"focus",
"hover",
"open",
"closed",
"selected",
"loading",
"visible",
"hidden",
]);
const AT_RULE_DOCS = new Map([ const AT_RULE_DOCS = new Map([
["mixin", "Declare a reusable STCSS mixin: `@mixin name($param) { … }`"], ["mixin", "Declare a reusable STCSS mixin: `@mixin name($param) { … }`"],
@ -19,28 +52,98 @@ const AT_RULE_DOCS = new Map([
["supports", "Standard `@supports` feature wrapper."], ["supports", "Standard `@supports` feature wrapper."],
["concentric", "Emit concentric nested radii: `@concentric 0.75rem { … }`"], ["concentric", "Emit concentric nested radii: `@concentric 0.75rem { … }`"],
["view-transition", "Emit `view-transition-name` on the attached rule."], ["view-transition", "Emit `view-transition-name` on the attached rule."],
["predict", "Declare which properties transition when prediction prefill changes (emits a smooth transition rule)."], [
["measure", "Mark the attached rule (or listed selectors / `@anchor`s) as a prediction target. Prerender measures the elements and writes geometry. Bare `@anchor` arguments compile to `[data-muddle-anchor=\"anchor\"]`."], "predict",
"Declare which properties transition when prediction prefill changes (emits a smooth transition rule).",
],
[
"measure",
'Mark the attached rule (or listed selectors / `@anchor`s) as a prediction target. Prerender measures the elements and writes geometry. Bare `@anchor` arguments compile to `[data-muddle-anchor="anchor"]`.',
],
]); ]);
const REFERENCE_DOC = "Reads pre-computed geometry recorded from a `@sample` anchor: compiles to `var(--muddle-relate-…)`.\n\nMetrics: `left`, `top`, `width`, `height`. `@anchor` compiles to `[data-muddle-anchor=\"anchor\"]`."; const REFERENCE_DOC =
'Reads pre-computed geometry recorded from a `@sample` anchor: compiles to `var(--muddle-relate-…)`.\n\nMetrics: `left`, `top`, `width`, `height`. `@anchor` compiles to `[data-muddle-anchor="anchor"]`.';
const RELATE_DOC = "Legacy relation value of an anchor (deprecated in favour of `$refer[metric](@anchor)`)."; const RELATE_DOC =
"Legacy relation value of an anchor (deprecated in favour of `$refer[metric](@anchor)`).";
const CSS_PROPERTIES = [ const CSS_PROPERTIES = [
"background", "background-color", "background-image", "border", "border-color", "border-radius", "border-width", "background",
"bottom", "box-shadow", "box-sizing", "color", "cursor", "display", "flex", "flex-basis", "flex-direction", "background-color",
"flex-shrink", "flex-wrap", "font-family", "font-size", "font-weight", "gap", "grid-area", "height", "inset", "background-image",
"justify-content", "left", "letter-spacing", "line-height", "margin", "max-height", "max-width", "min-height", "border",
"min-width", "opacity", "overflow", "padding", "pointer-events", "position", "right", "text-decoration", "border-color",
"text-transform", "top", "transform", "transition", "vertical-align", "visibility", "white-space", "width", "border-radius",
"border-width",
"bottom",
"box-shadow",
"box-sizing",
"color",
"cursor",
"display",
"flex",
"flex-basis",
"flex-direction",
"flex-shrink",
"flex-wrap",
"font-family",
"font-size",
"font-weight",
"gap",
"grid-area",
"height",
"inset",
"justify-content",
"left",
"letter-spacing",
"line-height",
"margin",
"max-height",
"max-width",
"min-height",
"min-width",
"opacity",
"overflow",
"padding",
"pointer-events",
"position",
"right",
"text-decoration",
"text-transform",
"top",
"transform",
"transition",
"vertical-align",
"visibility",
"white-space",
"width",
"z-index", "z-index",
]; ];
const HTML_ATTRIBUTES = [ const HTML_ATTRIBUTES = [
"alt", "aria-controls", "aria-current", "aria-description", "aria-expanded", "aria-hidden", "aria-label", "alt",
"aria-live", "autocomplete", "class", "content", "href", "id", "placeholder", "rel", "role", "src", "style", "aria-controls",
"tabindex", "target", "title", "type", "aria-current",
"aria-description",
"aria-expanded",
"aria-hidden",
"aria-label",
"aria-live",
"autocomplete",
"class",
"content",
"href",
"id",
"placeholder",
"rel",
"role",
"src",
"style",
"tabindex",
"target",
"title",
"type",
]; ];
const INDEX = { const INDEX = {
@ -78,11 +181,17 @@ async function readText(uri) {
function posFromOffset(text, offset) { function posFromOffset(text, offset) {
const before = text.slice(0, offset); const before = text.slice(0, offset);
const newline = before.lastIndexOf("\n"); const newline = before.lastIndexOf("\n");
return new vscode.Position(before.split("\n").length - 1, offset - newline - 1); return new vscode.Position(
before.split("\n").length - 1,
offset - newline - 1,
);
} }
function rangeOf(text, offset, length) { function rangeOf(text, offset, length) {
return new vscode.Range(posFromOffset(text, offset), posFromOffset(text, offset + Math.max(length, 1))); return new vscode.Range(
posFromOffset(text, offset),
posFromOffset(text, offset + Math.max(length, 1)),
);
} }
function isStcss(document) { function isStcss(document) {
@ -121,7 +230,12 @@ function indexStcssDocument(uri, text) {
const anchorRegex = /@([A-Za-z_][\w-]*)/g; const anchorRegex = /@([A-Za-z_][\w-]*)/g;
while ((match = anchorRegex.exec(text))) { while ((match = anchorRegex.exec(text))) {
if (AT_RULES.has(match[1])) continue; if (AT_RULES.has(match[1])) continue;
rememberAnchor(match[1], uri, posFromOffset(text, match.index).line + 1, "referenced in STCSS"); rememberAnchor(
match[1],
uri,
posFromOffset(text, match.index).line + 1,
"referenced in STCSS",
);
} }
} }
@ -138,70 +252,152 @@ async function indexWorkspace() {
INDEX.spugFiles = spugFiles; INDEX.spugFiles = spugFiles;
INDEX.styleFiles = styleFiles; INDEX.styleFiles = styleFiles;
await Promise.all([ await Promise.all([
...spugFiles.map(async (uri) => indexSpugDocument(uri, await readText(uri))), ...spugFiles.map(async (uri) =>
...styleFiles.map(async (uri) => indexStcssDocument(uri, await readText(uri))), indexSpugDocument(uri, await readText(uri)),
),
...styleFiles.map(async (uri) =>
indexStcssDocument(uri, await readText(uri)),
),
]); ]);
} }
function anchorCompletions() { function anchorCompletions() {
return [...INDEX.anchors.keys()].map((name) => { return [...INDEX.anchors.keys()].map((name) => {
const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Keyword); const item = new vscode.CompletionItem(
`@${name}`,
vscode.CompletionItemKind.Keyword,
);
item.detail = "relation anchor"; item.detail = "relation anchor";
const first = INDEX.anchors.get(name)[0]; const first = INDEX.anchors.get(name)[0];
if (first) item.documentation = new vscode.MarkdownString(`${first.kind}: \`${relativeToWorkspace(first.uri)}:${first.line}\``); if (first)
item.documentation = new vscode.MarkdownString(
`${first.kind}: \`${relativeToWorkspace(first.uri)}:${first.line}\``,
);
return item; return item;
}); });
} }
function stcssCompletions() { function stcssCompletions() {
const atRules = [...AT_RULE_DOCS.keys()].map((name) => { const atRules = [...AT_RULE_DOCS.keys()].map((name) => {
const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Struct); const item = new vscode.CompletionItem(
`@${name}`,
vscode.CompletionItemKind.Struct,
);
item.detail = "stcss at-rule"; item.detail = "stcss at-rule";
item.insertText = new vscode.SnippetString(name === "concentric" || name === "media" ? `${name} $1 {\n\t$0\n}` : `${name};`); item.insertText = new vscode.SnippetString(
name === "concentric" || name === "media"
? `${name} $1 {\n\t$0\n}`
: `${name};`,
);
item.documentation = new vscode.MarkdownString(AT_RULE_DOCS.get(name)); item.documentation = new vscode.MarkdownString(AT_RULE_DOCS.get(name));
return item; return item;
}); });
const metrics = ["left", "top", "width", "height"].map((metric) => { const metrics = ["left", "top", "width", "height"].map((metric) => {
const item = new vscode.CompletionItem(`$refer[${metric}](@)`, vscode.CompletionItemKind.Function); const item = new vscode.CompletionItem(
item.insertText = new vscode.SnippetString(`$refer[${metric}](@\${1:anchor})`); `$refer[${metric}](@)`,
vscode.CompletionItemKind.Function,
);
item.insertText = new vscode.SnippetString(
`$refer[${metric}](@\${1:anchor})`,
);
item.documentation = new vscode.MarkdownString(REFERENCE_DOC); item.documentation = new vscode.MarkdownString(REFERENCE_DOC);
return item; return item;
}); });
const relate = new vscode.CompletionItem("$relate(@)", vscode.CompletionItemKind.Function); const relate = new vscode.CompletionItem(
"$relate(@)",
vscode.CompletionItemKind.Function,
);
relate.insertText = new vscode.SnippetString("$relate(@${1:anchor})"); relate.insertText = new vscode.SnippetString("$relate(@${1:anchor})");
relate.documentation = new vscode.MarkdownString(RELATE_DOC); relate.documentation = new vscode.MarkdownString(RELATE_DOC);
const states = [...STATE_WORDS].map((state) => { const states = [...STATE_WORDS].map((state) => {
const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.EnumMember); const item = new vscode.CompletionItem(
state,
vscode.CompletionItemKind.EnumMember,
);
item.detail = "anchor state"; item.detail = "anchor state";
return item; return item;
}); });
const properties = CSS_PROPERTIES.map((name) => { const properties = CSS_PROPERTIES.map((name) => {
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property); const item = new vscode.CompletionItem(
name,
vscode.CompletionItemKind.Property,
);
item.insertText = new vscode.SnippetString(`${name}: $0;`); item.insertText = new vscode.SnippetString(`${name}: $0;`);
return item; return item;
}); });
const variables = [...INDEX.variables.keys()].map((name) => { const variables = [...INDEX.variables.keys()].map((name) => {
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable); const item = new vscode.CompletionItem(
name,
vscode.CompletionItemKind.Variable,
);
item.detail = "stcss variable"; item.detail = "stcss variable";
return item; return item;
}); });
return [...atRules, ...metrics, relate, ...states, ...properties, ...variables]; return [
...atRules,
...metrics,
relate,
...states,
...properties,
...variables,
];
} }
function spugCompletions() { function spugCompletions() {
const keywords = ["extends", "include", "block", "append", "prepend", "mixin", "slot", "yield", "island", "component", "render", "doctype"].map((word) => { const keywords = [
const item = new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword); "extends",
"include",
"block",
"append",
"prepend",
"mixin",
"slot",
"yield",
"island",
"component",
"render",
"doctype",
].map((word) => {
const item = new vscode.CompletionItem(
word,
vscode.CompletionItemKind.Keyword,
);
return item; return item;
}); });
const javascript = new vscode.CompletionItem("javascript!", vscode.CompletionItemKind.Keyword); const javascript = new vscode.CompletionItem(
"javascript!",
vscode.CompletionItemKind.Keyword,
);
javascript.insertText = new vscode.SnippetString("javascript!\n\t$0\nend"); javascript.insertText = new vscode.SnippetString("javascript!\n\t$0\nend");
const control = ["if", "else", "unless", "each", "while", "case", "when", "default", "props"].map((word) => new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword)); const control = [
"if",
"else",
"unless",
"each",
"while",
"case",
"when",
"default",
"props",
].map(
(word) =>
new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword),
);
const attributes = HTML_ATTRIBUTES.map((name) => { const attributes = HTML_ATTRIBUTES.map((name) => {
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property); const item = new vscode.CompletionItem(
name,
vscode.CompletionItemKind.Property,
);
item.insertText = new vscode.SnippetString(`${name}=$0`); item.insertText = new vscode.SnippetString(`${name}=$0`);
return item; return item;
}); });
return [...keywords, javascript, ...control, ...attributes, ...anchorCompletions()]; return [
...keywords,
javascript,
...control,
...attributes,
...anchorCompletions(),
];
} }
function stcssHover(document, position) { function stcssHover(document, position) {
@ -212,14 +408,20 @@ function stcssHover(document, position) {
const variable = /\$[\w-]+$/.exec(preceding)?.[0]; const variable = /\$[\w-]+$/.exec(preceding)?.[0];
if (variable) { if (variable) {
const hits = INDEX.variables.get(variable) ?? []; const hits = INDEX.variables.get(variable) ?? [];
return new vscode.Hover(new vscode.MarkdownString( return new vscode.Hover(
hits.length ? `**${variable}**: defined in ${hits.map((h) => `${relativeToWorkspace(h.uri)}:${h.line}`).join(", ")}` : `**${variable}**`, new vscode.MarkdownString(
)); hits.length
? `**${variable}**: defined in ${hits.map((h) => `${relativeToWorkspace(h.uri)}:${h.line}`).join(", ")}`
: `**${variable}**`,
),
);
} }
const refer = /(?:\$\w+(?:\[[\w-]+\])?)$/.exec(preceding)?.[0]; const refer = /(?:\$\w+(?:\[[\w-]+\])?)$/.exec(preceding)?.[0];
if (refer) { if (refer) {
if (refer.startsWith("$refer")) return new vscode.Hover(new vscode.MarkdownString(REFERENCE_DOC)); if (refer.startsWith("$refer"))
if (refer.startsWith("$relate")) return new vscode.Hover(new vscode.MarkdownString(RELATE_DOC)); return new vscode.Hover(new vscode.MarkdownString(REFERENCE_DOC));
if (refer.startsWith("$relate"))
return new vscode.Hover(new vscode.MarkdownString(RELATE_DOC));
} }
return null; return null;
} }
@ -227,15 +429,30 @@ function stcssHover(document, position) {
function hoverAnchor(name) { function hoverAnchor(name) {
const hits = INDEX.anchors.get(name) ?? []; const hits = INDEX.anchors.get(name) ?? [];
if (!hits.length) return null; if (!hits.length) return null;
const body = hits.slice(0, 8).map((hit) => `- ${hit.kind}: \`${relativeToWorkspace(hit.uri)}:${hit.line}\``).join("\n"); const body = hits
return new vscode.Hover(new vscode.MarkdownString(`**@${name}**: relation anchor\n\n${body}`)); .slice(0, 8)
.map(
(hit) => `- ${hit.kind}: \`${relativeToWorkspace(hit.uri)}:${hit.line}\``,
)
.join("\n");
return new vscode.Hover(
new vscode.MarkdownString(`**@${name}**: relation anchor\n\n${body}`),
);
} }
function spugHover(document, position) { function spugHover(document, position) {
const range = document.getWordRangeAtPosition(position, /@?[A-Za-z_][\w-]*/); const range = document.getWordRangeAtPosition(position, /@?[A-Za-z_][\w-]*/);
if (!range) return null; if (!range) return null;
const word = document.getText(range); const word = document.getText(range);
if (word.startsWith("@") || document.getText(new vscode.Range(range.start.translate({ characterDelta: -1 }), range.start)) === "@") { if (
word.startsWith("@") ||
document.getText(
new vscode.Range(
range.start.translate({ characterDelta: -1 }),
range.start,
),
) === "@"
) {
return hoverAnchor(word.replace(/^@/, "")); return hoverAnchor(word.replace(/^@/, ""));
} }
return null; return null;
@ -244,23 +461,38 @@ function spugHover(document, position) {
function anchorDefinition(name) { function anchorDefinition(name) {
return (INDEX.anchors.get(name) ?? []) return (INDEX.anchors.get(name) ?? [])
.filter((hit) => hit.kind === "declared in SPUG") .filter((hit) => hit.kind === "declared in SPUG")
.map((hit) => new vscode.Location(hit.uri, new vscode.Position(hit.line - 1, 0))); .map(
(hit) =>
new vscode.Location(hit.uri, new vscode.Position(hit.line - 1, 0)),
);
} }
function stcssDefinition(document, position) { function stcssDefinition(document, position) {
const line = document.lineAt(position.line).text.slice(0, position.character + 1); const line = document
.lineAt(position.line)
.text.slice(0, position.character + 1);
const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(line)?.[1]; const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(line)?.[1];
if (anchor) return anchorDefinition(anchor); if (anchor) return anchorDefinition(anchor);
return null; return null;
} }
function spugDefinition(document, position) { function spugDefinition(document, position) {
const before = document.lineAt(position.line).text.slice(0, position.character + 1); const before = document
.lineAt(position.line)
.text.slice(0, position.character + 1);
const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(before)?.[1]; const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(before)?.[1];
if (anchor) { if (anchor) {
// jump from anchor to its STCSS references // jump from anchor to its STCSS references
const refs = (INDEX.anchors.get(anchor) ?? []).filter((hit) => hit.kind === "referenced in STCSS"); const refs = (INDEX.anchors.get(anchor) ?? []).filter(
return refs.map((hit) => new vscode.Location(hit.uri, new vscode.Range(hit.line - 1, 0, hit.line - 1, 200))); (hit) => hit.kind === "referenced in STCSS",
);
return refs.map(
(hit) =>
new vscode.Location(
hit.uri,
new vscode.Range(hit.line - 1, 0, hit.line - 1, 200),
),
);
} }
return null; return null;
} }
@ -270,7 +502,8 @@ function diagnosticCollection() {
} }
function lintDocument(document, collection) { function lintDocument(document, collection) {
if (document.languageId !== "stcss" && document.languageId !== "spug") return []; if (document.languageId !== "stcss" && document.languageId !== "spug")
return [];
const text = document.getText(); const text = document.getText();
const issues = []; const issues = [];
@ -285,13 +518,25 @@ function lintDocument(document, collection) {
} else if (char === "}") { } else if (char === "}") {
depth -= 1; depth -= 1;
if (depth < 0) { if (depth < 0) {
issues.push(new vscode.Diagnostic(rangeOf(text, index, 1), "Unexpected `}`.", vscode.DiagnosticSeverity.Error)); issues.push(
new vscode.Diagnostic(
rangeOf(text, index, 1),
"Unexpected `}`.",
vscode.DiagnosticSeverity.Error,
),
);
depth = 0; depth = 0;
} }
} }
} }
if (depth > 0) { if (depth > 0) {
issues.push(new vscode.Diagnostic(rangeOf(text, lastOpen, 1), "Unclosed `{` block.", vscode.DiagnosticSeverity.Error)); issues.push(
new vscode.Diagnostic(
rangeOf(text, lastOpen, 1),
"Unclosed `{` block.",
vscode.DiagnosticSeverity.Error,
),
);
} }
if (document.languageId === "stcss") { if (document.languageId === "stcss") {
@ -301,14 +546,23 @@ function lintDocument(document, collection) {
const name = match[1]; const name = match[1];
const offset = match.index; const offset = match.index;
const previous = text[offset - 1]; const previous = text[offset - 1];
const dynamic = text.slice(offset, text.indexOf("\n", offset) === -1 ? text.length : text.indexOf("\n", offset)).includes("("); const dynamic = text
.slice(
offset,
text.indexOf("\n", offset) === -1
? text.length
: text.indexOf("\n", offset),
)
.includes("(");
if (AT_RULES.has(name) || previous === "$" || dynamic) continue; if (AT_RULES.has(name) || previous === "$" || dynamic) continue;
if (!INDEX.anchors.has(name)) { if (!INDEX.anchors.has(name)) {
issues.push(new vscode.Diagnostic( issues.push(
new vscode.Diagnostic(
rangeOf(text, offset, match[0].length), rangeOf(text, offset, match[0].length),
`No SPUG file declares relation anchor \`@${name}\`.`, `No SPUG file declares relation anchor \`@${name}\`.`,
vscode.DiagnosticSeverity.Warning, vscode.DiagnosticSeverity.Warning,
)); ),
);
} }
} }
} }
@ -318,7 +572,8 @@ function lintDocument(document, collection) {
} }
function lintVisible() { function lintVisible() {
for (const editor of vscode.window.visibleTextEditors) lintDocument(editor.document, diagnostics); for (const editor of vscode.window.visibleTextEditors)
lintDocument(editor.document, diagnostics);
} }
function symbolKindFor(document) { function symbolKindFor(document) {
@ -339,15 +594,22 @@ function symbolsOf(document) {
"", "",
symbolKindFor(document), symbolKindFor(document),
new vscode.Range(line, 0, line, text.length), new vscode.Range(line, 0, line, text.length),
new vscode.Range(line, match[1].length, line, match[1].length + label.length), new vscode.Range(
line,
match[1].length,
line,
match[1].length + label.length,
),
); );
flat.push({ symbol, indent: match[1].length }); flat.push({ symbol, indent: match[1].length });
} }
const roots = []; const roots = [];
const stack = []; const stack = [];
for (const entry of flat) { for (const entry of flat) {
while (stack.length && entry.indent <= stack[stack.length - 1].indent) stack.pop(); while (stack.length && entry.indent <= stack[stack.length - 1].indent)
if (stack.length) stack[stack.length - 1].symbol.children.push(entry.symbol); stack.pop();
if (stack.length)
stack[stack.length - 1].symbol.children.push(entry.symbol);
else roots.push(entry.symbol); else roots.push(entry.symbol);
stack.push(entry); stack.push(entry);
} }
@ -372,14 +634,19 @@ function foldingOf(document) {
if (!text.trim()) continue; if (!text.trim()) continue;
while (stack.length && indent <= stack[stack.length - 1].indent) { while (stack.length && indent <= stack[stack.length - 1].indent) {
const start = stack.pop(); const start = stack.pop();
if (line - 1 > start) ranges.push(new vscode.FoldingRange(start, line - 1)); if (line - 1 > start)
ranges.push(new vscode.FoldingRange(start, line - 1));
} }
stack.push({ indent, line }); stack.push({ indent, line });
} }
} }
while (stack.length) { while (stack.length) {
const start = typeof stack[stack.length - 1] === "number" ? stack.pop() : stack.pop().line; const start =
if (document.lineCount - 1 > start) ranges.push(new vscode.FoldingRange(start, document.lineCount - 1)); typeof stack[stack.length - 1] === "number"
? stack.pop()
: stack.pop().line;
if (document.lineCount - 1 > start)
ranges.push(new vscode.FoldingRange(start, document.lineCount - 1));
} }
return ranges; return ranges;
} }
@ -393,7 +660,12 @@ function completionsFor(document, position) {
const prefix = before.trim(); const prefix = before.trim();
if (prefix.length === 0 && position.character > 0) return []; if (prefix.length === 0 && position.character > 0) return [];
// completions popped while just typing whitespace mid-line are noise // completions popped while just typing whitespace mid-line are noise
if (before.length > 0 && /\s$/.test(before) && !/[$@:(\[=,{]\s*$/.test(before)) return []; if (
before.length > 0 &&
/\s$/.test(before) &&
!/[$@:(\[=,{]\s*$/.test(before)
)
return [];
return isStcss(document) ? stcssCompletions(before) : spugCompletions(before); return isStcss(document) ? stcssCompletions(before) : spugCompletions(before);
} }
@ -412,8 +684,17 @@ function activate(context) {
vscode.languages.registerCompletionItemProvider( vscode.languages.registerCompletionItemProvider(
["stcss", "spug"], ["stcss", "spug"],
{ provideCompletionItems(document, position) { return completionsFor(document, position); } }, {
"@", "$", ".", "-", ":", "=", provideCompletionItems(document, position) {
return completionsFor(document, position);
},
},
"@",
"$",
".",
"-",
":",
"=",
), ),
vscode.languages.registerHoverProvider(["stcss", "spug"], { vscode.languages.registerHoverProvider(["stcss", "spug"], {
@ -421,7 +702,9 @@ function activate(context) {
const word = document.getWordRangeAtPosition(position, /[@$]?[\w-]+/); const word = document.getWordRangeAtPosition(position, /[@$]?[\w-]+/);
if (!word) return null; if (!word) return null;
try { try {
return isStcss(document) ? stcssHover(document, position) : spugHover(document, position); return isStcss(document)
? stcssHover(document, position)
: spugHover(document, position);
} catch { } catch {
return null; return null;
} }
@ -431,7 +714,9 @@ function activate(context) {
vscode.languages.registerDefinitionProvider(["stcss", "spug"], { vscode.languages.registerDefinitionProvider(["stcss", "spug"], {
provideDefinition(document, position) { provideDefinition(document, position) {
try { try {
return isStcss(document) ? stcssDefinition(document, position) : spugDefinition(document, position); return isStcss(document)
? stcssDefinition(document, position)
: spugDefinition(document, position);
} catch { } catch {
return null; return null;
} }
@ -439,14 +724,20 @@ function activate(context) {
}), }),
vscode.languages.registerDocumentSymbolProvider(["stcss", "spug"], { vscode.languages.registerDocumentSymbolProvider(["stcss", "spug"], {
provideDocumentSymbols(document) { return symbolsOf(document); }, provideDocumentSymbols(document) {
return symbolsOf(document);
},
}), }),
vscode.languages.registerFoldingRangeProvider(["stcss", "spug"], { vscode.languages.registerFoldingRangeProvider(["stcss", "spug"], {
provideFoldingRanges(document) { return foldingOf(document); }, provideFoldingRanges(document) {
return foldingOf(document);
},
}), }),
vscode.workspace.onDidOpenTextDocument((document) => lintDocument(document, diagnostics)), vscode.workspace.onDidOpenTextDocument((document) =>
lintDocument(document, diagnostics),
),
vscode.workspace.onDidChangeTextDocument((event) => { vscode.workspace.onDidChangeTextDocument((event) => {
lintDocument(event.document, diagnostics); lintDocument(event.document, diagnostics);
refresh(); refresh();

View file

@ -29,9 +29,13 @@
"frontmatter": { "frontmatter": {
"name": "meta.embedded.block.ts.spug", "name": "meta.embedded.block.ts.spug",
"begin": "^---\\s*$", "begin": "^---\\s*$",
"beginCaptures": { "0": { "name": "punctuation.definition.frontmatter.begin.spug" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.frontmatter.begin.spug" }
},
"end": "^---\\s*$", "end": "^---\\s*$",
"endCaptures": { "0": { "name": "punctuation.definition.frontmatter.end.spug" } }, "endCaptures": {
"0": { "name": "punctuation.definition.frontmatter.end.spug" }
},
"patterns": [{ "include": "source.ts" }] "patterns": [{ "include": "source.ts" }]
}, },
"javascript-block": { "javascript-block": {
@ -133,7 +137,9 @@
"2": { "name": "punctuation.definition.string.begin.stcss" } "2": { "name": "punctuation.definition.string.begin.stcss" }
}, },
"end": "(?<!\\\\)\\2", "end": "(?<!\\\\)\\2",
"endCaptures": { "0": { "name": "punctuation.definition.string.end.stcss" } }, "endCaptures": {
"0": { "name": "punctuation.definition.string.end.stcss" }
},
"patterns": [ "patterns": [
{ "include": "#interpolation" }, { "include": "#interpolation" },
{ "include": "#expression" } { "include": "#expression" }
@ -142,24 +148,37 @@
"output": { "output": {
"contentName": "meta.embedded.expression.ts", "contentName": "meta.embedded.expression.ts",
"begin": "(!?=)", "begin": "(!?=)",
"beginCaptures": { "1": { "name": "punctuation.separator.filters.spug" } }, "beginCaptures": {
"1": { "name": "punctuation.separator.filters.spug" }
},
"end": "$", "end": "$",
"patterns": [{ "include": "#expression" }] "patterns": [{ "include": "#expression" }]
}, },
"interpolation": { "interpolation": {
"name": "meta.interpolation.spug", "name": "meta.interpolation.spug",
"begin": "([#!])\\{", "begin": "([#!])\\{",
"beginCaptures": { "0": { "name": "punctuation.definition.interpolation.begin.spug" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.interpolation.begin.spug" }
},
"end": "\\}", "end": "\\}",
"endCaptures": { "0": { "name": "punctuation.definition.interpolation.end.spug" } }, "endCaptures": {
"0": { "name": "punctuation.definition.interpolation.end.spug" }
},
"contentName": "meta.embedded.expression.ts", "contentName": "meta.embedded.expression.ts",
"patterns": [{ "include": "#curly-nesting" }, { "include": "#expression" }] "patterns": [
{ "include": "#curly-nesting" },
{ "include": "#expression" }
]
}, },
"curly-nesting": { "curly-nesting": {
"begin": "\\{", "begin": "\\{",
"beginCaptures": { "0": { "name": "punctuation.definition.brace.curly.spug" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.brace.curly.spug" }
},
"end": "\\}", "end": "\\}",
"endCaptures": { "0": { "name": "punctuation.definition.brace.curly.spug" } }, "endCaptures": {
"0": { "name": "punctuation.definition.brace.curly.spug" }
},
"patterns": [ "patterns": [
{ "include": "#curly-nesting" }, { "include": "#curly-nesting" },
{ "include": "#expression" } { "include": "#expression" }
@ -191,9 +210,15 @@
"match": "\\b(?:if|else|each|while|return|function|const|let|var|import|from|export)\\b", "match": "\\b(?:if|else|each|while|return|function|const|let|var|import|from|export)\\b",
"name": "keyword.control.ts" "name": "keyword.control.ts"
}, },
{ "match": "\\b\\d+(?:\\.\\d+)?\\b", "name": "constant.numeric.decimal.js" }, {
"match": "\\b\\d+(?:\\.\\d+)?\\b",
"name": "constant.numeric.decimal.js"
},
{ "match": "[A-Za-z_$][\\w$]*", "name": "variable.other.js" }, { "match": "[A-Za-z_$][\\w$]*", "name": "variable.other.js" },
{ "match": "&&|\\|\\||[?:,+\\-*/%<>=!]=[?]?|[?:,+\\-*/%<>=!]", "name": "keyword.operator.js" } {
"match": "&&|\\|\\||[?:,+\\-*/%<>=!]=[?]?|[?:,+\\-*/%<>=!]",
"name": "keyword.operator.js"
}
] ]
} }
} }

View file

@ -53,9 +53,14 @@
"begin": "^([^\\n{}]*?)\\s*\\{", "begin": "^([^\\n{}]*?)\\s*\\{",
"end": "\\}", "end": "\\}",
"beginCaptures": { "beginCaptures": {
"1": { "name": "meta.selector.stcss", "patterns": [{ "include": "#selector-content" }] } "1": {
"name": "meta.selector.stcss",
"patterns": [{ "include": "#selector-content" }]
}
},
"endCaptures": {
"0": { "name": "punctuation.definition.block.end.bracket.curly.stcss" }
}, },
"endCaptures": { "0": { "name": "punctuation.definition.block.end.bracket.curly.stcss" } },
"patterns": [ "patterns": [
{ "include": "#comment" }, { "include": "#comment" },
{ "include": "#javascript-block" }, { "include": "#javascript-block" },
@ -132,9 +137,13 @@
"attribute-selector": { "attribute-selector": {
"name": "meta.attribute-selector.stcss", "name": "meta.attribute-selector.stcss",
"begin": "\\[", "begin": "\\[",
"beginCaptures": { "0": { "name": "punctuation.definition.attribute-selector.begin.stcss" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.attribute-selector.begin.stcss" }
},
"end": "\\]", "end": "\\]",
"endCaptures": { "0": { "name": "punctuation.definition.attribute-selector.end.stcss" } }, "endCaptures": {
"0": { "name": "punctuation.definition.attribute-selector.end.stcss" }
},
"patterns": [ "patterns": [
{ "include": "#interpolation" }, { "include": "#interpolation" },
{ "include": "#variable" }, { "include": "#variable" },
@ -169,16 +178,27 @@
"interpolation": { "interpolation": {
"name": "meta.interpolation.stcss", "name": "meta.interpolation.stcss",
"begin": "#\\{", "begin": "#\\{",
"beginCaptures": { "0": { "name": "punctuation.definition.interpolation.begin.stcss" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.interpolation.begin.stcss" }
},
"end": "\\}", "end": "\\}",
"endCaptures": { "0": { "name": "punctuation.definition.interpolation.end.stcss" } }, "endCaptures": {
"patterns": [{ "include": "#curly-nesting" }, { "include": "#expression" }] "0": { "name": "punctuation.definition.interpolation.end.stcss" }
},
"patterns": [
{ "include": "#curly-nesting" },
{ "include": "#expression" }
]
}, },
"curly-nesting": { "curly-nesting": {
"begin": "\\{", "begin": "\\{",
"beginCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.brace.curly.stcss" }
},
"end": "\\}", "end": "\\}",
"endCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } }, "endCaptures": {
"0": { "name": "punctuation.definition.brace.curly.stcss" }
},
"patterns": [ "patterns": [
{ "include": "#curly-nesting" }, { "include": "#curly-nesting" },
{ "include": "#expression" } { "include": "#expression" }
@ -192,15 +212,24 @@
"2": { "name": "punctuation.definition.string.begin.stcss" } "2": { "name": "punctuation.definition.string.begin.stcss" }
}, },
"end": "(?<!\\\\)\\2", "end": "(?<!\\\\)\\2",
"endCaptures": { "0": { "name": "punctuation.definition.string.end.stcss" } }, "endCaptures": {
"patterns": [{ "include": "#interpolation" }, { "include": "#expression" }] "0": { "name": "punctuation.definition.string.end.stcss" }
},
"patterns": [
{ "include": "#interpolation" },
{ "include": "#expression" }
]
}, },
"string": { "string": {
"name": "string.quoted.stcss", "name": "string.quoted.stcss",
"begin": "(['\"])", "begin": "(['\"])",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.stcss" } }, "beginCaptures": {
"0": { "name": "punctuation.definition.string.begin.stcss" }
},
"end": "(?<!\\\\)\\1", "end": "(?<!\\\\)\\1",
"endCaptures": { "0": { "name": "punctuation.definition.string.end.stcss" } } "endCaptures": {
"0": { "name": "punctuation.definition.string.end.stcss" }
}
}, },
"number": { "number": {
"match": "(-)?(?<!\\w)(\\d+\\.?\\d*)(rem|em|px|vh|vw|%|ms|s|vmin|vmax|fr|deg)?\\b", "match": "(-)?(?<!\\w)(\\d+\\.?\\d*)(rem|em|px|vh|vw|%|ms|s|vmin|vmax|fr|deg)?\\b",
@ -239,7 +268,10 @@
{ "include": "#number" }, { "include": "#number" },
{ "include": "#color" }, { "include": "#color" },
{ "include": "#variable" }, { "include": "#variable" },
{ "match": "--[\\w-]+", "name": "variable.other.css.custom-property.stcss" }, {
"match": "--[\\w-]+",
"name": "variable.other.css.custom-property.stcss"
},
{ "include": "#function-call" }, { "include": "#function-call" },
{ "include": "#operator" } { "include": "#operator" }
] ]

105
index.ts
View file

@ -58,10 +58,19 @@ export {
} from "./core/container.ts"; } from "./core/container.ts";
export { resolveConfig, loadConfig } from "./core/config.ts"; export { resolveConfig, loadConfig } from "./core/config.ts";
export { ServerBuildContext, TemplateContext } from "./core/context.ts"; export { ServerBuildContext, TemplateContext } from "./core/context.ts";
export { MemorySessionStore, type SessionStore, type SessionRecord } from "./core/sessions.ts"; export {
MemorySessionStore,
type SessionStore,
type SessionRecord,
} from "./core/sessions.ts";
export { RoutePipeline, type RouteGuard } from "./core/pipeline.ts"; export { RoutePipeline, type RouteGuard } from "./core/pipeline.ts";
export { format } from "./core/format.ts"; export { format } from "./core/format.ts";
export { createTemplate, muddleTemplates, type MuddleTemplate, type TemplateResult } from "./core/templates.ts"; export {
createTemplate,
muddleTemplates,
type MuddleTemplate,
type TemplateResult,
} from "./core/templates.ts";
export { export {
MuddleError, MuddleError,
RouteNotFoundError, RouteNotFoundError,
@ -122,8 +131,19 @@ export {
export { Route, getRoutes } from "./decorators/Route.ts"; export { Route, getRoutes } from "./decorators/Route.ts";
export { Service } from "./decorators/Service.ts"; export { Service } from "./decorators/Service.ts";
export { View, getViews } from "./decorators/View.ts"; export { View, getViews } from "./decorators/View.ts";
export { RenderAhead, getRenderAhead, type RenderAheadSource } from "./decorators/RenderAhead.ts"; export {
export { RenderRoute, getRenderRoutes, routeFolder, collectionRoute, type CollectionRenderRoute, type RenderRouteSource } from "./decorators/RenderRoute.ts"; RenderAhead,
getRenderAhead,
type RenderAheadSource,
} from "./decorators/RenderAhead.ts";
export {
RenderRoute,
getRenderRoutes,
routeFolder,
collectionRoute,
type CollectionRenderRoute,
type RenderRouteSource,
} from "./decorators/RenderRoute.ts";
export { Astro, type AstroOptions } from "./decorators/Astro.ts"; export { Astro, type AstroOptions } from "./decorators/Astro.ts";
export { Auth, getAuth, type AuthOptions } from "./decorators/Auth.ts"; export { Auth, getAuth, type AuthOptions } from "./decorators/Auth.ts";
@ -145,8 +165,24 @@ export {
type ServerComponent, type ServerComponent,
type SpugComponentRender, type SpugComponentRender,
} from "./views/components.ts"; } from "./views/components.ts";
export { compileCss, stcssLoader, sassLoader, styleTag, type SassLoaderOptions } from "./views/css/index.ts"; export {
export { analyzeReactComponent, backportProject, cssToStcss, detectBackportSource, htmlToSpug, reactToSpug, type BackportResult, type BackportSource, type ReactHookAnalysis } from "./core/backport.ts"; compileCss,
stcssLoader,
sassLoader,
styleTag,
type SassLoaderOptions,
} from "./views/css/index.ts";
export {
analyzeReactComponent,
backportProject,
cssToStcss,
detectBackportSource,
htmlToSpug,
reactToSpug,
type BackportResult,
type BackportSource,
type ReactHookAnalysis,
} from "./core/backport.ts";
export { export {
markdownLoader, markdownLoader,
parseFrontmatter, parseFrontmatter,
@ -162,8 +198,18 @@ export {
type RenderContext, type RenderContext,
type TemplateRuntime, type TemplateRuntime,
} from "./views/engine/runtime.ts"; } from "./views/engine/runtime.ts";
export { SPUG_COMPLETIONS, STCSS_COMPLETIONS, type LanguageCompletion } from "./views/language.ts"; export {
export { stage, StageManager, type KeyframeStep, type ScrollSceneOptions, type TransitionPortalOptions } from "./views/stage.ts"; SPUG_COMPLETIONS,
STCSS_COMPLETIONS,
type LanguageCompletion,
} from "./views/language.ts";
export {
stage,
StageManager,
type KeyframeStep,
type ScrollSceneOptions,
type TransitionPortalOptions,
} from "./views/stage.ts";
export { export {
createHooks, createHooks,
defineHooks, defineHooks,
@ -181,7 +227,12 @@ export {
type StcssValueHookContext, type StcssValueHookContext,
} from "./views/hooks.ts"; } from "./views/hooks.ts";
export { filesystemCache, cloudflareCache, type Cache, type CloudflareCacheLike } from "./core/cache.ts"; export {
filesystemCache,
cloudflareCache,
type Cache,
type CloudflareCacheLike,
} from "./core/cache.ts";
// Adapters // Adapters
export { serve, type BunServerOptions } from "./adapters/server-bun.ts"; export { serve, type BunServerOptions } from "./adapters/server-bun.ts";
@ -191,13 +242,23 @@ export {
type WorkerEnv, type WorkerEnv,
type WranglerSetupOptions, type WranglerSetupOptions,
} from "./adapters/cloudflare-wrangler.ts"; } from "./adapters/cloudflare-wrangler.ts";
export { createHandler, setupVercel, type NodeHandler } from "./adapters/vercel-vercel.ts"; export {
createHandler,
setupVercel,
type NodeHandler,
} from "./adapters/vercel-vercel.ts";
export { export {
prerender, prerender,
type PrerenderOptions, type PrerenderOptions,
type PrerenderResult, type PrerenderResult,
} from "./adapters/static.ts"; } from "./adapters/static.ts";
export { predictLayout, type BreakpointAcc, type LayoutPrediction, type LayoutPredictionOptions, type ReflectiveAnchorGroup } from "./adapters/prediction.ts"; export {
predictLayout,
type BreakpointAcc,
type LayoutPrediction,
type LayoutPredictionOptions,
type ReflectiveAnchorGroup,
} from "./adapters/prediction.ts";
// Integrations // Integrations
export { export {
@ -211,10 +272,26 @@ export {
type ReactIntegrationOptions, type ReactIntegrationOptions,
type TrackerOptions, type TrackerOptions,
} from "./integrations/react.ts"; } from "./integrations/react.ts";
export { concentric, layoutHooks, type Utility, type UtilityContext } from "./integrations/hooks.ts"; export {
concentric,
layoutHooks,
type Utility,
type UtilityContext,
} from "./integrations/hooks.ts";
export { googleFonts, type GoogleFontsOptions } from "./integrations/fonts.ts"; export { googleFonts, type GoogleFontsOptions } from "./integrations/fonts.ts";
export { responsivityIntegration, DEFAULT_BREAKPOINTS, type ResponsivityOptions } from "./integrations/responsivity.ts"; export {
export { authIntegration, authFromRequest, authGuard, type AuthIdentity, type OAuthProvider, type AuthIntegrationOptions } from "./integrations/auth.ts"; responsivityIntegration,
DEFAULT_BREAKPOINTS,
type ResponsivityOptions,
} from "./integrations/responsivity.ts";
export {
authIntegration,
authFromRequest,
authGuard,
type AuthIdentity,
type OAuthProvider,
type AuthIntegrationOptions,
} from "./integrations/auth.ts";
// Testing // Testing
export { export {

View file

@ -3,93 +3,246 @@ import type { RouteGuard } from "../core/pipeline.ts";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { MemorySessionStore, type SessionStore } from "../core/sessions.ts"; import { MemorySessionStore, type SessionStore } from "../core/sessions.ts";
export const authGuard: RouteGuard = async (context) => { if (!context.locals.auth) { const identity = await authFromRequest(context.raw as Request); if (identity) context.locals.auth = identity; } }; export const authGuard: RouteGuard = async (context) => {
if (!context.locals.auth) {
const identity = await authFromRequest(context.raw as Request);
if (identity) context.locals.auth = identity;
}
};
export interface AuthIdentity { id: string; email?: string; roles?: string[] } export interface AuthIdentity {
export interface OAuthProvider { name: string; authorizationUrl: string; clientId: string; redirectUri: string; scope?: string[]; tokenUrl: string; clientSecret?: string; userInfoUrl: string; identity(profile: Record<string, unknown>): AuthIdentity } id: string;
email?: string;
roles?: string[];
}
export interface OAuthProvider {
name: string;
authorizationUrl: string;
clientId: string;
redirectUri: string;
scope?: string[];
tokenUrl: string;
clientSecret?: string;
userInfoUrl: string;
identity(profile: Record<string, unknown>): AuthIdentity;
}
export interface AuthIntegrationOptions { export interface AuthIntegrationOptions {
login?: (email: string, password: string) => Promise<AuthIdentity | undefined> | AuthIdentity | undefined; login?: (
email: string,
password: string,
) => Promise<AuthIdentity | undefined> | AuthIdentity | undefined;
providers?: OAuthProvider[]; providers?: OAuthProvider[];
route?: string; route?: string;
store?: SessionStore | ((context: IntegrationContext) => Promise<SessionStore> | SessionStore); store?:
| SessionStore
| ((context: IntegrationContext) => Promise<SessionStore> | SessionStore);
sessionTtl?: number; sessionTtl?: number;
} }
const defaultStore = new MemorySessionStore(); const defaultStore = new MemorySessionStore();
const sessionKey = (id: string) => `session:${createHash("sha256").update(id).digest("hex")}`; const sessionKey = (id: string) =>
async function cookie(identity: AuthIdentity, store: SessionStore, ttl: number): Promise<string> { `session:${createHash("sha256").update(id).digest("hex")}`;
async function cookie(
identity: AuthIdentity,
store: SessionStore,
ttl: number,
): Promise<string> {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await store.set(sessionKey(id), { value: { identity }, expires: Date.now() + ttl * 1000 }); await store.set(sessionKey(id), {
value: { identity },
expires: Date.now() + ttl * 1000,
});
return `muddle_auth=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttl}`; return `muddle_auth=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttl}`;
} }
/** Form and OAuth entry points. Token exchange remains provider-owned. */ /** Form and OAuth entry points. Token exchange remains provider-owned. */
export function authIntegration(options: AuthIntegrationOptions = {}): Integration { export function authIntegration(
options: AuthIntegrationOptions = {},
): Integration {
const route = options.route ?? "/auth"; const route = options.route ?? "/auth";
let store: SessionStore = defaultStore; let store: SessionStore = defaultStore;
const ttl = options.sessionTtl ?? 3600; const ttl = options.sessionTtl ?? 3600;
return { return {
name: "auth", name: "auth",
async request(context) { const identity = await authFromRequest(context.raw as Request, store); if (identity) context.locals.auth = identity; }, async request(context) {
const identity = await authFromRequest(context.raw as Request, store);
if (identity) context.locals.auth = identity;
},
async setup(context: IntegrationContext) { async setup(context: IntegrationContext) {
store = typeof options.store === "function" ? await options.store(context) : options.store ?? defaultStore; store =
context.route("GET", `${route}/login`, () => new Response('<form method="post"><label>Email <input name="email" type="email"></label><label>Password <input name="password" type="password"></label><button>Sign in</button></form>', { headers: { "content-type": "text/html; charset=utf-8" } })); typeof options.store === "function"
? await options.store(context)
: (options.store ?? defaultStore);
context.route(
"GET",
`${route}/login`,
() =>
new Response(
'<form method="post"><label>Email <input name="email" type="email"></label><label>Password <input name="password" type="password"></label><button>Sign in</button></form>',
{ headers: { "content-type": "text/html; charset=utf-8" } },
),
);
context.route("POST", `${route}/login`, async (request, ctx) => { context.route("POST", `${route}/login`, async (request, ctx) => {
if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); if (request.headers.get("origin") !== new URL(request.url).origin)
if (!ctx.body || typeof ctx.body !== "object" || Array.isArray(ctx.body)) return new Response("Invalid credentials", { status: 400 }); return new Response("Invalid origin", { status: 403 });
if (
!ctx.body ||
typeof ctx.body !== "object" ||
Array.isArray(ctx.body)
)
return new Response("Invalid credentials", { status: 400 });
const data = ctx.body as Record<string, string>; const data = ctx.body as Record<string, string>;
const identity = await options.login?.(String(data.email ?? ""), String(data.password ?? "")); const identity = await options.login?.(
if (request.headers.get("content-type")?.includes("application/json")) return identity ? Response.json({ user: identity }, { headers: { "set-cookie": await cookie(identity, store, ttl) } }) : Response.json({ error: "Unauthorized" }, { status: 401 }); String(data.email ?? ""),
return identity ? new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(identity, store, ttl) } }) : new Response("Unauthorized", { status: 401 }); String(data.password ?? ""),
);
if (request.headers.get("content-type")?.includes("application/json"))
return identity
? Response.json(
{ user: identity },
{
headers: { "set-cookie": await cookie(identity, store, ttl) },
},
)
: Response.json({ error: "Unauthorized" }, { status: 401 });
return identity
? new Response(null, {
status: 302,
headers: {
location: "/",
"set-cookie": await cookie(identity, store, ttl),
},
})
: new Response("Unauthorized", { status: 401 });
}); });
context.route("POST", `${route}/logout`, async (request) => { context.route("POST", `${route}/logout`, async (request) => {
if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); if (request.headers.get("origin") !== new URL(request.url).origin)
const id = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; return new Response("Invalid origin", { status: 403 });
const id = request.headers
.get("cookie")
?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1];
if (id) await store.delete(sessionKey(id)); if (id) await store.delete(sessionKey(id));
return new Response(null, { status: 302, headers: { location: "/", "set-cookie": "muddle_auth=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" } }); return new Response(null, {
status: 302,
headers: {
location: "/",
"set-cookie":
"muddle_auth=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax",
},
});
}); });
for (const provider of options.providers ?? []) { for (const provider of options.providers ?? []) {
context.route("GET", `${route}/${provider.name}`, async () => { context.route("GET", `${route}/${provider.name}`, async () => {
const state = crypto.randomUUID(); const state = crypto.randomUUID();
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"); const verifier = Buffer.from(
await store.set(`oauth:${state}`, { value: { verifier, provider: provider.name }, expires: Date.now() + 600_000 }); crypto.getRandomValues(new Uint8Array(32)),
).toString("base64url");
await store.set(`oauth:${state}`, {
value: { verifier, provider: provider.name },
expires: Date.now() + 600_000,
});
const url = new URL(provider.authorizationUrl); const url = new URL(provider.authorizationUrl);
url.searchParams.set("client_id", provider.clientId); url.searchParams.set("client_id", provider.clientId);
url.searchParams.set("redirect_uri", provider.redirectUri); url.searchParams.set("redirect_uri", provider.redirectUri);
url.searchParams.set("response_type", "code"); url.searchParams.set("response_type", "code");
url.searchParams.set("scope", (provider.scope ?? []).join(" ")); url.searchParams.set("scope", (provider.scope ?? []).join(" "));
url.searchParams.set("state", state); url.searchParams.set("state", state);
url.searchParams.set("code_challenge", Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")); url.searchParams.set(
"code_challenge",
Buffer.from(
await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
),
).toString("base64url"),
);
url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("code_challenge_method", "S256");
return new Response(null, { status: 302, headers: { location: url.toString(), "set-cookie": `muddle_oauth=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600` } }); return new Response(null, {
status: 302,
headers: {
location: url.toString(),
"set-cookie": `muddle_oauth=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
},
}); });
context.route("GET", `${route}/${provider.name}/callback`, async (request) => { });
context.route(
"GET",
`${route}/${provider.name}/callback`,
async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
const state = url.searchParams.get("state") ?? ""; const state = url.searchParams.get("state") ?? "";
const bound = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_oauth=([^;]+)/)?.[1]; const bound = request.headers
if (bound !== state || !url.searchParams.get("code")) return new Response("Invalid OAuth state", { status: 400 }); .get("cookie")
?.match(/(?:^|;\s*)muddle_oauth=([^;]+)/)?.[1];
if (bound !== state || !url.searchParams.get("code"))
return new Response("Invalid OAuth state", { status: 400 });
const record = await store.consume(`oauth:${state}`); const record = await store.consume(`oauth:${state}`);
const transaction = record?.value as { provider: string; verifier: string } | undefined; const transaction = record?.value as
if (!transaction || record!.expires < Date.now() || transaction.provider !== provider.name) return new Response("Invalid OAuth state", { status: 400 }); | { provider: string; verifier: string }
const body = new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code")!, client_id: provider.clientId, redirect_uri: provider.redirectUri, code_verifier: transaction.verifier }); | undefined;
if (provider.clientSecret) body.set("client_secret", provider.clientSecret); if (
const tokenResponse = await fetch(provider.tokenUrl, { method: "POST", body }); !transaction ||
if (!tokenResponse.ok) return new Response("OAuth exchange failed", { status: 502 }); record!.expires < Date.now() ||
const token = await tokenResponse.json() as { access_token?: string }; transaction.provider !== provider.name
if (!token.access_token) return new Response("Missing OAuth token", { status: 502 }); )
const profile = await fetch(provider.userInfoUrl, { headers: { authorization: `Bearer ${token.access_token}` } }); return new Response("Invalid OAuth state", { status: 400 });
if (!profile.ok) return new Response("OAuth profile failed", { status: 502 }); const body = new URLSearchParams({
return new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(provider.identity(await profile.json() as Record<string, unknown>), store, ttl) } }); grant_type: "authorization_code",
code: url.searchParams.get("code")!,
client_id: provider.clientId,
redirect_uri: provider.redirectUri,
code_verifier: transaction.verifier,
}); });
if (provider.clientSecret)
body.set("client_secret", provider.clientSecret);
const tokenResponse = await fetch(provider.tokenUrl, {
method: "POST",
body,
});
if (!tokenResponse.ok)
return new Response("OAuth exchange failed", { status: 502 });
const token = (await tokenResponse.json()) as {
access_token?: string;
};
if (!token.access_token)
return new Response("Missing OAuth token", { status: 502 });
const profile = await fetch(provider.userInfoUrl, {
headers: { authorization: `Bearer ${token.access_token}` },
});
if (!profile.ok)
return new Response("OAuth profile failed", { status: 502 });
return new Response(null, {
status: 302,
headers: {
location: "/",
"set-cookie": await cookie(
provider.identity(
(await profile.json()) as Record<string, unknown>,
),
store,
ttl,
),
},
});
},
);
} }
}, },
}; };
} }
export async function authFromRequest(request: Request, store: SessionStore = defaultStore): Promise<AuthIdentity | undefined> { export async function authFromRequest(
const value = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; request: Request,
store: SessionStore = defaultStore,
): Promise<AuthIdentity | undefined> {
const value = request.headers
.get("cookie")
?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1];
if (!value) return undefined; if (!value) return undefined;
const session = await store.get(sessionKey(value)); const session = await store.get(sessionKey(value));
if (!session || session.expires < Date.now()) { await store.delete(sessionKey(value)); return undefined; } if (!session || session.expires < Date.now()) {
await store.delete(sessionKey(value));
return undefined;
}
return session.value.identity as AuthIdentity; return session.value.identity as AuthIdentity;
} }

View file

@ -11,9 +11,15 @@ export interface GoogleFontsOptions {
function memoryCache(): Cache { function memoryCache(): Cache {
const entries = new Map<string, Uint8Array>(); const entries = new Map<string, Uint8Array>();
return { return {
async get(key) { return entries.get(key); }, async get(key) {
async put(key, value) { entries.set(key, value); }, return entries.get(key);
async delete(key) { entries.delete(key); }, },
async put(key, value) {
entries.set(key, value);
},
async delete(key) {
entries.delete(key);
},
}; };
} }
@ -22,7 +28,9 @@ function text(value: Uint8Array): string {
} }
function googleUrl(options: GoogleFontsOptions): string { function googleUrl(options: GoogleFontsOptions): string {
const family = options.families.map((value) => `family=${encodeURIComponent(value.replace(/\s+/g, "+"))}`).join("&"); const family = options.families
.map((value) => `family=${encodeURIComponent(value.replace(/\s+/g, "+"))}`)
.join("&");
return `https://fonts.googleapis.com/css2?${family}&display=${options.display ?? "swap"}`; return `https://fonts.googleapis.com/css2?${family}&display=${options.display ?? "swap"}`;
} }
@ -43,13 +51,23 @@ export function googleFonts(options: GoogleFontsOptions): Integration {
css = new Uint8Array(await response.arrayBuffer()); css = new Uint8Array(await response.arrayBuffer());
await cache.put(key, css, { ttl: 86400 }); await cache.put(key, css, { ttl: 86400 });
} }
const proxied = text(css).replace(/url\((https:\/\/[^)]+)\)/g, (_match, url: string) => `url(${proxyPrefix}${encodeURIComponent(url)})`); const proxied = text(css).replace(
return new Response(proxied, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=86400" } }); /url\((https:\/\/[^)]+)\)/g,
(_match, url: string) =>
`url(${proxyPrefix}${encodeURIComponent(url)})`,
);
return new Response(proxied, {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=86400",
},
});
}); });
context.route("GET", `${proxyPrefix}*`, async (request) => { context.route("GET", `${proxyPrefix}*`, async (request) => {
const encoded = new URL(request.url).pathname.slice(proxyPrefix.length); const encoded = new URL(request.url).pathname.slice(proxyPrefix.length);
const url = decodeURIComponent(encoded); const url = decodeURIComponent(encoded);
if (!url.startsWith("https://fonts.gstatic.com/")) return new Response("Not Found", { status: 404 }); if (!url.startsWith("https://fonts.gstatic.com/"))
return new Response("Not Found", { status: 404 });
const key = `font:${url}`; const key = `font:${url}`;
let font = await cache.get(key); let font = await cache.get(key);
if (!font) { if (!font) {
@ -58,7 +76,12 @@ export function googleFonts(options: GoogleFontsOptions): Integration {
font = new Uint8Array(await response.arrayBuffer()); font = new Uint8Array(await response.arrayBuffer());
await cache.put(key, font, { ttl: 31536000 }); await cache.put(key, font, { ttl: 31536000 });
} }
return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable" } }); return new Response(font, {
headers: {
"content-type": "font/woff2",
"cache-control": "public, max-age=31536000, immutable",
},
});
}); });
}, },
}; };

View file

@ -29,7 +29,8 @@ export function layoutHooks(
const utilities = new Map<string, Utility>(); const utilities = new Map<string, Utility>();
hook({ hook({
utilities: (name, utility) => utilities.set(name, utility), utilities: (name, utility) => utilities.set(name, utility),
concentric: (name) => utilities.set(name ?? "concentric", concentric(name)), concentric: (name) =>
utilities.set(name ?? "concentric", concentric(name)),
}); });
context.loader("utilities", { context.loader("utilities", {
name: "utilities", name: "utilities",
@ -38,7 +39,10 @@ export function layoutHooks(
const css = file.source.split(/\s+/).flatMap((token) => { const css = file.source.split(/\s+/).flatMap((token) => {
const [name, value] = token.split(":", 2); const [name, value] = token.split(":", 2);
const utility = utilities.get(name ?? ""); const utility = utilities.get(name ?? "");
return utility?.({ name: name ?? "", value, selector: `.${token}` }) ?? []; return (
utility?.({ name: name ?? "", value, selector: `.${token}` }) ??
[]
);
}); });
return { data: {}, content: css.join("\n") }; return { data: {}, content: css.join("\n") };
}, },

View file

@ -107,7 +107,7 @@ export function mouseTrackerCss(options: TrackerOptions = {}): string {
const rules = Array.from({ length: columns * rows }, (_, index) => { const rules = Array.from({ length: columns * rows }, (_, index) => {
const x = Math.floor(index / rows) + 1; const x = Math.floor(index / rows) + 1;
const y = (index % rows) + 1; const y = (index % rows) + 1;
return `${selector}:has(.mouse-tracker__cell:nth-child(${index + 1}):hover) { --muddle-mouse-x: ${x / columns * 100}%; --muddle-mouse-y: ${y / rows * 100}%; }`; return `${selector}:has(.mouse-tracker__cell:nth-child(${index + 1}):hover) { --muddle-mouse-x: ${(x / columns) * 100}%; --muddle-mouse-y: ${(y / rows) * 100}%; }`;
}); });
return `${selector} { position: relative; --muddle-mouse-x: 50%; --muddle-mouse-y: 50%; }\n${selector} > .mouse-tracker { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr); pointer-events: none; }\n${selector}[data-muddle-capture="true"] > .mouse-tracker .mouse-tracker__cell { pointer-events: auto; }\n${rules.join("\n")}`; return `${selector} { position: relative; --muddle-mouse-x: 50%; --muddle-mouse-y: 50%; }\n${selector} > .mouse-tracker { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr); pointer-events: none; }\n${selector}[data-muddle-capture="true"] > .mouse-tracker .mouse-tracker__cell { pointer-events: auto; }\n${rules.join("\n")}`;
} }
@ -179,7 +179,10 @@ export function reactIntegration(
}, },
}); });
context.route("GET", route, () => context.route(
"GET",
route,
() =>
new Response(reactCss(options), { new Response(reactCss(options), {
headers: { headers: {
"content-type": "text/css; charset=utf-8", "content-type": "text/css; charset=utf-8",

View file

@ -6,10 +6,17 @@ export interface ResponsivityOptions {
route?: string; route?: string;
} }
export const DEFAULT_BREAKPOINTS = { sm: "40rem", md: "48rem", lg: "64rem", xl: "80rem" }; export const DEFAULT_BREAKPOINTS = {
sm: "40rem",
md: "48rem",
lg: "64rem",
xl: "80rem",
};
/** Adds mobile-first `@responsive name {}` blocks to STCSS. */ /** Adds mobile-first `@responsive name {}` blocks to STCSS. */
export function responsivityIntegration(options: ResponsivityOptions = {}): Integration { export function responsivityIntegration(
options: ResponsivityOptions = {},
): Integration {
const breakpoints = { ...DEFAULT_BREAKPOINTS, ...options.breakpoints }; const breakpoints = { ...DEFAULT_BREAKPOINTS, ...options.breakpoints };
return { return {
name: "responsivity", name: "responsivity",
@ -17,7 +24,12 @@ export function responsivityIntegration(options: ResponsivityOptions = {}): Inte
context.loader("responsive-stcss", { context.loader("responsive-stcss", {
name: "responsive-stcss", name: "responsive-stcss",
extensions: [".responsive.stcss"], extensions: [".responsive.stcss"],
load(file) { return { data: { breakpoints }, content: compileCss(file.source, { breakpoints }) }; }, load(file) {
return {
data: { breakpoints },
content: compileCss(file.source, { breakpoints }),
};
},
}); });
}, },
}; };

View file

@ -1,2 +1,5 @@
/** Compatibility export. The implementation lives in the isolated package. */ /** Compatibility export. The implementation lives in the isolated package. */
export { shadcnIntegration, type ShadcnIntegrationOptions } from "../modules/shadcn/src/integration.ts"; export {
shadcnIntegration,
type ShadcnIntegrationOptions,
} from "../modules/shadcn/src/integration.ts";

View file

@ -7,6 +7,9 @@ default:
test: test:
bun test ./tests bun test ./tests
fmt:
bunx @biomejs/biome format --fix
loc: loc:
@echo "Loc: counting source lines in the codebase..." @echo "Loc: counting source lines in the codebase..."
@find . \ @find . \

View file

@ -6,38 +6,109 @@
*/ */
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import type { import type { Integration, Mounts } from "muddle";
Integration,
Mounts,
} from "muddle";
import type { Views } from "muddle/views"; import type { Views } from "muddle/views";
export interface AmezingAccentPreset {
export interface AmezingAccentPreset { name: string; light: string; dark: string } name: string;
light: string;
dark: string;
}
/** Accent presets from ametrine/data/accent_presets.toml. */ /** Accent presets from ametrine/data/accent_presets.toml. */
export const ACCENT_PRESETS: Record<string, AmezingAccentPreset> = { export const ACCENT_PRESETS: Record<string, AmezingAccentPreset> = {
red: { name: "red", light: "hsl(342.0779, 72%, 58%)", dark: "hsl(345.18988, 100%, 69%)" }, red: {
orange: { name: "orange", light: "hsl(15.771428, 74%, 54%)", dark: "hsl(19.731544, 96%, 70%)" }, name: "red",
yellow: { name: "yellow", light: "hsl(34.639175, 91%, 42%)", dark: "hsl(44.70588, 100%, 70%)" }, light: "hsl(342.0779, 72%, 58%)",
green: { name: "green", light: "hsl(153.7815, 61%, 38%)", dark: "hsl(90, 59%, 66%)" }, dark: "hsl(345.18988, 100%, 69%)",
blue: { name: "blue", light: "hsl(192, 71%, 38%)", dark: "hsl(186.42857, 71%, 69%)" }, },
purple: { name: "purple", light: "hsl(254.11766, 44%, 55%)", dark: "hsl(249.88234, 77%, 78%)" }, orange: {
teal: { name: "teal", light: "hsl(180, 65%, 35%)", dark: "hsl(183, 85%, 65%)" }, name: "orange",
pink: { name: "pink", light: "hsl(333.88236, 69%, 58%)", dark: "hsl(336.78634, 100%, 70%)" }, light: "hsl(15.771428, 74%, 54%)",
cyan: { name: "cyan", light: "hsl(195, 71%, 35%)", dark: "hsl(193.41046, 72%, 70%)" }, dark: "hsl(19.731544, 96%, 70%)",
indigo: { name: "indigo", light: "hsl(232.05884, 61%, 42%)", dark: "hsl(234.61538, 73%, 71%)" }, },
amber: { name: "amber", light: "hsl(38.57695, 94%, 44%)", dark: "hsl(40.35484, 100%, 72%)" }, yellow: {
ochrea: { name: "ochrea", light: "hsl(45.75298, 95%, 45%)", dark: "hsl(46.09091, 100%, 78%)" }, name: "yellow",
lime: { name: "lime", light: "hsl(86.25406, 60%, 42%)", dark: "hsl(86.84211, 93%, 71%)" }, light: "hsl(34.639175, 91%, 42%)",
maroon: { name: "maroon", light: "hsl(321.81819, 26%, 41%)", dark: "hsl(322.10526, 66%, 66%)" }, dark: "hsl(44.70588, 100%, 70%)",
navy: { name: "navy", light: "hsl(216, 44%, 38%)", dark: "hsl(218, 69%, 69%)" }, },
olive: { name: "olive", light: "hsl(81, 36%, 41%)", dark: "hsl(81, 64%, 65%)" }, green: {
slate: { name: "slate", light: "hsl(212.53197, 15%, 44%)", dark: "hsl(212.90323, 61%, 72%)" }, name: "green",
light: "hsl(153.7815, 61%, 38%)",
dark: "hsl(90, 59%, 66%)",
},
blue: {
name: "blue",
light: "hsl(192, 71%, 38%)",
dark: "hsl(186.42857, 71%, 69%)",
},
purple: {
name: "purple",
light: "hsl(254.11766, 44%, 55%)",
dark: "hsl(249.88234, 77%, 78%)",
},
teal: {
name: "teal",
light: "hsl(180, 65%, 35%)",
dark: "hsl(183, 85%, 65%)",
},
pink: {
name: "pink",
light: "hsl(333.88236, 69%, 58%)",
dark: "hsl(336.78634, 100%, 70%)",
},
cyan: {
name: "cyan",
light: "hsl(195, 71%, 35%)",
dark: "hsl(193.41046, 72%, 70%)",
},
indigo: {
name: "indigo",
light: "hsl(232.05884, 61%, 42%)",
dark: "hsl(234.61538, 73%, 71%)",
},
amber: {
name: "amber",
light: "hsl(38.57695, 94%, 44%)",
dark: "hsl(40.35484, 100%, 72%)",
},
ochrea: {
name: "ochrea",
light: "hsl(45.75298, 95%, 45%)",
dark: "hsl(46.09091, 100%, 78%)",
},
lime: {
name: "lime",
light: "hsl(86.25406, 60%, 42%)",
dark: "hsl(86.84211, 93%, 71%)",
},
maroon: {
name: "maroon",
light: "hsl(321.81819, 26%, 41%)",
dark: "hsl(322.10526, 66%, 66%)",
},
navy: {
name: "navy",
light: "hsl(216, 44%, 38%)",
dark: "hsl(218, 69%, 69%)",
},
olive: {
name: "olive",
light: "hsl(81, 36%, 41%)",
dark: "hsl(81, 64%, 65%)",
},
slate: {
name: "slate",
light: "hsl(212.53197, 15%, 44%)",
dark: "hsl(212.90323, 61%, 72%)",
},
}; };
function parseHsl(value: string): [string, string, string] | undefined { function parseHsl(value: string): [string, string, string] | undefined {
const match = /hsl\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*\)/.exec(value); const match =
/hsl\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*\)/.exec(
value,
);
return match ? [match[1]!, match[2]!, match[3]!] : undefined; return match ? [match[1]!, match[2]!, match[3]!] : undefined;
} }
@ -45,7 +116,11 @@ function parseHsl(value: string): [string, string, string] | undefined {
export function accentStyle(accent: string | [string, string?]): string { export function accentStyle(accent: string | [string, string?]): string {
const inputs = Array.isArray(accent) ? accent : [accent]; const inputs = Array.isArray(accent) ? accent : [accent];
const light = ACCENT_PRESETS[inputs[0] ?? ""]?.light ?? inputs[0]; const light = ACCENT_PRESETS[inputs[0] ?? ""]?.light ?? inputs[0];
const dark = ACCENT_PRESETS[inputs[1] ?? inputs[0] ?? ""]?.dark ?? inputs[1] ?? ACCENT_PRESETS[inputs[0] ?? ""]?.dark ?? inputs[0]; const dark =
ACCENT_PRESETS[inputs[1] ?? inputs[0] ?? ""]?.dark ??
inputs[1] ??
ACCENT_PRESETS[inputs[0] ?? ""]?.dark ??
inputs[0];
const lightHsl = parseHsl(light ?? ""); const lightHsl = parseHsl(light ?? "");
const darkHsl = parseHsl(dark ?? ""); const darkHsl = parseHsl(dark ?? "");
if (!lightHsl || !darkHsl) throw new Error(`Unknown accent color: ${accent}`); if (!lightHsl || !darkHsl) throw new Error(`Unknown accent color: ${accent}`);
@ -86,27 +161,55 @@ const COMPONENTS = [
] as const; ] as const;
function componentTemplates(): [string, string][] { function componentTemplates(): [string, string][] {
return COMPONENTS.map((name) => [`amezing-${name}`, fileURLToPath(new URL(`./components/${name}.spug`, import.meta.url))]); return COMPONENTS.map((name) => [
`amezing-${name}`,
fileURLToPath(new URL(`./components/${name}.spug`, import.meta.url)),
]);
} }
interface TemplateViews { interface TemplateViews {
component(name: string, component: unknown): unknown; component(name: string, component: unknown): unknown;
engine?: { renderComponent(path: string, props: Record<string, unknown>, context: { locals: Record<string, unknown>; params: Record<string, string> }): Promise<string> }; engine?: {
renderComponent(
path: string,
props: Record<string, unknown>,
context: {
locals: Record<string, unknown>;
params: Record<string, string>;
},
): Promise<string>;
};
} }
function registerAll(views: TemplateViews, accent: string | undefined): void { function registerAll(views: TemplateViews, accent: string | undefined): void {
for (const [name, template] of componentTemplates()) { for (const [name, template] of componentTemplates()) {
views.component(name, { views.component(name, {
async render(props: Record<string, unknown>, context: { locals: Record<string, unknown>; params: Record<string, string> }) { async render(
return views.engine!.renderComponent(template, { accentStyle: accent, ...props }, context); props: Record<string, unknown>,
context: {
locals: Record<string, unknown>;
params: Record<string, string>;
},
) {
return views.engine!.renderComponent(
template,
{ accentStyle: accent, ...props },
context,
);
}, },
}); });
} }
} }
/** Registers the Ametrine components on the views registry. */ /** Registers the Ametrine components on the views registry. */
export function registerAmezing(views: Views, options: { accentColor?: string | [string, string?] } = {}): void { export function registerAmezing(
registerAll(views as unknown as Parameters<typeof registerAll>[0], options.accentColor ? accentStyle(options.accentColor) : undefined); views: Views,
options: { accentColor?: string | [string, string?] } = {},
): void {
registerAll(
views as unknown as Parameters<typeof registerAll>[0],
options.accentColor ? accentStyle(options.accentColor) : undefined,
);
} }
/** Compiled theme stylesheet (Citrine base + Ametrine layout). */ /** Compiled theme stylesheet (Citrine base + Ametrine layout). */
@ -133,22 +236,44 @@ export const amezingFonts: Record<string, string> = {
"/fonts/commit-mono.woff2": "fonts/commit-mono.woff2", "/fonts/commit-mono.woff2": "fonts/commit-mono.woff2",
}; };
export function amezingMount(options: { accentColor?: string | [string, string?] } = {}): Mounts { export function amezingMount(
const accent = options.accentColor ? accentStyle(options.accentColor) : undefined; options: { accentColor?: string | [string, string?] } = {},
): Mounts {
const accent = options.accentColor
? accentStyle(options.accentColor)
: undefined;
return { return {
views: (views) => registerAll(views as unknown as Parameters<typeof registerAll>[0], accent), views: (views) =>
registerAll(
views as unknown as Parameters<typeof registerAll>[0],
accent,
),
routes: (route) => { routes: (route) => {
for (const [routePath, file] of Object.entries(amezingScripts)) { for (const [routePath, file] of Object.entries(amezingScripts)) {
route("GET", `/_muddle/amezing/${routePath}`, async () => route(
new Response(await readFile(new URL(`./${file}`, import.meta.url), "utf8"), { "GET",
`/_muddle/amezing/${routePath}`,
async () =>
new Response(
await readFile(new URL(`./${file}`, import.meta.url), "utf8"),
{
headers: { "content-type": "text/javascript; charset=utf-8" }, headers: { "content-type": "text/javascript; charset=utf-8" },
})); },
),
);
} }
for (const [routePath, file] of Object.entries(amezingFonts)) { for (const [routePath, file] of Object.entries(amezingFonts)) {
route("GET", routePath, async () => route(
new Response(await readFile(new URL(`./${file}`, import.meta.url)), { "GET",
routePath,
async () =>
new Response(
await readFile(new URL(`./${file}`, import.meta.url)),
{
headers: { "content-type": "font/woff2" }, headers: { "content-type": "font/woff2" },
})); },
),
);
} }
}, },
styles: { "/_muddle/amezing.css": () => amezingStyles() }, styles: { "/_muddle/amezing.css": () => amezingStyles() },
@ -156,6 +281,8 @@ export function amezingMount(options: { accentColor?: string | [string, string?]
} }
/** Convenience integration. */ /** Convenience integration. */
export function amezingIntegration(options: { accentColor?: string | [string, string?] } = {}): Integration { export function amezingIntegration(
options: { accentColor?: string | [string, string?] } = {},
): Integration {
return { name: "amezing", mounts: [amezingMount(options)] }; return { name: "amezing", mounts: [amezingMount(options)] };
} }

View file

@ -2,6 +2,11 @@
"name": "@muddle/amezing", "name": "@muddle/amezing",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"exports": { ".": "./index.ts", "./styles": "./styles/ametrine.css" }, "exports": {
"peerDependencies": { "muddle": "*" } ".": "./index.ts",
"./styles": "./styles/ametrine.css"
},
"peerDependencies": {
"muddle": "*"
}
} }

View file

@ -47,9 +47,7 @@
} }
@mixin chip { @mixin chip {
transition: transition: all var(--transition), transform var(--transition-bezier);
all var(--transition),
transform var(--transition-bezier);
box-shadow: var(--edge-highlight); box-shadow: var(--edge-highlight);
border-radius: var(--rounded-corner-small); border-radius: var(--rounded-corner-small);
background-color: var(--bg-muted-1); background-color: var(--bg-muted-1);

View file

@ -344,8 +344,7 @@
// FONTS // FONTS
--font-system-ui: system-ui, sans-serif; --font-system-ui: system-ui, sans-serif;
--font-ui-monospace: ui-monospace, monospace; --font-ui-monospace: ui-monospace, monospace;
--font-emoji: --font-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji"; "Noto Color Emoji";
// FONT SIZES // FONT SIZES
@ -376,16 +375,13 @@
--disabled-opacity: 0.4; --disabled-opacity: 0.4;
// SHADOWS // SHADOWS
--shadow: --shadow: 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03),
0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03),
0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07), 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07),
0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03); 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03);
--shadow-inset: --shadow-inset: inset 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03),
inset 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03),
inset 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07), inset 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07),
inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03); inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03);
--shadow-raised: --shadow-raised: 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.06),
0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.06),
0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.14), 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.14),
0 #{rpx(4)} #{rpx(12)} #{rpx(4)} rgba(0, 0, 0, 0.06); 0 #{rpx(4)} #{rpx(12)} #{rpx(4)} rgba(0, 0, 0, 0.06);
--shadow-glass: 0 #{rpx(6)} #{rpx(12)} #{rpx(-6)} rgba(0, 0, 0, 0.1); --shadow-glass: 0 #{rpx(6)} #{rpx(12)} #{rpx(-6)} rgba(0, 0, 0, 0.1);
@ -555,16 +551,15 @@
--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a)); --contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));
// SHADOWS // SHADOWS
--shadow-glow: --shadow-glow: 0 0 0 #{rpx(1)} var(--bg-muted-2),
0 0 0 #{rpx(1)} var(--bg-muted-2),
0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1), 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1),
0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2); 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2);
--shadow-glow-inset: --shadow-glow-inset: inset 0 0 0 #{rpx(1)} var(--bg-muted-2),
inset 0 0 0 #{rpx(1)} var(--bg-muted-2),
inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1), inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1),
inset 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2); inset 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2);
--text-shadow-glow: --text-shadow-glow: var(--bg-muted-2) 0 0 #{rpx(4)}, var(
var(--bg-muted-2) 0 0 #{rpx(4)}, var(--accent-color) 0 0 #{rpx(12)}; --accent-color
) 0 0 #{rpx(12)};
} }
} }
} }

View file

@ -237,9 +237,7 @@ kbd {
transition-duration: var(--transition); transition-duration: var(--transition);
transition-property: transform, box-shadow, background-color; transition-property: transform, box-shadow, background-color;
cursor: pointer; cursor: pointer;
box-shadow: box-shadow: var(--edge-highlight), inset 0 -0.125rem 0 var(--bg-muted-2);
var(--edge-highlight),
inset 0 -0.125rem 0 var(--bg-muted-2);
border-radius: var(--rounded-corner-small); border-radius: var(--rounded-corner-small);
background-color: var(--bg-muted-1); background-color: var(--bg-muted-1);
padding: 0 0.375rem; padding: 0 0.375rem;
@ -362,9 +360,7 @@ details {
--icon: var(--icon-caret-down); --icon: var(--icon-caret-down);
display: inline-block; display: inline-block;
vertical-align: rpx(-2); vertical-align: rpx(-2);
transition: transition: all var(--transition), transform var(--transition-bezier);
all var(--transition),
transform var(--transition-bezier);
margin-inline-end: 0.25rem; margin-inline-end: 0.25rem;
} }
} }

View file

@ -76,9 +76,7 @@ input[type="checkbox"] {
position: absolute; position: absolute;
transform: scale(0.5); transform: scale(0.5);
opacity: 0; opacity: 0;
transition: transition: opacity var(--transition), transform var(--transition-bezier);
opacity var(--transition),
transform var(--transition-bezier);
content: ""; content: "";
} }

View file

@ -9,9 +9,7 @@ span.spoiler {
&.solid { &.solid {
filter: none; filter: none;
transition: transition: background-color var(--transition), color var(--transition);
background-color var(--transition),
color var(--transition);
border-radius: var(--rounded-corner-small); border-radius: var(--rounded-corner-small);
background-color: var(--fg-muted-1); background-color: var(--fg-muted-1);
color: transparent; color: transparent;

View file

@ -243,8 +243,10 @@
display: inline-block; display: inline-block;
transition: var(--transition-bezier); transition: var(--transition-bezier);
transition-property: transition-property:
border-start-start-radius, border-end-start-radius, border-start-start-radius,
border-start-end-radius, border-end-end-radius; border-end-start-radius,
border-start-end-radius,
border-end-end-radius;
border-radius: var(--rounded-corner-small); border-radius: var(--rounded-corner-small);
background-color: var(--bg-muted-1); background-color: var(--bg-muted-1);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;

View file

@ -9,9 +9,7 @@
background-color: var(--bg-muted-1); background-color: var(--bg-muted-1);
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
border-radius: 0.25rem; border-radius: 0.25rem;
transition: transition: background-color var(--transition), color var(--transition);
background-color var(--transition),
color var(--transition);
&:hover { &:hover {
background-color: var(--bg-muted-2); background-color: var(--bg-muted-2);

View file

@ -15,9 +15,7 @@
background-color: var(--bg-muted-1); background-color: var(--bg-muted-1);
color: var(--fg-color); color: var(--fg-color);
font-size: var(--font-size-medium); font-size: var(--font-size-medium);
transition: transition: border-color var(--transition), box-shadow var(--transition);
border-color var(--transition),
box-shadow var(--transition);
&:focus { &:focus {
outline: none; outline: none;

View file

@ -19,9 +19,7 @@
left: 0.75rem; left: 0.75rem;
color: var(--fg-muted-2); color: var(--fg-muted-2);
pointer-events: none; pointer-events: none;
transition: transition: color var(--transition), opacity var(--transition-bezier);
color var(--transition),
opacity var(--transition-bezier);
opacity: 0.7; opacity: 0.7;
} }

View file

@ -6,9 +6,7 @@
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
z-index: 9999; z-index: 9999;
transition: transition: visibility var(--transition), opacity var(--transition);
visibility var(--transition),
opacity var(--transition);
inset: 0; inset: 0;
background-color: var(--shade-color); background-color: var(--shade-color);
padding-block-start: 15vh; padding-block-start: 15vh;
@ -74,9 +72,7 @@
position: absolute; position: absolute;
transform: translateX(0.5rem); transform: translateX(0.5rem);
opacity: 0; opacity: 0;
transition: transition: opacity var(--transition), transform var(--transition-bezier);
opacity var(--transition),
transform var(--transition-bezier);
cursor: help; cursor: help;
inset-block-start: 0.625rem; inset-block-start: 0.625rem;
inset-inline-end: 1rem; inset-inline-end: 1rem;

View file

@ -116,9 +116,7 @@
a { a {
display: flex; display: flex;
transition: transition: color var(--transition), transform var(--transition-bezier);
color var(--transition),
transform var(--transition-bezier);
height: fit-content; height: fit-content;
color: var(--fg-muted-1); color: var(--fg-muted-1);

View file

@ -203,9 +203,7 @@
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
gap: 0.25rem; gap: 0.25rem;
transition: transition: width var(--transition), visibility var(--transition);
width var(--transition),
visibility var(--transition);
margin-block: 0; margin-block: 0;
box-shadow: var(--edge-highlight), var(--shadow-glass); box-shadow: var(--edge-highlight), var(--shadow-glass);
border-radius: 1.125rem; border-radius: 1.125rem;

View file

@ -9,8 +9,14 @@
z-index: 3; z-index: 3;
transition-duration: var(--transition); transition-duration: var(--transition);
transition-property: transition-property:
visibility, transform, inset-block-start, box-shadow, border-inline-end, visibility,
border-start-end-radius, border-end-end-radius, height; transform,
inset-block-start,
box-shadow,
border-inline-end,
border-start-end-radius,
border-end-end-radius,
height;
inset-block-start: 0; inset-block-start: 0;
inset-inline-start: 0; inset-inline-start: 0;
border-inline-end: rpx(1) solid var(--bg-muted-2); border-inline-end: rpx(1) solid var(--bg-muted-2);

File diff suppressed because one or more lines are too long

View file

@ -146,22 +146,34 @@ function initSearch() {
for (const match of result.matches) { for (const match of result.matches) {
if (match.key === "title") continue; if (match.key === "title") continue;
const indices = match.indices.sort((a, b) => Math.abs(a[1] - a[0] - searchVal.length) - Math.abs(b[1] - b[0] - searchVal.length)).slice(0, MAX_RESULTS); const indices = match.indices
.sort(
(a, b) =>
Math.abs(a[1] - a[0] - searchVal.length) -
Math.abs(b[1] - b[0] - searchVal.length),
)
.slice(0, MAX_RESULTS);
const value = match.value; const value = match.value;
for (const ind of indices) { for (const ind of indices) {
const start = Math.max(0, ind[0] - TEASER_SIZE); const start = Math.max(0, ind[0] - TEASER_SIZE);
const end = Math.min(value.length - 1, ind[1] + TEASER_SIZE); const end = Math.min(value.length - 1, ind[1] + TEASER_SIZE);
output += "<span>" output +=
+ value.substring(start, ind[0]) "<span>" +
+ `<strong>${value.substring(ind[0], ind[1] + 1)}</strong>` value.substring(start, ind[0]) +
+ value.substring(ind[1] + 1, end) `<strong>${value.substring(ind[0], ind[1] + 1)}</strong>` +
+ "</span>"; value.substring(ind[1] + 1, end) +
"</span>";
} }
if (match.indices.length > MAX_RESULTS) { if (match.indices.length > MAX_RESULTS) {
const moreMatchesText = document.getElementById("more-matches-text").textContent; const moreMatchesText =
output += `<span class="more-matches">${moreMatchesText}</span>`.replace("$MATCHES", `+${match.indices.length - MAX_RESULTS}`); document.getElementById("more-matches-text").textContent;
output +=
`<span class="more-matches">${moreMatchesText}</span>`.replace(
"$MATCHES",
`+${match.indices.length - MAX_RESULTS}`,
);
} }
} }
return output + "</div>"; return output + "</div>";

View file

@ -24,33 +24,87 @@ export interface AuditOptions {
includeDisclosures?: boolean; includeDisclosures?: boolean;
} }
export async function analyzeHtml(html: string, kind: "accessible" | "overflow", options: AuditOptions = {}): Promise<AuditReport[]> { export async function analyzeHtml(
html: string,
kind: "accessible" | "overflow",
options: AuditOptions = {},
): Promise<AuditReport[]> {
const browser = await chromium.launch(); const browser = await chromium.launch();
try { try {
const reports: AuditReport[] = []; const reports: AuditReport[] = [];
for (const viewport of options.viewports ?? [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { for (const viewport of options.viewports ?? [
const context = await browser.newContext({ viewport, javaScriptEnabled: false }); { width: 1440, height: 900 },
{ width: 390, height: 844 },
]) {
const context = await browser.newContext({
viewport,
javaScriptEnabled: false,
});
try { try {
const page = await context.newPage(); const page = await context.newPage();
await page.route("**/*", (route) => route.abort()); await page.route("**/*", (route) => route.abort());
await page.setContent(html, { waitUntil: "load" }); await page.setContent(html, { waitUntil: "load" });
const result = await page.evaluate(inspectDocument, { kind, ignore: options.ignore ?? [], disclosures: options.includeDisclosures ?? false }); const result = await page.evaluate(inspectDocument, {
kind,
ignore: options.ignore ?? [],
disclosures: options.includeDisclosures ?? false,
});
const findings = result.findings as AuditFinding[]; const findings = result.findings as AuditFinding[];
const score = Math.max(0, Math.round(100 - findings.reduce((sum, finding) => sum + (finding.severity === "error" ? 10 : finding.severity === "warning" ? 4 : 2), 0))); const score = Math.max(
const report: AuditReport = { viewport, findings, score, checked: result.checked, method: "static-rendered-heuristic" }; 0,
Math.round(
100 -
findings.reduce(
(sum, finding) =>
sum +
(finding.severity === "error"
? 10
: finding.severity === "warning"
? 4
: 2),
0,
),
),
);
const report: AuditReport = {
viewport,
findings,
score,
checked: result.checked,
method: "static-rendered-heuristic",
};
reports.push(report); reports.push(report);
if (options.failBelow !== undefined && score < options.failBelow) throw new Error(`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`); if (options.failBelow !== undefined && score < options.failBelow) {
if (options.failOnError && findings.some((finding) => finding.severity === "error")) throw new Error(`${kind} audit found errors at ${viewport.width}px`); throw new Error(
} finally { await context.close(); } `${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`,
);
}
if (
options.failOnError &&
findings.some((finding) => finding.severity === "error")
) {
throw new Error(`${kind} audit found errors at ${viewport.width}px`);
}
} finally {
await context.close();
}
} }
return reports; return reports;
} finally { await browser.close(); } } finally {
await browser.close();
}
} }
export function accessibleIntegration(options: AuditOptions = {}): Integration { export function accessibleIntegration(options: AuditOptions = {}): Integration {
return { name: "accessible", analyze: (page) => analyzeHtml(page.html, "accessible", options) }; return {
name: "accessible",
analyze: (page) => analyzeHtml(page.html, "accessible", options),
};
} }
export function overflowIntegration(options: AuditOptions = {}): Integration { export function overflowIntegration(options: AuditOptions = {}): Integration {
return { name: "overflow", analyze: (page) => analyzeHtml(page.html, "overflow", options) }; return {
name: "overflow",
analyze: (page) => analyzeHtml(page.html, "overflow", options),
};
} }

View file

@ -1,9 +1,22 @@
/** Runs inside the browser against static DOM and resolved CSS, without executing page scripts. */ export function inspectDocument(options: {
export function inspectDocument(options: { kind: "accessible" | "overflow"; ignore: string[]; disclosures: boolean }) { kind: "accessible" | "overflow";
ignore: string[];
disclosures: boolean;
}) {
const { document, getComputedStyle, innerWidth } = globalThis as any; const { document, getComputedStyle, innerWidth } = globalThis as any;
const findings: { rule: string; selector: string; severity: "error" | "warning" | "review"; message: string; evidence: Record<string, unknown> }[] = []; const findings: {
rule: string;
selector: string;
severity: "error" | "warning" | "review";
message: string;
evidence: Record<string, unknown>;
}[] = [];
const elements = [...document.querySelectorAll("body *")]; const elements = [...document.querySelectorAll("body *")];
if (options.disclosures) for (const element of elements) if (element.tagName === "DETAILS") element.open = true; if (options.disclosures) {
for (const element of elements) {
if (element.tagName === "DETAILS") element.open = true;
}
}
const selector = (node: any): string => { const selector = (node: any): string => {
if (node.id) return `[id=${JSON.stringify(node.id)}]`; if (node.id) return `[id=${JSON.stringify(node.id)}]`;
const parts: string[] = []; const parts: string[] = [];
@ -14,62 +27,282 @@ export function inspectDocument(options: { kind: "accessible" | "overflow"; igno
} }
return `body > ${parts.join(" > ")}`; return `body > ${parts.join(" > ")}`;
}; };
const add = (node: any, rule: string, message: string, evidence: Record<string, unknown> = {}, severity: "error" | "warning" | "review" = "error") => findings.push({ selector: selector(node), rule, message, evidence, severity }); const add = (
const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; node: any,
rule: string,
message: string,
evidence: Record<string, unknown> = {},
severity: "error" | "warning" | "review" = "error",
) =>
findings.push({
selector: selector(node),
rule,
message,
evidence,
severity,
});
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const ctx = canvas.getContext("2d", { willReadFrequently: true }); const ctx = canvas.getContext("2d", { willReadFrequently: true });
const color = (value: string): number[] => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; }; const color = (value: string): number[] => {
const composite = (front: number[], back: number[]): number[] => front.slice(0, 3).map((channel, index) => channel * front[3]! / 255 + back[index]! * (1 - front[3]! / 255)).concat(255); ctx.clearRect(0, 0, 1, 1);
const luminance = (rgb: number[]): number => rgb.slice(0, 3).map((value) => { const c = value / 255; return c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4; }).reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index]!, 0); ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data];
};
const composite = (front: number[], back: number[]): number[] =>
front
.slice(0, 3)
.map(
(channel, index) =>
(channel * front[3]!) / 255 + back[index]! * (1 - front[3]! / 255),
)
.concat(255);
const luminance = (rgb: number[]): number =>
rgb
.slice(0, 3)
.map((value) => {
const c = value / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index]!,
0,
);
let checked = 0; let checked = 0;
for (const node of elements) { for (const node of elements) {
if (options.ignore.some((ignore) => node.closest(ignore))) continue; if (options.ignore.some((ignore) => node.closest(ignore))) continue;
const style = getComputedStyle(node); const style = getComputedStyle(node);
const box = node.getBoundingClientRect(); const box = node.getBoundingClientRect();
if (!box.width || !box.height || style.display === "none" || style.visibility !== "visible" || node.closest("[hidden], [inert]")) continue; if (
!box.width ||
!box.height ||
style.display === "none" ||
style.visibility !== "visible" ||
node.closest("[hidden], [inert]")
)
continue;
checked++; checked++;
if (options.kind === "overflow") { if (options.kind === "overflow") {
const clippedX = ["hidden", "clip"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; const clippedX =
const clippedY = ["hidden", "clip"].includes(style.overflowY) && node.scrollHeight > node.clientHeight + 1; ["hidden", "clip"].includes(style.overflowX) &&
const truncated = style.textOverflow === "ellipsis" || (style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0); node.scrollWidth > node.clientWidth + 1;
if ((clippedX || clippedY) && !truncated) add(node, "clipping", "Content exceeds a clipping boundary", { overflowX: style.overflowX, overflowY: style.overflowY, clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, clientHeight: node.clientHeight, scrollHeight: node.scrollHeight }); const clippedY =
["hidden", "clip"].includes(style.overflowY) &&
node.scrollHeight > node.clientHeight + 1;
const truncated =
style.textOverflow === "ellipsis" ||
(style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0);
if ((clippedX || clippedY) && !truncated) {
add(node, "clipping", "Content exceeds a clipping boundary", {
overflowX: style.overflowX,
overflowY: style.overflowY,
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
});
}
let managed = false; let managed = false;
for (let parent = node.parentElement; parent; parent = parent.parentElement) if (["auto", "scroll", "hidden", "clip"].includes(getComputedStyle(parent).overflowX)) { managed = true; break; } for (
if (!managed && (box.right > innerWidth + 1 || box.left < -1)) add(node, "viewport-overflow", "Element extends outside the viewport without a containing scroll region", { left: box.left, right: box.right, viewportWidth: innerWidth }); let parent = node.parentElement;
parent;
parent = parent.parentElement
) {
if (
["auto", "scroll", "hidden", "clip"].includes(
getComputedStyle(parent).overflowX,
)
) {
managed = true;
break;
}
}
if (!managed && (box.right > innerWidth + 1 || box.left < -1)) {
add(
node,
"viewport-overflow",
"Element extends outside the viewport without a containing scroll region",
{
left: box.left,
right: box.right,
viewportWidth: innerWidth,
},
);
}
continue; continue;
} }
const label = node.getAttribute("aria-label") || (node.getAttribute("aria-labelledby") || "").split(/\s+/).map((id: string) => document.getElementById(id)?.textContent || "").join("").trim() || [...(node.labels || [])].map((label: any) => label.textContent).join("").trim(); const label =
for (const id of (node.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean)) if (!document.getElementById(id)) add(node, "aria-reference", "aria-labelledby references a missing element", { id }); node.getAttribute("aria-label") ||
if (["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) && !node.disabled && (box.width < 24 || box.height < 24)) add(node, "target-size", "Interactive target is smaller than 24 CSS pixels; spacing exceptions require review", { width: box.width, height: box.height }, "review"); (node.getAttribute("aria-labelledby") || "")
if (node.tagName === "IMG" && !node.hasAttribute("alt")) add(node, "image-alt", "Image lacks an alt attribute"); .split(/\s+/)
if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !["hidden", "submit", "button", "reset"].includes(node.type) && !label) add(node, "control-name", "Form control lacks a programmatic label"); .map((id: string) => document.getElementById(id)?.textContent || "")
if (["A", "BUTTON"].includes(node.tagName) && !label && !node.textContent.trim() && !node.querySelector("img[alt]")) add(node, "control-name", "Interactive element lacks an accessible name"); .join("")
const ownText = [...node.childNodes].some((child: any) => child.nodeType === 3 && child.textContent.trim()); .trim() ||
[...(node.labels || [])]
.map((label: any) => label.textContent)
.join("")
.trim();
for (const id of (node.getAttribute("aria-labelledby") || "")
.split(/\s+/)
.filter(Boolean)) {
if (!document.getElementById(id)) {
add(
node,
"aria-reference",
"aria-labelledby references a missing element",
{ id },
);
}
}
if (
["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) &&
!node.disabled &&
(box.width < 24 || box.height < 24)
) {
add(
node,
"target-size",
"Interactive target is smaller than 24 CSS pixels; spacing exceptions require review",
{ width: box.width, height: box.height },
"review",
);
}
if (node.tagName === "IMG" && !node.hasAttribute("alt")) {
add(node, "image-alt", "Image lacks an alt attribute");
}
if (
["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) &&
!["hidden", "submit", "button", "reset"].includes(node.type) &&
!label
)
add(node, "control-name", "Form control lacks a programmatic label");
if (
["A", "BUTTON"].includes(node.tagName) &&
!label &&
!node.textContent.trim() &&
!node.querySelector("img[alt]")
) {
add(node, "control-name", "Interactive element lacks an accessible name");
}
const ownText = [...node.childNodes].some(
(child: any) => child.nodeType === 3 && child.textContent.trim(),
);
if (!ownText) continue; if (!ownText) continue;
const chain: any[] = []; let opacity = 1; const chain: any[] = [];
for (let parent = node; parent; parent = parent.parentElement) { chain.unshift(parent); opacity *= Number(getComputedStyle(parent).opacity); } let opacity = 1;
for (let parent = node; parent; parent = parent.parentElement) {
chain.unshift(parent);
opacity *= Number(getComputedStyle(parent).opacity);
}
let background = [255, 255, 255, 255]; let background = [255, 255, 255, 255];
const effects: Record<string, unknown>[] = []; const effects: Record<string, unknown>[] = [];
for (const parent of chain) { for (const parent of chain) {
const css = getComputedStyle(parent); const css = getComputedStyle(parent);
background = composite(color(css.backgroundColor), background); background = composite(color(css.backgroundColor), background);
if (css.filter !== "none" || css.backdropFilter !== "none" || css.mixBlendMode !== "normal" || css.backgroundImage !== "none" || css.clipPath !== "none" || css.maskImage !== "none") effects.push({ selector: selector(parent), filter: css.filter, backdropFilter: css.backdropFilter, blend: css.mixBlendMode, backgroundImage: css.backgroundImage, clipPath: css.clipPath, maskImage: css.maskImage }); if (
css.filter !== "none" ||
css.backdropFilter !== "none" ||
css.mixBlendMode !== "normal" ||
css.backgroundImage !== "none" ||
css.clipPath !== "none" ||
css.maskImage !== "none"
) {
effects.push({
selector: selector(parent),
filter: css.filter,
backdropFilter: css.backdropFilter,
blend: css.mixBlendMode,
backgroundImage: css.backgroundImage,
clipPath: css.clipPath,
maskImage: css.maskImage,
});
} }
const foreground = color(style.color); foreground[3] = foreground[3]! * opacity; }
const foreground = color(style.color);
foreground[3] = foreground[3]! * opacity;
const text = composite(foreground, background); const text = composite(foreground, background);
const l1 = luminance(text), l2 = luminance(background); const l1 = luminance(text),
const contrast = (Math.max(l1, l2) + .05) / (Math.min(l1, l2) + .05); l2 = luminance(background);
const threshold = parseFloat(style.fontSize) >= 24 || (parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700) ? 3 : 4.5; const contrast = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
if (effects.length) add(node, "visibility-effects", "Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive", { effects, opacity, nominalContrast: contrast }, "review"); const threshold =
if (contrast < threshold) add(node, "text-contrast", "Estimated text contrast falls below the configured text threshold", { contrast, threshold, opacity, estimate: effects.length > 0 }, effects.length ? "review" : "error"); parseFloat(style.fontSize) >= 24 ||
if (opacity < .5) add(node, "low-opacity", "Effective ancestor opacity reduces text visibility", { opacity }, "warning"); (parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700)
? 3
: 4.5;
if (effects.length) {
add(
node,
"visibility-effects",
"Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive",
{ effects, opacity, nominalContrast: contrast },
"review",
);
}
if (contrast < threshold) {
add(
node,
"text-contrast",
"Estimated text contrast falls below the configured text threshold",
{ contrast, threshold, opacity, estimate: effects.length > 0 },
effects.length ? "review" : "error",
);
}
if (opacity < 0.5) {
add(
node,
"low-opacity",
"Effective ancestor opacity reduces text visibility",
{ opacity },
"warning",
);
}
} }
if (options.kind === "accessible") { if (options.kind === "accessible") {
const ids = new Set<string>(); const ids = new Set<string>();
for (const node of document.querySelectorAll("[id]")) { if (ids.has(node.id)) add(node, "duplicate-id", "Duplicate identifiers make references ambiguous", { id: node.id }); ids.add(node.id); } for (const node of document.querySelectorAll("[id]")) {
if (ids.has(node.id)) {
add(
node,
"duplicate-id",
"Duplicate identifiers make references ambiguous",
{ id: node.id },
);
}
ids.add(node.id);
}
let previous = 0; let previous = 0;
for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) { const level = Number(heading.tagName.slice(1)); if (previous && level > previous + 1) add(heading, "heading-order", "Heading hierarchy skips a level", { previous, level }, "warning"); previous = level; } for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) {
if (!document.documentElement.lang) findings.push({ selector: "html", rule: "document-language", severity: "error", message: "Document language is missing", evidence: {} }); const level = Number(heading.tagName.slice(1));
if (!document.title.trim()) findings.push({ selector: "head", rule: "document-title", severity: "error", message: "Document title is missing", evidence: {} }); if (previous && level > previous + 1) {
add(
heading,
"heading-order",
"Heading hierarchy skips a level",
{ previous, level },
"warning",
);
}
previous = level;
}
if (!document.documentElement.lang) {
findings.push({
selector: "html",
rule: "document-language",
severity: "error",
message: "Document language is missing",
evidence: {},
});
}
if (!document.title.trim()) {
findings.push({
selector: "head",
rule: "document-title",
severity: "error",
message: "Document title is missing",
evidence: {},
});
}
} }
return { findings, checked }; return { findings, checked };
} }

View file

@ -3,5 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"exports": "./index.ts", "exports": "./index.ts",
"peerDependencies": { "muddle": "*", "playwright": "^1.0.0" } "peerDependencies": {
"muddle": "*",
"playwright": "^1.0.0"
}
} }

View file

@ -7,23 +7,51 @@ import { experimental_AstroContainer as AstroContainer } from "astro/container";
import ts from "typescript"; import ts from "typescript";
import type { Integration, IntegrationContext } from "muddle/contracts"; import type { Integration, IntegrationContext } from "muddle/contracts";
export interface AstroIntegrationOptions { dir?: string; partials?: boolean } export interface AstroIntegrationOptions {
dir?: string;
partials?: boolean;
}
function routeFor(root: string, file: string): string { function routeFor(root: string, file: string): string {
const path = relative(root, file).replace(/\\/g, "/").replace(/\.astro$/, ""); const path = relative(root, file)
.replace(/\\/g, "/")
.replace(/\.astro$/, "");
return `/${path.replace(/index$/, "").replace(/\/+/g, "/") || ""}` || "/"; return `/${path.replace(/index$/, "").replace(/\/+/g, "/") || ""}` || "/";
} }
export function astroIntegration(options: AstroIntegrationOptions = {}): Integration { export function astroIntegration(
options: AstroIntegrationOptions = {},
): Integration {
return { return {
name: "astro", name: "astro",
async setup(context: IntegrationContext) { async setup(context: IntegrationContext) {
context.loader("astro", { name: "astro", extensions: [".astro"], async load(file) { return { data: {}, content: await renderAstro(file.source, undefined, file.path) }; } }); context.loader("astro", {
name: "astro",
extensions: [".astro"],
async load(file) {
return {
data: {},
content: await renderAstro(file.source, undefined, file.path),
};
},
});
const dir = resolve(context.config.root, options.dir ?? "src/pages"); const dir = resolve(context.config.root, options.dir ?? "src/pages");
if (!existsSync(dir)) return; if (!existsSync(dir)) return;
for (const file of await astroFiles(dir)) { for (const file of await astroFiles(dir)) {
const route = routeFor(dir, file); const route = routeFor(dir, file);
context.route("GET", route, async (request) => new Response(await renderAstro(await readFile(file, "utf8"), request, file), { headers: { "content-type": "text/html; charset=utf-8" } })); context.route(
"GET",
route,
async (request) =>
new Response(
await renderAstro(await readFile(file, "utf8"), request, file),
{
headers: {
"content-type": "text/html; charset=utf-8",
},
},
),
);
} }
}, },
}; };
@ -39,42 +67,92 @@ async function astroFiles(dir: string): Promise<string[]> {
return files; return files;
} }
export async function renderAstro(source: string, request = new Request("http://localhost/"), filename = resolve("inline.astro")): Promise<string> { export async function renderAstro(
source: string,
request = new Request("http://localhost/"),
filename = resolve("inline.astro"),
): Promise<string> {
const component = await compileAstro(source, filename); const component = await compileAstro(source, filename);
const container = await AstroContainer.create(); const container = await AstroContainer.create();
return container.renderToString(component, { request }); return container.renderToString(component, { request });
} }
async function compileAstro(source: string, filename: string, ancestors: string[] = []): Promise<any> { async function compileAstro(
if (ancestors.includes(filename)) throw new Error(`Circular Astro import ${filename}`); source: string,
const result = await transform(source, { filename, internalURL: "astro/runtime/server/index.js" }); filename: string,
let code = result.code.replace(/,\s*createMetadata as \$\$createMetadata/, "").replace(/export const \$\$metadata = [^\n]+\n/g, ""); ancestors: string[] = [],
): Promise<any> {
if (ancestors.includes(filename)) {
throw new Error(`Circular Astro import ${filename}`);
}
const result = await transform(source, {
filename,
internalURL: "astro/runtime/server/index.js",
});
let code = result.code
.replace(/,\s*createMetadata as \$\$createMetadata/, "")
.replace(/export const \$\$metadata = [^\n]+\n/g, "");
for (const match of [...code.matchAll(/from\s+["']([^"']+)["']/g)]) { for (const match of [...code.matchAll(/from\s+["']([^"']+)["']/g)]) {
const specifier = match[1]!; const specifier = match[1]!;
let url: string; let url: string;
if (specifier.endsWith(".astro")) { if (specifier.endsWith(".astro")) {
const file = resolve(filename, "..", specifier); const file = resolve(filename, "..", specifier);
const child = await compileAstro(await readFile(file, "utf8"), file, [...ancestors, filename]); const child = await compileAstro(await readFile(file, "utf8"), file, [
...ancestors,
filename,
]);
const key = `__muddle_astro_${crypto.randomUUID().replaceAll("-", "")}`; const key = `__muddle_astro_${crypto.randomUUID().replaceAll("-", "")}`;
(globalThis as Record<string, unknown>)[key] = child; (globalThis as Record<string, unknown>)[key] = child;
url = `data:text/javascript;base64,${Buffer.from(`export default globalThis[${JSON.stringify(key)}]`).toString("base64")}`; url = `data:text/javascript;base64,${Buffer.from(
} else url = specifier.startsWith(".") ? pathToFileURL(resolve(filename, "..", specifier)).href : import.meta.resolve(specifier); `export default globalThis[${JSON.stringify(key)}]`,
code = code.replaceAll(`"${specifier}"`, JSON.stringify(url)).replaceAll(`'${specifier}'`, JSON.stringify(url)); ).toString("base64")}`;
} else {
url = specifier.startsWith(".")
? pathToFileURL(resolve(filename, "..", specifier)).href
: import.meta.resolve(specifier);
} }
code = ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText; code = code
return (await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`)).default; .replaceAll(`"${specifier}"`, JSON.stringify(url))
.replaceAll(`'${specifier}'`, JSON.stringify(url));
}
code = ts.transpileModule(code, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
return (
await import(
`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`
)
).default;
} }
/** Import into TemplateContext with `.import("Card", astroComponent("./Card.astro"))`. */ /** Import into TemplateContext with `.import("Card", astroComponent("./Card.astro"))`. */
export function astroComponent(file: string) { export function astroComponent(file: string) {
return { return {
async render(props: Record<string, unknown>, context: { params: Record<string, string>; locals: Record<string, unknown> }) { async render(
props: Record<string, unknown>,
context: {
params: Record<string, string>;
locals: Record<string, unknown>;
},
) {
const path = resolve(file); const path = resolve(file);
const component = await compileAstro(await readFile(path, "utf8"), path); const component = await compileAstro(await readFile(path, "utf8"), path);
const container = await AstroContainer.create(); const container = await AstroContainer.create();
const slots: Record<string, string> = {}; const slots: Record<string, string> = {};
for (const [key, value] of Object.entries(props)) if (typeof value === "function") slots[key === "content" ? "default" : key] = await value(); for (const [key, value] of Object.entries(props)) {
return container.renderToString(component, { props, params: context.params, locals: context.locals, slots }); if (typeof value === "function") {
slots[key === "content" ? "default" : key] = await value();
}
}
return container.renderToString(component, {
props,
params: context.params,
locals: context.locals,
slots,
});
}, },
}; };
} }

View file

@ -1,19 +1,39 @@
import type { TableClass } from "muddle/contracts/typegen/orm/index.ts"; import type { TableClass } from "muddle/contracts/typegen/orm/index.ts";
import { getTableMetadata } from "./decorators/Table.ts"; import { getTableMetadata } from "./decorators/Table.ts";
export interface SchemaColumn { name: string; type: string; nullable: boolean } export interface SchemaColumn {
export interface SchemaTable { name: string; columns: SchemaColumn[] } name: string;
export interface Migration { up: string[]; down: string[] } type: string;
nullable: boolean;
}
export interface SchemaTable {
name: string;
columns: SchemaColumn[];
}
export interface Migration {
up: string[];
down: string[];
}
export function schemaOf(tables: TableClass[]): SchemaTable[] { export function schemaOf(tables: TableClass[]): SchemaTable[] {
return tables.map((table) => { return tables.map((table) => {
const metadata = getTableMetadata(table); const metadata = getTableMetadata(table);
if (!metadata) throw new Error(`Missing @Table metadata for ${table.name}`); if (!metadata) throw new Error(`Missing @Table metadata for ${table.name}`);
return { name: metadata.name, columns: [...metadata.fields.values()].map((field) => ({ name: field.column, type: field.type, nullable: field.nullable })) }; return {
name: metadata.name,
columns: [...metadata.fields.values()].map((field) => ({
name: field.column,
type: field.type,
nullable: field.nullable,
})),
};
}); });
} }
export function diffSchema(previous: SchemaTable[], next: SchemaTable[]): Migration { export function diffSchema(
previous: SchemaTable[],
next: SchemaTable[],
): Migration {
const oldTables = new Map(previous.map((table) => [table.name, table])); const oldTables = new Map(previous.map((table) => [table.name, table]));
const nextTables = new Map(next.map((table) => [table.name, table])); const nextTables = new Map(next.map((table) => [table.name, table]));
const up: string[] = []; const up: string[] = [];
@ -21,19 +41,27 @@ export function diffSchema(previous: SchemaTable[], next: SchemaTable[]): Migrat
for (const table of next) { for (const table of next) {
const old = oldTables.get(table.name); const old = oldTables.get(table.name);
if (!old) { if (!old) {
up.push(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`); up.push(
`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`,
);
down.unshift(`DROP TABLE ${table.name};`); down.unshift(`DROP TABLE ${table.name};`);
continue; continue;
} }
const oldColumns = new Map(old.columns.map((column) => [column.name, column])); const oldColumns = new Map(
for (const column of table.columns) if (!oldColumns.has(column.name)) { old.columns.map((column) => [column.name, column]),
);
for (const column of table.columns)
if (!oldColumns.has(column.name)) {
up.push(`ALTER TABLE ${table.name} ADD COLUMN ${columnSql(column)};`); up.push(`ALTER TABLE ${table.name} ADD COLUMN ${columnSql(column)};`);
down.unshift(`ALTER TABLE ${table.name} DROP COLUMN ${column.name};`); down.unshift(`ALTER TABLE ${table.name} DROP COLUMN ${column.name};`);
} }
} }
for (const table of previous) if (!nextTables.has(table.name)) { for (const table of previous)
if (!nextTables.has(table.name)) {
up.push(`DROP TABLE ${table.name};`); up.push(`DROP TABLE ${table.name};`);
down.unshift(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`); down.unshift(
`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`,
);
} }
return { up, down }; return { up, down };
} }

View file

@ -3,33 +3,66 @@ import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
/** Persistent sessions and atomic OAuth transaction consumption for SQLite, D1 and Postgres. */ /** Persistent sessions and atomic OAuth transaction consumption for SQLite, D1 and Postgres. */
export class DatabaseSessionStore implements SessionStore { export class DatabaseSessionStore implements SessionStore {
private constructor(private readonly driver: SqlDriver, private readonly table: string) {} private constructor(
private readonly driver: SqlDriver,
private readonly table: string,
) {}
static async create(driver: SqlDriver, table = "muddle_sessions"): Promise<DatabaseSessionStore> { static async create(
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error("Invalid session table name"); driver: SqlDriver,
await driver.execute(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL, expires BIGINT NOT NULL)`); table = "muddle_sessions",
): Promise<DatabaseSessionStore> {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
throw new Error("Invalid session table name");
await driver.execute(
`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL, expires BIGINT NOT NULL)`,
);
return new DatabaseSessionStore(driver, table); return new DatabaseSessionStore(driver, table);
} }
async get(key: string): Promise<SessionRecord | undefined> { async get(key: string): Promise<SessionRecord | undefined> {
const rows = await this.driver.query<{ value: string; expires: number }>(`SELECT value, expires FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); const rows = await this.driver.query<{ value: string; expires: number }>(
`SELECT value, expires FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`,
[key],
);
const row = rows[0]; const row = rows[0];
if (!row || Number(row.expires) <= Date.now()) { await this.delete(key); return undefined; } if (!row || Number(row.expires) <= Date.now()) {
await this.delete(key);
return undefined;
}
return { value: JSON.parse(row.value), expires: Number(row.expires) }; return { value: JSON.parse(row.value), expires: Number(row.expires) };
} }
async set(key: string, record: SessionRecord): Promise<void> { async set(key: string, record: SessionRecord): Promise<void> {
const args = [key, JSON.stringify(record.value), record.expires]; const args = [key, JSON.stringify(record.value), record.expires];
await this.driver.execute(`INSERT INTO "${this.table}" (id, value, expires) VALUES (${args.map((_, index) => this.driver.placeholder(index + 1)).join(", ")}) ON CONFLICT (id) DO UPDATE SET value = excluded.value, expires = excluded.expires`, args); await this.driver.execute(
`INSERT INTO "${this.table}" (id, value, expires) VALUES (${args.map((_, index) => this.driver.placeholder(index + 1)).join(", ")}) ON CONFLICT (id) DO UPDATE SET value = excluded.value, expires = excluded.expires`,
args,
);
} }
async delete(key: string): Promise<void> { await this.driver.execute(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); } async delete(key: string): Promise<void> {
await this.driver.execute(
`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`,
[key],
);
}
async consume(key: string): Promise<SessionRecord | undefined> { async consume(key: string): Promise<SessionRecord | undefined> {
const rows = await this.driver.query<{ value: string; expires: number }>(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)} RETURNING value, expires`, [key]); const rows = await this.driver.query<{ value: string; expires: number }>(
`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)} RETURNING value, expires`,
[key],
);
const row = rows[0]; const row = rows[0];
return row && Number(row.expires) > Date.now() ? { value: JSON.parse(row.value), expires: Number(row.expires) } : undefined; return row && Number(row.expires) > Date.now()
? { value: JSON.parse(row.value), expires: Number(row.expires) }
: undefined;
} }
async prune(): Promise<void> { await this.driver.execute(`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`, [Date.now()]); } async prune(): Promise<void> {
await this.driver.execute(
`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`,
[Date.now()],
);
}
} }

View file

@ -6,34 +6,61 @@ import { renderMarkdown } from "muddle/markdown";
import katex from "katex"; import katex from "katex";
export function renderLatex(source: string, displayMode = false): string { export function renderLatex(source: string, displayMode = false): string {
return katex.renderToString(source, { displayMode, throwOnError: false, trust: false }); return katex.renderToString(source, {
displayMode,
throwOnError: false,
trust: false,
});
} }
export function renderMathMarkdown(source: string): string { export function renderMathMarkdown(source: string): string {
const formulas: string[] = []; const formulas: string[] = [];
const body = source.replace(/\$\$([\s\S]+?)\$\$|(?<!\\)\$([^$\n]+?)\$/g, (_match, display, inline) => { const body = source.replace(
/\$\$([\s\S]+?)\$\$|(?<!\\)\$([^$\n]+?)\$/g,
(_match, display, inline) => {
formulas.push(renderLatex(display ?? inline, display !== undefined)); formulas.push(renderLatex(display ?? inline, display !== undefined));
return `MUDDLEMATH${formulas.length - 1}END`; return `MUDDLEMATH${formulas.length - 1}END`;
}); },
return renderMarkdown(body).replace(/MUDDLEMATH(\d+)END/g, (_match, index) => formulas[Number(index)] ?? ""); );
return renderMarkdown(body).replace(
/MUDDLEMATH(\d+)END/g,
(_match, index) => formulas[Number(index)] ?? "",
);
} }
export interface LatexIntegrationOptions { route?: string } export interface LatexIntegrationOptions {
route?: string;
}
export function latexIntegration(options: LatexIntegrationOptions = {}): Integration { export function latexIntegration(
options: LatexIntegrationOptions = {},
): Integration {
const route = options.route ?? "/_muddle/katex.css"; const route = options.route ?? "/_muddle/katex.css";
return { return {
name: "latex", name: "latex",
async setup(context: IntegrationContext) { async setup(context: IntegrationContext) {
const base = dirname(fileURLToPath(import.meta.resolve("katex/package.json"))); const base = dirname(
fileURLToPath(import.meta.resolve("katex/package.json")),
);
const css = await readFile(join(base, "dist", "katex.min.css"), "utf8"); const css = await readFile(join(base, "dist", "katex.min.css"), "utf8");
context.loader("latex-markdown", { context.loader("latex-markdown", {
name: "latex-markdown", name: "latex-markdown",
extensions: [".latex.md", ".latex.markdown"], extensions: [".latex.md", ".latex.markdown"],
load(file) { return { data: {}, content: renderMathMarkdown(file.source) }; }, load(file) {
return { data: {}, content: renderMathMarkdown(file.source) };
},
}); });
context.route("GET", route, () => new Response(css, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=31536000, immutable" } })); context.route(
"GET",
route,
() =>
new Response(css, {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=31536000, immutable",
},
}),
);
}, },
}; };
} }

View file

@ -10,5 +10,16 @@ export async function renderMdx(source: string): Promise<string> {
} }
export function mdxIntegration(): Integration { export function mdxIntegration(): Integration {
return { name: "mdx", setup(context) { context.loader("mdx", { name: "mdx", extensions: [".mdx"], async load(file) { return { data: {}, content: await renderMdx(file.source) }; } }); } }; return {
name: "mdx",
setup(context) {
context.loader("mdx", {
name: "mdx",
extensions: [".mdx"],
async load(file) {
return { data: {}, content: await renderMdx(file.source) };
},
});
},
};
} }

View file

@ -19,9 +19,11 @@ export async function minifyHtml(source: string): Promise<string> {
}); });
} }
export async function minifyJavaScript(source: string): Promise<string> { export async function minifyJavaScript(source: string): Promise<string> {
return (await terser.minify(source, { format: { comments: false } })).code ?? source; return (
(await terser.minify(source, { format: { comments: false } })).code ??
source
);
} }
export function optimizeSvg(source: string): string { export function optimizeSvg(source: string): string {
@ -36,16 +38,29 @@ export function imageOptimization(optimizer: ImageOptimizer): ImageOptimizer {
return optimizer; return optimizer;
} }
function loaderIntegration(name: string, extension: string, transform: (source: string) => string | Promise<string>): Integration { function loaderIntegration(
name: string,
extension: string,
transform: (source: string) => string | Promise<string>,
): Integration {
return { return {
name, name,
async transform(asset) { async transform(asset) {
if (!asset.path.endsWith(extension)) return asset; if (!asset.path.endsWith(extension)) return asset;
const source = typeof asset.content === "string" ? asset.content : new TextDecoder().decode(asset.content); const source =
typeof asset.content === "string"
? asset.content
: new TextDecoder().decode(asset.content);
return { ...asset, content: await transform(source) }; return { ...asset, content: await transform(source) };
}, },
setup(context: IntegrationContext) { setup(context: IntegrationContext) {
context.loader(name, { name, extensions: [extension], async load(file) { return { data: {}, content: await transform(file.source) }; } }); context.loader(name, {
name,
extensions: [extension],
async load(file) {
return { data: {}, content: await transform(file.source) };
},
});
}, },
}; };
} }
@ -66,20 +81,31 @@ export function svgoIntegration(): Integration {
return loaderIntegration("svgo", ".svg", optimizeSvg); return loaderIntegration("svgo", ".svg", optimizeSvg);
} }
export function imageOptimizationIntegration(optimizer: ImageOptimizer): Integration { export function imageOptimizationIntegration(
optimizer: ImageOptimizer,
): Integration {
return { return {
name: "image-optimization", name: "image-optimization",
async transform(asset) { async transform(asset) {
if (!/\.(png|jpe?g|webp|avif)$/i.test(asset.path)) return asset; if (!/\.(png|jpe?g|webp|avif)$/i.test(asset.path)) return asset;
const input = typeof asset.content === "string" ? new TextEncoder().encode(asset.content) : asset.content; const input =
return { ...asset, content: await optimizer.optimize(input, asset.contentType) }; typeof asset.content === "string"
? new TextEncoder().encode(asset.content)
: asset.content;
return {
...asset,
content: await optimizer.optimize(input, asset.contentType),
};
}, },
setup(context: IntegrationContext) { setup(context: IntegrationContext) {
context.loader("image", { context.loader("image", {
name: "image", name: "image",
extensions: [".png", ".jpg", ".jpeg", ".webp", ".avif"], extensions: [".png", ".jpg", ".jpeg", ".webp", ".avif"],
async load(file) { async load(file) {
const optimized = await optimizer.optimize(new TextEncoder().encode(file.source), "image/*"); const optimized = await optimizer.optimize(
new TextEncoder().encode(file.source),
"image/*",
);
return { data: { optimized }, content: file.source }; return { data: { optimized }, content: file.source };
}, },
}); });

View file

@ -1,18 +1,32 @@
import type { Plugin, Parser } from "prettier"; import type { Plugin, Parser } from "prettier";
import { formatSource, type FormatLanguage } from "muddle/formatter"; import { formatSource, type FormatLanguage } from "muddle/formatter";
interface Document { formatted: string } interface Document {
formatted: string;
}
const parser = (language: FormatLanguage): Parser<Document> => ({ const parser = (language: FormatLanguage): Parser<Document> => ({
astFormat: "muddle-document", astFormat: "muddle-document",
async parse(source, options) { return { formatted: await formatSource(source, language, options) }; }, async parse(source, options) {
return { formatted: await formatSource(source, language, options) };
},
locStart: () => 0, locStart: () => 0,
locEnd: (node) => node.formatted.length, locEnd: (node) => node.formatted.length,
}); });
const plugin: Plugin<Document> = { const plugin: Plugin<Document> = {
languages: [ languages: [
{ name: "SPUG", parsers: ["spug"], extensions: [".spug", ".stpug", ".stug"], vscodeLanguageIds: ["spug"] }, {
{ name: "STCSS", parsers: ["stcss"], extensions: [".stcss"], vscodeLanguageIds: ["stcss"] }, name: "SPUG",
parsers: ["spug"],
extensions: [".spug", ".stpug", ".stug"],
vscodeLanguageIds: ["spug"],
},
{
name: "STCSS",
parsers: ["stcss"],
extensions: [".stcss"],
vscodeLanguageIds: ["stcss"],
},
], ],
parsers: { spug: parser("spug"), stcss: parser("stcss") }, parsers: { spug: parser("spug"), stcss: parser("stcss") },
printers: { "muddle-document": { print: (path) => path.node.formatted } }, printers: { "muddle-document": { print: (path) => path.node.formatted } },

View file

@ -3,5 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"exports": "./index.ts", "exports": "./index.ts",
"peerDependencies": { "muddle": "*", "prettier": "^3.0.0" } "peerDependencies": {
"muddle": "*",
"prettier": "^3.0.0"
}
} }

View file

@ -1 +1,4 @@
export { shadcnIntegration, type ShadcnIntegrationOptions } from "./src/integration.ts"; export {
shadcnIntegration,
type ShadcnIntegrationOptions,
} from "./src/integration.ts";

View file

@ -3,5 +3,7 @@
"type": "module", "type": "module",
"exports": "./index.ts", "exports": "./index.ts",
"version": "0.1.0", "version": "0.1.0",
"peerDependencies": { "muddle": "*" } "peerDependencies": {
"muddle": "*"
}
} }

View file

@ -1,6 +1,11 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { basename, extname, join, resolve } from "node:path"; import { basename, extname, join, resolve } from "node:path";
import { cssToStcss, reactToSpug, type Integration, type IntegrationContext } from "muddle"; import {
cssToStcss,
reactToSpug,
type Integration,
type IntegrationContext,
} from "muddle";
export interface ShadcnIntegrationOptions { export interface ShadcnIntegrationOptions {
components: string[]; components: string[];
@ -15,32 +20,51 @@ interface RegistryItem {
} }
/** Pulls ShadCN registry entries and writes their Muddle ports into the host project. */ /** Pulls ShadCN registry entries and writes their Muddle ports into the host project. */
export function shadcnIntegration(options: ShadcnIntegrationOptions): Integration { export function shadcnIntegration(
options: ShadcnIntegrationOptions,
): Integration {
return { return {
name: "shadcn", name: "shadcn",
async setup(context: IntegrationContext) { async setup(context: IntegrationContext) {
const fetcher = options.fetch ?? globalThis.fetch; const fetcher = options.fetch ?? globalThis.fetch;
const destination = resolve(context.config.root, options.dir ?? "modules/app/views/components"); const destination = resolve(
context.config.root,
options.dir ?? "modules/app/views/components",
);
const styles = resolve(context.config.root, "styles/shadcn"); const styles = resolve(context.config.root, "styles/shadcn");
const seen = new Set<string>(); const seen = new Set<string>();
const pull = async (name: string): Promise<void> => { const pull = async (name: string): Promise<void> => {
if (seen.has(name)) return; if (seen.has(name)) return;
seen.add(name); seen.add(name);
const base = options.registry ?? "https://ui.shadcn.com/r/styles/new-york/{name}.json"; const base =
const url = base.includes("{name}") ? base.replace("{name}", encodeURIComponent(name)) : `${base.replace(/\/$/, "")}/${encodeURIComponent(name)}.json`; options.registry ??
"https://ui.shadcn.com/r/styles/new-york/{name}.json";
const url = base.includes("{name}")
? base.replace("{name}", encodeURIComponent(name))
: `${base.replace(/\/$/, "")}/${encodeURIComponent(name)}.json`;
const response = await fetcher(url); const response = await fetcher(url);
if (!response.ok) throw new Error(`shadcn registry request failed for ${name} with ${response.status}`); if (!response.ok)
const item = await response.json() as RegistryItem; throw new Error(
for (const dependency of item.registryDependencies ?? []) await pull(dependency.replace(/^@[^/]+\//, "")); `shadcn registry request failed for ${name} with ${response.status}`,
);
const item = (await response.json()) as RegistryItem;
for (const dependency of item.registryDependencies ?? [])
await pull(dependency.replace(/^@[^/]+\//, ""));
for (const file of item.files ?? []) { for (const file of item.files ?? []) {
const extension = extname(file.path).toLowerCase(); const extension = extname(file.path).toLowerCase();
const stem = basename(file.path).replace(/\.(tsx|jsx|css)$/i, ""); const stem = basename(file.path).replace(/\.(tsx|jsx|css)$/i, "");
if (extension === ".tsx" || extension === ".jsx") { if (extension === ".tsx" || extension === ".jsx") {
await mkdir(destination, { recursive: true }); await mkdir(destination, { recursive: true });
await writeFile(join(destination, `${stem}.spug`), reactToSpug(file.content, file.path)); await writeFile(
join(destination, `${stem}.spug`),
reactToSpug(file.content, file.path),
);
} else if (extension === ".css") { } else if (extension === ".css") {
await mkdir(styles, { recursive: true }); await mkdir(styles, { recursive: true });
await writeFile(join(styles, `${stem}.stcss`), cssToStcss(file.content, file.path)); await writeFile(
join(styles, `${stem}.stcss`),
cssToStcss(file.content, file.path),
);
} }
} }
}; };

View file

@ -10,20 +10,42 @@ export interface TailwindOptions {
content?: string[]; content?: string[];
} }
const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".html", ".spug", ".mdx"]); const SCANNED_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".html",
".spug",
".mdx",
]);
export async function tailwindCandidates(root: string, paths?: string[]): Promise<string[]> { export async function tailwindCandidates(
root: string,
paths?: string[],
): Promise<string[]> {
const candidates = new Set<string>(); const candidates = new Set<string>();
const visit = async (dir: string): Promise<void> => { const visit = async (dir: string): Promise<void> => {
for (const entry of await readdir(dir, { withFileTypes: true })) { for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) continue; if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name.startsWith(".")
)
continue;
const path = join(dir, entry.name); const path = join(dir, entry.name);
if (entry.isDirectory()) { await visit(path); continue; } if (entry.isDirectory()) {
await visit(path);
continue;
}
if (!SCANNED_EXTENSIONS.has(extname(entry.name))) continue; if (!SCANNED_EXTENSIONS.has(extname(entry.name))) continue;
if (paths && !paths.some((value) => path.includes(value))) continue; if (paths && !paths.some((value) => path.includes(value))) continue;
const source = await readFile(path, "utf8"); const source = await readFile(path, "utf8");
for (const match of source.matchAll(/(?:class|className)\s*=\s*["'`]([^"'`]+)["'`]/g)) { for (const match of source.matchAll(
for (const value of (match[1] ?? "").split(/\s+/)) if (value) candidates.add(value); /(?:class|className)\s*=\s*["'`]([^"'`]+)["'`]/g,
)) {
for (const value of (match[1] ?? "").split(/\s+/))
if (value) candidates.add(value);
} }
} }
}; };
@ -31,32 +53,75 @@ export async function tailwindCandidates(root: string, paths?: string[]): Promis
return [...candidates]; return [...candidates];
} }
export async function compileTailwind(css: string, candidates: string[] = []): Promise<string> { export async function compileTailwind(
const tailwindCss = join(dirname(fileURLToPath(import.meta.resolve("tailwindcss/package.json"))), "index.css"); css: string,
candidates: string[] = [],
): Promise<string> {
const tailwindCss = join(
dirname(fileURLToPath(import.meta.resolve("tailwindcss/package.json"))),
"index.css",
);
const compiler = await compile(css, { const compiler = await compile(css, {
base: process.cwd(), base: process.cwd(),
loadStylesheet: async (id) => { loadStylesheet: async (id) => {
if (!["tailwindcss", "tailwindcss/theme.css", "tailwindcss/utilities.css"].includes(id)) throw new Error(`Unsupported Tailwind stylesheet "${id}"`); if (
const path = id === "tailwindcss" ? tailwindCss : join(dirname(tailwindCss), id.split("/")[1]!); ![
return { path, base: process.cwd(), content: await readFile(path, "utf8") }; "tailwindcss",
"tailwindcss/theme.css",
"tailwindcss/utilities.css",
].includes(id)
)
throw new Error(`Unsupported Tailwind stylesheet "${id}"`);
const path =
id === "tailwindcss"
? tailwindCss
: join(dirname(tailwindCss), id.split("/")[1]!);
return {
path,
base: process.cwd(),
content: await readFile(path, "utf8"),
};
}, },
}); });
return compiler.build(candidates); return compiler.build(candidates);
} }
export function tailwindIntegration(options: TailwindOptions = {}): Integration { export function tailwindIntegration(
options: TailwindOptions = {},
): Integration {
const route = options.route ?? "/_muddle/tailwind.css"; const route = options.route ?? "/_muddle/tailwind.css";
const source = options.css ?? '@import "tailwindcss";'; const source = options.css ?? '@import "tailwindcss";';
return { return {
name: "tailwind", name: "tailwind",
async setup(context: IntegrationContext) { async setup(context: IntegrationContext) {
const build = () => tailwindCandidates(context.config.root, options.content).then((candidates) => compileTailwind(source, candidates)); const build = () =>
tailwindCandidates(context.config.root, options.content).then(
(candidates) => compileTailwind(source, candidates),
);
context.loader("tailwind", { context.loader("tailwind", {
name: "tailwind", name: "tailwind",
extensions: [".tailwind.css"], extensions: [".tailwind.css"],
async load(file) { return { data: {}, content: await compileTailwind(file.source, await tailwindCandidates(context.config.root, options.content)) }; }, async load(file) {
return {
data: {},
content: await compileTailwind(
file.source,
await tailwindCandidates(context.config.root, options.content),
),
};
},
}); });
context.route("GET", route, async () => new Response(await build(), { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=60" } })); context.route(
"GET",
route,
async () =>
new Response(await build(), {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=60",
},
}),
);
}, },
}; };
} }

View file

@ -1,11 +1,7 @@
// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. // AUTO-GENERATED by contracts/typegen/generator.ts, do not edit.
// Run `bun run typegen <project>` to update this file. // Run `bun run typegen <project>` to update this file.
export interface TableRows {}
export interface TableRows {
}
export type TableName = keyof TableRows; export type TableName = keyof TableRows;

View file

@ -4,9 +4,21 @@
import type { Minify } from "./minify.ts"; import type { Minify } from "./minify.ts";
export interface ViewProps { export interface ViewProps {
"modules/app/views/index": { title: string; nav: {label:string,href:string}[]; posts: {id:string,title:string,excerpt:string}[] }; "modules/app/views/index": {
"modules/app/views/layout": { title: string; nav: {label:string,href:string}[]; currentPath?: string }; title: string;
"modules/app/views/post": { title: string; nav: {label:string,href:string}[]; post: {id:string,title:string,excerpt:string}|null }; nav: { label: string; href: string }[];
posts: { id: string; title: string; excerpt: string }[];
};
"modules/app/views/layout": {
title: string;
nav: { label: string; href: string }[];
currentPath?: string;
};
"modules/app/views/post": {
title: string;
nav: { label: string; href: string }[];
post: { id: string; title: string; excerpt: string } | null;
};
} }
/** Path of a view of the project. */ /** Path of a view of the project. */

View file

@ -19,5 +19,4 @@ export default defineConfig({
routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"], routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"],
outDir: "dist", outDir: "dist",
}, },
}); });

View file

@ -20,12 +20,20 @@ const app = await App.create({
integrations: [...resolved.integrations, amezingIntegration()], integrations: [...resolved.integrations, amezingIntegration()],
}); });
export async function build(options: { progress?: (event: { current: number; total: number; route: string }) => void } = {}): Promise<void> { export async function build(
options: {
progress?: (event: {
current: number;
total: number;
route: string;
}) => void;
} = {},
): Promise<void> {
if (!resolved.prerender) return; if (!resolved.prerender) return;
const results = await prerender(app, { const results = await prerender(app, {
routes: resolved.prerender.routes, routes: resolved.prerender.routes,
outDir: join(resolved.root, resolved.prerender.outDir ?? "dist"), outDir: join(resolved.root, resolved.prerender.outDir ?? "dist"),
progress: options.progress progress: options.progress,
}); });
for (const result of results) { for (const result of results) {
console.log(`muddle: prerendered ${result.route} -> ${result.file}`); console.log(`muddle: prerendered ${result.route} -> ${result.file}`);

View file

@ -1,11 +1,7 @@
// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. // AUTO-GENERATED by contracts/typegen/generator.ts, do not edit.
// Run `bun run typegen <project>` to update this file. // Run `bun run typegen <project>` to update this file.
export interface TableRows {}
export interface TableRows {
}
export type TableName = keyof TableRows; export type TableName = keyof TableRows;

View file

@ -4,10 +4,51 @@
import type { Minify } from "./minify.ts"; import type { Minify } from "./minify.ts";
export interface ViewProps { export interface ViewProps {
"modules/app/views/feature": { title: string; nav: {id:string,label:string,href:string,active?:boolean}[]; albums: {id:string,name:string,cover:string,tracks:{title:string,duration:string}[]}[]; ribbonStyle?: string; label: string; prose: string|null; features: {label:string,href:string}[]; current?: string }; "modules/app/views/feature": {
"modules/app/views/index": { title: string; nav: {id:string,label:string,href:string,active?:boolean}[]; albums: {id:string,name:string,cover:string,tracks:{title:string,duration:string}[]}[]; ribbonStyle?: string; intro: {id:string,label:string,note:string,tint:string}[]; features: {label:string,href:string}[] }; title: string;
nav: { id: string; label: string; href: string; active?: boolean }[];
albums: {
id: string;
name: string;
cover: string;
tracks: { title: string; duration: string }[];
}[];
ribbonStyle?: string;
label: string;
prose: string | null;
features: { label: string; href: string }[];
current?: string;
};
"modules/app/views/index": {
title: string;
nav: { id: string; label: string; href: string; active?: boolean }[];
albums: {
id: string;
name: string;
cover: string;
tracks: { title: string; duration: string }[];
}[];
ribbonStyle?: string;
intro: { id: string; label: string; note: string; tint: string }[];
features: { label: string; href: string }[];
};
"modules/app/views/layout": Record<string, never>; "modules/app/views/layout": Record<string, never>;
"modules/app/views/navbar": { nav: {id:string,label:string,href:string,active?:boolean,detail:string}[]; albums: {id:string,name:string,date:string,cover:string,tracks:{title:string,duration:string}[]}[] }; "modules/app/views/navbar": {
nav: {
id: string;
label: string;
href: string;
active?: boolean;
detail: string;
}[];
albums: {
id: string;
name: string;
date: string;
cover: string;
tracks: { title: string; duration: string }[];
}[];
};
} }
/** Path of a view of the project. */ /** Path of a view of the project. */

View file

@ -11,7 +11,13 @@ export default defineConfig({
integrations: [stcssIntegration({ dir: "styles" })], integrations: [stcssIntegration({ dir: "styles" })],
prerender: { prerender: {
routes: ["/", "/features/interpolate", "/features/refer", "/features/predict", "/features/route"], routes: [
"/",
"/features/interpolate",
"/features/refer",
"/features/predict",
"/features/route",
],
outDir: "dist", outDir: "dist",
}, },
}); });

View file

@ -5,7 +5,8 @@
const BASE = "https://musicbrainz.org/ws/2"; const BASE = "https://musicbrainz.org/ws/2";
const ARTIST = "4ae36ade-1798-48c4-b06b-cc68b7d3d83f"; const ARTIST = "4ae36ade-1798-48c4-b06b-cc68b7d3d83f";
const USER_AGENT = "muddle-react-sample/0.1 (discography demo; devnull@crit.rip)"; const USER_AGENT =
"muddle-react-sample/0.1 (discography demo; devnull@crit.rip)";
const TTL = 1000 * 60 * 30; const TTL = 1000 * 60 * 30;
export interface Track { export interface Track {
@ -53,25 +54,31 @@ async function mbJson<T>(path: string, attempt = 0): Promise<T> {
} }
throw new Error(`musicbrainz: ${response.status} ${path}`); throw new Error(`musicbrainz: ${response.status} ${path}`);
} }
return await response.json() as T; return (await response.json()) as T;
} }
function isStudioAlbum(entry: ReleaseGroup): boolean { function isStudioAlbum(entry: ReleaseGroup): boolean {
const title = entry.title.toLowerCase(); const title = entry.title.toLowerCase();
return !/(live|remix|commentary|edition|remastered|deluxe|second sky|secret sky|matt dipirro)/.test(title); return !/(live|remix|commentary|edition|remastered|deluxe|second sky|secret sky|matt dipirro)/.test(
title,
);
} }
async function tracksFor(groupId: string): Promise<Track[]> { async function tracksFor(groupId: string): Promise<Track[]> {
const releases = await mbJson<{ releases?: { id: string; country?: string }[] }>( const releases = await mbJson<{
`/release-group/${groupId}?inc=releases&fmt=json`, releases?: { id: string; country?: string }[];
); }>(`/release-group/${groupId}?inc=releases&fmt=json`);
// prefer a digital or worldwide edition: sort by shortest country code chain // prefer a digital or worldwide edition: sort by shortest country code chain
const candidate = (releases.releases ?? []).slice() const candidate = (releases.releases ?? [])
.slice()
.sort((a, b) => (a.country?.length ?? 9) - (b.country?.length ?? 9))[0]; .sort((a, b) => (a.country?.length ?? 9) - (b.country?.length ?? 9))[0];
if (!candidate) return []; if (!candidate) return [];
const release = await mbJson<MbRelease>(`/release/${candidate.id}?inc=recordings+media&fmt=json`); const release = await mbJson<MbRelease>(
const media = (release.media ?? []).find((medium) => (medium.tracks?.length ?? 0) > 0) `/release/${candidate.id}?inc=recordings+media&fmt=json`,
?? release.media?.[0]; );
const media =
(release.media ?? []).find((medium) => (medium.tracks?.length ?? 0) > 0) ??
release.media?.[0];
return (media?.tracks ?? []).map((track) => ({ return (media?.tracks ?? []).map((track) => ({
title: track.title, title: track.title,
duration: formatDuration(track.length), duration: formatDuration(track.length),
@ -85,10 +92,15 @@ export async function discography(): Promise<Album[]> {
`/artist/${ARTIST}?inc=release-groups&fmt=json`, `/artist/${ARTIST}?inc=release-groups&fmt=json`,
); );
const groups = (data["release-groups"] ?? []) const groups = (data["release-groups"] ?? [])
.filter((entry) => entry["primary-type"] === "Album" .filter(
&& (entry["secondary-types"]?.length ?? 0) === 0 (entry) =>
&& isStudioAlbum(entry)) entry["primary-type"] === "Album" &&
.sort((a, b) => a["first-release-date"].localeCompare(b["first-release-date"])) (entry["secondary-types"]?.length ?? 0) === 0 &&
isStudioAlbum(entry),
)
.sort((a, b) =>
a["first-release-date"].localeCompare(b["first-release-date"]),
)
.slice(0, 4); .slice(0, 4);
const albums: Album[] = []; const albums: Album[] = [];
for (const [index, group] of groups.entries()) { for (const [index, group] of groups.entries()) {
@ -125,7 +137,8 @@ function offlineDiscography(): Album[] {
id: "6df00711-624a-4960-9f15-39baf9332cbb", id: "6df00711-624a-4960-9f15-39baf9332cbb",
name: "Worlds", name: "Worlds",
date: "2014-08-08", date: "2014-08-08",
cover: "https://coverartarchive.org/release-group/6df00711-624a-4960-9f15-39baf9332cbb/front-250", cover:
"https://coverartarchive.org/release-group/6df00711-624a-4960-9f15-39baf9332cbb/front-250",
tracks: [ tracks: [
{ title: "Divinity", duration: "6:09" }, { title: "Divinity", duration: "6:09" },
{ title: "Sad Machine", duration: "5:54" }, { title: "Sad Machine", duration: "5:54" },
@ -144,7 +157,8 @@ function offlineDiscography(): Album[] {
id: "e2cc165d-b935-4e7c-8728-77cf01ab21ed", id: "e2cc165d-b935-4e7c-8728-77cf01ab21ed",
name: "Nurture", name: "Nurture",
date: "2021-04-23", date: "2021-04-23",
cover: "https://coverartarchive.org/release-group/e2cc165d-b935-4e7c-8728-77cf01ab21ed/front-250", cover:
"https://coverartarchive.org/release-group/e2cc165d-b935-4e7c-8728-77cf01ab21ed/front-250",
tracks: [ tracks: [
{ title: "Lifelike", duration: "0:43" }, { title: "Lifelike", duration: "0:43" },
{ title: "Look at the Sky", duration: "4:02" }, { title: "Look at the Sky", duration: "4:02" },
@ -166,7 +180,8 @@ function offlineDiscography(): Album[] {
id: "87964290-66b9-4fd9-bea3-1bfe9de92862", id: "87964290-66b9-4fd9-bea3-1bfe9de92862",
name: "SMILE! :D", name: "SMILE! :D",
date: "2024-07-26", date: "2024-07-26",
cover: "https://coverartarchive.org/release-group/87964290-66b9-4fd9-bea3-1bfe9de92862/front-250", cover:
"https://coverartarchive.org/release-group/87964290-66b9-4fd9-bea3-1bfe9de92862/front-250",
tracks: [ tracks: [
{ title: "Knock Yourself Out XD", duration: "3:13" }, { title: "Knock Yourself Out XD", duration: "3:13" },
{ title: "Cheerleader", duration: "3:01" }, { title: "Cheerleader", duration: "3:01" },

View file

@ -19,13 +19,20 @@ export interface Feature {
const ROOT = import.meta.dir; const ROOT = import.meta.dir;
const DEFINITIONS: Feature[] = [ const DEFINITIONS: Feature[] = [];
];
export const FEATURES: Feature[] = DEFINITIONS.map((feature) => ({ ...feature, prose: (prose = feature.prose) => prose() })); export const FEATURES: Feature[] = DEFINITIONS.map((feature) => ({
...feature,
prose: (prose = feature.prose) => prose(),
}));
export const NAV: NavItem[] = [ export const NAV: NavItem[] = [
{ id: "home", label: "Home", href: "/", detail: "Porter discography powered by muddle" }, {
id: "home",
label: "Home",
href: "/",
detail: "Porter discography powered by muddle",
},
...DEFINITIONS.map((feature) => ({ ...DEFINITIONS.map((feature) => ({
id: feature.slug, id: feature.slug,
label: feature.label, label: feature.label,
@ -75,4 +82,3 @@ export const INTRO: IntroTopic[] = [
tint: "amber", tint: "amber",
}, },
]; ];

View file

@ -24,17 +24,19 @@ body {
margin: 0 auto; margin: 0 auto;
padding: clamp(2rem, 9vw, 7rem) 0; padding: clamp(2rem, 9vw, 7rem) 0;
} }
.porter__intro, .porter__item { .porter__intro,
.porter__item {
padding: clamp(1.25rem, 4vw, 3rem); padding: clamp(1.25rem, 4vw, 3rem);
} }
.porter__intro h1 { .porter__intro h1 {
max-width: 44rem; max-width: 44rem;
margin: 0; margin: 0;
font-size: clamp(2.75rem, 7vw, 5.75rem); font-size: clamp(2.75rem, 7vw, 5.75rem);
line-height: .95; line-height: 0.95;
letter-spacing: -.07em; letter-spacing: -0.07em;
} }
.porter__intro p, .porter__item p { .porter__intro p,
.porter__item p {
max-width: 39rem; max-width: 39rem;
color: var(--muted-foreground); color: var(--muted-foreground);
line-height: 1.65; line-height: 1.65;
@ -55,7 +57,7 @@ body {
font-size: 1.35rem; font-size: 1.35rem;
} }
.rounded-xl { .rounded-xl {
border-radius: .75rem; border-radius: 0.75rem;
} }
.border { .border {
border: 1px solid var(--border); border: 1px solid var(--border);
@ -67,7 +69,9 @@ body {
color: var(--card-foreground); color: var(--card-foreground);
} }
.shadow { .shadow {
box-shadow: 0 1px 3px rgb(0 0 0 / .1), 0 1px 2px rgb(0 0 0 / .06); box-shadow:
0 1px 3px rgb(0 0 0 / 0.1),
0 1px 2px rgb(0 0 0 / 0.06);
} }
.muddle-react { .muddle-react {
display: inline-flex; display: inline-flex;
@ -75,16 +79,16 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 0; border: 0;
border-radius: .375rem; border-radius: 0.375rem;
background: var(--primary); background: var(--primary);
color: var(--primary-foreground); color: var(--primary-foreground);
padding: .55rem .9rem; padding: 0.55rem 0.9rem;
font: inherit; font: inherit;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
} }
.muddle-react:hover { .muddle-react:hover {
opacity: .9; opacity: 0.9;
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.porter__grid { .porter__grid {

View file

@ -25,9 +25,7 @@ const dryRun = process.argv.includes("--dry-run");
const token = process.env.MUDDLE_FORGEJO_TOKEN; const token = process.env.MUDDLE_FORGEJO_TOKEN;
if (!dryRun && !token) { if (!dryRun && !token) {
throw new Error( throw new Error("Set MUDDLE_FORGEJO_TOKEN or use --dry-run.");
"Set MUDDLE_FORGEJO_TOKEN or use --dry-run.",
);
} }
async function forgejo( async function forgejo(
@ -53,8 +51,7 @@ async function ensureWorkspaceModule(name: string): Promise<void> {
if (!existsSync(manifest)) { if (!existsSync(manifest)) {
await writeFile( await writeFile(
manifest, manifest,
`${ `${JSON.stringify(
JSON.stringify(
{ {
name: `@muddle/${name}`, name: `@muddle/${name}`,
version: "0.1.0", version: "0.1.0",
@ -64,8 +61,7 @@ async function ensureWorkspaceModule(name: string): Promise<void> {
}, },
null, null,
2, 2,
) )}\n`,
}\n`,
); );
await writeFile(join(dir, "index.ts"), "export {};\n"); await writeFile(join(dir, "index.ts"), "export {};\n");
} }
@ -105,9 +101,7 @@ for (const name of moduleNames) {
await ensureRemoteRepository(name); await ensureRemoteRepository(name);
console.log( console.log(
`${ `${
dryRun dryRun ? "would provision" : "provisioned"
? "would provision"
: "provisioned"
} @muddle/${name} at ${api}/${owner}/${name}`, } @muddle/${name} at ${api}/${owner}/${name}`,
); );
} }

View file

@ -27,9 +27,7 @@ const selected = requested.length > 0 ? requested : modules;
const token = process.env.MUDDLE_FORGEJO_TOKEN; const token = process.env.MUDDLE_FORGEJO_TOKEN;
if (push && !token) { if (push && !token) {
throw new Error( throw new Error("Set MUDDLE_FORGEJO_TOKEN before using --push.");
"Set MUDDLE_FORGEJO_TOKEN before using --push.",
);
} }
for (const name of selected) { for (const name of selected) {
if (!modules.includes(name)) { if (!modules.includes(name)) {
@ -88,22 +86,19 @@ try {
await run(["git", "add", "--", prefix]); await run(["git", "add", "--", prefix]);
await run(["git", "commit", "-m", message, "--", prefix]); await run(["git", "commit", "-m", message, "--", prefix]);
} }
const branch = await run([ const branch = await run(["git", "subtree", "split", "--prefix", prefix]);
"git", await run(
"subtree", [
"split",
"--prefix",
prefix,
]);
await run([
"git", "git",
"push", "push",
`${api}/${owner}/${name}.git`, `${api}/${owner}/${name}.git`,
`${branch}:refs/heads/main`, `${branch}:refs/heads/main`,
], { ],
{
GIT_ASKPASS: askPass, GIT_ASKPASS: askPass,
GIT_TERMINAL_PROMPT: "0", GIT_TERMINAL_PROMPT: "0",
}); },
);
console.log(`pushed @muddle/${name}`); console.log(`pushed @muddle/${name}`);
} }
} finally { } finally {

View file

@ -2,20 +2,34 @@ import { expect, test } from "bun:test";
import { App } from "../index.ts"; import { App } from "../index.ts";
import { resolveConfig } from "../core/config.ts"; import { resolveConfig } from "../core/config.ts";
import { createViews } from "../views/api.ts"; import { createViews } from "../views/api.ts";
import { amezingIntegration, amezingStyles, accentStyle } from "../modules/amezing/index.ts"; import {
amezingIntegration,
amezingStyles,
accentStyle,
} from "../modules/amezing/index.ts";
function createAmezingApp() { function createAmezingApp() {
const views = createViews({ viewsDir: process.cwd() }); const views = createViews({ viewsDir: process.cwd() });
return App.create({ config: resolveConfig(), views, integrations: [amezingIntegration()] }); return App.create({
config: resolveConfig(),
views,
integrations: [amezingIntegration()],
});
} }
test("amezing integration mounts the theme components and assets", async () => { test("amezing integration mounts the theme components and assets", async () => {
const app = await createAmezingApp(); const app = await createAmezingApp();
const response = await app.fetch(new Request("https://example.test/_muddle/amezing/closable.js")); const response = await app.fetch(
new Request("https://example.test/_muddle/amezing/closable.js"),
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const font = await app.fetch(new Request("https://example.test/fonts/commissioner.woff2")); const font = await app.fetch(
new Request("https://example.test/fonts/commissioner.woff2"),
);
expect(font.status).toBe(200); expect(font.status).toBe(200);
const styles = await app.fetch(new Request("https://example.test/_muddle/amezing.css")); const styles = await app.fetch(
new Request("https://example.test/_muddle/amezing.css"),
);
expect((await styles.text()).length).toBeGreaterThan(0); expect((await styles.text()).length).toBeGreaterThan(0);
}); });

View file

@ -1,34 +1,84 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { analyzeHtml } from "../modules/analysis/index.ts"; import { analyzeHtml } from "../modules/analysis/index.ts";
import { App } from "../core/app.ts"; import { App } from "../core/app.ts";
import { terserIntegration, htmlMinifyIntegration, imageOptimizationIntegration } from "../integrations/optimize.ts"; import {
terserIntegration,
htmlMinifyIntegration,
imageOptimizationIntegration,
} from "../integrations/optimize.ts";
test("accessibility scoring reports semantics, alpha contrast and visual-effect uncertainty", async () => { test("accessibility scoring reports semantics, alpha contrast and visual-effect uncertainty", async () => {
const html = '<html lang="en"><head><title>Audit</title><style>body{background:white;color:black}.faint{color:rgba(0,0,0,.1)}.blur{filter:blur(1px);backdrop-filter:blur(4px)}</style></head><body><input><p class="faint">Low contrast</p><p class="blur">Filtered text</p><img src="missing.png"></body></html>'; const html =
const [report] = await analyzeHtml(html, "accessible", { viewports: [{ width: 390, height: 844 }] }); '<html lang="en"><head><title>Audit</title><style>body{background:white;color:black}.faint{color:rgba(0,0,0,.1)}.blur{filter:blur(1px);backdrop-filter:blur(4px)}</style></head><body><input><p class="faint">Low contrast</p><p class="blur">Filtered text</p><img src="missing.png"></body></html>';
expect(report!.findings.map((finding) => finding.rule)).toContain("control-name"); const [report] = await analyzeHtml(html, "accessible", {
expect(report!.findings.map((finding) => finding.rule)).toContain("text-contrast"); viewports: [{ width: 390, height: 844 }],
expect(report!.findings.map((finding) => finding.rule)).toContain("visibility-effects"); });
expect(report!.findings.map((finding) => finding.rule)).toContain(
"control-name",
);
expect(report!.findings.map((finding) => finding.rule)).toContain(
"text-contrast",
);
expect(report!.findings.map((finding) => finding.rule)).toContain(
"visibility-effects",
);
expect(report!.score).toBeLessThan(100); expect(report!.score).toBeLessThan(100);
expect(report!.findings.find((finding) => finding.rule === "visibility-effects")!.severity).toBe("review"); expect(
report!.findings.find((finding) => finding.rule === "visibility-effects")!
.severity,
).toBe("review");
}); });
test("overflow analysis distinguishes intentional scrolling and clipped content", async () => { test("overflow analysis distinguishes intentional scrolling and clipped content", async () => {
const html = '<html><head><style>body{margin:0}.wide{width:700px}.scroll{width:200px;overflow:auto}.clip{width:100px;height:10px;overflow:hidden}</style></head><body><div class="wide">Wide</div><div class="scroll"><div class="wide">Scrollable</div></div><div class="clip"><div style="height:200px">Clipped</div></div></body></html>'; const html =
'<html><head><style>body{margin:0}.wide{width:700px}.scroll{width:200px;overflow:auto}.clip{width:100px;height:10px;overflow:hidden}</style></head><body><div class="wide">Wide</div><div class="scroll"><div class="wide">Scrollable</div></div><div class="clip"><div style="height:200px">Clipped</div></div></body></html>';
const reports = await analyzeHtml(html, "overflow"); const reports = await analyzeHtml(html, "overflow");
expect(reports[0]!.findings.filter((finding) => finding.rule === "viewport-overflow")).toHaveLength(0); expect(
expect(reports[1]!.findings.filter((finding) => finding.rule === "viewport-overflow")).toHaveLength(1); reports[0]!.findings.filter(
expect(reports[1]!.findings.map((finding) => finding.rule)).toContain("clipping"); (finding) => finding.rule === "viewport-overflow",
),
).toHaveLength(0);
expect(
reports[1]!.findings.filter(
(finding) => finding.rule === "viewport-overflow",
),
).toHaveLength(1);
expect(reports[1]!.findings.map((finding) => finding.rule)).toContain(
"clipping",
);
}); });
test("async optimization runs on staged assets and preserves binary image input", async () => { test("async optimization runs on staged assets and preserves binary image input", async () => {
let bytes: number[] = []; let bytes: number[] = [];
const app = await App.create({ integrations: [terserIntegration(), htmlMinifyIntegration(), imageOptimizationIntegration({ async optimize(input) { bytes = [...input]; return new Uint8Array([9, 8]); } })] }); const app = await App.create({
const js = await app.transformAsset({ path: "app.js", contentType: "text/javascript", content: 'const url = "https://example.test"; console.log(url);' }); integrations: [
expect(String(js.content)).toContain("https://example.test"); terserIntegration(),
const html = await app.transformAsset({ path: "index.html", contentType: "text/html", content: '<html><body> <p>Text</p> </body></html>' }); htmlMinifyIntegration(),
expect(String(html.content)).not.toContain(" "); imageOptimizationIntegration({
const image = await app.transformAsset({ path: "image.png", contentType: "image/png", content: new Uint8Array([0, 255, 128]) }); async optimize(input) {
expect(bytes).toEqual([0, 255, 128]); bytes = [...input];
expect([...image.content as Uint8Array]).toEqual([9, 8]); return new Uint8Array([9, 8]);
},
}),
],
});
const js = await app.transformAsset({
path: "app.js",
contentType: "text/javascript",
content: 'const url = "https://example.test"; console.log(url);',
});
expect(String(js.content)).toContain("https://example.test");
const html = await app.transformAsset({
path: "index.html",
contentType: "text/html",
content: "<html><body> <p>Text</p> </body></html>",
});
expect(String(html.content)).not.toContain(" ");
const image = await app.transformAsset({
path: "image.png",
contentType: "image/png",
content: new Uint8Array([0, 255, 128]),
});
expect(bytes).toEqual([0, 255, 128]);
expect([...(image.content as Uint8Array)]).toEqual([9, 8]);
}); });

View file

@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { analyzeReactComponent, astroToSpug, backportProject, cssToStcss, htmlToSpug, reactToSpug, zolaToSpug } from "../core/backport.ts"; import {
analyzeReactComponent,
astroToSpug,
backportProject,
cssToStcss,
htmlToSpug,
reactToSpug,
zolaToSpug,
} from "../core/backport.ts";
import { shadcnIntegration } from "../integrations/shadcn.ts"; import { shadcnIntegration } from "../integrations/shadcn.ts";
import { sassLoader } from "../views/css/sass.ts"; import { sassLoader } from "../views/css/sass.ts";
import { TemplateEngine } from "../views/engine/runtime.ts"; import { TemplateEngine } from "../views/engine/runtime.ts";
@ -15,14 +23,20 @@ describe("backport", () => {
}); });
test("turns HTML structures into indented SPUG", () => { test("turns HTML structures into indented SPUG", () => {
const result = htmlToSpug('<main class="page"><h1>Hello</h1><img src="hero.png"></main>'); const result = htmlToSpug(
'<main class="page"><h1>Hello</h1><img src="hero.png"></main>',
);
expect(result).toContain("main.page"); expect(result).toContain("main.page");
expect(result).toContain(" h1"); expect(result).toContain(" h1");
expect(result).toContain('img(src="hero.png")'); expect(result).toContain('img(src="hero.png")');
}); });
test("ports Astro layouts, slots and imported components", () => { test("ports Astro layouts, slots and imported components", () => {
const page = astroToSpug(`---\nimport Layout from "../layouts/Layout.astro";\nimport Card from "../components/Card.astro";\n---\n<Layout title="Hello"><Card title={title} /><p>Body</p></Layout>`, "/site/src/pages/index.astro", "/site/src/pages"); const page = astroToSpug(
`---\nimport Layout from "../layouts/Layout.astro";\nimport Card from "../components/Card.astro";\n---\n<Layout title="Hello"><Card title={title} /><p>Body</p></Layout>`,
"/site/src/pages/index.astro",
"/site/src/pages",
);
expect(page).toContain("extends layouts/Layout.spug"); expect(page).toContain("extends layouts/Layout.spug");
expect(page).toContain("block content"); expect(page).toContain("block content");
expect(page).toContain("component card"); expect(page).toContain("component card");
@ -31,7 +45,9 @@ describe("backport", () => {
}); });
test("ports Zola inheritance, partials and template expressions", () => { test("ports Zola inheritance, partials and template expressions", () => {
const page = zolaToSpug(`{% extends "base.html" %}\n{% block content %}<h1>{{ page.title }}</h1>{% include "partials/nav.html" %}{% endblock %}`); const page = zolaToSpug(
`{% extends "base.html" %}\n{% block content %}<h1>{{ page.title }}</h1>{% include "partials/nav.html" %}{% endblock %}`,
);
expect(page).toContain("extends ./base.spug"); expect(page).toContain("extends ./base.spug");
expect(page).toContain("block content"); expect(page).toContain("block content");
expect(page).toContain("include ./partials/nav.spug"); expect(page).toContain("include ./partials/nav.spug");
@ -39,16 +55,22 @@ describe("backport", () => {
}); });
test("ports React JSX components through the TypeScript AST", () => { test("ports React JSX components through the TypeScript AST", () => {
const component = reactToSpug('import { Button } from "./button"; export function Card({ title }) { return <section className="card"><h2>{title}</h2><Button variant="outline">Open</Button></section>; }'); const component = reactToSpug(
expect(component).toContain("section(class=\"card\")"); 'import { Button } from "./button"; export function Card({ title }) { return <section className="card"><h2>{title}</h2><Button variant="outline">Open</Button></section>; }',
);
expect(component).toContain('section(class="card")');
expect(component).toContain("= title"); expect(component).toContain("= title");
expect(component).toContain("component button"); expect(component).toContain("component button");
expect(component).toContain('variant: "outline"'); expect(component).toContain('variant: "outline"');
}); });
test("analyzes hooks and lowers static state into a javascript block", () => { test("analyzes hooks and lowers static state into a javascript block", () => {
const source = 'function Toggle() { const [open] = useState(false); useEffect(() => {}, []); return <button>{open}</button>; }'; const source =
expect(analyzeReactComponent(source).hooks).toEqual(["useEffect", "useState"]); "function Toggle() { const [open] = useState(false); useEffect(() => {}, []); return <button>{open}</button>; }";
expect(analyzeReactComponent(source).hooks).toEqual([
"useEffect",
"useState",
]);
expect(reactToSpug(source)).toContain("javascript!"); expect(reactToSpug(source)).toContain("javascript!");
expect(reactToSpug(source)).toContain("const open = false;"); expect(reactToSpug(source)).toContain("const open = false;");
}); });
@ -61,44 +83,96 @@ describe("backport", () => {
await mkdir(join(root, "src/content/posts"), { recursive: true }); await mkdir(join(root, "src/content/posts"), { recursive: true });
await mkdir(join(root, "src/layouts"), { recursive: true }); await mkdir(join(root, "src/layouts"), { recursive: true });
await mkdir(join(root, "src/components"), { recursive: true }); await mkdir(join(root, "src/components"), { recursive: true });
await Bun.write(join(root, "src/pages/index.astro"), "<main><h1>Hi</h1></main>"); await Bun.write(
await Bun.write(join(root, "src/pages/about.astro"), "---\nimport Shell from '../layouts/Shell.astro';\n---\n<Shell><p>About</p></Shell>"); join(root, "src/pages/index.astro"),
await Bun.write(join(root, "src/layouts/Shell.astro"), "<html><body><slot /></body></html>"); "<main><h1>Hi</h1></main>",
await Bun.write(join(root, "src/components/Card.astro"), "<article><slot /></article>"); );
await Bun.write(
join(root, "src/pages/about.astro"),
"---\nimport Shell from '../layouts/Shell.astro';\n---\n<Shell><p>About</p></Shell>",
);
await Bun.write(
join(root, "src/layouts/Shell.astro"),
"<html><body><slot /></body></html>",
);
await Bun.write(
join(root, "src/components/Card.astro"),
"<article><slot /></article>",
);
await Bun.write(join(root, "src/styles/site.css"), ".page { color: red; }"); await Bun.write(join(root, "src/styles/site.css"), ".page { color: red; }");
await Bun.write(join(root, "src/content/posts/hello.md"), "---\ntitle: Hello\n---\n\nBody"); await Bun.write(
join(root, "src/content/posts/hello.md"),
"---\ntitle: Hello\n---\n\nBody",
);
const destination = join(root, "muddle"); const destination = join(root, "muddle");
const result = await backportProject(root, destination); const result = await backportProject(root, destination);
expect(result.source).toBe("astro"); expect(result.source).toBe("astro");
expect(await readFile(join(destination, "modules/app/views/index.spug"), "utf8")).toContain("main"); expect(
expect(await readFile(join(destination, "styles/site.stcss"), "utf8")).toContain(".page"); await readFile(join(destination, "modules/app/views/index.spug"), "utf8"),
expect(await readFile(join(destination, "content/posts/hello.md"), "utf8")).toContain("title: Hello"); ).toContain("main");
expect(await readFile(join(destination, "ct.config.ts"), "utf8")).toContain('name: "posts"'); expect(
const engine = new TemplateEngine({ viewsDir: join(destination, "modules/app/views") }); await readFile(join(destination, "styles/site.stcss"), "utf8"),
).toContain(".page");
expect(
await readFile(join(destination, "content/posts/hello.md"), "utf8"),
).toContain("title: Hello");
expect(await readFile(join(destination, "ct.config.ts"), "utf8")).toContain(
'name: "posts"',
);
const engine = new TemplateEngine({
viewsDir: join(destination, "modules/app/views"),
});
expect(await engine.render("index.spug")).toContain("<main><h1>Hi</h1>"); expect(await engine.render("index.spug")).toContain("<main><h1>Hi</h1>");
expect(await engine.render("about.spug")).toContain("<html><body><p>About</p>"); expect(await engine.render("about.spug")).toContain(
"<html><body><p>About</p>",
);
}); });
test("carries Zola configuration and partial templates into the Muddle project", async () => { test("carries Zola configuration and partial templates into the Muddle project", async () => {
const root = await mkdtemp(join(tmpdir(), "muddle-zola-")); const root = await mkdtemp(join(tmpdir(), "muddle-zola-"));
await mkdir(join(root, "templates/partials"), { recursive: true }); await mkdir(join(root, "templates/partials"), { recursive: true });
await mkdir(join(root, "content/posts"), { recursive: true }); await mkdir(join(root, "content/posts"), { recursive: true });
await Bun.write(join(root, "config.toml"), 'title = "Zola notes"\nbase_url = "https://notes.example"'); await Bun.write(
await Bun.write(join(root, "templates/index.html"), '{% extends "base.html" %}{% block content %}<h1>{{ config.title }}</h1>{% endblock %}'); join(root, "config.toml"),
await Bun.write(join(root, "templates/partials/nav.html"), "<nav>Nav</nav>"); 'title = "Zola notes"\nbase_url = "https://notes.example"',
await Bun.write(join(root, "content/posts/first.md"), "---\ntitle: First\n---\n\nPost"); );
await Bun.write(
join(root, "templates/index.html"),
'{% extends "base.html" %}{% block content %}<h1>{{ config.title }}</h1>{% endblock %}',
);
await Bun.write(
join(root, "templates/partials/nav.html"),
"<nav>Nav</nav>",
);
await Bun.write(
join(root, "content/posts/first.md"),
"---\ntitle: First\n---\n\nPost",
);
const destination = join(root, "muddle"); const destination = join(root, "muddle");
await backportProject(root, destination); await backportProject(root, destination);
expect(await readFile(join(destination, "modules/app/site.config.ts"), "utf8")).toContain("Zola notes"); expect(
expect(await readFile(join(destination, "modules/app/views/partials/nav.spug"), "utf8")).toContain("nav"); await readFile(join(destination, "modules/app/site.config.ts"), "utf8"),
expect(await readFile(join(destination, "modules/app/views/index.spug"), "utf8")).toContain("extends ./base.spug"); ).toContain("Zola notes");
expect(
await readFile(
join(destination, "modules/app/views/partials/nav.spug"),
"utf8",
),
).toContain("nav");
expect(
await readFile(join(destination, "modules/app/views/index.spug"), "utf8"),
).toContain("extends ./base.spug");
}); });
test("compiles Sass before handing CSS to STCSS", async () => { test("compiles Sass before handing CSS to STCSS", async () => {
const root = await mkdtemp(join(tmpdir(), "muddle-sass-")); const root = await mkdtemp(join(tmpdir(), "muddle-sass-"));
const path = join(root, "site.scss"); const path = join(root, "site.scss");
await writeFile(path, "$ink: #123456; .card { color: $ink; }"); await writeFile(path, "$ink: #123456; .card { color: $ink; }");
const loaded = await sassLoader().load({ path, relativePath: "styles/site.scss", source: await readFile(path, "utf8") }); const loaded = await sassLoader().load({
path,
relativePath: "styles/site.scss",
source: await readFile(path, "utf8"),
});
expect(loaded.content).toContain("color: #123456;"); expect(loaded.content).toContain("color: #123456;");
}); });
@ -106,13 +180,33 @@ describe("backport", () => {
const root = await mkdtemp(join(tmpdir(), "muddle-shadcn-")); const root = await mkdtemp(join(tmpdir(), "muddle-shadcn-"));
const integration = shadcnIntegration({ const integration = shadcnIntegration({
components: ["button"], components: ["button"],
fetch: async () => new Response(JSON.stringify({ files: [ fetch: async () =>
{ path: "button.tsx", content: "export function Button() { return <button className=\"btn\">Save</button>; }" }, new Response(
JSON.stringify({
files: [
{
path: "button.tsx",
content:
'export function Button() { return <button className="btn">Save</button>; }',
},
{ path: "button.css", content: ".btn { color: red; }" }, { path: "button.css", content: ".btn { color: red; }" },
] })), ],
}),
),
}); });
await integration.setup?.({ config: { root } as any, route() {}, loader() {} }); await integration.setup?.({
expect(await readFile(join(root, "modules/app/views/components/button.spug"), "utf8")).toContain("button"); config: { root } as any,
expect(await readFile(join(root, "styles/shadcn/button.stcss"), "utf8")).toContain(".btn"); route() {},
loader() {},
});
expect(
await readFile(
join(root, "modules/app/views/components/button.spug"),
"utf8",
),
).toContain("button");
expect(
await readFile(join(root, "styles/shadcn/button.stcss"), "utf8"),
).toContain(".btn");
}); });
}); });

View file

@ -49,10 +49,7 @@ describe("container", () => {
} }
const container = new Container(); const container = new Container();
container.registerFactory( container.registerFactory(AsyncService, async () => new AsyncService());
AsyncService,
async () => new AsyncService(),
);
const service = await container.resolveAsync(AsyncService); const service = await container.resolveAsync(AsyncService);
expect(await service.load()).toBe("loaded"); expect(await service.load()).toBe("loaded");

View file

@ -55,13 +55,24 @@ $ink #101828
test("runs application-defined STCSS expansions from a contextual registry", () => { test("runs application-defined STCSS expansions from a contextual registry", () => {
const hooks = defineHooks((hook) => { const hooks = defineHooks((hook) => {
hook.stcss.value("double", ({ inner, resolve }) => `calc(${resolve(inner)} * 2)`); hook.stcss.value(
hook.stcss.declaration(({ selector, declaration }) => declaration.property === "outline-token" "double",
({ inner, resolve }) => `calc(${resolve(inner)} * 2)`,
);
hook.stcss.declaration(({ selector, declaration }) =>
declaration.property === "outline-token"
? [`${selector}::before { content: "hook"; }`] ? [`${selector}::before { content: "hook"; }`]
: undefined); : undefined,
hook.stcss.atRule("paint", ({ selector, params }) => `${selector} { color: ${params}; }`); );
hook.stcss.atRule(
"paint",
({ selector, params }) => `${selector} { color: ${params}; }`,
);
}); });
const css = compileCss(`.card { width: double(12px); outline-token: yes; @paint rebeccapurple; }`, { hooks }); const css = compileCss(
`.card { width: double(12px); outline-token: yes; @paint rebeccapurple; }`,
{ hooks },
);
expect(css).toContain("width: calc(12px * 2);"); expect(css).toContain("width: calc(12px * 2);");
expect(css).toContain('.card::before { content: "hook"; }'); expect(css).toContain('.card::before { content: "hook"; }');
expect(css).toContain(".card { color: rebeccapurple; }"); expect(css).toContain(".card { color: rebeccapurple; }");
@ -81,7 +92,9 @@ $ink #101828
expect(css).toContain("left: 50%;"); expect(css).toContain("left: 50%;");
expect(css).toContain("top: 50%;"); expect(css).toContain("top: 50%;");
expect(css).toContain("transform: translate(-50%, -50%);"); expect(css).toContain("transform: translate(-50%, -50%);");
expect(css).toContain("margin-left: calc(calc(50% - (30rem / 2)) + var(--muddle-pad, 1rem));"); expect(css).toContain(
"margin-left: calc(calc(50% - (30rem / 2)) + var(--muddle-pad, 1rem));",
);
expect(css).toContain("padding: calc(var(--muddle-pad, 1rem) * 2);"); expect(css).toContain("padding: calc(var(--muddle-pad, 1rem) * 2);");
expect(css).toContain("position: absolute;"); expect(css).toContain("position: absolute;");
expect(css).toContain("transform: translate(-50%, -50%) scale(.96);"); expect(css).toContain("transform: translate(-50%, -50%) scale(.96);");
@ -89,9 +102,14 @@ $ink #101828
test("registers application calculation hooks", () => { test("registers application calculation hooks", () => {
const hooks = defineHooks((hook) => { const hooks = defineHooks((hook) => {
hook.stcss.calculation("third", ({ args, resolve }) => `calc(${resolve(args ?? "100%")} / 3)`); hook.stcss.calculation(
"third",
({ args, resolve }) => `calc(${resolve(args ?? "100%")} / 3)`,
);
}); });
expect(compileCss(".column { width: $third(90%); }", { hooks })).toContain("width: calc(90% / 3);"); expect(compileCss(".column { width: $third(90%); }", { hooks })).toContain(
"width: calc(90% / 3);",
);
}); });
test("resolves the variables", () => { test("resolves the variables", () => {
@ -192,14 +210,18 @@ $space: 1;
} }
nav li:when(nav @hello1:clicked) { opacity: 1; } nav li:when(nav @hello1:clicked) { opacity: 1; }
`); `);
expect(css).toContain('nav:has([data-muddle-anchor="hello0"]:active) li:nth-child(1)'); expect(css).toContain(
'nav:has([data-muddle-anchor="hello0"]:active) li:nth-child(1)',
);
expect(css).toContain("var(--muddle-relate-hello1-x)"); expect(css).toContain("var(--muddle-relate-hello1-x)");
expect(css).toContain("var(--muddle-relate-hello1-width)"); expect(css).toContain("var(--muddle-relate-hello1-width)");
expect(css).toContain('nav:has([data-muddle-anchor="hello1"]:active) li'); expect(css).toContain('nav:has([data-muddle-anchor="hello1"]:active) li');
}); });
test("resolves $refer as a relation value", () => { test("resolves $refer as a relation value", () => {
const css = compileCss(`.tooltip { left: $refer[left](@trigger); width: $refer[width](@trigger); }`); const css = compileCss(
`.tooltip { left: $refer[left](@trigger); width: $refer[width](@trigger); }`,
);
expect(css).toContain("var(--muddle-relate-trigger-x)"); expect(css).toContain("var(--muddle-relate-trigger-x)");
expect(css).toContain("var(--muddle-relate-trigger-width)"); expect(css).toContain("var(--muddle-relate-trigger-width)");
}); });
@ -225,9 +247,13 @@ nav li:when(nav @hello1:clicked) { opacity: 1; }
border-radius: concentric var(--outer, 12px); border-radius: concentric var(--outer, 12px);
} }
`); `);
expect(css).toContain(".panel > * {\n\tborder-radius: calc((0.75rem) - (1rem) - (2px));"); expect(css).toContain(
".panel > * {\n\tborder-radius: calc((0.75rem) - (1rem) - (2px));",
);
expect(css).toContain(".panel-default > * {\n\tborder-radius: inherit;"); expect(css).toContain(".panel-default > * {\n\tborder-radius: inherit;");
expect(css).toContain(".blind > * {\n\tborder-radius: calc((var(--outer, 12px)) - (12px) - (var(--bw, 1px)));"); expect(css).toContain(
".blind > * {\n\tborder-radius: calc((var(--outer, 12px)) - (12px) - (var(--bw, 1px)));",
);
}); });
test("expands freeform gradients into radial gradient stacks", () => { test("expands freeform gradients into radial gradient stacks", () => {
@ -236,22 +262,35 @@ nav li:when(nav @hello1:clicked) { opacity: 1; }
background-image: freeform-gradient(hsl(270 50% 60%) 10% 50%, red 70% 40%, rgb(0 128 0) 40% 80% 30%); background-image: freeform-gradient(hsl(270 50% 60%) 10% 50%, red 70% 40%, rgb(0 128 0) 40% 80% 30%);
} }
`); `);
expect(css).toContain("radial-gradient(circle farthest-side at 10% 50%, hsl(270 50% 60%), transparent)"); expect(css).toContain(
expect(css).toContain("radial-gradient(circle farthest-side at 70% 40%, red, transparent)"); "radial-gradient(circle farthest-side at 10% 50%, hsl(270 50% 60%), transparent)",
expect(css).toContain("radial-gradient(circle at 40% 80%, rgb(0 128 0), rgb(0 128 0) calc(30% * 0.97), transparent 30%)"); );
expect(() => compileCss(".x { background-image: freeform-gradient(red 1% 2% 3% 4%); }")).toThrow(); expect(css).toContain(
"radial-gradient(circle farthest-side at 70% 40%, red, transparent)",
);
expect(css).toContain(
"radial-gradient(circle at 40% 80%, rgb(0 128 0), rgb(0 128 0) calc(30% * 0.97), transparent 30%)",
);
expect(() =>
compileCss(
".x { background-image: freeform-gradient(red 1% 2% 3% 4%); }",
),
).toThrow();
}); });
}); });
describe("stcss @measure", () => { describe("stcss @measure", () => {
test("records prediction targets and anchors them as relations", () => { test("records prediction targets and anchors them as relations", () => {
const measured: string[] = []; const measured: string[] = [];
const css = compileCss(` const css = compileCss(
`
.ribbon { .ribbon {
@measure; @measure;
} }
@measure @trigger, .card, panel; @measure @trigger, .card, panel;
`, { measured }); `,
{ measured },
);
expect(css).toContain("/* muddle-predict:"); expect(css).toContain("/* muddle-predict:");
expect(measured).toEqual([ expect(measured).toEqual([
".ribbon", ".ribbon",

View file

@ -14,9 +14,7 @@ const server = Bun.serve({
success: true, success: true,
result: [ result: [
{ {
results: [ results: [{ id: "1", title: "First", position: 1 }],
{ id: "1", title: "First", position: 1 },
],
success: true, success: true,
meta: { changes: 1 }, meta: { changes: 1 },
}, },

View file

@ -32,5 +32,4 @@ describe("hello world sample", () => {
const response = await get("/nope"); const response = await get("/nope");
expect(response.status).toBe(404); expect(response.status).toBe(404);
}); });
}); });

View file

@ -7,10 +7,20 @@ import { defineHooks } from "../views/hooks.ts";
test("Spug hook registry composes render expansions with route context", async () => { test("Spug hook registry composes render expansions with route context", async () => {
const hooks = defineHooks((hook) => { const hooks = defineHooks((hook) => {
hook.spug("stamp", ({ html, path, locals }) => `${html}<meta data-view="${path}" data-title="${String(locals.title)}">`); hook.spug(
"stamp",
({ html, path, locals }) =>
`${html}<meta data-view="${path}" data-title="${String(locals.title)}">`,
);
}); });
const html = await hooks.renderSpug({ html: "<main>hello</main>", path: "/views/index.spug", locals: { title: "Muddle" } }); const html = await hooks.renderSpug({
expect(html).toContain('<meta data-view="/views/index.spug" data-title="Muddle">'); html: "<main>hello</main>",
path: "/views/index.spug",
locals: { title: "Muddle" },
});
expect(html).toContain(
'<meta data-view="/views/index.spug" data-title="Muddle">',
);
}); });
test("Views applies Spug hooks after template composition", async () => { test("Views applies Spug hooks after template composition", async () => {
@ -21,7 +31,9 @@ test("Views applies Spug hooks after template composition", async () => {
hook.spug("mark", ({ html }) => `${html}<aside>expanded</aside>`); hook.spug("mark", ({ html }) => `${html}<aside>expanded</aside>`);
}); });
const views = createViews({ root, viewsDir: root, hooks }); const views = createViews({ root, viewsDir: root, hooks });
expect(await views.render("index.spug", { title: "Muddle" } as never)).toContain("<aside>expanded</aside>"); expect(
await views.render("index.spug", { title: "Muddle" } as never),
).toContain("<aside>expanded</aside>");
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }

View file

@ -39,7 +39,7 @@ describe("react integration", () => {
test("renders the markup of the mouse tracker", () => { test("renders the markup of the mouse tracker", () => {
const markup = mouseTrackerMarkup({ columns: 2, rows: 2 }); const markup = mouseTrackerMarkup({ columns: 2, rows: 2 });
expect((markup.match(/mouse-tracker__cell/g) ?? [])).toHaveLength(4); expect(markup.match(/mouse-tracker__cell/g) ?? []).toHaveLength(4);
expect(markup).toContain('class="mouse-tracker"'); expect(markup).toContain('class="mouse-tracker"');
expect(markup).toContain('data-muddle-capture="false"'); expect(markup).toContain('data-muddle-capture="false"');
}); });
@ -76,8 +76,6 @@ describe("react integration", () => {
loader: () => undefined, loader: () => undefined,
}); });
expect(routes).toEqual([ expect(routes).toEqual([{ method: "GET", path: "/_muddle/react.css" }]);
{ method: "GET", path: "/_muddle/react.css" },
]);
}); });
}); });

View file

@ -4,7 +4,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { ContentRegistry } from "../core/content.ts"; import { ContentRegistry } from "../core/content.ts";
import { resolveConfig } from "../core/config.ts"; import { resolveConfig } from "../core/config.ts";
import { markdownLoader, parseFrontmatter, renderMarkdown } from "../views/markdown.ts"; import {
markdownLoader,
parseFrontmatter,
renderMarkdown,
} from "../views/markdown.ts";
/** /**
* Tests of the markdown loader and of the content collections. * Tests of the markdown loader and of the content collections.
@ -23,7 +27,9 @@ describe("markdown", () => {
}); });
test("renders the inline markup", () => { test("renders the inline markup", () => {
expect(renderMarkdown("**bold** and *italic*")).toContain("<strong>bold</strong>"); expect(renderMarkdown("**bold** and *italic*")).toContain(
"<strong>bold</strong>",
);
expect(renderMarkdown("`code`")).toContain("<code>code</code>"); expect(renderMarkdown("`code`")).toContain("<code>code</code>");
expect(renderMarkdown("[link](/x)")).toContain('<a href="/x">link</a>'); expect(renderMarkdown("[link](/x)")).toContain('<a href="/x">link</a>');
}); });
@ -36,7 +42,7 @@ describe("markdown", () => {
expect(html).toContain("<p>A paragraph.</p>"); expect(html).toContain("<p>A paragraph.</p>");
expect(html).toContain("<li>one</li>"); expect(html).toContain("<li>one</li>");
expect(html).toContain("<blockquote>"); expect(html).toContain("<blockquote>");
expect(html).toContain("<pre><code class=\"language-js\">"); expect(html).toContain('<pre><code class="language-js">');
}); });
}); });
@ -81,7 +87,11 @@ describe("content collections", () => {
test("rejects an invalid frontmatter", async () => { test("rejects an invalid frontmatter", async () => {
const dir = await mkdtemp(join(tmpdir(), "muddle-content-")); const dir = await mkdtemp(join(tmpdir(), "muddle-content-"));
await writeFile(join(dir, "broken.md"), "---\ndraft: true\n---\n\nBody", "utf8"); await writeFile(
join(dir, "broken.md"),
"---\ndraft: true\n---\n\nBody",
"utf8",
);
const registry = new ContentRegistry( const registry = new ContentRegistry(
resolveConfig({ resolveConfig({

View file

@ -5,7 +5,11 @@ import { tmpdir } from "node:os";
import { astroComponent } from "../modules/astro/index.ts"; import { astroComponent } from "../modules/astro/index.ts";
import { TemplateContext } from "../core/context.ts"; import { TemplateContext } from "../core/context.ts";
import { createViews } from "../views/api.ts"; import { createViews } from "../views/api.ts";
import { authFromRequest, authGuard, authIntegration } from "../integrations/auth.ts"; import {
authFromRequest,
authGuard,
authIntegration,
} from "../integrations/auth.ts";
import { App } from "../core/app.ts"; import { App } from "../core/app.ts";
import { resolveConfig } from "../core/config.ts"; import { resolveConfig } from "../core/config.ts";
import { renderMdx } from "../modules/mdx/index.ts"; import { renderMdx } from "../modules/mdx/index.ts";
@ -14,30 +18,73 @@ import { compileCss } from "../views/css/index.ts";
test("Astro imports compose with typed props and SPUG slots", async () => { test("Astro imports compose with typed props and SPUG slots", async () => {
const root = await mkdtemp(join(tmpdir(), "astro-compose-")); const root = await mkdtemp(join(tmpdir(), "astro-compose-"));
try { try {
await writeFile(join(root, "Title.astro"), '---\nconst { title } = Astro.props;\n---\n<h2>{title}</h2>'); await writeFile(
await writeFile(join(root, "Card.astro"), '---\nimport Title from "./Title.astro";\nconst { title } = Astro.props;\n---\n<article><Title title={title} /><slot /></article>'); join(root, "Title.astro"),
await writeFile(join(root, "index.spug"), 'component Card\n title: "Composed"\n render content\n p Slot body'); "---\nconst { title } = Astro.props;\n---\n<h2>{title}</h2>",
const context = new TemplateContext().import("Card", astroComponent(join(root, "Card.astro"))); );
await writeFile(
join(root, "Card.astro"),
'---\nimport Title from "./Title.astro";\nconst { title } = Astro.props;\n---\n<article><Title title={title} /><slot /></article>',
);
await writeFile(
join(root, "index.spug"),
'component Card\n title: "Composed"\n render content\n p Slot body',
);
const context = new TemplateContext().import(
"Card",
astroComponent(join(root, "Card.astro")),
);
const views = createViews({ viewsDir: root, context }); const views = createViews({ viewsDir: root, context });
const html = await views.renderFile("index"); const html = await views.renderFile("index");
expect(html).toContain("<h2>Composed</h2>"); expect(html).toContain("<h2>Composed</h2>");
expect(html).toContain("<p>Slot body</p>"); expect(html).toContain("<p>Slot body</p>");
} finally { await rm(root, { recursive: true, force: true }); } } finally {
await rm(root, { recursive: true, force: true });
}
}); });
test("form authentication issues an opaque session and rejects forged identity cookies", async () => { test("form authentication issues an opaque session and rejects forged identity cookies", async () => {
const app = await App.create({ config: resolveConfig(), integrations: [authIntegration({ login: (email, password) => password === "secret" ? { id: email } : undefined })] }); const app = await App.create({
const response = await app.fetch(new Request("https://example.test/auth/login", { method: "POST", headers: { origin: "https://example.test" }, body: new URLSearchParams({ email: "ada", password: "secret" }) })); config: resolveConfig(),
integrations: [
authIntegration({
login: (email, password) =>
password === "secret" ? { id: email } : undefined,
}),
],
});
const response = await app.fetch(
new Request("https://example.test/auth/login", {
method: "POST",
headers: { origin: "https://example.test" },
body: new URLSearchParams({ email: "ada", password: "secret" }),
}),
);
expect(response.status).toBe(302); expect(response.status).toBe(302);
const cookie = response.headers.get("set-cookie")!; const cookie = response.headers.get("set-cookie")!;
expect((await authFromRequest(new Request("https://example.test", { headers: { cookie } })))?.id).toBe("ada"); expect(
expect(await authFromRequest(new Request("https://example.test", { headers: { cookie: 'muddle_auth=%7B%22id%22%3A%22admin%22%7D' } }))).toBeUndefined(); (
await authFromRequest(
new Request("https://example.test", { headers: { cookie } }),
)
)?.id,
).toBe("ada");
expect(
await authFromRequest(
new Request("https://example.test", {
headers: { cookie: "muddle_auth=%7B%22id%22%3A%22admin%22%7D" },
}),
),
).toBeUndefined();
}); });
test("OAuth callback validates browser-bound state and exchanges a PKCE code", async () => { test("OAuth callback validates browser-bound state and exchanges a PKCE code", async () => {
const original = globalThis.fetch; const original = globalThis.fetch;
let exchanged = false; let exchanged = false;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { globalThis.fetch = (async (
input: string | URL | Request,
init?: RequestInit,
) => {
if (String(input).endsWith("/token")) { if (String(input).endsWith("/token")) {
const body = init?.body as URLSearchParams; const body = init?.body as URLSearchParams;
expect(body.get("code_verifier")?.length).toBeGreaterThan(40); expect(body.get("code_verifier")?.length).toBeGreaterThan(40);
@ -47,15 +94,48 @@ test("OAuth callback validates browser-bound state and exchanges a PKCE code", a
return Response.json({ sub: "oauth-user" }); return Response.json({ sub: "oauth-user" });
}) as typeof fetch; }) as typeof fetch;
try { try {
const app = await App.create({ config: resolveConfig(), integrations: [authIntegration({ providers: [{ name: "test", authorizationUrl: "https://provider.test/authorize", tokenUrl: "https://provider.test/token", userInfoUrl: "https://provider.test/user", clientId: "test", redirectUri: "https://app.test/auth/test/callback", identity: (profile) => ({ id: String(profile.sub) }) }] })] }); const app = await App.create({
config: resolveConfig(),
integrations: [
authIntegration({
providers: [
{
name: "test",
authorizationUrl: "https://provider.test/authorize",
tokenUrl: "https://provider.test/token",
userInfoUrl: "https://provider.test/user",
clientId: "test",
redirectUri: "https://app.test/auth/test/callback",
identity: (profile) => ({ id: String(profile.sub) }),
},
],
}),
],
});
const start = await app.fetch(new Request("https://app.test/auth/test")); const start = await app.fetch(new Request("https://app.test/auth/test"));
const authorization = new URL(start.headers.get("location")!); const authorization = new URL(start.headers.get("location")!);
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); expect(authorization.searchParams.get("code_challenge_method")).toBe(
"S256",
);
const callback = `https://app.test/auth/test/callback?code=test-code&state=${authorization.searchParams.get("state")}`; const callback = `https://app.test/auth/test/callback?code=test-code&state=${authorization.searchParams.get("state")}`;
expect((await app.fetch(new Request(callback))).status).toBe(400); expect((await app.fetch(new Request(callback))).status).toBe(400);
const response = await app.fetch(new Request(callback, { headers: { cookie: start.headers.get("set-cookie")! } })); const response = await app.fetch(
new Request(callback, {
headers: { cookie: start.headers.get("set-cookie")! },
}),
);
expect(response.status).toBe(302); expect(response.status).toBe(302);
expect(exchanged).toBe(true); expect(exchanged).toBe(true);
expect((await app.fetch(new Request(callback, { headers: { cookie: start.headers.get("set-cookie")! } }))).status).toBe(400); expect(
} finally { globalThis.fetch = original; } (
await app.fetch(
new Request(callback, {
headers: { cookie: start.headers.get("set-cookie")! },
}),
)
).status,
).toBe(400);
} finally {
globalThis.fetch = original;
}
}); });

View file

@ -125,9 +125,9 @@ describe("orm", () => {
expect(await users.count()).toBe(3); expect(await users.count()).toBe(3);
expect(await users.where("age", ">", 30).count()).toBe(2); expect(await users.where("age", ">", 30).count()).toBe(2);
expect( expect((await users.orderBy("age", "desc").limit(1).all())[0]?.name).toBe(
(await users.orderBy("age", "desc").limit(1).all())[0]?.name, "Alan",
).toBe("Alan"); );
expect( expect(
await users await users
.where([{ column: "name", operator: "=", value: "Ada" }]) .where([{ column: "name", operator: "=", value: "Ada" }])

View file

@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createDatabase, type Database } from "../modules/database/src/index.ts"; import {
createDatabase,
type Database,
} from "../modules/database/src/index.ts";
import { Field } from "../modules/database/src/decorators/Field.ts"; import { Field } from "../modules/database/src/decorators/Field.ts";
import { Hydrate } from "../modules/database/src/decorators/Hydrate.ts"; import { Hydrate } from "../modules/database/src/decorators/Hydrate.ts";
import { Table } from "../modules/database/src/decorators/Table.ts"; import { Table } from "../modules/database/src/decorators/Table.ts";
@ -52,7 +55,9 @@ async function database(): Promise<Database> {
describe.skipIf(!url)("postgres", () => { describe.skipIf(!url)("postgres", () => {
test("creates the table of a model", async () => { test("creates the table of a model", async () => {
const db = await database(); const db = await database();
const rows = await db.model(User).query( const rows = await db
.model(User)
.query(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'muddle_users'", "SELECT table_name FROM information_schema.tables WHERE table_name = 'muddle_users'",
); );
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);

View file

@ -2,10 +2,18 @@ import { expect, test } from "bun:test";
import { predictLayout } from "../adapters/prediction.ts"; import { predictLayout } from "../adapters/prediction.ts";
test("prediction measures desktop and mobile geometry and restores closed disclosure", async () => { test("prediction measures desktop and mobile geometry and restores closed disclosure", async () => {
const html = '<!doctype html><html><head><style>.panel { width: 400px; height: 180px; } @media(max-width:48rem) { .panel { width: 200px; height: 240px; } }</style></head><body><details><summary>Menu</summary><div class="panel" data-muddle-anchor="panel">Content</div></details></body></html>'; const html =
const result = await predictLayout(html, { selectors: [".panel"], strict: true }); '<!doctype html><html><head><style>.panel { width: 400px; height: 180px; } @media(max-width:48rem) { .panel { width: 200px; height: 240px; } }</style></head><body><details><summary>Menu</summary><div class="panel" data-muddle-anchor="panel">Content</div></details></body></html>';
expect(result.predictions.map((prediction) => prediction.width)).toEqual([400, 200]); const result = await predictLayout(html, {
expect(result.predictions.map((prediction) => prediction.height)).toEqual([180, 240]); selectors: [".panel"],
strict: true,
});
expect(result.predictions.map((prediction) => prediction.width)).toEqual([
400, 200,
]);
expect(result.predictions.map((prediction) => prediction.height)).toEqual([
180, 240,
]);
expect(result.html).not.toContain("<details open"); expect(result.html).not.toContain("<details open");
expect(result.html).toContain('data-muddle-predicted="predict-0"'); expect(result.html).toContain('data-muddle-predicted="predict-0"');
expect(result.html).toContain("--muddle-predicted-desktop-width: 400px"); expect(result.html).toContain("--muddle-predicted-desktop-width: 400px");
@ -26,8 +34,14 @@ test("prediction resolves a target that is sized from an ahead-of-time relation"
<div class="source" data-muddle-anchor="source"></div> <div class="source" data-muddle-anchor="source"></div>
<div class="target"></div> <div class="target"></div>
</body></html>`; </body></html>`;
const result = await predictLayout(html, { selectors: [".target"], strict: true }); const result = await predictLayout(html, {
const target = result.predictions.filter((prediction) => prediction.selector === '[data-muddle-predicted="predict-0"]'); selectors: [".target"],
strict: true,
});
const target = result.predictions.filter(
(prediction) =>
prediction.selector === '[data-muddle-predicted="predict-0"]',
);
expect(target.map((prediction) => prediction.width)).toEqual([240, 160]); expect(target.map((prediction) => prediction.width)).toEqual([240, 160]);
expect(target.map((prediction) => prediction.x)).toEqual([100, 24]); expect(target.map((prediction) => prediction.x)).toEqual([100, 24]);
expect(result.html).toContain("--muddle-predicted-desktop-width: 240px"); expect(result.html).toContain("--muddle-predicted-desktop-width: 240px");
@ -44,22 +58,39 @@ test("prediction settles chained relations before writing the static page", asyn
<div class="middle" data-muddle-anchor="middle"></div> <div class="middle" data-muddle-anchor="middle"></div>
<div class="target"></div> <div class="target"></div>
</body></html>`; </body></html>`;
const result = await predictLayout(html, { selectors: [".target"], strict: true }); const result = await predictLayout(html, {
const target = result.predictions.filter((prediction) => prediction.selector === '[data-muddle-predicted="predict-0"]'); selectors: [".target"],
strict: true,
});
const target = result.predictions.filter(
(prediction) =>
prediction.selector === '[data-muddle-predicted="predict-0"]',
);
expect(target.map((prediction) => prediction.width)).toEqual([216, 216]); expect(target.map((prediction) => prediction.width)).toEqual([216, 216]);
expect(target.map((prediction) => prediction.x)).toEqual([72, 72]); expect(target.map((prediction) => prediction.x)).toEqual([72, 72]);
}); });
test("prediction follows the page's widest mobile breakpoint", async () => { test("prediction follows the page's widest mobile breakpoint", async () => {
const html = '<!doctype html><html><head><style>.panel { width: 500px; height: 24px; } @media (max-width: 40rem) { .panel { width: 280px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>'; const html =
const result = await predictLayout(html, { selectors: [".panel"], strict: true }); '<!doctype html><html><head><style>.panel { width: 500px; height: 24px; } @media (max-width: 40rem) { .panel { width: 280px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>';
expect(result.predictions.map((prediction) => prediction.width)).toEqual([500, 280]); const result = await predictLayout(html, {
selectors: [".panel"],
strict: true,
});
expect(result.predictions.map((prediction) => prediction.width)).toEqual([
500, 280,
]);
expect(result.html).toContain("@media (max-width: 40rem)"); expect(result.html).toContain("@media (max-width: 40rem)");
}); });
test("prediction emits each selected breakpointAcc relation layer", async () => { test("prediction emits each selected breakpointAcc relation layer", async () => {
const html = '<!doctype html><html><head><style>.panel { width: 600px; height: 24px; } @media (max-width: 64rem) { .panel { width: 420px; } } @media (max-width: 48rem) { .panel { width: 300px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>'; const html =
const result = await predictLayout(html, { selectors: [".panel"], breakpointAcc: ["LG", "MD"], strict: true }); '<!doctype html><html><head><style>.panel { width: 600px; height: 24px; } @media (max-width: 64rem) { .panel { width: 420px; } } @media (max-width: 48rem) { .panel { width: 300px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>';
const result = await predictLayout(html, {
selectors: [".panel"],
breakpointAcc: ["LG", "MD"],
strict: true,
});
expect(result.html).toContain("--muddle-relate-panel-lg-width: 420px"); expect(result.html).toContain("--muddle-relate-panel-lg-width: 420px");
expect(result.html).toContain("--muddle-relate-panel-md-width: 300px"); expect(result.html).toContain("--muddle-relate-panel-md-width: 300px");
expect(result.html).toContain("@media (max-width: 64rem)"); expect(result.html).toContain("@media (max-width: 64rem)");
@ -67,9 +98,12 @@ test("prediction emits each selected breakpointAcc relation layer", async () =>
}); });
test("prediction discovers dotted anchor groups and emits reflective metadata", async () => { test("prediction discovers dotted anchor groups and emits reflective metadata", async () => {
const html = '<!doctype html><html><head><style>.item { width: 200px; }</style></head><body><a class="item" data-muddle-anchor="docs.intro"></a><a class="item" data-muddle-anchor="docs.api"></a></body></html>'; const html =
'<!doctype html><html><head><style>.item { width: 200px; }</style></head><body><a class="item" data-muddle-anchor="docs.intro"></a><a class="item" data-muddle-anchor="docs.api"></a></body></html>';
const result = await predictLayout(html, { strict: true }); const result = await predictLayout(html, { strict: true });
expect(result.groups).toEqual([{ name: "docs", anchors: ["docs.intro", "docs.api"] }]); expect(result.groups).toEqual([
{ name: "docs", anchors: ["docs.intro", "docs.api"] },
]);
expect(result.html).toContain('data-muddle-group="docs"'); expect(result.html).toContain('data-muddle-group="docs"');
expect(result.html).toContain('--muddle-reflect-docs-count: 2'); expect(result.html).toContain("--muddle-reflect-docs-count: 2");
}); });

Some files were not shown because too many files have changed in this diff Show more