Initial commit
This commit is contained in:
commit
743eb6a8e8
320 changed files with 27303 additions and 0 deletions
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# port upstream
|
||||
ametrine/
|
||||
|
||||
# shit small-time slopcoding
|
||||
.opencode/
|
||||
|
||||
/_muddle
|
||||
7
README.md
Normal file
7
README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# muddle
|
||||
|
||||
> [!CAUTION]
|
||||
> MUDDLE IS EXPERIMENTAL SOFTWARE. EXPECT LITERALLY ANYTHING TO BREAK AT ANY TIME.
|
||||
> DURING TESTING THINGS HAVE WORKED. THEY WILL NOT ALWAYS.
|
||||
|
||||
Muddle is a Muddle Dark Wet Dub SSG.
|
||||
53
adapters/cloudflare-wrangler.ts
Normal file
53
adapters/cloudflare-wrangler.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { App } from "../core/app.ts";
|
||||
import { existsSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface WorkerEnv {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkerContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException?(): void;
|
||||
}
|
||||
|
||||
export interface WorkerOptions {
|
||||
/** Builds the application from the bindings of the worker. */
|
||||
create?: (env: WorkerEnv) => App | Promise<App>;
|
||||
}
|
||||
|
||||
export interface WranglerSetupOptions {
|
||||
name?: string;
|
||||
main?: string;
|
||||
compatibilityDate?: string;
|
||||
}
|
||||
|
||||
/** Creates a minimal Wrangler config when a project has not supplied one. */
|
||||
export async function setupWrangler(root: string, options: WranglerSetupOptions = {}): Promise<string> {
|
||||
const path = join(resolve(root), "wrangler.jsonc");
|
||||
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");
|
||||
await writeFile(path, source, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Creates the fetch handler of a worker. */
|
||||
export function createWorker(app: App | WorkerOptions["create"]): {
|
||||
fetch(
|
||||
request: Request,
|
||||
env: WorkerEnv,
|
||||
ctx: WorkerContext,
|
||||
): Promise<Response>;
|
||||
} {
|
||||
let instance: App | undefined = typeof app === "function" ? undefined : app;
|
||||
|
||||
return {
|
||||
async fetch(request: Request, env: WorkerEnv, _ctx: WorkerContext) {
|
||||
if (!instance) {
|
||||
instance = await (app as (env: WorkerEnv) => Promise<App>)(env);
|
||||
}
|
||||
return instance.fetch(request);
|
||||
},
|
||||
};
|
||||
}
|
||||
4
adapters/index.ts
Normal file
4
adapters/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./cloudflare-wrangler.ts";
|
||||
export * from "./server-bun.ts";
|
||||
export * from "./static.ts";
|
||||
export * from "./vercel-vercel.ts";
|
||||
442
adapters/prediction.ts
Normal file
442
adapters/prediction.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
export type BreakpointAcc = "XL" | "LG" | "MD" | "SM";
|
||||
|
||||
export interface LayoutPredictionOptions {
|
||||
selectors?: string[];
|
||||
viewport?: { width: number; height: number };
|
||||
strict?: boolean;
|
||||
mobileViewport?: { width: number; height: number };
|
||||
/** Media query receiving the mobile relation values. Defaults to the page's widest max-width query. */
|
||||
mobileMediaQuery?: string;
|
||||
/** Named responsive layers to calculate ahead of time. Multiple values produce cascading relation layers. */
|
||||
breakpointAcc?: BreakpointAcc | BreakpointAcc[];
|
||||
/** Maximum number of relation propagation passes before a static build fails. */
|
||||
maxPasses?: number;
|
||||
/** Emit discovered dotted-anchor group metadata for reflective selectors. Enabled by default. */
|
||||
reflective?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutPrediction {
|
||||
selector: string;
|
||||
index: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
mobile?: boolean;
|
||||
anchor?: string | null;
|
||||
backgroundColor?: string;
|
||||
color?: string;
|
||||
transition?: string;
|
||||
/** Extra computed properties requested through `$refer[<prop>](...)`. */
|
||||
relate?: Record<string, string>;
|
||||
/** Scoped mirrors of the element itself, requested through `$self[<prop>]`. */
|
||||
self?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ReflectiveAnchorGroup {
|
||||
name: string;
|
||||
anchors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures a rendered page inside headless chromium.
|
||||
*
|
||||
* The script discovers which `$refer` and `$self` properties the shipped
|
||||
* stylesheets request, labels every measure target and mirrors the requested
|
||||
* properties for every anchor element. Geometry properties (`scroll-height`,
|
||||
* `client-width`, ...) are read as numbers off the element, every other name
|
||||
* is read from its computed style.
|
||||
*
|
||||
* The pass runs twice: once for the desktop viewport and, with `remeasure`,
|
||||
* again on the mobile viewport where only the previously labelled targets are
|
||||
* visited.
|
||||
*/
|
||||
const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
|
||||
const { remeasure, selectors = [] } = input;
|
||||
const document = (globalThis as unknown as { document: any }).document;
|
||||
const getComputedStyle = (globalThis as unknown as { getComputedStyle: (element: any) => any }).getComputedStyle;
|
||||
type Entry = {
|
||||
selector: string;
|
||||
index: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
anchor: string | null;
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
transition: string;
|
||||
relate?: Record<string, string>;
|
||||
self?: Record<string, string>;
|
||||
};
|
||||
|
||||
const GEOMETRY = new Set([
|
||||
"scroll-height",
|
||||
"scroll-width",
|
||||
"client-height",
|
||||
"client-width",
|
||||
"offset-height",
|
||||
"offset-width",
|
||||
]);
|
||||
|
||||
const STANDARDS = new Set([
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"background-color",
|
||||
"color",
|
||||
"transition",
|
||||
]);
|
||||
|
||||
const propertyValue = (element: any, style: any, property: string): string => {
|
||||
switch (property) {
|
||||
case "scroll-height": return `${element.scrollHeight}px`;
|
||||
case "scroll-width": return `${element.scrollWidth}px`;
|
||||
case "client-height": return `${element.clientHeight}px`;
|
||||
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) ?? "";
|
||||
}
|
||||
};
|
||||
|
||||
// Which `$refer` and `$self` properties do the shipped stylesheets request?
|
||||
const styles: string[] = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
try {
|
||||
for (const rule of [...sheet.cssRules]) styles.push(rule.cssText);
|
||||
} catch {
|
||||
// cross-origin stylesheets are unreadable, keep going
|
||||
}
|
||||
}
|
||||
const selfProps: string[] = [];
|
||||
const seenSelf = new Set<string>();
|
||||
for (const text of styles) {
|
||||
for (const match of text.matchAll(/--muddle-self-([a-zA-Z][\w-]*)/g)) {
|
||||
if (!seenSelf.has(match[1]!)) {
|
||||
seenSelf.add(match[1]!);
|
||||
selfProps.push(match[1]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
const anchors = [...document.querySelectorAll("[data-muddle-anchor]")] as any[];
|
||||
if (!remeasure) {
|
||||
for (const element of anchors) {
|
||||
const name = element.getAttribute("data-muddle-anchor") as string | null;
|
||||
if (!name?.includes(".")) continue;
|
||||
const parts = name.split(".");
|
||||
const groups = parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join("."));
|
||||
element.setAttribute("data-muddle-group", groups.join(" "));
|
||||
}
|
||||
}
|
||||
const requests = new Map<string, string[]>();
|
||||
for (const element of anchors) {
|
||||
const name = element.getAttribute("data-muddle-anchor");
|
||||
if (!name) continue;
|
||||
const key = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
for (const text of styles) {
|
||||
const needle = `--muddle-relate-${key}-`;
|
||||
let index = text.indexOf(needle);
|
||||
while (index !== -1) {
|
||||
const property = /^[a-zA-Z][\w-]*/.exec(text.slice(index + needle.length))?.[0];
|
||||
if (property && !requests.get(key)?.includes(property)) {
|
||||
requests.set(key, [...(requests.get(key) ?? []), property]);
|
||||
}
|
||||
index = text.indexOf(needle, index + needle.length + (property?.length ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label the measure targets once, they keep their id across viewports.
|
||||
if (!remeasure) {
|
||||
const selected = new Set<any>();
|
||||
for (const selector of selectors) {
|
||||
for (const element of document.querySelectorAll(selector)) selected.add(element);
|
||||
}
|
||||
for (const [index, element] of [...selected].entries()) {
|
||||
element.setAttribute("data-muddle-predicted", `predict-${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Entry[] = [];
|
||||
for (const [index, element] of ([...document.querySelectorAll("[data-muddle-predicted]")] as any[]).entries()) {
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const entry: Entry = {
|
||||
selector: `[data-muddle-predicted="${element.getAttribute("data-muddle-predicted")}"]`,
|
||||
index,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
anchor: element.getAttribute("data-muddle-anchor"),
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
transition: style.transition,
|
||||
};
|
||||
const name = element.getAttribute("data-muddle-anchor");
|
||||
if (name) {
|
||||
const key = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const relate: Record<string, string> = {};
|
||||
for (const property of requests.get(key) ?? []) {
|
||||
if (STANDARDS.has(property)) continue;
|
||||
const value = propertyValue(element, style, property);
|
||||
if (value) relate[property] = value;
|
||||
}
|
||||
if (Object.keys(relate).length > 0) entry.relate = relate;
|
||||
const self: Record<string, string> = {};
|
||||
for (const property of selfProps) {
|
||||
const value = propertyValue(element, style, property);
|
||||
if (value) self[property] = value;
|
||||
}
|
||||
if (Object.keys(self).length > 0) entry.self = self;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
function predictionVariables(prediction: LayoutPrediction, variant: string): string {
|
||||
return [
|
||||
`${prediction.selector} {`,
|
||||
`--muddle-predicted-${variant}-width: ${prediction.width}px;`,
|
||||
`--muddle-predicted-${variant}-height: ${prediction.height}px;`,
|
||||
`--muddle-predicted-${variant}-x: ${prediction.x}px;`,
|
||||
`--muddle-predicted-${variant}-y: ${prediction.y}px;`,
|
||||
`--muddle-predicted-width: var(--muddle-predicted-${variant}-width);`,
|
||||
`--muddle-predicted-height: var(--muddle-predicted-${variant}-height);`,
|
||||
`--muddle-predicted-x: var(--muddle-predicted-${variant}-x);`,
|
||||
`--muddle-predicted-y: var(--muddle-predicted-${variant}-y);`,
|
||||
"}",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation variables of one anchor.
|
||||
*
|
||||
* The suffixed variables hold the raw measurement of the variant, the plain
|
||||
* alias resolves to whichever variant applies at the current viewport.
|
||||
*/
|
||||
function relationVariables(prediction: LayoutPrediction, variant: string): string {
|
||||
if (!prediction.anchor) return "";
|
||||
const key = prediction.anchor.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const prefix = `--muddle-relate-${key}-${variant}`;
|
||||
const values = [
|
||||
`${prefix}-x: ${prediction.x}px`,
|
||||
`${prefix}-y: ${prediction.y}px`,
|
||||
`${prefix}-width: ${prediction.width}px`,
|
||||
`${prefix}-height: ${prediction.height}px`,
|
||||
prediction.backgroundColor ? `${prefix}-background-color: ${prediction.backgroundColor}` : "",
|
||||
prediction.color ? `${prefix}-color: ${prediction.color}` : "",
|
||||
prediction.transition ? `${prefix}-transition: ${prediction.transition}` : "",
|
||||
`--muddle-relate-${key}-x: var(${prefix}-x)`,
|
||||
`--muddle-relate-${key}-y: var(${prefix}-y)`,
|
||||
`--muddle-relate-${key}-width: var(${prefix}-width)`,
|
||||
`--muddle-relate-${key}-height: var(${prefix}-height)`,
|
||||
prediction.backgroundColor ? `--muddle-relate-${key}-background-color: var(${prefix}-background-color)` : "",
|
||||
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]) => [
|
||||
`${prefix}-${property}: ${value}`,
|
||||
`--muddle-relate-${key}-${property}: var(${prefix}-${property})`,
|
||||
]))
|
||||
.filter(Boolean);
|
||||
return `:root {\n\t${values.join(";\n")};\n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped `$self` mirrors of one anchor element.
|
||||
*
|
||||
* `$self[<property>]` resolves through the element itself, so the mirrored
|
||||
* value cannot leak between elements that show the same anchor name once.
|
||||
*/
|
||||
function selfVariables(prediction: LayoutPrediction): string {
|
||||
if (!prediction.anchor) return "";
|
||||
const entries = Object.entries(prediction.self ?? {});
|
||||
if (entries.length === 0) return "";
|
||||
const values = entries.map(([property, value]) => `--muddle-self-${property}: ${value}`);
|
||||
return `[data-muddle-anchor="${prediction.anchor}"] {\n\t${values.join(";\n")};\n}`;
|
||||
}
|
||||
|
||||
interface PredictionLayer {
|
||||
name: string;
|
||||
media: string;
|
||||
viewport: { width: number; height: number };
|
||||
predictions: LayoutPrediction[];
|
||||
}
|
||||
|
||||
function reflectiveAnchorGroups(predictions: LayoutPrediction[]): ReflectiveAnchorGroup[] {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const prediction of predictions) {
|
||||
const anchor = prediction.anchor;
|
||||
if (!anchor?.includes(".")) continue;
|
||||
const parts = anchor.split(".");
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
const name = parts.slice(0, index).join(".");
|
||||
const entries = groups.get(name) ?? [];
|
||||
if (!entries.includes(anchor)) entries.push(anchor);
|
||||
groups.set(name, entries);
|
||||
}
|
||||
}
|
||||
return [...groups].map(([name, anchors]) => ({ name, anchors }));
|
||||
}
|
||||
|
||||
function reflectionCss(groups: ReflectiveAnchorGroup[]): string[] {
|
||||
return groups.flatMap((group) => {
|
||||
const key = group.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return [
|
||||
`[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}; }`),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function predictionCss(desktop: LayoutPrediction[], layers: PredictionLayer[], groups: ReflectiveAnchorGroup[]): string {
|
||||
const rules = (predictions: LayoutPrediction[], variant: string) => predictions.flatMap((entry) => [
|
||||
predictionVariables(entry, variant),
|
||||
relationVariables(entry, variant),
|
||||
selfVariables(entry),
|
||||
]);
|
||||
return [
|
||||
...reflectionCss(groups),
|
||||
"@media (min-width: 0) {",
|
||||
...rules(desktop, "desktop"),
|
||||
"}",
|
||||
...layers.flatMap((layer) => [`@media ${layer.media} {`, ...rules(layer.predictions, layer.name), "}"]),
|
||||
]
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function mobileMediaQuery(html: string, explicit?: string): string {
|
||||
if (explicit) 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())
|
||||
.sort((left, right) => mediaWidth(right) - mediaWidth(left));
|
||||
return candidates[0] ? `(max-width: ${candidates[0]})` : "(max-width: 48rem)";
|
||||
}
|
||||
|
||||
function mediaWidth(value: string): number {
|
||||
const match = /^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i.exec(value);
|
||||
if (!match) return 0;
|
||||
const amount = Number(match[1]);
|
||||
return amount * (match[2]?.toLowerCase() === "px" ? 1 : 16);
|
||||
}
|
||||
|
||||
function viewportForMobileMedia(query: string, fallback: { width: number; height: number }): { width: number; height: number } {
|
||||
const match = /max-width\s*:\s*([^)\s]+)/i.exec(query);
|
||||
const width = match ? mediaWidth(match[1]!) : 0;
|
||||
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 }> = {
|
||||
XL: { width: 1280, media: "(max-width: 80rem)" },
|
||||
LG: { width: 1024, media: "(max-width: 64rem)" },
|
||||
MD: { width: 768, media: "(max-width: 48rem)" },
|
||||
SM: { width: 640, media: "(max-width: 40rem)" },
|
||||
};
|
||||
|
||||
function predictionLayers(options: LayoutPredictionOptions, html: string): Omit<PredictionLayer, "predictions">[] {
|
||||
const requested = options.breakpointAcc
|
||||
? [...new Set(Array.isArray(options.breakpointAcc) ? options.breakpointAcc : [options.breakpointAcc])]
|
||||
: [];
|
||||
if (requested.length) {
|
||||
return requested
|
||||
.sort((left, right) => BREAKPOINTS[right].width - BREAKPOINTS[left].width)
|
||||
.map((accuracy) => ({
|
||||
name: accuracy.toLowerCase(),
|
||||
media: BREAKPOINTS[accuracy].media,
|
||||
viewport: { width: BREAKPOINTS[accuracy].width - 1, height: 844 },
|
||||
}));
|
||||
}
|
||||
const media = mobileMediaQuery(html, options.mobileMediaQuery);
|
||||
return [{ name: "mobile", media, viewport: options.mobileViewport ?? viewportForMobileMedia(media, { width: 390, height: 844 }) }];
|
||||
}
|
||||
|
||||
/** Reads the `@measure` targets emitted into stylesheets by the STCSS compiler. */
|
||||
export function extractPredictSelectors(html: string): string[] {
|
||||
return [...html.matchAll(/\/\*\s*muddle-predict\s*:([\s\S]*?)\*\//g)].flatMap((match) => (match[1]
|
||||
? match[1].split(/[|,]/).map((selector) => selector.trim()).filter(Boolean)
|
||||
: []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bakes prediction and relation variables into prerendered html.
|
||||
*
|
||||
* A headless chromium renders the page at the desktop viewport, measures it,
|
||||
* then repeats at the mobile viewport. The results are inlined as a single
|
||||
* `<style>` block in the `<head>` of the returned page, so the shipped bundle
|
||||
* contains no scripting at all.
|
||||
*/
|
||||
export async function predictLayout(html: string, options: LayoutPredictionOptions): Promise<{ html: string; predictions: LayoutPrediction[]; groups: ReflectiveAnchorGroup[] }> {
|
||||
let browser: import("playwright").Browser | undefined;
|
||||
try {
|
||||
const { chromium } = await import("playwright");
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
|
||||
await page.setContent(html, { waitUntil: "networkidle" });
|
||||
const layers = predictionLayers(options, html);
|
||||
|
||||
// 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")));
|
||||
await page.locator("details").evaluateAll((elements) => {
|
||||
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]"])];
|
||||
// First collect the independent anchor geometry. Relation-driven targets
|
||||
// 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 groups = options.reflective === false ? [] : reflectiveAnchorGroups(initialDesktop);
|
||||
const initialLayers: PredictionLayer[] = [];
|
||||
for (const layer of layers) {
|
||||
await page.setViewportSize(layer.viewport);
|
||||
initialLayers.push({ ...layer, predictions: await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[] });
|
||||
}
|
||||
|
||||
// Propagate the measurements until the emitted relation stylesheet stops
|
||||
// changing. A relation can itself size an anchor used by another relation,
|
||||
// so a single replay only settles one edge of that graph.
|
||||
let desktop = initialDesktop;
|
||||
let measuredLayers = initialLayers;
|
||||
let css = predictionCss(desktop, measuredLayers, groups);
|
||||
let settled = false;
|
||||
for (let pass = 0; pass < (options.maxPasses ?? 8); pass++) {
|
||||
const provisional = await page.addStyleTag({ content: css });
|
||||
await page.setViewportSize(options.viewport ?? { width: 1440, height: 900 });
|
||||
const nextDesktop = await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[];
|
||||
const nextLayers: PredictionLayer[] = [];
|
||||
for (const layer of measuredLayers) {
|
||||
await page.setViewportSize(layer.viewport);
|
||||
nextLayers.push({ ...layer, predictions: await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[] });
|
||||
}
|
||||
await provisional.evaluate((element) => element.remove());
|
||||
const nextCss = predictionCss(nextDesktop, nextLayers, groups);
|
||||
desktop = nextDesktop;
|
||||
measuredLayers = nextLayers;
|
||||
if (nextCss === css) {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
css = nextCss;
|
||||
}
|
||||
if (!settled) throw new Error(`relation prediction did not settle within ${options.maxPasses ?? 8} passes`);
|
||||
css = predictionCss(desktop, measuredLayers, groups);
|
||||
|
||||
await page.locator("details").evaluateAll((elements, states) => {
|
||||
elements.forEach((element, index) => {
|
||||
if (!states[index]) (element as unknown as { removeAttribute(name: string): void }).removeAttribute("open");
|
||||
});
|
||||
}, openStates);
|
||||
const rendered = await page.content();
|
||||
return { html: rendered.replace("</head>", `<style>${css}</style></head>`), predictions: [...desktop, ...measuredLayers.flatMap((layer) => layer.predictions)], groups };
|
||||
} catch (error) {
|
||||
if (options.strict) throw error;
|
||||
console.warn(`muddle prediction skipped: ${(error as Error).message.split("\n")[0]}`);
|
||||
return { html, predictions: [], groups: [] };
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
}
|
||||
123
adapters/server-bun.ts
Normal file
123
adapters/server-bun.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { App } from "../core/app.ts";
|
||||
import { toFetchResponse } from "../core/app.ts";
|
||||
import { contentTypeOf } from "../core/http.ts";
|
||||
import { predictLayout } from "./prediction.ts";
|
||||
import { inlinePredictionStyles } from "./static.ts";
|
||||
|
||||
export interface BunServerOptions {
|
||||
/** Port the server listens on, `0` picks a free port. */
|
||||
port?: number;
|
||||
/** Hostname the server binds to. */
|
||||
hostname?: string;
|
||||
/** Directory with the static assets. */
|
||||
publicDir?: string;
|
||||
/** Called for every request, useful for logging. */
|
||||
onRequest?: (request: Request) => void;
|
||||
/** Predict and cache rendered HTML before its first response. Defaults to true. */
|
||||
predictViews?: boolean;
|
||||
}
|
||||
|
||||
interface CachedResponse {
|
||||
body: ArrayBuffer;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: [string, string][];
|
||||
}
|
||||
|
||||
/** Serves the application. */
|
||||
export function serve(app: App, options: BunServerOptions = {}) {
|
||||
const publicDir = options.publicDir ? resolve(options.publicDir) : undefined;
|
||||
const rendered = new Map<string, Promise<CachedResponse>>();
|
||||
|
||||
return Bun.serve({
|
||||
port: options.port ?? 3000,
|
||||
hostname: options.hostname ?? "0.0.0.0",
|
||||
async fetch(request) {
|
||||
options.onRequest?.(request);
|
||||
if (publicDir) {
|
||||
const asset = await serveAsset(request, publicDir);
|
||||
if (asset) return asset;
|
||||
}
|
||||
if (options.predictViews === false || request.method !== "GET") return app.fetch(request);
|
||||
const key = request.url;
|
||||
let predicted = rendered.get(key);
|
||||
if (!predicted) {
|
||||
predicted = renderPredictedView(app, request).then(cacheResponse);
|
||||
rendered.set(key, predicted);
|
||||
}
|
||||
try {
|
||||
return await cloneResponse(await predicted);
|
||||
} catch (error) {
|
||||
rendered.delete(key);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPredictedView(app: App, request: Request): Promise<Response> {
|
||||
const response = await app.fetch(request);
|
||||
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;
|
||||
const html = await response.text();
|
||||
const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html);
|
||||
if (!hasRelations) return responseFromText(html, response);
|
||||
const assetHtml = await inlinePredictionStyles(app, html, new URL(request.url).origin);
|
||||
const predicted = await predictLayout(assetHtml, { strict: true });
|
||||
return responseFromText(predicted.html, response);
|
||||
}
|
||||
|
||||
function responseFromText(html: string, source: Response): Response {
|
||||
const headers = new Headers(source.headers);
|
||||
headers.delete("content-length");
|
||||
return new Response(html, { status: source.status, statusText: source.statusText, headers });
|
||||
}
|
||||
|
||||
function cloneResponse(source: CachedResponse): Response {
|
||||
return new Response(source.body.slice(0), {
|
||||
status: source.status,
|
||||
statusText: source.statusText,
|
||||
headers: source.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function cacheResponse(source: Response): Promise<CachedResponse> {
|
||||
return {
|
||||
body: await source.arrayBuffer(),
|
||||
status: source.status,
|
||||
statusText: source.statusText,
|
||||
headers: [...source.headers],
|
||||
};
|
||||
}
|
||||
|
||||
/** Serves a static asset of the public directory. */
|
||||
export async function serveAsset(
|
||||
request: Request,
|
||||
publicDir: string,
|
||||
): Promise<Response | undefined> {
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return undefined;
|
||||
|
||||
const requested = resolve(publicDir, decodeURIComponent(url.pathname).replace(/^[/\\]+/, ""));
|
||||
const relativePath = relative(publicDir, requested);
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined;
|
||||
const path = existsSync(join(requested, "index.html")) ? join(requested, "index.html") : requested;
|
||||
if (!existsSync(path)) return undefined;
|
||||
|
||||
let file: Uint8Array;
|
||||
try {
|
||||
file = await readFile(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"content-type": contentTypeOf(extname(path)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { toFetchResponse };
|
||||
151
adapters/static.ts
Normal file
151
adapters/static.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import type { App } from "../core/app.ts";
|
||||
import { getRenderAhead } from "../decorators/RenderAhead.ts";
|
||||
import { getRenderRoutes, type RenderRouteSource } from "../decorators/RenderRoute.ts";
|
||||
import { ContentRegistry } from "../core/content.ts";
|
||||
import { predictLayout, type LayoutPredictionOptions } from "./prediction.ts";
|
||||
|
||||
export interface PrerenderOptions {
|
||||
/** Routes that are rendered at build time. */
|
||||
routes?: string[];
|
||||
/** Directory the html files are written to. */
|
||||
outDir: string;
|
||||
/** Hostname used while rendering, defaults to `localhost`. */
|
||||
hostname?: string;
|
||||
/** Protocol used while rendering. */
|
||||
protocol?: string;
|
||||
/** Adds discovered dynamic routes before rendering. */
|
||||
discover?: (pattern: string) => Promise<string[]> | string[];
|
||||
progress?: (event: { current: number; total: number; route: string }) => void;
|
||||
predict?: LayoutPredictionOptions;
|
||||
}
|
||||
|
||||
export interface PrerenderResult {
|
||||
/** Rendered route. */
|
||||
route: string;
|
||||
/** File the html was written to. */
|
||||
file: string;
|
||||
/** Status code of the response. */
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** Renders the routes of the application into static html files. */
|
||||
export async function prerender(
|
||||
app: App,
|
||||
options: PrerenderOptions,
|
||||
): Promise<PrerenderResult[]> {
|
||||
const outDir = resolve(options.outDir);
|
||||
const stageDir = `${outDir}.staging`;
|
||||
await rm(stageDir, { recursive: true, force: true });
|
||||
await mkdir(stageDir, { recursive: true });
|
||||
const origin = `${options.protocol ?? "http"}://${options.hostname ?? "localhost"}`;
|
||||
const results: PrerenderResult[] = [];
|
||||
|
||||
const routes = new Set(options.routes ?? []);
|
||||
const content = app.resolvedConfig ? new ContentRegistry(app.resolvedConfig) : undefined;
|
||||
if (content) await content.load();
|
||||
for (const route of app.router.list()) {
|
||||
if (route.method !== "GET") continue;
|
||||
if (!route.path.includes(":")) routes.add(route.path);
|
||||
else {
|
||||
for (const source of getRenderRoutes(route.controller, route.methodName)) {
|
||||
for (const path of resolveRenderRoute(source, content)) routes.add(path);
|
||||
}
|
||||
const ahead = getRenderAhead(route.controller, route.methodName);
|
||||
const values = ahead ? (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();
|
||||
for (const [index, route] of orderedRoutes.entries()) {
|
||||
if (app.router.match("GET", route).kind === "not-found") {
|
||||
throw new Error(`Cannot prerender unknown route "${route}"`);
|
||||
}
|
||||
options.progress?.({ current: index + 1, total: orderedRoutes.length, route });
|
||||
const request = new Request(new URL(route, origin).toString());
|
||||
const response = await app.fetch(request);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (location) {
|
||||
console.log(`muddle: ${route} redirects to ${location}, skipped`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!contentType.includes("text/html")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let html = await response.text();
|
||||
// Relation anchors and STCSS prediction markers are static inputs to the
|
||||
// layout pass. Run Playwright automatically so consumers never need to
|
||||
// ship a geometry bridge merely because a page uses `$refer`.
|
||||
const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html);
|
||||
if (options.predict || hasRelations) {
|
||||
const assetHtml = await inlinePredictionStyles(app, html, origin);
|
||||
html = (await predictLayout(assetHtml, options.predict ?? {})).html;
|
||||
}
|
||||
const file = fileFor(stageDir, route);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
const asset = await app.transformAsset({ path: file, 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);
|
||||
results.push({ route, file, status: response.status });
|
||||
}
|
||||
|
||||
for (const route of app.router.listRaw()) {
|
||||
if (route.method !== "GET") continue;
|
||||
if (route.path.includes("*")) continue;
|
||||
const request = new Request(new URL(route.path, origin).toString());
|
||||
const response = await app.fetch(request);
|
||||
const file = join(stageDir, route.path.replace(/^\//, ""));
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
const contentType = 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);
|
||||
results.push({ route: route.path, file, status: response.status });
|
||||
}
|
||||
|
||||
const backup = `${outDir}.previous`;
|
||||
await rm(backup, { recursive: true, force: true });
|
||||
try {
|
||||
await rename(outDir, backup);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await rename(stageDir, outDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Inlines same-origin styles so Playwright can measure a complete document. */
|
||||
export async function inlinePredictionStyles(app: App, html: string, origin: string): Promise<string> {
|
||||
for (const match of [...html.matchAll(/<link\b[^>]*href="([^"]+)"[^>]*>/g)]) {
|
||||
const url = new URL(match[1]!, origin);
|
||||
if (url.origin !== origin || !match[0].includes('rel="stylesheet"')) continue;
|
||||
const response = await app.fetch(new Request(url.href));
|
||||
if (response.ok) html = html.replace(match[0], `<style>${await response.text()}</style>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function resolveRenderRoute(source: RenderRouteSource, content?: ContentRegistry): string[] {
|
||||
if (typeof source === "string") return [source];
|
||||
if (Array.isArray(source)) return source;
|
||||
return (content?.entries(source.collection) ?? []).map((entry) => source.path.replace(/:id\??/g, encodeURIComponent(entry.id)));
|
||||
}
|
||||
|
||||
/** Path of the html file of a route. */
|
||||
export function fileFor(outDir: string, route: string): string {
|
||||
const clean = route.split("?")[0] ?? "/";
|
||||
if (clean === "/" || clean === "") return join(outDir, "index.html");
|
||||
const segments = clean.split("/").filter((segment) => segment.length > 0);
|
||||
const last = segments.pop();
|
||||
if (segments.length === 0) {
|
||||
return join(outDir, `${last as string}.html`);
|
||||
}
|
||||
return join(outDir, ...segments, last as string, "index.html");
|
||||
}
|
||||
68
adapters/vercel-vercel.ts
Normal file
68
adapters/vercel-vercel.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { App } from "../core/app.ts";
|
||||
import { existsSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export type NodeHandler = (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => Promise<void>;
|
||||
|
||||
/** Creates a minimal Vercel configuration when the project has none. */
|
||||
export async function setupVercel(root: string): Promise<string> {
|
||||
const path = join(resolve(root), "vercel.json");
|
||||
if (existsSync(path)) return path;
|
||||
await writeFile(path, `${JSON.stringify({ cleanUrls: true }, null, 2)}\n`, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Creates the node handler of the application. */
|
||||
export function createHandler(app: App): NodeHandler {
|
||||
return async (request, response) => {
|
||||
const url = `http://${request.headers.host ?? "localhost"}${request.url ?? "/"}`;
|
||||
const method = (request.method ?? "GET").toUpperCase();
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(request.headers)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) headers.append(name, entry);
|
||||
continue;
|
||||
}
|
||||
headers.set(name, value);
|
||||
}
|
||||
|
||||
const body =
|
||||
method === "GET" || method === "HEAD"
|
||||
? undefined
|
||||
: await readBody(request);
|
||||
|
||||
const fetchRequest = new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
duplex: body ? "half" : undefined,
|
||||
} as RequestInit);
|
||||
|
||||
const result = await app.fetch(fetchRequest);
|
||||
response.statusCode = result.status;
|
||||
result.headers.forEach((value, name) => {
|
||||
response.setHeader(name, value);
|
||||
});
|
||||
if (result.body) {
|
||||
const buffer = Buffer.from(await result.arrayBuffer());
|
||||
response.end(buffer);
|
||||
return;
|
||||
}
|
||||
response.end();
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(request: IncomingMessage): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
34
biome.json
Normal file
34
biome.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
123
cli.ts
Executable file
123
cli.ts
Executable file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env bun
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { generate, collectTables } from "./contracts/typegen/generator.ts";
|
||||
import { format } from "./core/format.ts";
|
||||
import { loadConfig } from "./core/config.ts";
|
||||
import { setupWrangler } from "./adapters/cloudflare-wrangler.ts";
|
||||
import { setupVercel } from "./adapters/vercel-vercel.ts";
|
||||
import { backportProject } from "./core/backport.ts";
|
||||
import { createTemplate, muddleTemplates, type MuddleTemplate } from "./core/templates.ts";
|
||||
|
||||
function progress(event: { current: number; total: number; route: string }): void {
|
||||
const width = 20;
|
||||
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}`);
|
||||
}
|
||||
|
||||
async function dev(entry: string, root: string, port: number): Promise<void> {
|
||||
const moduleUrl = pathToFileURL(entry).href;
|
||||
const source = `const project = await import(${JSON.stringify(moduleUrl)}); if (!project.app || typeof project.app.fetch !== "function") throw new Error("Project entry must export an app with fetch(request)"); const server = Bun.serve({ port: ${port}, fetch: (request) => project.app.fetch(request) }); console.log(\`muddle dev listening on \${server.url}\`); await new Promise(() => {});`;
|
||||
const child = Bun.spawn([process.execPath, "--watch", "--eval", source], {
|
||||
cwd: root,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const stop = () => child.kill();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
await child.exited;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0] ?? "help";
|
||||
const portFlag = args.indexOf("--port");
|
||||
const requestedPort = portFlag === -1 ? undefined : Number(args[portFlag + 1]);
|
||||
const root = args.slice(1).find((argument, index, values) => {
|
||||
if (argument.startsWith("--")) return false;
|
||||
return values[index - 1] !== "--port";
|
||||
}) ?? process.cwd();
|
||||
if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65_535)) {
|
||||
throw new Error("--port must be an integer from 1 through 65535");
|
||||
}
|
||||
if (command === "typegen") {
|
||||
const result = await generate({ root });
|
||||
for (const file of result.written) console.log(`muddle generated ${relative(process.cwd(), file)}`);
|
||||
return;
|
||||
}
|
||||
if (command === "backport") {
|
||||
const source = args[1] ? resolve(args[1]) : process.cwd();
|
||||
const destination = args[2] ? resolve(args[2]) : resolve(source, "muddle");
|
||||
const result = await backportProject(source, destination);
|
||||
console.log(`muddle backported ${result.source} project to ${result.output} (${result.files.length} files)`);
|
||||
return;
|
||||
}
|
||||
if (command === "templates") {
|
||||
const name = args[1] as MuddleTemplate | undefined;
|
||||
if (!name) {
|
||||
console.log(`Available templates: ${muddleTemplates.join(", ")}`);
|
||||
console.log("Usage: muddle templates <docs|minimal> [destination]");
|
||||
return;
|
||||
}
|
||||
if (!muddleTemplates.includes(name)) throw new Error(`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;
|
||||
}
|
||||
if (command === "repo") {
|
||||
const target = resolve(root);
|
||||
const tables = await collectTables(target, ["**/*.table.ts"]);
|
||||
const out = join(target, "repositories");
|
||||
await mkdir(out, { recursive: true });
|
||||
for (const table of tables) {
|
||||
const rowType = table.fields
|
||||
.map((field) => {
|
||||
const tsType = field.type;
|
||||
const optional = field.nullable ? "?" : "";
|
||||
return `\t${field.property}${optional}: ${tsType};`;
|
||||
})
|
||||
.join("\n");
|
||||
const typeName = `${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Row`;
|
||||
const source = `import { Repository } from "@muddle/database";\n\nexport type ${typeName} = {\n${rowType}\n};\n\nexport class ${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Repository extends Repository<${typeName}> {}\n`;
|
||||
const file = join(out, `${table.name}.repository.ts`);
|
||||
await writeFile(file, source);
|
||||
console.log(`muddle generated ${relative(target, file)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "fmt") {
|
||||
const check = process.argv.includes("--check");
|
||||
const changed = await format(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;
|
||||
return;
|
||||
}
|
||||
if (command === "build" || command === "dev") {
|
||||
const target = resolve(root);
|
||||
const config = await loadConfig(target);
|
||||
const entry = resolve(target, config.entry ?? "index.ts");
|
||||
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 === "vercel-vercel") await setupVercel(target);
|
||||
const project = await import(entry) as { build?: (options?: { progress?: typeof progress }) => Promise<void>; app?: unknown };
|
||||
if (command === "build") {
|
||||
if (!project.build) throw new Error("Project must export build() for muddle build");
|
||||
await project.build({ progress });
|
||||
return;
|
||||
} else if (command === "dev") {
|
||||
if (!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);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log("muddle <typegen|repo|fmt|build|dev|backport|templates> [project-root] [--port number]");
|
||||
}
|
||||
|
||||
await main();
|
||||
229
contracts/index.ts
Normal file
229
contracts/index.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import type { RequestContext } from "./typegen/http/request.ts";
|
||||
|
||||
export * from "./typegen/http/index.ts";
|
||||
export * from "./typegen/orm/index.ts";
|
||||
export * from "./typegen/minify.ts";
|
||||
export type { ViewPath, ViewProps, ViewPropsOf } from "./typegen/views.ts";
|
||||
|
||||
/** A file handled by a loader. */
|
||||
export interface LoaderFile {
|
||||
/** Absolute path of the file. */
|
||||
path: string;
|
||||
/** Path relative to the project root. */
|
||||
relativePath: string;
|
||||
/** Raw content of the file. */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Result of a loader. */
|
||||
export interface LoadedFile {
|
||||
/** Data exposed to the application, e.g. frontmatter and rendered html. */
|
||||
data: Record<string, unknown>;
|
||||
/** Rendered representation, e.g. the compiled css or the markdown html. */
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface Loader {
|
||||
/** Name of the loader, used in error messages. */
|
||||
name: string;
|
||||
/** File extensions handled by the loader, e.g. `['.md']`. */
|
||||
extensions: string[];
|
||||
/** Loads a single file. */
|
||||
load(file: LoaderFile): Promise<LoadedFile> | LoadedFile;
|
||||
}
|
||||
|
||||
/** Options shared by all loaders. */
|
||||
export interface LoaderOptions {
|
||||
/** Directory the loader scans, relative to the project root. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
/** Configuration of the content collections. */
|
||||
export interface CollectionConfig {
|
||||
/** Name of the collection, e.g. `notes`. */
|
||||
name: string;
|
||||
/** Directory of the collection, relative to the project root. */
|
||||
dir: string;
|
||||
/** Loader used to read the files of the collection, by name or inline. */
|
||||
loader: Loader | string;
|
||||
/** Schema of the frontmatter, validated at load time. */
|
||||
schema?: ContentSchema;
|
||||
}
|
||||
|
||||
/** Schema of a content collection. */
|
||||
export interface ContentSchema {
|
||||
/** Required frontmatter fields. */
|
||||
required?: string[];
|
||||
/** Allowed values of a field. */
|
||||
enum?: Record<string, string[]>;
|
||||
/** Maximum length of a string field. */
|
||||
maxLength?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** A single entry of a content collection. */
|
||||
export interface CollectionEntry<
|
||||
Frontmatter extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
/** Identifier of the entry, derived from the file name. */
|
||||
id: string;
|
||||
/** Path of the file, relative to the project root. */
|
||||
path: string;
|
||||
/** Parsed frontmatter. */
|
||||
frontmatter: Frontmatter;
|
||||
/** Rendered body. */
|
||||
body: string;
|
||||
/** Raw markdown source. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Minimal view registry handed to integration mounts. */
|
||||
export interface MountableViews {
|
||||
/** Registers a named view component. */
|
||||
component(
|
||||
name: string,
|
||||
component: { render: (props: unknown, context: unknown) => string | Promise<string> },
|
||||
): unknown;
|
||||
}
|
||||
|
||||
/** A self-contained unit an integration mounts into the application. */
|
||||
export interface Mounts {
|
||||
/** Registers view components, e.g. the primitives of a theme. */
|
||||
views?(views: MountableViews): void | Promise<void>;
|
||||
/** Adds routes, e.g. assets, fonts and endpoints. */
|
||||
routes?(
|
||||
route: (method: string, path: string, handler: (request: Request) => Response | Promise<Response>) => void,
|
||||
): void | Promise<void>;
|
||||
/** Served inline stylesheets by route. */
|
||||
styles?: Record<string, string | (() => string | Promise<string>)>;
|
||||
}
|
||||
|
||||
/** Integration hook, called while the application is created. */
|
||||
export interface Integration {
|
||||
/** Transforms a staged build asset. Promises run in configured order. */
|
||||
transform?(asset: BuildAsset): BuildAsset | Promise<BuildAsset>;
|
||||
/** Analyzes a fully rendered page before the output is published. */
|
||||
analyze?(page: { route: string; html: string }): unknown | Promise<unknown>;
|
||||
/** Runs before routing, including raw integration endpoints. */
|
||||
request?(context: RequestContext): void | Promise<void>;
|
||||
/** Name of the integration. */
|
||||
name: string;
|
||||
/** Called with the resolved configuration. */
|
||||
setup?(context: IntegrationContext): void | Promise<void>;
|
||||
/** Full-stack units mounted while the application boots. */
|
||||
mounts?: Mounts[];
|
||||
/** Called before the server starts serving requests. */
|
||||
ready?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BuildAsset {
|
||||
path: string;
|
||||
contentType: string;
|
||||
content: string | Uint8Array;
|
||||
}
|
||||
|
||||
/** Context handed to an integration. */
|
||||
export interface IntegrationContext {
|
||||
/** Resolved project configuration. */
|
||||
config: ResolvedConfig;
|
||||
/** Adds a route to the application. */
|
||||
route(
|
||||
method: string,
|
||||
path: string,
|
||||
handler: (
|
||||
request: Request,
|
||||
context: RequestContext,
|
||||
) => Promise<Response> | Response,
|
||||
): void;
|
||||
/** Adds a loader to the project. */
|
||||
loader(name: string, loader: Loader): void;
|
||||
}
|
||||
|
||||
/** Adapter configuration, e.g. the port of the http server. */
|
||||
export interface AdapterOptions {
|
||||
/** Port the server listens on. */
|
||||
port?: number;
|
||||
/** Hostname the server binds to. */
|
||||
hostname?: string;
|
||||
/** Name of the adapter, e.g. `server-bun`. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Database configuration. */
|
||||
export interface DatabaseOptions {
|
||||
/** Dialect of the database. */
|
||||
dialect: "sqlite" | "postgres" | "d1";
|
||||
/** Path of the sqlite file or the connection string of postgres. */
|
||||
url?: string;
|
||||
/** Cloudflare D1 binding configuration. */
|
||||
d1?: { accountId: string; databaseId: string; apiToken: string };
|
||||
}
|
||||
|
||||
/** Prerendering configuration. */
|
||||
export interface PrerenderOptions {
|
||||
/** Routes rendered at build time. */
|
||||
routes: string[];
|
||||
/** Directory the html files are written to. */
|
||||
outDir?: string;
|
||||
/** Writes every route, even when the handler answers with a redirect. */
|
||||
fallback?: boolean;
|
||||
}
|
||||
|
||||
/** Project configuration, as written in `ct.config.ts`. */
|
||||
export interface MuddleConfig {
|
||||
/** Root directory of the project. */
|
||||
root?: string;
|
||||
/** Server entry module, relative to the project root. Defaults to `index.ts`. */
|
||||
entry?: string;
|
||||
/** Directory with the templates. */
|
||||
views?: string;
|
||||
/** Directory with the components. */
|
||||
components?: string;
|
||||
/** Directory with the markdown collections. */
|
||||
content?: string;
|
||||
/** Directory with the structured css files. */
|
||||
styles?: string;
|
||||
/** Directory with static assets. */
|
||||
publicDir?: string;
|
||||
/** Directories scanned for templates, relative to the project root. */
|
||||
viewRoots?: string[];
|
||||
/** Loaders available to the content collections. */
|
||||
loaders?: Record<string, Loader>;
|
||||
/** Content collections of the project. */
|
||||
collections?: CollectionConfig[];
|
||||
/** Integrations of the project. */
|
||||
integrations?: Integration[];
|
||||
/** Prerendering configuration. */
|
||||
prerender?: PrerenderOptions;
|
||||
/** Adapter used to serve the application. */
|
||||
adapter?: AdapterOptions;
|
||||
/** Database configuration. */
|
||||
database?: DatabaseOptions;
|
||||
/** Model files of the project, relative to the project root. */
|
||||
tables?: string[];
|
||||
/** Where the generated contracts are written to. */
|
||||
typegen?: { out?: string };
|
||||
}
|
||||
|
||||
/** Configuration with every default applied. */
|
||||
export interface ResolvedConfig extends MuddleConfig {
|
||||
root: string;
|
||||
views: string;
|
||||
components: string;
|
||||
content: string;
|
||||
styles: string;
|
||||
publicDir: string;
|
||||
viewRoots: string[];
|
||||
loaders: Record<string, Loader>;
|
||||
collections: CollectionConfig[];
|
||||
integrations: Integration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the configuration of a project.
|
||||
*
|
||||
* The helper only exists for the editor: it checks the configuration while
|
||||
* it is written in `ct.config.ts`.
|
||||
*/
|
||||
export function defineConfig(config: MuddleConfig): MuddleConfig {
|
||||
return config;
|
||||
}
|
||||
427
contracts/typegen/generator.ts
Normal file
427
contracts/typegen/generator.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
#!/usr/bin/env bun
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join, relative, resolve } from "node:path";
|
||||
import { lex } from "../../views/engine/lexer.ts";
|
||||
import { collectProps, parse } from "../../views/engine/parser.ts";
|
||||
import type { Node, PropDeclaration } from "../../views/engine/types.ts";
|
||||
import { compactTypeSource } from "./minify.ts";
|
||||
import { getMetadata } from "../../core/metadata.ts";
|
||||
import type { MuddleConfig } from "../index.ts";
|
||||
|
||||
export interface GeneratorOptions {
|
||||
/** Root directory of the project. */
|
||||
root?: string;
|
||||
/** Directory the generated contracts are written to. */
|
||||
out?: string;
|
||||
/** Glob like patterns of the model files, relative to the root. */
|
||||
tables?: string[];
|
||||
/** Directories scanned for templates, relative to the root. */
|
||||
viewRoots?: string[];
|
||||
}
|
||||
|
||||
export interface ViewContract {
|
||||
/** Key of the contract, relative to the project root. */
|
||||
key: string;
|
||||
/** Absolute path of the template. */
|
||||
path: string;
|
||||
/** Props declared by the template. */
|
||||
props: PropDeclaration[];
|
||||
}
|
||||
|
||||
export interface GeneratorResult {
|
||||
/** Contracts of the views. */
|
||||
views: ViewContract[];
|
||||
/** Contracts of the tables. */
|
||||
tables: {
|
||||
name: string;
|
||||
fields: {
|
||||
property: string;
|
||||
column: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
}[];
|
||||
}[];
|
||||
/** Files written by the generator. */
|
||||
written: string[];
|
||||
}
|
||||
|
||||
/** Default directory of contracts generated for a project. */
|
||||
export const defaultOut = "_muddle/contracts";
|
||||
|
||||
/** Recursively lists the pug files of a directory. */
|
||||
export async function listTemplates(dir: string): Promise<string[]> {
|
||||
if (!existsSync(dir)) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listTemplates(path)));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".spug")) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Reads the props of a template. */
|
||||
export async function readTemplate(path: string): Promise<PropDeclaration[]> {
|
||||
const source = await readFile(path, "utf8");
|
||||
const parsed = parse(lex(source));
|
||||
return collectProps(parsed.nodes);
|
||||
}
|
||||
|
||||
/** Collects the component usages of a template. */
|
||||
export async function readComponentUsages(
|
||||
path: string,
|
||||
): Promise<{ name: string; props: Record<string, string> }[]> {
|
||||
const source = await readFile(path, "utf8");
|
||||
const parsed = parse(lex(source));
|
||||
const usages: { name: string; props: Record<string, string> }[] = [];
|
||||
walk(parsed.nodes, (node) => {
|
||||
if (node.kind === "component" || node.kind === "island") {
|
||||
usages.push({ name: node.name, props: node.props });
|
||||
}
|
||||
});
|
||||
return usages;
|
||||
}
|
||||
|
||||
function walk(nodes: Node[], visit: (node: Node) => void): void {
|
||||
for (const node of nodes) {
|
||||
visit(node);
|
||||
const children = childrenOf(node);
|
||||
if (children) walk(children, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function childrenOf(node: Node): Node[] | undefined {
|
||||
switch (node.kind) {
|
||||
case "element":
|
||||
case "each":
|
||||
case "while":
|
||||
case "mixin":
|
||||
case "mixin-call":
|
||||
case "include":
|
||||
case "block":
|
||||
case "slot":
|
||||
case "comment":
|
||||
return node.children;
|
||||
case "if":
|
||||
return node.branches.flatMap((branch) => branch.children);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the configuration of a project. */
|
||||
export async function readConfig(root: string): Promise<MuddleConfig> {
|
||||
const configPath = join(root, "ct.config.ts");
|
||||
if (!existsSync(configPath)) return {};
|
||||
const module = (await import(configPath)) as { default?: MuddleConfig };
|
||||
return module.default ?? {};
|
||||
}
|
||||
|
||||
/** Renders the props of a view as a typescript member. */
|
||||
export function renderProps(props: PropDeclaration[]): string {
|
||||
if (props.length === 0) return "Record<string, never>";
|
||||
const members = props.map((prop) => {
|
||||
const type = compactTypeSource(prop.type || "unknown");
|
||||
const optional = prop.optional ? "?" : "";
|
||||
const fallback = prop.defaultValue ? ` = ${prop.defaultValue}` : "";
|
||||
return `${prop.name}${optional}: ${type}${fallback}`;
|
||||
});
|
||||
return `{ ${members.join("; ")} }`;
|
||||
}
|
||||
|
||||
/** Renders the contract of the views. */
|
||||
export function renderViewContracts(views: ViewContract[]): string {
|
||||
const entries = views
|
||||
.map((view) => `\t${JSON.stringify(view.key)}: ${renderProps(view.props)};`)
|
||||
.join("\n");
|
||||
return `// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit.
|
||||
// Run \`bun run typegen <project>\` to update this file.
|
||||
|
||||
import type { Minify } from "./minify.ts";
|
||||
|
||||
export interface ViewProps {
|
||||
${entries}
|
||||
}
|
||||
|
||||
/** Path of a view of the project. */
|
||||
export type ViewPath = keyof ViewProps;
|
||||
|
||||
/** Props of a single view. */
|
||||
export type ViewPropsOf<P extends ViewPath> = Minify<ViewProps[P]>;
|
||||
`;
|
||||
}
|
||||
|
||||
/** Maps a field type onto its typescript counterpart. */
|
||||
export function fieldTypeToTypeScript(type: string, nullable: boolean): string {
|
||||
const base = (() => {
|
||||
switch (type) {
|
||||
case "int":
|
||||
case "bigint":
|
||||
case "real":
|
||||
return "number";
|
||||
case "bool":
|
||||
return "boolean";
|
||||
case "json":
|
||||
return "unknown";
|
||||
case "blob":
|
||||
return "Uint8Array";
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
})();
|
||||
return nullable ? `${base} | null` : base;
|
||||
}
|
||||
|
||||
/** Renders the contract of the tables. */
|
||||
export function renderTableContracts(
|
||||
tables: {
|
||||
name: string;
|
||||
fields: {
|
||||
property: string;
|
||||
column: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
}[];
|
||||
}[],
|
||||
): string {
|
||||
const rows = tables
|
||||
.map((table) => {
|
||||
const members = table.fields
|
||||
.map(
|
||||
(field) =>
|
||||
`\t${JSON.stringify(field.property)}: ${fieldTypeToTypeScript(field.type, field.nullable)};`,
|
||||
)
|
||||
.join("\n");
|
||||
return `export interface ${pascalCase(table.name)}Row {\n${members}\n}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const map = tables
|
||||
.map(
|
||||
(table) =>
|
||||
`\t${JSON.stringify(table.name)}: ${pascalCase(table.name)}Row;`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit.
|
||||
// Run \`bun run typegen <project>\` to update this file.
|
||||
|
||||
${rows}
|
||||
|
||||
export interface TableRows {
|
||||
${map}
|
||||
}
|
||||
|
||||
export type TableName = keyof TableRows;
|
||||
|
||||
/** Row of a single table. */
|
||||
export type TableRowOf<T extends TableName> = TableRows[T];
|
||||
`;
|
||||
}
|
||||
|
||||
/** Converts a table name into a typescript identifier. */
|
||||
export function pascalCase(value: string): string {
|
||||
return value
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the component usages of the templates.
|
||||
*
|
||||
* Every prop handed to a component must be declared by the component and
|
||||
* every required prop of the component must be handed over.
|
||||
*/
|
||||
export async function validateComponents(
|
||||
root: string,
|
||||
views: ViewContract[],
|
||||
componentsDir: string,
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const componentProps = new Map<string, PropDeclaration[]>();
|
||||
|
||||
for (const file of await listTemplates(componentsDir)) {
|
||||
componentProps.set(basename(file, ".spug"), await readTemplate(file));
|
||||
}
|
||||
|
||||
for (const view of views) {
|
||||
for (const usage of await readComponentUsages(view.path)) {
|
||||
const declared = componentProps.get(usage.name);
|
||||
if (!declared) continue;
|
||||
const names = new Set(declared.map((prop) => prop.name));
|
||||
for (const prop of Object.keys(usage.props)) {
|
||||
if (!names.has(prop)) {
|
||||
errors.push(
|
||||
`${relative(root, view.path)}: component "${usage.name}" has no prop "${prop}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const prop of declared) {
|
||||
if (prop.optional) continue;
|
||||
if (!(prop.name in usage.props)) {
|
||||
errors.push(
|
||||
`${relative(root, view.path)}: component "${usage.name}" requires the prop "${prop.name}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Runs the generator. */
|
||||
export async function generate(
|
||||
options: GeneratorOptions = {},
|
||||
): Promise<GeneratorResult> {
|
||||
const root = resolve(options.root ?? process.cwd());
|
||||
const config = await readConfig(root);
|
||||
const configuredOut = options.out ?? config.typegen?.out ?? defaultOut;
|
||||
const out = resolve(root, configuredOut);
|
||||
|
||||
const viewRoots = (
|
||||
options.viewRoots ??
|
||||
config.viewRoots ?? [config.views ?? "views"]
|
||||
).map((entry) => resolve(root, entry));
|
||||
|
||||
const views: ViewContract[] = [];
|
||||
for (const viewRoot of viewRoots) {
|
||||
for (const file of await listTemplates(viewRoot)) {
|
||||
views.push({
|
||||
key: relative(root, file)
|
||||
.replace(/\.spug$/, "")
|
||||
.replace(/\\/g, "/"),
|
||||
path: file,
|
||||
props: await readTemplate(file),
|
||||
});
|
||||
}
|
||||
}
|
||||
views.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
const errors = await validateComponents(
|
||||
root,
|
||||
views,
|
||||
resolve(
|
||||
root,
|
||||
config.components ?? join(config.views ?? "views", "components"),
|
||||
),
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid component usage:\n${errors.map((error) => ` - ${error}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tables = await collectTables(root, options.tables ?? config.tables);
|
||||
|
||||
await mkdir(join(out, "orm"), { recursive: true });
|
||||
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");
|
||||
const viewsFile = join(out, "views.ts");
|
||||
await writeFile(viewsFile, renderViewContracts(views), "utf8");
|
||||
|
||||
const tablesFile = join(out, "orm", "tables.ts");
|
||||
await writeFile(tablesFile, renderTableContracts(tables), "utf8");
|
||||
const indexFile = join(out, "index.ts");
|
||||
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] };
|
||||
}
|
||||
|
||||
/** Collects the tables of the project by importing the model files. */
|
||||
export async function collectTables(
|
||||
root: string,
|
||||
patterns: string[] | undefined,
|
||||
): Promise<
|
||||
{
|
||||
name: string;
|
||||
fields: {
|
||||
property: string;
|
||||
column: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
}[];
|
||||
}[]
|
||||
> {
|
||||
if (!patterns || patterns.length === 0) return [];
|
||||
|
||||
const files: string[] = [];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.includes("**")) {
|
||||
const suffix = pattern.slice(pattern.lastIndexOf("*") + 1);
|
||||
files.push(...(await listFiles(root, suffix)));
|
||||
continue;
|
||||
}
|
||||
const star = pattern.indexOf("*");
|
||||
const dirPart = star === -1 ? pattern : pattern.slice(0, star);
|
||||
const suffix = star === -1 ? ".ts" : pattern.slice(star + 1);
|
||||
const dir = resolve(root, dirPart);
|
||||
const entries = existsSync(dir) ? await readdir(dir) : [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(suffix)) continue;
|
||||
files.push(join(dir, entry));
|
||||
}
|
||||
}
|
||||
|
||||
const tables: {
|
||||
name: string;
|
||||
fields: {
|
||||
property: string;
|
||||
column: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
}[];
|
||||
}[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const module = (await import(file)) as Record<string, unknown>;
|
||||
for (const exported of Object.values(module)) {
|
||||
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;
|
||||
if (!metadata) continue;
|
||||
tables.push({
|
||||
name: metadata.name,
|
||||
fields: [...metadata.fields.values()].map((field) => ({
|
||||
property: field.property,
|
||||
column: field.column,
|
||||
type: field.type,
|
||||
nullable: field.nullable,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
async function listFiles(dir: string, suffix: string): Promise<string[]> {
|
||||
const entries = existsSync(dir) ? await readdir(dir, { withFileTypes: true }) : [];
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await listFiles(path, suffix)));
|
||||
else if (entry.name.endsWith(suffix)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Entry point of the generator. */
|
||||
if (import.meta.main) {
|
||||
const root = process.argv[2];
|
||||
try {
|
||||
const result = await generate({ root });
|
||||
for (const file of result.written) {
|
||||
console.log(`muddle: generated ${relative(process.cwd(), file)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`muddle: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
48
contracts/typegen/http/headers.ts
Normal file
48
contracts/typegen/http/headers.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export type HeaderValue = string;
|
||||
|
||||
export type Headers = Record<string, HeaderValue>;
|
||||
|
||||
export type KnownHeaderName =
|
||||
| "accept"
|
||||
| "accept-charset"
|
||||
| "accept-encoding"
|
||||
| "authorization"
|
||||
| "cache-control"
|
||||
| "content-length"
|
||||
| "content-type"
|
||||
| "cookie"
|
||||
| "etag"
|
||||
| "host"
|
||||
| "if-none-match"
|
||||
| "location"
|
||||
| "origin"
|
||||
| "referer"
|
||||
| "set-cookie"
|
||||
| "user-agent"
|
||||
| "vary"
|
||||
| "x-forwarded-for"
|
||||
| "x-request-id";
|
||||
|
||||
/** Any header name; known names are suggested by the editor. */
|
||||
export type HeaderName = KnownHeaderName | (string & {});
|
||||
|
||||
export type ContentType =
|
||||
| "text/html; charset=utf-8"
|
||||
| "text/plain; charset=utf-8"
|
||||
| "application/json; charset=utf-8"
|
||||
| "application/x-www-form-urlencoded"
|
||||
| "multipart/form-data"
|
||||
| "application/octet-stream";
|
||||
|
||||
export const JSON_CONTENT_TYPE = "application/json; charset=utf-8" as const;
|
||||
export const HTML_CONTENT_TYPE = "text/html; charset=utf-8" as const;
|
||||
export const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8" as const;
|
||||
export const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" as const;
|
||||
|
||||
export function mediaType(contentType: string | undefined): string {
|
||||
if (!contentType) return "";
|
||||
const semi = contentType.indexOf(";");
|
||||
return (semi === -1 ? contentType : contentType.slice(0, semi))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
5
contracts/typegen/http/index.ts
Normal file
5
contracts/typegen/http/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./headers.ts";
|
||||
export * from "./method.ts";
|
||||
export * from "./request.ts";
|
||||
export * from "./respcode.ts";
|
||||
export * from "./response.ts";
|
||||
33
contracts/typegen/http/method.ts
Normal file
33
contracts/typegen/http/method.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export const HTTP_METHODS = [
|
||||
"GET",
|
||||
"HEAD",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
] as const;
|
||||
|
||||
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
|
||||
/** Methods that are allowed to carry a request body. */
|
||||
export type BodyMethod = Extract<
|
||||
HttpMethod,
|
||||
"POST" | "PUT" | "PATCH" | "DELETE"
|
||||
>;
|
||||
|
||||
/** Methods that never mutate server state. */
|
||||
export type SafeMethod = Extract<HttpMethod, "GET" | "HEAD" | "OPTIONS">;
|
||||
|
||||
export function isHttpMethod(value: string): value is HttpMethod {
|
||||
return (HTTP_METHODS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Normalises user supplied verbs (`post`, `Post`) to the canonical form. */
|
||||
export function normalizeMethod(value: string): HttpMethod {
|
||||
const upper = value.toUpperCase() as HttpMethod;
|
||||
if (!isHttpMethod(upper)) {
|
||||
throw new Error(`Unsupported HTTP method: ${value}`);
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
90
contracts/typegen/http/request.ts
Normal file
90
contracts/typegen/http/request.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { Headers } from "./headers.ts";
|
||||
import type { StatusCode } from "./respcode.ts";
|
||||
import type { HttpMethod } from "./method.ts";
|
||||
import type { HttpResponse } from "./response.ts";
|
||||
|
||||
/** Extracts `:param` names from a route pattern. */
|
||||
export type ParamNames<Path extends string> =
|
||||
Path extends `${string}:${infer Param}/${infer Rest}`
|
||||
? ParamNames<`/${Rest}`> | Param
|
||||
: Path extends `${string}:${infer Param}`
|
||||
? Param
|
||||
: never;
|
||||
|
||||
/** Query string keys of a route pattern (`/search?q=…` are not part of the path). */
|
||||
export type QueryNames<Path extends string> =
|
||||
Path extends `${string}?${infer Query}` ? Query : never;
|
||||
|
||||
export type PathParams<Path extends string> = {
|
||||
[K in ParamNames<Path>]: string;
|
||||
};
|
||||
|
||||
export type QueryParams<Path extends string> = {
|
||||
[K in QueryNames<Path>]: string;
|
||||
};
|
||||
|
||||
/** Everything a handler may read from an incoming request. */
|
||||
export interface HttpRequest<
|
||||
Path extends string = string,
|
||||
Body = unknown,
|
||||
ResponseBody = unknown,
|
||||
> {
|
||||
method: HttpMethod;
|
||||
/** Full request URL including the query string. */
|
||||
url: string;
|
||||
/** Path portion of the URL, without query string. */
|
||||
path: string;
|
||||
/** Route parameters extracted from `Path`. */
|
||||
params: PathParams<Path>;
|
||||
/** Parsed query string. */
|
||||
query: Record<string, string>;
|
||||
/** Parsed body (JSON / form / text / raw bytes). */
|
||||
body: Body;
|
||||
/** Fields of a submitted form. */
|
||||
form: FormFields;
|
||||
/** Lower-cased request headers. */
|
||||
headers: Headers;
|
||||
/** Abort signal, wired to the platform request lifecycle. */
|
||||
signal: AbortSignal;
|
||||
/** Typed response helper, the handler may return `response.json(...)` directly. */
|
||||
respond: ResponseBuilder<ResponseBody>;
|
||||
}
|
||||
|
||||
/** Typed response factory bound to a single request. */
|
||||
export interface ResponseBuilder<ResponseBody = unknown> {
|
||||
html(body: string, status?: StatusCode): HttpResponse<string>;
|
||||
json(body: unknown, status?: StatusCode): HttpResponse<unknown>;
|
||||
text(body: string, status?: StatusCode): HttpResponse<string>;
|
||||
redirect(location: string, status?: StatusCode): HttpResponse<never>;
|
||||
status(code: StatusCode): ResponseBuilder<ResponseBody>;
|
||||
header(name: string, value: string): ResponseBuilder<ResponseBody>;
|
||||
send(body?: ResponseBody): HttpResponse<ResponseBody>;
|
||||
}
|
||||
|
||||
/** Fields of a submitted form. */
|
||||
export interface FormFields {
|
||||
/** Value of a single field. */
|
||||
get(name: string): string | undefined;
|
||||
/** All fields of the form. */
|
||||
all(): Record<string, string | string[]>;
|
||||
/** `true` when the form contains the field. */
|
||||
has(name: string): boolean;
|
||||
/** All fields of the form as strings. */
|
||||
values(): Record<string, string>;
|
||||
}
|
||||
|
||||
/** Mutable per-request scratch space, shared by guards, handlers and views. */
|
||||
export interface RequestLocals {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The object handed to a controller method. */
|
||||
export interface RequestContext<
|
||||
Path extends string = string,
|
||||
Body = unknown,
|
||||
ResponseBody = unknown,
|
||||
> extends HttpRequest<Path, Body, ResponseBody> {
|
||||
locals: RequestLocals;
|
||||
/** Platform specific request object (`BunRequest`, `Request`, …). */
|
||||
raw: unknown;
|
||||
}
|
||||
39
contracts/typegen/http/respcode.ts
Normal file
39
contracts/typegen/http/respcode.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export const STATUS_CODES = [
|
||||
200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404, 405,
|
||||
406, 409, 410, 415, 418, 422, 429, 500, 501, 502, 503, 504,
|
||||
] as const;
|
||||
|
||||
export type StatusCode = (typeof STATUS_CODES)[number];
|
||||
|
||||
export type InfoStatus = Extract<StatusCode, 200 | 201 | 202 | 204>;
|
||||
export type RedirectStatus = Extract<
|
||||
StatusCode,
|
||||
301 | 302 | 303 | 304 | 307 | 308
|
||||
>;
|
||||
export type ClientErrorStatus = Extract<
|
||||
StatusCode,
|
||||
400 | 401 | 403 | 404 | 405 | 406 | 409 | 410 | 415 | 418 | 422 | 429
|
||||
>;
|
||||
export type ServerErrorStatus = Extract<
|
||||
StatusCode,
|
||||
500 | 501 | 502 | 503 | 504
|
||||
>;
|
||||
|
||||
export function isStatusCode(value: number): value is StatusCode {
|
||||
return (STATUS_CODES as readonly number[]).includes(value);
|
||||
}
|
||||
|
||||
/** Default status code for a given request method. */
|
||||
export function defaultStatusFor(method: string): StatusCode {
|
||||
switch (method.toUpperCase()) {
|
||||
case "POST":
|
||||
return 201;
|
||||
case "PUT":
|
||||
case "PATCH":
|
||||
return 200;
|
||||
case "DELETE":
|
||||
return 204;
|
||||
default:
|
||||
return 200;
|
||||
}
|
||||
}
|
||||
52
contracts/typegen/http/response.ts
Normal file
52
contracts/typegen/http/response.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Headers } from "./headers.ts";
|
||||
import type { StatusCode } from "./respcode.ts";
|
||||
|
||||
/** Response body union: everything the platform adapters know how to send. */
|
||||
export type ResponseBody =
|
||||
| string
|
||||
| Uint8Array
|
||||
| ArrayBuffer
|
||||
| ReadableStream<Uint8Array>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Response contract.
|
||||
*
|
||||
* Handlers may return this shape directly, or return a plain value which is
|
||||
* then wrapped by the router using the route's declared content type.
|
||||
*/
|
||||
export interface HttpResponse<Body = unknown> {
|
||||
status: StatusCode;
|
||||
headers: Headers;
|
||||
body: Body;
|
||||
}
|
||||
|
||||
/** A route that renders a template instead of returning a value. */
|
||||
export interface ViewResponse extends HttpResponse<string> {
|
||||
/** Template path relative to the views directory, e.g. `pages/index`. */
|
||||
template: string;
|
||||
/** Props handed to the template. */
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk of html that is rendered after the shell of the response.
|
||||
*
|
||||
* Deferred components never block the first byte, the response is streamed
|
||||
* and the chunk is appended as soon as it is ready.
|
||||
*/
|
||||
export interface DeferredChunk {
|
||||
/** Identifier of the chunk, used as the marker in the html. */
|
||||
id: string;
|
||||
/** Content of the chunk. */
|
||||
content: Promise<string>;
|
||||
}
|
||||
|
||||
/** Result of a view render. */
|
||||
export interface RenderedView {
|
||||
/** Html of the shell. */
|
||||
html: string;
|
||||
/** Chunks rendered after the shell. */
|
||||
deferred?: DeferredChunk[];
|
||||
}
|
||||
45
contracts/typegen/minify.ts
Normal file
45
contracts/typegen/minify.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/** Flattens an intersection into one object type. */
|
||||
export type Minify<T> = T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: { [K in keyof T]: T[K] } & {};
|
||||
|
||||
/** Alias of {@link Minify}, kept for readability in generated output. */
|
||||
export type Simplify<T> = Minify<T>;
|
||||
|
||||
/** Makes every property of `T` readonly, recursively. */
|
||||
export type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer U)[]
|
||||
? readonly DeepReadonly<U>[]
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T;
|
||||
|
||||
/** Removes `null` and `undefined` from a union. */
|
||||
export type Defined<T> = Exclude<T, null | undefined>;
|
||||
|
||||
/** Marks the selected keys of `T` as optional. */
|
||||
export type WithOptional<T, K extends keyof T> = Omit<T, K> &
|
||||
Partial<Pick<T, K>>;
|
||||
|
||||
/** Marks the selected keys of `T` as required. */
|
||||
export type WithRequired<T, K extends keyof T> = Omit<T, K> &
|
||||
Required<Pick<T, K>>;
|
||||
|
||||
export function compactTypeSource(source: string): string {
|
||||
const arrow = source.replace(/=>/g, "__ARROW__");
|
||||
return arrow
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*([|&=<>?:,()[\]{}])\s*/g, "$1")
|
||||
.replace(/;\s*$/, "")
|
||||
.trim()
|
||||
.replace(/__ARROW__/g, "=>");
|
||||
}
|
||||
|
||||
export function indent(source: string, depth = 1): string {
|
||||
const pad = " ".repeat(depth);
|
||||
return source
|
||||
.split("\n")
|
||||
.map((line) => (line.length > 0 ? pad + line : line))
|
||||
.join("\n");
|
||||
}
|
||||
80
contracts/typegen/orm/field.ts
Normal file
80
contracts/typegen/orm/field.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/** Storage types, mapped per dialect by the adapters. */
|
||||
export const FIELD_TYPES = [
|
||||
"text",
|
||||
"int",
|
||||
"bigint",
|
||||
"real",
|
||||
"bool",
|
||||
"blob",
|
||||
"json",
|
||||
"uuid",
|
||||
"timestamp",
|
||||
"date",
|
||||
] as const;
|
||||
|
||||
export type FieldType = (typeof FIELD_TYPES)[number];
|
||||
|
||||
export type ReferentialAction =
|
||||
| "CASCADE"
|
||||
| "SET NULL"
|
||||
| "RESTRICT"
|
||||
| "NO ACTION"
|
||||
| "SET DEFAULT";
|
||||
|
||||
export interface ReferenceOptions {
|
||||
/** Target table. */
|
||||
table: string;
|
||||
/** Target column, defaults to the primary key of the target table. */
|
||||
column?: string;
|
||||
onDelete?: ReferentialAction;
|
||||
onUpdate?: ReferentialAction;
|
||||
}
|
||||
|
||||
/** Options accepted by the `@Field()` decorator. */
|
||||
export interface FieldOptions<Value = any> {
|
||||
/** Storage type of the column. */
|
||||
type: FieldType;
|
||||
/** Column name, defaults to the snake_cased property name. */
|
||||
column?: string;
|
||||
/** Marks the column as part of the primary key. */
|
||||
primary?: boolean;
|
||||
/** Adds a unique constraint. */
|
||||
unique?: boolean;
|
||||
/** Allows `NULL` values. */
|
||||
nullable?: boolean;
|
||||
/** Column default, emitted into the `CREATE TABLE` statement. */
|
||||
default?: unknown;
|
||||
/** Foreign key reference. */
|
||||
references?: ReferenceOptions;
|
||||
/**
|
||||
* Transformation applied to the value right before it is written to the
|
||||
* database (after the write, before the commit).
|
||||
*/
|
||||
encode?: (value: any) => unknown;
|
||||
/**
|
||||
* Transformation applied to the raw column value while the row is hydrated,
|
||||
* i.e. before application code can access the property.
|
||||
*/
|
||||
decode?: (value: any) => Value;
|
||||
}
|
||||
|
||||
/** Runtime description of a single field, collected by the `@Field()` decorator. */
|
||||
export interface FieldDescriptor {
|
||||
/** Property name on the model class. */
|
||||
property: string;
|
||||
/** Column name in the database. */
|
||||
column: string;
|
||||
options: FieldOptions;
|
||||
}
|
||||
|
||||
/** Flattened column description used by the schema builder and the repository. */
|
||||
export interface ColumnMapping {
|
||||
property: string;
|
||||
column: string;
|
||||
type: FieldType;
|
||||
nullable: boolean;
|
||||
primary: boolean;
|
||||
unique: boolean;
|
||||
default: unknown;
|
||||
references?: ReferenceOptions;
|
||||
}
|
||||
3
contracts/typegen/orm/index.ts
Normal file
3
contracts/typegen/orm/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./field.ts";
|
||||
export * from "./repository.ts";
|
||||
export * from "./table.ts";
|
||||
108
contracts/typegen/orm/repository.ts
Normal file
108
contracts/typegen/orm/repository.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/** Comparison operators supported by the query builder. */
|
||||
export const WHERE_OPERATORS = [
|
||||
"=",
|
||||
"!=",
|
||||
"<",
|
||||
"<=",
|
||||
">",
|
||||
">=",
|
||||
"like",
|
||||
"ilike",
|
||||
"in",
|
||||
"not in",
|
||||
"is null",
|
||||
"is not null",
|
||||
] as const;
|
||||
|
||||
export type WhereOperator = (typeof WHERE_OPERATORS)[number];
|
||||
|
||||
export interface WhereCondition {
|
||||
column: string;
|
||||
operator: WhereOperator;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/** Where input accepted by the fluent builder. */
|
||||
export type WhereInput<Row> =
|
||||
| Partial<Record<Extract<keyof Row, string>, unknown>>
|
||||
| WhereCondition[];
|
||||
|
||||
export type OrderDirection = "asc" | "desc";
|
||||
|
||||
export interface OrderBy {
|
||||
column: string;
|
||||
direction: OrderDirection;
|
||||
}
|
||||
|
||||
/** Column names of a row, used to keep the builder type safe. */
|
||||
export type ColumnName<Row> = Extract<keyof Row, string>;
|
||||
|
||||
/** The value handed to `create()`. */
|
||||
export type CreateData<Row> = Partial<
|
||||
Record<Extract<keyof Row, string>, unknown>
|
||||
>;
|
||||
|
||||
export interface Repository<Row extends object> {
|
||||
/** Restricts the selected columns. */
|
||||
select(...columns: ColumnName<Row>[]): Repository<Row>;
|
||||
/** Adds a condition, implicitly combined with `AND`. */
|
||||
where(column: ColumnName<Row>, value: unknown): Repository<Row>;
|
||||
/** Adds a condition with an explicit operator. */
|
||||
where(
|
||||
column: ColumnName<Row>,
|
||||
operator: WhereOperator,
|
||||
value?: unknown,
|
||||
): Repository<Row>;
|
||||
/** Adds a raw condition list. */
|
||||
where(conditions: WhereCondition[]): Repository<Row>;
|
||||
/** Adds a group of conditions combined with `AND`. */
|
||||
and(conditions: WhereCondition[]): Repository<Row>;
|
||||
/** Adds a group of conditions combined with `OR`. */
|
||||
or(conditions: WhereCondition[]): Repository<Row>;
|
||||
/** Orders the result set. */
|
||||
orderBy(column: ColumnName<Row>, direction?: OrderDirection): Repository<Row>;
|
||||
/** Limits the result set. */
|
||||
limit(count: number): Repository<Row>;
|
||||
/** Skips rows. */
|
||||
offset(count: number): Repository<Row>;
|
||||
/** Runs a raw statement and returns the rows. */
|
||||
query<R = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: readonly unknown[],
|
||||
): Promise<R[]>;
|
||||
/** Executes the query and hydrates every row. */
|
||||
all(): Promise<Row[]>;
|
||||
/** Executes the query and hydrates the first row. */
|
||||
first(): Promise<Row | null>;
|
||||
/** Counts the rows matching the current conditions. */
|
||||
count(): Promise<number>;
|
||||
/** Inserts a row, encoding every value before it is committed. */
|
||||
create(data: Partial<Row>): Promise<Row>;
|
||||
/** Updates the matching rows, encoding every value before the commit. */
|
||||
update(data: Partial<Row>): Promise<number>;
|
||||
/** Deletes the matching rows. */
|
||||
delete(): Promise<number>;
|
||||
}
|
||||
|
||||
/** Dialect neutral SQL surface implemented by the adapters. */
|
||||
export interface SqlDriver {
|
||||
readonly dialect: "sqlite" | "postgres";
|
||||
/** Placeholder for the n-th parameter (1 based). */
|
||||
placeholder(index: number): string;
|
||||
/** `true` when the dialect understands `RETURNING`. */
|
||||
readonly supportsReturning: boolean;
|
||||
/** Runs a statement and returns all rows. */
|
||||
query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: readonly unknown[],
|
||||
): Promise<T[]>;
|
||||
/** Runs a statement and returns the number of affected rows. */
|
||||
execute(
|
||||
sql: string,
|
||||
params?: readonly unknown[],
|
||||
): Promise<{ changes: number }>;
|
||||
/** Runs `fn` inside a transaction. */
|
||||
transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T>;
|
||||
/** Releases the underlying resources. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
31
contracts/typegen/orm/table.ts
Normal file
31
contracts/typegen/orm/table.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { ColumnMapping } from "./field.ts";
|
||||
|
||||
/** Options accepted by the `@Table()` decorator. */
|
||||
export interface TableOptions {
|
||||
/** Optional schema/namespace, e.g. `public`. */
|
||||
schema?: string;
|
||||
/** Primary key columns, defaults to the columns flagged with `primary: true`. */
|
||||
primaryKey?: string | string[];
|
||||
/** Adds `created_at` / `updated_at` columns. */
|
||||
timestamps?: boolean;
|
||||
/** Emit `IF NOT EXISTS` when creating the table. */
|
||||
ifNotExists?: boolean;
|
||||
/** Additional `CREATE TABLE` suffix, e.g. `WITHOUT ROWID`. */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
/** Runtime description of a table, collected by the `@Table()` decorator. */
|
||||
export interface TableDescriptor {
|
||||
/** Table name. */
|
||||
name: string;
|
||||
options: TableOptions;
|
||||
/** Columns in declaration order. */
|
||||
columns: ColumnMapping[];
|
||||
/** Property names that are hydrated but never persisted. */
|
||||
hydrated: string[];
|
||||
}
|
||||
|
||||
/** A model class decorated with `@Table()`. */
|
||||
export type TableClass<Row = object> = {
|
||||
new (...args: any[]): Row;
|
||||
} & { readonly __table?: TableDescriptor };
|
||||
4
contracts/typegen/orm/tables.ts
Normal file
4
contracts/typegen/orm/tables.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Fallback table contracts. Project contracts are generated under `_muddle/contracts`. */
|
||||
export interface TableRows {}
|
||||
export type TableName = keyof TableRows;
|
||||
export type TableRowOf<T extends TableName> = TableRows[T];
|
||||
6
contracts/typegen/views.ts
Normal file
6
contracts/typegen/views.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { Minify } from "./minify.ts";
|
||||
|
||||
/** Fallback view contracts. Project contracts are generated under `_muddle/contracts`. */
|
||||
export interface ViewProps { [path: string]: Record<string, unknown> }
|
||||
export type ViewPath = string;
|
||||
export type ViewPropsOf<P extends ViewPath> = Minify<ViewProps[P]>;
|
||||
201
core/app.ts
Normal file
201
core/app.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { ModuleRegistry } from "./modules.ts";
|
||||
import { Router, type ViewDescriptor } from "./router.ts";
|
||||
import { createRequestContext, parseRequestBody } from "./http.ts";
|
||||
import { toFormFields } from "./form.ts";
|
||||
import type { Type } from "./container.ts";
|
||||
import type { HttpResponse } from "../contracts/typegen/http/index.ts";
|
||||
import type { RequestContext } from "../contracts/typegen/http/request.ts";
|
||||
import type { RenderedView } from "../contracts/typegen/http/response.ts";
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationContext,
|
||||
Mounts,
|
||||
ResolvedConfig,
|
||||
BuildAsset,
|
||||
} from "../contracts/index.ts";
|
||||
import type { ServerBuildContext } from "./context.ts";
|
||||
import type { RoutePipeline } from "./pipeline.ts";
|
||||
|
||||
/** Renders the templates of the application. */
|
||||
export interface ViewRenderer {
|
||||
renderDescriptor(
|
||||
descriptor: ViewDescriptor,
|
||||
context: RequestContext,
|
||||
): Promise<string | RenderedView> | string | RenderedView;
|
||||
/** Registry of named components, mount targets of the integrations. */
|
||||
components?: Map<string, { name: string; component: unknown; deferred: boolean }>;
|
||||
}
|
||||
|
||||
export interface AppOptions {
|
||||
/** Root modules of the application. */
|
||||
modules?: Type[];
|
||||
/** Renderer used for the templates of the routes. */
|
||||
views?: ViewRenderer;
|
||||
/** Resolved configuration of the project. */
|
||||
config?: ResolvedConfig;
|
||||
/** Integrations of the project. */
|
||||
integrations?: Integration[];
|
||||
server?: ServerBuildContext;
|
||||
pipeline?: RoutePipeline;
|
||||
}
|
||||
|
||||
export class App {
|
||||
readonly registry: ModuleRegistry;
|
||||
readonly router: Router;
|
||||
private readonly config?: ResolvedConfig;
|
||||
private readonly integrations: Integration[];
|
||||
private readonly views: ViewRenderer | undefined;
|
||||
private readonly server;
|
||||
|
||||
constructor(options: AppOptions = {}) {
|
||||
this.config = options.config;
|
||||
this.integrations = options.integrations ?? [];
|
||||
this.views = options.views;
|
||||
this.server = options.server?.build() ?? { headers: {}, locals: {} };
|
||||
this.registry = new ModuleRegistry();
|
||||
const views = options.views;
|
||||
this.router = new Router({
|
||||
pipeline: options.pipeline,
|
||||
renderView: views
|
||||
? (descriptor, context) => views.renderDescriptor(descriptor, context)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
for (const moduleType of options.modules ?? []) {
|
||||
this.registry.register(moduleType);
|
||||
}
|
||||
}
|
||||
|
||||
get resolvedConfig(): ResolvedConfig | undefined {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async transformAsset(asset: BuildAsset): Promise<BuildAsset> {
|
||||
for (const integration of this.integrations) if (integration.transform) asset = await integration.transform(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
async analyzePage(page: { route: string; html: string }): Promise<Record<string, unknown>> {
|
||||
const reports: Record<string, unknown> = {};
|
||||
for (const integration of this.integrations) if (integration.analyze) reports[integration.name] = await integration.analyze(page);
|
||||
return reports;
|
||||
}
|
||||
|
||||
/** Creates the application and awaits the asynchronous providers. */
|
||||
static async create(options: AppOptions = {}): Promise<App> {
|
||||
const app = new App(options);
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Runs the integrations and instantiates the controllers. */
|
||||
async init(): Promise<void> {
|
||||
const integrationContext: IntegrationContext = {
|
||||
config: this.config ?? ({} as ResolvedConfig),
|
||||
route: (method, path, handler) => this.router.raw(method, path, handler),
|
||||
loader: (name, loader) => {
|
||||
if (this.config) this.config.loaders[name] = loader;
|
||||
},
|
||||
};
|
||||
for (const integration of this.integrations) {
|
||||
await integration.setup?.(integrationContext);
|
||||
for (const mount of integration.mounts ?? []) {
|
||||
if (mount.views && this.views) {
|
||||
await mount.views(this.views as unknown as Parameters<NonNullable<Mounts["views"]>>[0]);
|
||||
}
|
||||
if (mount.routes) {
|
||||
await mount.routes((method, path, handler) =>
|
||||
this.router.raw(method, path, (_request, _context) => handler(_request)),
|
||||
);
|
||||
}
|
||||
if (mount.styles) {
|
||||
for (const [path, value] of Object.entries(mount.styles)) {
|
||||
this.router.raw("GET", path, async () =>
|
||||
new Response(typeof value === "function" ? await value() : value, {
|
||||
headers: { "content-type": "text/css; charset=utf-8" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.registry.bootstrapAsync();
|
||||
for (const { context, controller } of this.registry.controllers()) {
|
||||
const instance = await context.container.resolveAsync(controller);
|
||||
this.router.register(controller, instance);
|
||||
}
|
||||
|
||||
for (const integration of this.integrations) {
|
||||
await integration.ready?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a route that is not bound to a controller. */
|
||||
route(
|
||||
method: string,
|
||||
path: string,
|
||||
handler: (
|
||||
request: Request,
|
||||
context: RequestContext,
|
||||
) => Promise<Response> | Response,
|
||||
): void {
|
||||
this.router.raw(method, path, handler);
|
||||
}
|
||||
|
||||
/** Handles a fetch request and returns the platform response. */
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const context = createRequestContext(request);
|
||||
Object.assign(context.locals, this.server.locals);
|
||||
const response = await this.handle(request, context);
|
||||
const fetchResponse = toFetchResponse(response);
|
||||
for (const [name, value] of Object.entries(this.server.headers)) fetchResponse.headers.set(name, value);
|
||||
return fetchResponse;
|
||||
}
|
||||
|
||||
/** Handles a request with a pre-built context. */
|
||||
async handle(
|
||||
request: Request,
|
||||
context: RequestContext,
|
||||
): Promise<HttpResponse> {
|
||||
for (const integration of this.integrations) await integration.request?.(context);
|
||||
context.body = await parseRequestBody(request);
|
||||
context.form = toFormFields(
|
||||
context.body &&
|
||||
typeof context.body === "object" &&
|
||||
!Array.isArray(context.body)
|
||||
? context.body
|
||||
: undefined,
|
||||
);
|
||||
return this.router.handle(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts the framework response into a fetch `Response`. */
|
||||
export function toFetchResponse(response: HttpResponse): Response {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(response.headers ?? {})) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const nullBody = status === 204 || status === 304;
|
||||
const body = response.body;
|
||||
|
||||
if (nullBody || body === undefined || body === null) {
|
||||
return new Response(null, { status, headers });
|
||||
}
|
||||
if (typeof body === "string") {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
if (body instanceof Uint8Array) {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
return new Response(JSON.stringify(body), { status, headers });
|
||||
}
|
||||
497
core/backport.ts
Normal file
497
core/backport.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { transform } from "lightningcss";
|
||||
import ts from "typescript";
|
||||
|
||||
export type BackportSource = "astro" | "zola";
|
||||
|
||||
export interface BackportResult {
|
||||
source: BackportSource;
|
||||
output: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
const ASSET_EXTENSIONS = new Set([
|
||||
".avif",
|
||||
".gif",
|
||||
".ico",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".png",
|
||||
".svg",
|
||||
".webp",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".pdf",
|
||||
".txt",
|
||||
]);
|
||||
|
||||
export function detectBackportSource(root: string): BackportSource {
|
||||
if (
|
||||
existsSync(join(root, "config.toml")) ||
|
||||
existsSync(join(root, "content")) && existsSync(join(root, "templates"))
|
||||
) return "zola";
|
||||
if (
|
||||
existsSync(join(root, "astro.config.mjs")) ||
|
||||
existsSync(join(root, "astro.config.ts")) ||
|
||||
existsSync(join(root, "src", "pages"))
|
||||
) return "astro";
|
||||
throw new Error(`Could not identify a Zola or Astro project in ${root}`);
|
||||
}
|
||||
|
||||
export async function backportProject(
|
||||
input: string,
|
||||
output: string,
|
||||
source = detectBackportSource(input),
|
||||
): Promise<BackportResult> {
|
||||
const inputRoot = resolve(input);
|
||||
const outputRoot = resolve(output);
|
||||
if (inputRoot === outputRoot) {
|
||||
throw new Error(
|
||||
"The Muddle destination must be different from the source directory",
|
||||
);
|
||||
}
|
||||
const files: string[] = [];
|
||||
const write = async (path: string, content: string) => {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, content);
|
||||
files.push(relative(outputRoot, path).replaceAll("\\", "/"));
|
||||
};
|
||||
|
||||
const contentRoot = join(inputRoot, 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/layouts", to: "modules/app/views/layouts" },
|
||||
{ from: "src/components", to: "modules/app/views/components" },
|
||||
{ from: "src/content", to: "content" },
|
||||
{ from: "public", to: "public" },
|
||||
{ from: "src/assets", to: "public/assets" },
|
||||
{ from: "src/styles", to: "styles" },
|
||||
]
|
||||
: [
|
||||
{ from: "templates", to: "modules/app/views" },
|
||||
{ from: "content", to: "content" },
|
||||
{ from: "static", to: "public" },
|
||||
{ from: "sass", to: "styles" },
|
||||
];
|
||||
for (const mapping of mappings) {
|
||||
const folder = join(inputRoot, mapping.from);
|
||||
if (existsSync(folder)) {
|
||||
await convertFolder(folder, join(outputRoot, mapping.to), write, source, folder);
|
||||
}
|
||||
}
|
||||
return { source, output: outputRoot, files };
|
||||
}
|
||||
|
||||
async function collectionNames(root: string): Promise<string[]> {
|
||||
if (!existsSync(root)) return [];
|
||||
const names: string[] = [];
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) names.push(entry.name.replace(/[^\w-]/g, "-"));
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
async function zolaSiteConfig(path: string): Promise<Record<string, string>> {
|
||||
if (!existsSync(path)) return {};
|
||||
const source = await readFile(path, "utf8");
|
||||
const values: Record<string, string> = {};
|
||||
for (const key of ["title", "base_url", "description", "default_language"]) {
|
||||
const match = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']\\s*$`, "m").exec(source);
|
||||
if (match?.[1] !== undefined) values[key] = match[1];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function writeProjectShell(
|
||||
root: string,
|
||||
write: (path: string, content: string) => Promise<void>,
|
||||
collections: string[],
|
||||
site: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const collectionConfig = collections.length
|
||||
? `,\n collections: [${collections.map((name) => `{ name: ${JSON.stringify(name)}, dir: ${JSON.stringify(`content/${name}`)}, loader: "markdown" }`).join(", ")}]`
|
||||
: "";
|
||||
await write(
|
||||
join(root, "package.json"),
|
||||
`{\n "name": "muddle-backport",\n "private": true,\n "type": "module",\n "scripts": { "dev": "muddle dev", "build": "muddle build", "typegen": "muddle typegen" },\n "dependencies": { "muddle": "latest" }\n}\n`,
|
||||
);
|
||||
await write(
|
||||
join(root, "ct.config.ts"),
|
||||
`import { defineConfig } from "muddle/contracts";\nimport { markdownIntegration, stcssIntegration, sassIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n root: import.meta.dir,\n views: "modules/app/views",\n viewRoots: ["modules/app/views"],\n integrations: [markdownIntegration(), stcssIntegration(), sassIntegration()]${collectionConfig},\n prerender: { routes: ["/"], outDir: "dist" },\n});\n`,
|
||||
);
|
||||
await write(
|
||||
join(root, "index.ts"),
|
||||
`import { join } from "node:path";\nimport { App } from "muddle/core/app";\nimport { prerender } from "muddle/adapters/static";\nimport { createViews } from "muddle/views/api";\nimport { resolved } from "./config.ts";\nimport { AppModule } from "./modules/app/app.module.ts";\n\nexport const app = await App.create({ modules: [AppModule], config: resolved, integrations: resolved.integrations, views: createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components }) });\nexport async function build() {\n if (resolved.prerender) await prerender(app, { ...resolved.prerender, outDir: join(resolved.root, resolved.prerender.outDir ?? "dist") });\n}\n`,
|
||||
);
|
||||
await write(
|
||||
join(root, "config.ts"),
|
||||
`import { resolveConfig } from "muddle/core/config";\nimport config from "./ct.config.ts";\nexport const resolved = resolveConfig(config);\n`,
|
||||
);
|
||||
await write(
|
||||
join(root, "modules/app/app.module.ts"),
|
||||
`import { Module } from "muddle/decorators";\nimport { AppController } from "./app.controller.ts";\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`,
|
||||
);
|
||||
await write(
|
||||
join(root, "modules/app/site.config.ts"),
|
||||
`/** Static values imported from the source site's configuration. */\nexport const site = ${JSON.stringify({ title: site.title ?? "Muddle backport", baseUrl: site.base_url ?? "", description: site.description ?? "", language: site.default_language ?? "en" }, null, 2)} as const;\n`,
|
||||
);
|
||||
await write(
|
||||
join(root, "modules/app/app.controller.ts"),
|
||||
`import { Controller, View, ViewRoot } from "muddle/decorators";\nimport { site } from "./site.config.ts";\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n @View("index.spug")\n index() { return { title: site.title, site }; }\n}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function convertFolder(
|
||||
source: string,
|
||||
destination: string,
|
||||
write: (path: string, content: string) => Promise<void>,
|
||||
kind: BackportSource,
|
||||
sourceRoot: string,
|
||||
): Promise<void> {
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
const from = join(source, entry.name);
|
||||
const to = join(destination, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await convertFolder(from, to, write, kind, sourceRoot);
|
||||
continue;
|
||||
}
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (
|
||||
extension === ".astro" || extension === ".html" ||
|
||||
extension === ".htm" || extension === ".tera"
|
||||
) {
|
||||
await write(
|
||||
to.replace(/\.(astro|html?|tera)$/i, ".spug"),
|
||||
kind === "astro"
|
||||
? astroToSpug(await readFile(from, "utf8"), from, sourceRoot)
|
||||
: zolaToSpug(await readFile(from, "utf8")),
|
||||
);
|
||||
} else if (extension === ".tsx" || extension === ".jsx") {
|
||||
await write(to.replace(/\.(tsx|jsx)$/i, ".spug"), reactToSpug(await readFile(from, "utf8"), from));
|
||||
} else if (extension === ".css") {
|
||||
await write(
|
||||
to.replace(/\.css$/i, ".stcss"),
|
||||
cssToStcss(await readFile(from, "utf8"), from),
|
||||
);
|
||||
} else if (extension === ".scss" || extension === ".sass") {
|
||||
await write(
|
||||
to.replace(/\.(scss|sass)$/i, ".scss"),
|
||||
await readFile(from, "utf8"),
|
||||
);
|
||||
} else if (
|
||||
extension === ".md" || extension === ".markdown" ||
|
||||
ASSET_EXTENSIONS.has(extension)
|
||||
) {
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
await cp(from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reactToSpug(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 imports = new Set<string>();
|
||||
let root: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined;
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (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.isReturnStatement(node) && node.expression && !root) {
|
||||
const expression = unwrapJsxExpression(node.expression);
|
||||
if (isJsxNode(expression)) root = expression;
|
||||
}
|
||||
if (ts.isArrowFunction(node) && !root && ts.isExpression(node.body)) {
|
||||
const expression = unwrapJsxExpression(node.body);
|
||||
if (isJsxNode(expression)) root = expression;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(file);
|
||||
if (!root) throw new Error(`No JSX return value found in ${filename}`);
|
||||
const aot = analysis.state.map((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`
|
||||
: "";
|
||||
return `${script}${jsxToSpug(root, imports).trim()}\n`;
|
||||
}
|
||||
|
||||
export interface ReactHookAnalysis {
|
||||
hooks: string[];
|
||||
state: { name: string; initial: string }[];
|
||||
}
|
||||
|
||||
/** Finds React hooks and extracts AOT-safe initial state from a component. */
|
||||
export function analyzeReactComponent(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 state: { name: string; initial: string }[] = [];
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = ts.isIdentifier(node.expression) ? node.expression.text
|
||||
: ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : "";
|
||||
if (/^use[A-Z]/.test(name)) hooks.add(name);
|
||||
if (name === "useState" && ts.isVariableDeclaration(node.parent) && ts.isArrayBindingPattern(node.parent.name)) {
|
||||
const binding = node.parent.name.elements[0];
|
||||
if (binding && ts.isBindingElement(binding) && ts.isIdentifier(binding.name)) {
|
||||
state.push({ name: binding.name.text, initial: node.arguments[0]?.getText() ?? "undefined" });
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(file);
|
||||
return { hooks: [...hooks].sort(), state };
|
||||
}
|
||||
|
||||
function unwrapJsxExpression(node: ts.Expression): ts.Expression {
|
||||
return ts.isParenthesizedExpression(node) ? unwrapJsxExpression(node.expression) : node;
|
||||
}
|
||||
|
||||
function isJsxNode(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 {
|
||||
const pad = " ".repeat(depth);
|
||||
if (ts.isJsxText(node)) {
|
||||
const text = node.getText().replace(/\s+/g, " ").trim();
|
||||
return text ? `${pad}| ${text}` : "";
|
||||
}
|
||||
if (ts.isJsxExpression(node)) {
|
||||
if (!node.expression) return "";
|
||||
return `${pad}= ${node.expression.getText()}`;
|
||||
}
|
||||
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 rawName = opening.tagName.getText();
|
||||
const name = rawName === "Comp" ? "button" : rawName;
|
||||
const component = ( /^[A-Z]/.test(name) || imports.has(name)) && rawName !== "Comp";
|
||||
const attributes = jsxAttributes(opening.attributes.properties, component);
|
||||
const line = component ? `${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)) {
|
||||
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");
|
||||
return [line + props, children].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function jsxAttributes(properties: ts.NodeArray<ts.JsxAttributeLike>, component = false): { inline: string; props: string[] } {
|
||||
const inline: string[] = [];
|
||||
const props: string[] = [];
|
||||
for (const property of properties) {
|
||||
if (ts.isJsxSpreadAttribute(property)) continue;
|
||||
const name = property.name.getText();
|
||||
if (name === "ref") continue;
|
||||
const initializer = property.initializer;
|
||||
if (!initializer) { inline.push(`${name}=true`); continue; }
|
||||
const value = ts.isStringLiteral(initializer) ? JSON.stringify(initializer.text)
|
||||
: ts.isJsxExpression(initializer) && initializer.expression ? `(${initializer.expression.getText()})`
|
||||
: JSON.stringify(initializer.getText());
|
||||
const normalized = name === "className" ? "class" : name;
|
||||
const classValue = normalized === "class" && ts.isJsxExpression(initializer) && initializer.expression
|
||||
? staticClassValue(initializer.expression) ?? JSON.stringify("muddle-react")
|
||||
: value;
|
||||
if (component) props.push(`${normalized}: ${classValue}`);
|
||||
else inline.push(`${normalized}=${classValue}`);
|
||||
}
|
||||
return { inline: inline.length ? `(${inline.join(", ")})` : "", props };
|
||||
}
|
||||
|
||||
function staticClassValue(expression: ts.Expression): string | undefined {
|
||||
if (ts.isStringLiteral(expression)) return JSON.stringify(expression.text);
|
||||
if (!ts.isCallExpression(expression)) return undefined;
|
||||
const classes = expression.arguments.flatMap((argument) => ts.isStringLiteral(argument) ? [argument.text] : []);
|
||||
return classes.length ? JSON.stringify(classes.join(" ")) : undefined;
|
||||
}
|
||||
|
||||
function kebab(value: string): string {
|
||||
return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
|
||||
}
|
||||
|
||||
export function cssToStcss(source: string, filename = "style.css"): string {
|
||||
const css = transform({
|
||||
filename,
|
||||
code: Buffer.from(source),
|
||||
minify: false,
|
||||
}).code.toString();
|
||||
let depth = 0;
|
||||
const lines: string[] = [];
|
||||
for (const token of css.replace(/\r/g, "").split(/([{};])/)) {
|
||||
const value = token.trim();
|
||||
if (!value) continue;
|
||||
if (value === "}") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
lines.push(`${" ".repeat(depth)}}`);
|
||||
continue;
|
||||
}
|
||||
if (value === "{") {
|
||||
const previous = lines.pop() ?? "";
|
||||
lines.push(`${previous} {`);
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === ";") {
|
||||
const previous = lines.pop() ?? "";
|
||||
lines.push(`${previous};`);
|
||||
continue;
|
||||
}
|
||||
lines.push(`${" ".repeat(depth)}${value}`);
|
||||
}
|
||||
return `${lines.join("\n").trim()}\n`;
|
||||
}
|
||||
|
||||
export function htmlToSpug(source: string): string {
|
||||
const body = source.replace(/^---[\s\S]*?---\s*/m, "").replace(
|
||||
/^---[\s\S]*?---\s*/m,
|
||||
"",
|
||||
).replace(/<!--[\s\S]*?-->/g, "");
|
||||
const tokens = body.match(/<[^>]+>|[^<]+/g) ?? [];
|
||||
const lines: string[] = [];
|
||||
let depth = 0;
|
||||
for (const token of tokens) {
|
||||
if (/^<\//.test(token)) {
|
||||
depth = Math.max(0, depth - 1);
|
||||
continue;
|
||||
}
|
||||
if (/^<!|^<\?/.test(token)) continue;
|
||||
if (token.startsWith("<")) {
|
||||
const match = /^<([\w:-]+)([\s\S]*?)\/?\s*>$/.exec(token);
|
||||
if (!match) continue;
|
||||
const tag = match[1]!;
|
||||
const attrs = attributesToSpug(match[2] ?? "");
|
||||
lines.push(`${" ".repeat(depth)}${tag}${attrs}`);
|
||||
if (
|
||||
!/\/$/.test(token) &&
|
||||
!new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]).has(tag)
|
||||
) depth += 1;
|
||||
} else {
|
||||
const text = token.replace(/\s+/g, " ").trim();
|
||||
if (text) {
|
||||
lines.push(
|
||||
`${" ".repeat(depth)}| ${
|
||||
text.replace(/\{\{\s*(.*?)\s*\}\}/g, "#{ $1 }").replace(/\{([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\}/g, "#{ $1 }")
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** Converts an Astro source file without evaluating its frontmatter. */
|
||||
export function astroToSpug(source: string, file = "page.astro", pagesRoot = ""): string {
|
||||
const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source)?.[1] ?? "";
|
||||
let body = source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
|
||||
const imports = new Map<string, string>();
|
||||
for (const entry of frontmatter.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?/g)) {
|
||||
imports.set(entry[1]!, entry[2]!);
|
||||
}
|
||||
const layout = /^\s*<([A-Z][\w$]*)\b([^>]*)>([\s\S]*)<\/\1>\s*$/.exec(body);
|
||||
let prefix = "";
|
||||
if (layout && imports.has(layout[1]!)) {
|
||||
const imported = imports.get(layout[1]!)!;
|
||||
if (/(?:^|\/)layouts\//.test(imported)) {
|
||||
const absoluteLayout = resolve(dirname(file), imported);
|
||||
const pageRelative = relative(pagesRoot, file);
|
||||
const layoutRelative = relative(dirname(pagesRoot), absoluteLayout).replace(/\.astro$/, ".spug");
|
||||
const target = relative(dirname(pageRelative), layoutRelative).replaceAll("\\", "/");
|
||||
prefix = `extends ${target}\n\nblock content\n`;
|
||||
body = layout[3]!;
|
||||
}
|
||||
}
|
||||
body = body
|
||||
.replace(/<slot\s+name=["']([\w-]+)["']\s*\/?\s*>/g, "<muddle-slot data-name=\"$1\"></muddle-slot>")
|
||||
.replace(/<slot\s*\/?\s*>/g, "<muddle-slot></muddle-slot>");
|
||||
const converted = htmlToSpug(body)
|
||||
.replace(/^(\s*)muddle-slot\(data-name="([\w-]+)"\)\s*$/gm, "$1block $2")
|
||||
.replace(/^(\s*)muddle-slot\s*$/gm, "$1block content");
|
||||
const componentized = componentCalls(converted, imports);
|
||||
return prefix ? `${prefix}${indent(componentized)}` : componentized;
|
||||
}
|
||||
|
||||
/** Converts Tera inheritance, blocks, includes and expressions into SPUG. */
|
||||
export function zolaToSpug(source: string): string {
|
||||
let result = source
|
||||
.replace(/\{#([\s\S]*?)#\}/g, "")
|
||||
.replace(/\{%\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*endblock\s*%\}/g, "")
|
||||
.replace(/\{%\s*if\s+(.+?)\s*%\}/g, "if $1")
|
||||
.replace(/\{%\s*endif\s*%\}/g, "")
|
||||
.replace(/\{%\s*for\s+([\w$]+)\s+in\s+(.+?)\s*%\}/g, "each $1 in $2")
|
||||
.replace(/\{%\s*endfor\s*%\}/g, "")
|
||||
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, "#{ $1 }");
|
||||
result = htmlToSpug(result);
|
||||
return result.replace(/\| (extends|include|block|if|each) (.+)$/gm, "$1 $2");
|
||||
}
|
||||
|
||||
function componentCalls(source: string, imports: Map<string, string>): string {
|
||||
const output: string[] = [];
|
||||
for (const line of source.split("\n")) {
|
||||
const match = /^(\s*)([A-Z][\w$]*)(.*)$/.exec(line);
|
||||
if (!match || !imports.has(match[2]!)) { output.push(line); continue; }
|
||||
const imported = imports.get(match[2]!)!;
|
||||
const name = imported.replace(/^.*\//, "").replace(/\.astro$/, "").replace(/[A-Z]/g, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`);
|
||||
const attributeText = /\((.*)\)$/.exec(match[3]!)?.[1];
|
||||
output.push(`${match[1]}component ${name}`);
|
||||
for (const pair of attributeText?.split(/\s*,\s*/) ?? []) {
|
||||
const [key = "", raw = "true"] = pair.split(/=(.+)/);
|
||||
const expression = /^\((.*)\)$/.exec(raw.trim())?.[1] ?? raw.trim().replace(/^\{\s*|\s*\}$/g, "");
|
||||
const dynamic = raw.trim().startsWith("{") || raw.trim().startsWith("(");
|
||||
output.push(`${match[1]} ${key.trim()}: ${dynamic ? expression : expression === "true" ? "true" : JSON.stringify(expression)}`);
|
||||
}
|
||||
}
|
||||
return `${output.filter(Boolean).join("\n").trim()}\n`;
|
||||
}
|
||||
|
||||
function indent(source: string): string {
|
||||
return source.split("\n").filter(Boolean).map((line) => ` ${line}`).join("\n") + "\n";
|
||||
}
|
||||
|
||||
function attributesToSpug(raw: string): string {
|
||||
const classes: string[] = [];
|
||||
const attributes: string[] = [];
|
||||
for (
|
||||
const match of raw.matchAll(
|
||||
/([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g,
|
||||
)
|
||||
) {
|
||||
const name = match[1]!;
|
||||
const value = match[2] ?? match[3] ?? match[4];
|
||||
if (name === "class" && value) {
|
||||
classes.push(...value.split(/\s+/).filter(Boolean));
|
||||
continue;
|
||||
}
|
||||
if (name === "id" && value && !value.startsWith("{")) {
|
||||
attributes.push(`id=${JSON.stringify(value)}`);
|
||||
continue;
|
||||
}
|
||||
if (value === undefined) attributes.push(`${name}=true`);
|
||||
else if (/^\{[\s\S]*\}$/.test(value)) attributes.push(`${name}=(${value.slice(1, -1).trim()})`);
|
||||
else attributes.push(`${name}=${JSON.stringify(value)}`);
|
||||
}
|
||||
return `${classes.map((value) => `.${value}`).join("")}${attributes.length ? `(${attributes.join(", ")})` : ""}`;
|
||||
}
|
||||
55
core/cache.ts
Normal file
55
core/cache.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface Cache {
|
||||
get(key: string): Promise<Uint8Array | undefined>;
|
||||
put(key: string, value: Uint8Array, options?: { ttl?: number }): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CloudflareCacheLike {
|
||||
match(request: Request): Promise<Response | undefined>;
|
||||
put(request: Request, response: Response): Promise<void>;
|
||||
delete(request: Request): Promise<boolean>;
|
||||
}
|
||||
|
||||
function cacheKey(key: string): string {
|
||||
return encodeURIComponent(key);
|
||||
}
|
||||
|
||||
export function filesystemCache(dir: string): Cache {
|
||||
return {
|
||||
async get(key) {
|
||||
const path = join(dir, cacheKey(key));
|
||||
if (!existsSync(path)) return undefined;
|
||||
return new Uint8Array(await readFile(path));
|
||||
},
|
||||
async put(key, value) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, cacheKey(key));
|
||||
const staged = `${path}.next`;
|
||||
await writeFile(staged, value);
|
||||
await rename(staged, path);
|
||||
},
|
||||
async delete(key) {
|
||||
const path = join(dir, cacheKey(key));
|
||||
if (existsSync(path)) await rename(path, `${path}.deleted`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function cloudflareCache(cache: CloudflareCacheLike, origin = "https://muddle.cache"): Cache {
|
||||
const request = (key: string) => new Request(new URL(`/${encodeURIComponent(key)}`, origin).toString());
|
||||
return {
|
||||
async get(key) {
|
||||
const response = await cache.match(request(key));
|
||||
return response ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
||||
},
|
||||
async put(key, value, options) {
|
||||
const headers = options?.ttl ? { "cache-control": `max-age=${options.ttl}` } : undefined;
|
||||
await cache.put(request(key), new Response(value, { headers }));
|
||||
},
|
||||
async delete(key) { await cache.delete(request(key)); },
|
||||
};
|
||||
}
|
||||
36
core/config.ts
Normal file
36
core/config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { join, resolve } from "node:path";
|
||||
import type { MuddleConfig, ResolvedConfig } from "../contracts/index.ts";
|
||||
|
||||
/** Applies the defaults of a project configuration. */
|
||||
export function resolveConfig(
|
||||
config: MuddleConfig = {},
|
||||
root: string = process.cwd(),
|
||||
): ResolvedConfig {
|
||||
const resolvedRoot = resolve(config.root ?? root);
|
||||
const views = config.views ?? "views";
|
||||
return {
|
||||
...config,
|
||||
root: resolvedRoot,
|
||||
views: resolve(resolvedRoot, views),
|
||||
components: resolve(
|
||||
resolvedRoot,
|
||||
config.components ?? join(views, "components"),
|
||||
),
|
||||
content: resolve(resolvedRoot, config.content ?? "content"),
|
||||
styles: resolve(resolvedRoot, config.styles ?? "styles"),
|
||||
publicDir: resolve(resolvedRoot, config.publicDir ?? "public"),
|
||||
viewRoots: (config.viewRoots ?? [views]).map((entry) =>
|
||||
resolve(resolvedRoot, entry),
|
||||
),
|
||||
loaders: config.loaders ?? {},
|
||||
collections: config.collections ?? [],
|
||||
integrations: config.integrations ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the configuration of a project from its `ct.config.ts`. */
|
||||
export async function loadConfig(root: string): Promise<MuddleConfig> {
|
||||
const configPath = join(root, "ct.config.ts");
|
||||
const module = (await import(configPath)) as { default?: MuddleConfig };
|
||||
return module.default ?? {};
|
||||
}
|
||||
307
core/container.ts
Normal file
307
core/container.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { UnknownProviderError } from "./errors.ts";
|
||||
import { getLatestMetadata, getMetadata } from "./metadata.ts";
|
||||
|
||||
export type Type<T = unknown> = new (...args: never[]) => T;
|
||||
export type AbstractType<T = unknown> = abstract new (...args: never[]) => T;
|
||||
export type Token<T = unknown> = Type<T> | AbstractType<T> | symbol | string;
|
||||
|
||||
export type ProviderScope = "singleton" | "request" | "transient";
|
||||
|
||||
export interface ProviderDefinition<T = unknown> {
|
||||
/** Token the provider is registered under, defaults to `useClass`. */
|
||||
provide?: Token<T>;
|
||||
/** Static value. */
|
||||
useValue?: T;
|
||||
/** Class instantiated by the container. */
|
||||
useClass?: AbstractType<T>;
|
||||
/** Factory function, dependencies are resolved through `inject`. */
|
||||
useFactory?: (...dependencies: unknown[]) => T;
|
||||
/** Tokens handed to `useFactory` (or used for `useClass` overrides). */
|
||||
inject?: Token[];
|
||||
/** Lifetime of the provider. */
|
||||
scope?: ProviderScope;
|
||||
}
|
||||
|
||||
interface FactoryEntry {
|
||||
token: Token;
|
||||
scope: ProviderScope;
|
||||
owner: Container;
|
||||
produce: (scope?: Container) => unknown;
|
||||
/** Asynchronous variant, used by {@link Container.resolveAsync}. */
|
||||
produceAsync?: (scope?: Container) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const PARAM_TYPES_METADATA = "design:paramtypes";
|
||||
export const INJECT_TOKENS_METADATA = "muddle:inject";
|
||||
export const OPTIONAL_PARAMS_METADATA = "muddle:optional";
|
||||
|
||||
interface InjectEntry {
|
||||
index: number;
|
||||
token: Token;
|
||||
}
|
||||
|
||||
/** Reads the `@Inject()` overrides of a constructor. */
|
||||
export function getInjectTokens(target: object): Map<number, Token> {
|
||||
const entries = getLatestMetadata(INJECT_TOKENS_METADATA, target) as
|
||||
| InjectEntry[]
|
||||
| undefined;
|
||||
const map = new Map<number, Token>();
|
||||
for (const entry of entries ?? []) {
|
||||
map.set(entry.index, entry.token);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Reads the parameters marked with `@Optional()`. */
|
||||
export function getOptionalParams(target: object): Set<number> {
|
||||
const entries = getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as
|
||||
| number[]
|
||||
| undefined;
|
||||
return new Set(entries ?? []);
|
||||
}
|
||||
|
||||
export class Container {
|
||||
private readonly factories = new Map<Token, FactoryEntry>();
|
||||
readonly instances = new Map<Token, unknown>();
|
||||
|
||||
/** Called when a token cannot be found in this container or its parents. */
|
||||
fallbackResolver?: (token: Token, scope?: Container) => unknown;
|
||||
|
||||
constructor(readonly parent?: Container) {}
|
||||
|
||||
/** Creates a child container that shares factories but isolates instances. */
|
||||
createScope(): Container {
|
||||
const scope = new Container(this);
|
||||
scope.fallbackResolver = (token, inner) =>
|
||||
this.lookupFallback(token, inner);
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** Registers a factory entry. */
|
||||
set(token: Token, entry: Omit<FactoryEntry, "token" | "owner">): void {
|
||||
this.factories.set(token, { ...entry, token, owner: this });
|
||||
}
|
||||
|
||||
/** `true` when the token is known to this container or one of its parents. */
|
||||
has(token: Token): boolean {
|
||||
return this.findEntry(token) !== undefined;
|
||||
}
|
||||
|
||||
/** Every token known to this container. */
|
||||
tokens(): Token[] {
|
||||
return [...this.factories.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a token.
|
||||
* Providers built with an asynchronous factory return a promise, use
|
||||
* {@link resolveAsync} to await them.
|
||||
*/
|
||||
resolve<T>(token: Token<T>, scope?: Container): T {
|
||||
const entry = this.findEntry(token);
|
||||
if (!entry) {
|
||||
const fallback = this.lookupFallback(token, scope);
|
||||
if (fallback !== undefined) return fallback as T;
|
||||
throw new UnknownProviderError(token);
|
||||
}
|
||||
|
||||
if (entry.scope === "transient") {
|
||||
return entry.produce(scope) as T;
|
||||
}
|
||||
|
||||
const cache = entry.scope === "request" ? (scope ?? this) : entry.owner;
|
||||
const cached = cache.instances.get(token);
|
||||
if (cached !== undefined) return cached as T;
|
||||
|
||||
const value = entry.produce(scope);
|
||||
cache.instances.set(token, value);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** Resolves a token, awaits async providers. */
|
||||
async resolveAsync<T>(token: Token<T>, scope?: Container): Promise<T> {
|
||||
const entry = this.findEntry(token);
|
||||
if (!entry) {
|
||||
const fallback = this.lookupFallback(token, scope);
|
||||
if (fallback !== undefined) return (await fallback) as T;
|
||||
throw new UnknownProviderError(token);
|
||||
}
|
||||
|
||||
if (entry.scope === "transient") {
|
||||
const value = entry.produceAsync
|
||||
? await entry.produceAsync(scope)
|
||||
: await entry.produce(scope);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const cache = entry.scope === "request" ? (scope ?? this) : entry.owner;
|
||||
const cached = cache.instances.get(token);
|
||||
if (cached !== undefined) return (await cached) as T;
|
||||
|
||||
const value = entry.produceAsync
|
||||
? await entry.produceAsync(scope)
|
||||
: await entry.produce(scope);
|
||||
cache.instances.set(token, value);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
private findEntry(token: Token): FactoryEntry | undefined {
|
||||
const own = this.factories.get(token);
|
||||
if (own) return own;
|
||||
return this.parent?.findEntry(token);
|
||||
}
|
||||
|
||||
private lookupFallback(token: Token, scope?: Container): unknown {
|
||||
const fallback = this.fallbackResolver ?? this.parent?.fallbackResolver;
|
||||
if (!fallback) return undefined;
|
||||
return fallback(token, scope);
|
||||
}
|
||||
|
||||
/** Registers a class, resolving its constructor dependencies automatically. */
|
||||
registerClass<T>(
|
||||
ctor: AbstractType<T>,
|
||||
options: { scope?: ProviderScope; inject?: Token[] } = {},
|
||||
): void {
|
||||
this.set(ctor as Token, {
|
||||
scope: options.scope ?? "singleton",
|
||||
produce: (scope) => this.instantiate(ctor, options.inject, scope),
|
||||
produceAsync: async (scope) =>
|
||||
this.instantiateAsync(ctor, options.inject, scope),
|
||||
});
|
||||
}
|
||||
|
||||
/** Registers a plain value. */
|
||||
registerValue<T>(token: Token<T>, value: T): void {
|
||||
this.set(token as Token, { scope: "singleton", produce: () => value });
|
||||
}
|
||||
|
||||
/** Registers a factory function. */
|
||||
registerFactory<T>(
|
||||
token: Token<T>,
|
||||
factory: (...dependencies: unknown[]) => T | Promise<T>,
|
||||
options: { inject?: Token[]; scope?: ProviderScope } = {},
|
||||
): void {
|
||||
this.set(token as Token, {
|
||||
scope: options.scope ?? "singleton",
|
||||
produce: (scope) => {
|
||||
const dependencies = (options.inject ?? []).map((dependency) =>
|
||||
this.resolve(dependency, scope),
|
||||
);
|
||||
return factory(...dependencies);
|
||||
},
|
||||
produceAsync: async (scope) => {
|
||||
const dependencies = await Promise.all(
|
||||
(options.inject ?? []).map((dependency) =>
|
||||
this.resolveAsync(dependency, scope),
|
||||
),
|
||||
);
|
||||
return factory(...dependencies);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Registers a provider definition. */
|
||||
registerProvider<T>(provider: ProviderDefinition<T>): void {
|
||||
if (provider.useValue !== undefined) {
|
||||
this.registerValue(
|
||||
(provider.provide ?? provider.useClass) as Token,
|
||||
provider.useValue,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (provider.useFactory) {
|
||||
const token = provider.provide ?? provider.useFactory;
|
||||
this.registerFactory(token as Token, provider.useFactory, {
|
||||
inject: provider.inject,
|
||||
scope: provider.scope,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (provider.useClass) {
|
||||
this.registerClass(provider.useClass, {
|
||||
scope: provider.scope,
|
||||
inject: provider.inject,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new UnknownProviderError(provider.provide);
|
||||
}
|
||||
|
||||
/** Instantiates a class, resolving constructor parameters by type. */
|
||||
instantiate<T>(
|
||||
ctor: AbstractType<T>,
|
||||
inject?: Token[],
|
||||
scope?: Container,
|
||||
): T {
|
||||
const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ??
|
||||
[]) as unknown[];
|
||||
const overrides = getInjectTokens(ctor as object);
|
||||
const optional = getOptionalParams(ctor as object);
|
||||
const dependencies: unknown[] = [];
|
||||
|
||||
const length = Math.max(
|
||||
paramTypes.length,
|
||||
overrides.size,
|
||||
inject?.length ?? 0,
|
||||
);
|
||||
for (let index = 0; index < length; index++) {
|
||||
const token =
|
||||
overrides.get(index) ??
|
||||
inject?.[index] ??
|
||||
(paramTypes[index] as Token | undefined);
|
||||
if (token === undefined || token === Object || token === Function) {
|
||||
if (optional.has(index)) {
|
||||
dependencies.push(undefined);
|
||||
continue;
|
||||
}
|
||||
throw new UnknownProviderError(
|
||||
`${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`,
|
||||
);
|
||||
}
|
||||
dependencies.push(this.resolve(token, scope));
|
||||
}
|
||||
|
||||
return new (ctor as unknown as new (...args: unknown[]) => T)(
|
||||
...dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
/** Instantiates a class and awaits asynchronous dependencies. */
|
||||
async instantiateAsync<T>(
|
||||
ctor: AbstractType<T>,
|
||||
inject?: Token[],
|
||||
scope?: Container,
|
||||
): Promise<T> {
|
||||
const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ??
|
||||
[]) as unknown[];
|
||||
const overrides = getInjectTokens(ctor as object);
|
||||
const optional = getOptionalParams(ctor as object);
|
||||
const dependencies: unknown[] = [];
|
||||
|
||||
const length = Math.max(
|
||||
paramTypes.length,
|
||||
overrides.size,
|
||||
inject?.length ?? 0,
|
||||
);
|
||||
for (let index = 0; index < length; index++) {
|
||||
const token =
|
||||
overrides.get(index) ??
|
||||
inject?.[index] ??
|
||||
(paramTypes[index] as Token | undefined);
|
||||
if (token === undefined || token === Object || token === Function) {
|
||||
if (optional.has(index)) {
|
||||
dependencies.push(undefined);
|
||||
continue;
|
||||
}
|
||||
throw new UnknownProviderError(
|
||||
`${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`,
|
||||
);
|
||||
}
|
||||
dependencies.push(await this.resolveAsync(token, scope));
|
||||
}
|
||||
|
||||
return new (ctor as unknown as new (...args: unknown[]) => T)(
|
||||
...dependencies,
|
||||
);
|
||||
}
|
||||
}
|
||||
163
core/content.ts
Normal file
163
core/content.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type {
|
||||
CollectionConfig,
|
||||
CollectionEntry,
|
||||
ContentSchema,
|
||||
LoadedFile,
|
||||
ResolvedConfig,
|
||||
} from "../contracts/index.ts";
|
||||
import { Service } from "../decorators/Service.ts";
|
||||
|
||||
/** Validates the frontmatter of an entry against the schema of a collection. */
|
||||
export function validateFrontmatter(
|
||||
schema: ContentSchema | undefined,
|
||||
frontmatter: Record<string, unknown>,
|
||||
): string[] {
|
||||
if (!schema) return [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const field of schema.required ?? []) {
|
||||
const value = frontmatter[field];
|
||||
if (value === undefined || value === "") {
|
||||
errors.push(`${field} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, allowed] of Object.entries(schema.enum ?? {})) {
|
||||
const value = frontmatter[field];
|
||||
if (value === undefined) continue;
|
||||
if (!allowed.includes(String(value))) {
|
||||
errors.push(`${field} must be one of ${allowed.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, length] of Object.entries(schema.maxLength ?? {})) {
|
||||
const value = frontmatter[field];
|
||||
if (value === undefined) continue;
|
||||
if (String(value).length > length) {
|
||||
errors.push(`${field} must be at most ${length} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Registry of the content collections of a project. */
|
||||
export class ContentRegistry {
|
||||
private readonly collections = new Map<string, CollectionEntry[]>();
|
||||
|
||||
constructor(private readonly config: ResolvedConfig) {}
|
||||
|
||||
/** Loads every collection of the project. */
|
||||
async load(): Promise<void> {
|
||||
for (const collection of this.config.collections ?? []) {
|
||||
this.collections.set(
|
||||
collection.name,
|
||||
await this.loadCollection(collection),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries of a collection. */
|
||||
entries<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
name: string,
|
||||
): CollectionEntry<T>[] {
|
||||
return (this.collections.get(name) ?? []) as CollectionEntry<T>[];
|
||||
}
|
||||
|
||||
/** A single entry of a collection. */
|
||||
get<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
name: string,
|
||||
id: string,
|
||||
): CollectionEntry<T> | undefined {
|
||||
return this.entries<T>(name).find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
/** Names of the loaded collections. */
|
||||
names(): string[] {
|
||||
return [...this.collections.keys()];
|
||||
}
|
||||
|
||||
private async loadCollection(
|
||||
collection: CollectionConfig,
|
||||
): Promise<CollectionEntry[]> {
|
||||
const dir = resolve(this.config.root, collection.dir);
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
const loader =
|
||||
typeof collection.loader === "string"
|
||||
? this.config.loaders[collection.loader]
|
||||
: collection.loader;
|
||||
if (!loader) {
|
||||
throw new Error(
|
||||
`Unknown loader "${collection.loader}" of the collection "${collection.name}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = await listCollectionFiles(dir, loader.extensions);
|
||||
const entries: CollectionEntry[] = [];
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const path = join(dir, file);
|
||||
const source = await readFile(path, "utf8");
|
||||
const loaded: LoadedFile = await loader.load({
|
||||
path,
|
||||
relativePath: relative(this.config.root, path).replace(/\\/g, "/"),
|
||||
source,
|
||||
});
|
||||
|
||||
const errors = validateFrontmatter(collection.schema, loaded.data);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid frontmatter in ${relative(this.config.root, path)}:\n${errors
|
||||
.map((error) => ` - ${error}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = file.replace(/\.[^.]+$/, "").replace(/\\/g, "/");
|
||||
entries.push({
|
||||
id,
|
||||
path: relative(this.config.root, path).replace(/\\/g, "/"),
|
||||
frontmatter: loaded.data,
|
||||
body: loaded.content ?? "",
|
||||
content: source,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
async function listCollectionFiles(dir: string, extensions: string[], prefix = ""): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const relativePath = join(prefix, entry.name);
|
||||
if (entry.isDirectory()) 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();
|
||||
}
|
||||
|
||||
/** Service exposing the content collections to the controllers. */
|
||||
@Service()
|
||||
export class ContentService {
|
||||
constructor(private readonly registry: ContentRegistry) {}
|
||||
|
||||
/** Entries of a collection. */
|
||||
list<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
name: string,
|
||||
): CollectionEntry<T>[] {
|
||||
return this.registry.entries<T>(name);
|
||||
}
|
||||
|
||||
/** A single entry of a collection. */
|
||||
get<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
name: string,
|
||||
id: string,
|
||||
): CollectionEntry<T> | undefined {
|
||||
return this.registry.get<T>(name, id);
|
||||
}
|
||||
}
|
||||
24
core/context.ts
Normal file
24
core/context.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Headers } from "../contracts/typegen/http/headers.ts";
|
||||
|
||||
export class ServerBuildContext {
|
||||
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; }
|
||||
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 {
|
||||
readonly imports = new Map<string, { 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);
|
||||
return this;
|
||||
}
|
||||
private readonly values: { templates: string[]; inputs: Record<string, unknown>; contracts: Record<string, string> } = { 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 } }; }
|
||||
}
|
||||
46
core/errors.ts
Normal file
46
core/errors.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/** Base error for every failure raised by the framework. */
|
||||
export class MuddleError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "MuddleError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a dependency cannot be resolved from the current module graph. */
|
||||
export class UnknownProviderError extends MuddleError {
|
||||
constructor(token: unknown) {
|
||||
super(`Cannot resolve dependency: ${describeToken(token)}`);
|
||||
this.name = "UnknownProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a request does not match any route. */
|
||||
export class RouteNotFoundError extends MuddleError {
|
||||
constructor(method: string, path: string) {
|
||||
super(`No route matches ${method} ${path}`);
|
||||
this.name = "RouteNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised while compiling or rendering a template. */
|
||||
export class TemplateError extends MuddleError {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "TemplateError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when the ORM cannot find a table definition. */
|
||||
export class SchemaError extends MuddleError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SchemaError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Human readable representation of an injection token. */
|
||||
export function describeToken(token: unknown): string {
|
||||
if (typeof token === "function") return token.name || "anonymous class";
|
||||
if (typeof token === "symbol") return token.toString();
|
||||
return String(token);
|
||||
}
|
||||
145
core/form.ts
Normal file
145
core/form.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { FormFields } from "../contracts/typegen/http/request.ts";
|
||||
|
||||
/** Reads the fields of a parsed form body. */
|
||||
export function toFormFields(body: unknown): FormFields {
|
||||
const source =
|
||||
body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
get(name: string) {
|
||||
const value = source[name];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Array.isArray(value) ? String(value[0]) : String(value);
|
||||
},
|
||||
all() {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
result[key] = Array.isArray(value)
|
||||
? value.map((entry) => String(entry))
|
||||
: String(value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
has(name: string) {
|
||||
return name in source;
|
||||
},
|
||||
values() {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
result[key] = Array.isArray(value)
|
||||
? String(value[0] ?? "")
|
||||
: String(value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Rules of a single form field. */
|
||||
export interface FieldRule {
|
||||
/** The field has to be filled in. */
|
||||
required?: boolean;
|
||||
/** Type of the field. */
|
||||
type?: "string" | "number" | "boolean" | "email";
|
||||
/** Minimum value of a number field. */
|
||||
min?: number;
|
||||
/** Maximum value of a number field. */
|
||||
max?: number;
|
||||
/** Maximum length of a string field. */
|
||||
maxLength?: number;
|
||||
/** Allowed values. */
|
||||
enum?: string[];
|
||||
/** Value used when the field is empty. */
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export type FormSchema = Record<string, FieldRule>;
|
||||
|
||||
/** Result of a validation. */
|
||||
export type ValidationResult<T> =
|
||||
| { ok: true; value: T; errors: Record<string, string> }
|
||||
| { ok: false; value: undefined; errors: Record<string, string> };
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** Validates the fields of a form. */
|
||||
export function validate<T extends Record<string, unknown>>(
|
||||
fields: FormFields,
|
||||
schema: FormSchema,
|
||||
): ValidationResult<T> {
|
||||
const errors: Record<string, string> = {};
|
||||
const value: Record<string, unknown> = {};
|
||||
|
||||
for (const [name, rule] of Object.entries(schema)) {
|
||||
const raw = fields.get(name);
|
||||
const empty = raw === undefined || raw.trim().length === 0;
|
||||
|
||||
if (empty) {
|
||||
if (rule.default !== undefined) {
|
||||
value[name] = rule.default;
|
||||
continue;
|
||||
}
|
||||
if (rule.required) {
|
||||
errors[name] = `${name} is required`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown = raw;
|
||||
if (rule.type === "number") {
|
||||
const numeric = Number(raw);
|
||||
if (Number.isNaN(numeric)) {
|
||||
errors[name] = `${name} must be a number`;
|
||||
continue;
|
||||
}
|
||||
if (rule.min !== undefined && numeric < rule.min) {
|
||||
errors[name] = `${name} must be at least ${rule.min}`;
|
||||
continue;
|
||||
}
|
||||
if (rule.max !== undefined && numeric > rule.max) {
|
||||
errors[name] = `${name} must be at most ${rule.max}`;
|
||||
continue;
|
||||
}
|
||||
parsed = numeric;
|
||||
} else if (rule.type === "boolean") {
|
||||
parsed = raw === "on" || raw === "true" || raw === "1";
|
||||
} else if (rule.type === "email") {
|
||||
if (!EMAIL.test(raw)) {
|
||||
errors[name] = `${name} must be a valid email address`;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
rule.maxLength !== undefined &&
|
||||
String(parsed).length > rule.maxLength
|
||||
) {
|
||||
errors[name] = `${name} must be at most ${rule.maxLength} characters`;
|
||||
continue;
|
||||
}
|
||||
if (rule.enum && !rule.enum.includes(String(parsed))) {
|
||||
errors[name] = `${name} must be one of ${rule.enum.join(", ")}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
value[name] = parsed;
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, value: undefined, errors };
|
||||
}
|
||||
return { ok: true, value: value as T, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the location of a redirect that carries a message.
|
||||
*/
|
||||
export function redirectWithMessage(
|
||||
location: string,
|
||||
message?: string,
|
||||
): string {
|
||||
if (!message) return location;
|
||||
const separator = location.includes("?") ? "&" : "?";
|
||||
return `${location}${separator}message=${encodeURIComponent(message)}`;
|
||||
}
|
||||
63
core/format-source.ts
Normal file
63
core/format-source.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { foldLines } from "../views/engine/lexer.ts";
|
||||
|
||||
export interface FormatOptions { tabWidth?: number; useTabs?: boolean; printWidth?: number }
|
||||
export type FormatLanguage = "spug" | "stpug" | "stug" | "stcss";
|
||||
|
||||
/** 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> {
|
||||
const prettier = await import("prettier");
|
||||
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) => {
|
||||
const index = scripts.length;
|
||||
scripts.push(JSON.stringify({ directive, code, ending }));
|
||||
return language === "stcss" ? `/* MUDDLE_SCRIPT_${index} */` : `${indentation}MUDDLE_SCRIPT_${index}`;
|
||||
});
|
||||
const rawBlocks: { marker: string; body: string[]; prefix: string }[] = [];
|
||||
if (language !== "stcss") {
|
||||
const sourceLines = protectedSource.split("\n");
|
||||
const output: string[] = [];
|
||||
for (let index = 0; index < sourceLines.length; index++) {
|
||||
const line = sourceLines[index]!;
|
||||
const match = /^(\s*)(?:[\w.#-]+(?:\([^]*\))?\.|\/\/-?|\$\$\$)\s*$/.exec(line);
|
||||
if (!match) { output.push(line); continue; }
|
||||
const prefix = match[1]!;
|
||||
const body: string[] = [line.slice(prefix.length)];
|
||||
while (index + 1 < sourceLines.length) {
|
||||
const next = sourceLines[index + 1]!;
|
||||
if (next.trim() && (next.match(/^\s*/)?.[0].length ?? 0) <= prefix.length) break;
|
||||
body.push(next.startsWith(prefix) ? next.slice(prefix.length) : next);
|
||||
index++;
|
||||
}
|
||||
const marker = `MUDDLE_RAW_${rawBlocks.length}`;
|
||||
rawBlocks.push({ marker, body, prefix });
|
||||
output.push(prefix + marker);
|
||||
}
|
||||
protectedSource = output.join("\n");
|
||||
}
|
||||
const unit = options.useTabs ? "\t" : " ".repeat(options.tabWidth ?? 2);
|
||||
let formatted: string;
|
||||
if (language === "stcss") {
|
||||
formatted = await prettier.format(protectedSource, { parser: "scss", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false, printWidth: options.printWidth ?? 80 });
|
||||
} else {
|
||||
const stack = [0];
|
||||
const lines: string[] = [];
|
||||
for (const line of foldLines(protectedSource)) {
|
||||
while (stack.length > 1 && line.indent < stack[stack.length - 1]!) stack.pop();
|
||||
if (line.indent > stack[stack.length - 1]!) stack.push(line.indent);
|
||||
const indent = unit.repeat(stack.length - 1);
|
||||
lines.push(`${indent}${line.text.trimEnd()}`);
|
||||
}
|
||||
formatted = `${lines.join("\n")}\n`;
|
||||
}
|
||||
for (let index = 0; index < scripts.length; index++) {
|
||||
const script = JSON.parse(scripts[index]!) as { directive: string; 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) => {
|
||||
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}`;
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
22
core/format.ts
Normal file
22
core/format.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { readdir, readFile, writeFile, stat } from "node:fs/promises";
|
||||
import { join, extname } from "node:path";
|
||||
import { formatSource, type FormatLanguage, type FormatOptions } from "./format-source.ts";
|
||||
|
||||
export async function format(root: string, options: FormatOptions & { check?: boolean } = {}): Promise<string[]> {
|
||||
const changed: string[] = [];
|
||||
if ((await stat(root)).isFile()) {
|
||||
const language = extname(root).slice(1) as FormatLanguage;
|
||||
if (!["spug", "stpug", "stug", "stcss"].includes(language)) return changed;
|
||||
const source = await readFile(root, "utf8");
|
||||
const next = await formatSource(source, language, options);
|
||||
if (next !== source) { changed.push(root); if (!options.check) await writeFile(root, next, "utf8"); }
|
||||
return changed;
|
||||
}
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.name.startsWith(".") || ["node_modules", "dist", "dist.previous", "shadcn-svelte", "_muddle"].includes(entry.name)) continue;
|
||||
const path = join(root, entry.name);
|
||||
changed.push(...await format(path, options));
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
239
core/http.ts
Normal file
239
core/http.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import type {
|
||||
Headers,
|
||||
HttpResponse,
|
||||
ResponseBuilder,
|
||||
StatusCode,
|
||||
} from "../contracts/typegen/http/index.ts";
|
||||
import {
|
||||
FORM_CONTENT_TYPE,
|
||||
HTML_CONTENT_TYPE,
|
||||
JSON_CONTENT_TYPE,
|
||||
TEXT_CONTENT_TYPE,
|
||||
mediaType,
|
||||
} from "../contracts/typegen/http/index.ts";
|
||||
import type { RequestContext } from "../contracts/typegen/http/request.ts";
|
||||
import { toFormFields } from "./form.ts";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
|
||||
/** Creates an HTML response. */
|
||||
export function html(
|
||||
body: string,
|
||||
status: StatusCode = 200,
|
||||
): HttpResponse<string> {
|
||||
return { status, headers: { "content-type": HTML_CONTENT_TYPE }, body };
|
||||
}
|
||||
|
||||
/** Creates a JSON response. */
|
||||
export function json(
|
||||
body: unknown,
|
||||
status: StatusCode = 200,
|
||||
): HttpResponse<unknown> {
|
||||
return { status, headers: { "content-type": JSON_CONTENT_TYPE }, body };
|
||||
}
|
||||
|
||||
/** Creates a plain text response. */
|
||||
export function text(
|
||||
body: string,
|
||||
status: StatusCode = 200,
|
||||
): HttpResponse<string> {
|
||||
return { status, headers: { "content-type": TEXT_CONTENT_TYPE }, body };
|
||||
}
|
||||
|
||||
/** Creates a redirect response. */
|
||||
export function redirect(
|
||||
location: string,
|
||||
status: StatusCode = 302,
|
||||
): HttpResponse<never> {
|
||||
return { status, headers: { location }, body: undefined as never };
|
||||
}
|
||||
|
||||
/** Creates a `404` response. */
|
||||
export function notFound(message = "Not Found"): HttpResponse<string> {
|
||||
return text(message, 404);
|
||||
}
|
||||
|
||||
/** Creates a response builder bound to a status code and a set of headers. */
|
||||
export function createResponseBuilder(
|
||||
initialStatus: StatusCode = 200,
|
||||
initialHeaders: Headers = {},
|
||||
): ResponseBuilder {
|
||||
let status = initialStatus;
|
||||
const headers: Headers = { ...initialHeaders };
|
||||
const builder: ResponseBuilder = {
|
||||
html(body, code) {
|
||||
return {
|
||||
status: code ?? status,
|
||||
headers: { ...headers, "content-type": HTML_CONTENT_TYPE },
|
||||
body,
|
||||
};
|
||||
},
|
||||
json(body, code) {
|
||||
return {
|
||||
status: code ?? status,
|
||||
headers: { ...headers, "content-type": JSON_CONTENT_TYPE },
|
||||
body,
|
||||
};
|
||||
},
|
||||
text(body, code) {
|
||||
return {
|
||||
status: code ?? status,
|
||||
headers: { ...headers, "content-type": TEXT_CONTENT_TYPE },
|
||||
body,
|
||||
};
|
||||
},
|
||||
redirect(location, code) {
|
||||
return {
|
||||
status: code ?? 302,
|
||||
headers: { ...headers, location },
|
||||
body: undefined as never,
|
||||
};
|
||||
},
|
||||
status(code) {
|
||||
status = code as StatusCode;
|
||||
return builder;
|
||||
},
|
||||
header(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
return builder;
|
||||
},
|
||||
send(body) {
|
||||
return { status, headers: { ...headers }, body };
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
/** Parses the body of a fetch `Request` according to its content type. */
|
||||
export async function parseRequestBody(request: Request): Promise<unknown> {
|
||||
const type = mediaType(request.headers.get("content-type") ?? "");
|
||||
if (request.method === "GET" || request.method === "HEAD") return undefined;
|
||||
if (type === "application/json") {
|
||||
const raw = await request.text();
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new Error(`Invalid JSON body: ${(cause as Error).message}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === FORM_CONTENT_TYPE) {
|
||||
const form = await request.formData();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of form.entries()) {
|
||||
result[key] = typeof value === "string" ? value : value.name;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (type === "text/plain") {
|
||||
return await request.text();
|
||||
}
|
||||
if (
|
||||
type === "" ||
|
||||
type === "multipart/form-data" ||
|
||||
type === "application/octet-stream"
|
||||
) {
|
||||
const buffer = await request.arrayBuffer();
|
||||
return buffer.byteLength === 0 ? undefined : buffer;
|
||||
}
|
||||
return await request.text();
|
||||
}
|
||||
|
||||
/** Parses a query string into a plain object. */
|
||||
export function parseQuery(search: string): Record<string, string> {
|
||||
const params = new URLSearchParams(search);
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Lower-cases every header name of a header record. */
|
||||
export function normalizeHeaders(
|
||||
headers: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
result[name.toLowerCase()] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Content type of a file extension. */
|
||||
export function contentTypeOf(extension: string): string {
|
||||
switch (extension) {
|
||||
case ".html":
|
||||
case ".htm":
|
||||
return "text/html; charset=utf-8";
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8";
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
return "text/javascript; charset=utf-8";
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".ico":
|
||||
return "image/x-icon";
|
||||
case ".txt":
|
||||
return "text/plain; charset=utf-8";
|
||||
case ".woff2":
|
||||
return "font/woff2";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
/** Serves a file of a static root. */
|
||||
export async function serveStaticFile(
|
||||
root: string,
|
||||
path: string,
|
||||
): Promise<HttpResponse | undefined> {
|
||||
const relative = decodeURIComponent(path.split("?")[0] ?? "");
|
||||
const target = resolve(root, `.${relative.startsWith("/") ? relative : `/${relative}`}`);
|
||||
if (!target.startsWith(resolve(root))) return undefined;
|
||||
if (!existsSync(target)) return undefined;
|
||||
if (!statSync(target).isFile()) return undefined;
|
||||
|
||||
const file = await readFile(target);
|
||||
return {
|
||||
status: 200,
|
||||
headers: { "content-type": contentTypeOf(extname(target)) },
|
||||
body: new Uint8Array(file),
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds the request context handed to controllers. */
|
||||
export function createRequestContext(request: Request): RequestContext {
|
||||
const url = new URL(request.url);
|
||||
const headers = normalizeHeaders(
|
||||
Object.fromEntries(request.headers.entries()),
|
||||
);
|
||||
const context: RequestContext = {
|
||||
method: request.method as RequestContext["method"],
|
||||
url: request.url,
|
||||
path: url.pathname,
|
||||
params: {},
|
||||
query: parseQuery(url.search),
|
||||
body: undefined,
|
||||
form: toFormFields(undefined),
|
||||
headers,
|
||||
signal: request.signal,
|
||||
locals: {},
|
||||
raw: request,
|
||||
respond: createResponseBuilder(),
|
||||
};
|
||||
return context;
|
||||
}
|
||||
7
core/index.ts
Normal file
7
core/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from "./app.ts";
|
||||
export * from "./container.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./http.ts";
|
||||
export * from "./metadata.ts";
|
||||
export * from "./modules.ts";
|
||||
export * from "./router.ts";
|
||||
179
core/metadata.ts
Normal file
179
core/metadata.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
export type MetadataKey = string | symbol;
|
||||
|
||||
type MetadataMap = Map<MetadataKey, unknown[]>;
|
||||
type PropertyMetadataMap = Map<MetadataKey, MetadataMap>;
|
||||
|
||||
const metadataStore = new WeakMap<object, MetadataMap>();
|
||||
const propertyMetadataStore = new WeakMap<object, PropertyMetadataMap>();
|
||||
|
||||
function mapFor(target: object): MetadataMap {
|
||||
let map = metadataStore.get(target);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
metadataStore.set(target, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function propertyMapFor(target: object, propertyKey: MetadataKey): MetadataMap {
|
||||
let properties = propertyMetadataStore.get(target);
|
||||
if (!properties) {
|
||||
properties = new Map();
|
||||
propertyMetadataStore.set(target, properties);
|
||||
}
|
||||
let map = properties.get(propertyKey);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
properties.set(propertyKey, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Stores `metadataValue` for `metadataKey` on `target` (and `propertyKey`). */
|
||||
export function defineMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
metadataValue: unknown,
|
||||
target: object,
|
||||
propertyKey?: MetadataKey,
|
||||
): void {
|
||||
const map = mapFor(target);
|
||||
const existing = map.get(metadataKey) ?? [];
|
||||
existing.push(metadataValue);
|
||||
map.set(metadataKey, existing);
|
||||
if (propertyKey !== undefined) {
|
||||
const propertyMap = propertyMapFor(target, propertyKey);
|
||||
const propertyExisting = propertyMap.get(metadataKey) ?? [];
|
||||
propertyExisting.push(metadataValue);
|
||||
propertyMap.set(metadataKey, propertyExisting);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads metadata declared directly on `target`. */
|
||||
export function getOwnMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
target: object,
|
||||
propertyKey?: MetadataKey,
|
||||
): unknown | undefined {
|
||||
if (propertyKey !== undefined) {
|
||||
const properties = propertyMetadataStore.get(target);
|
||||
return properties?.get(propertyKey)?.get(metadataKey)?.[0];
|
||||
}
|
||||
return metadataStore.get(target)?.get(metadataKey)?.[0];
|
||||
}
|
||||
|
||||
/** Reads metadata declared on `target` or inherited from its prototype chain. */
|
||||
export function getMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
target: object,
|
||||
propertyKey?: MetadataKey,
|
||||
): unknown | undefined {
|
||||
let current: object | null = target;
|
||||
while (current) {
|
||||
const value = getOwnMetadata(metadataKey, current, propertyKey);
|
||||
if (value !== undefined) return value;
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** `true` when metadata is declared on `target` or an ancestor. */
|
||||
export function hasMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
target: object,
|
||||
propertyKey?: MetadataKey,
|
||||
): boolean {
|
||||
return getMetadata(metadataKey, target, propertyKey) !== undefined;
|
||||
}
|
||||
|
||||
/** Removes all metadata registered for `target`. */
|
||||
export function deleteMetadata(target: object): void {
|
||||
metadataStore.delete(target);
|
||||
}
|
||||
|
||||
/** Reads the most recent metadata entry declared for `target`. */
|
||||
export function getLatestMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
target: object,
|
||||
propertyKey?: MetadataKey,
|
||||
): unknown | undefined {
|
||||
if (propertyKey !== undefined) {
|
||||
const properties = propertyMetadataStore.get(target);
|
||||
const entries = properties?.get(propertyKey)?.get(metadataKey);
|
||||
return entries?.[entries.length - 1];
|
||||
}
|
||||
const entries = metadataStore.get(target)?.get(metadataKey);
|
||||
return entries?.[entries.length - 1];
|
||||
}
|
||||
|
||||
/** Owner of a decorated member. */
|
||||
export function ownerOf(target: object): object {
|
||||
if (typeof target === "function") return target as object;
|
||||
return (target as { constructor: object }).constructor;
|
||||
}
|
||||
|
||||
/** Stores metadata of a method or a parameter on the owning class. */
|
||||
export function defineMemberMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
metadataValue: unknown,
|
||||
target: object,
|
||||
propertyKey?: MetadataKey,
|
||||
): void {
|
||||
defineMetadata(metadataKey, metadataValue, ownerOf(target), propertyKey);
|
||||
}
|
||||
|
||||
/** Reads every metadata entry stored for a method. */
|
||||
export function getMemberMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
target: object,
|
||||
propertyKey: MetadataKey,
|
||||
): unknown[] {
|
||||
const properties = propertyMetadataStore.get(target);
|
||||
const map = properties?.get(propertyKey);
|
||||
if (!map) return [];
|
||||
return map.get(metadataKey) ?? [];
|
||||
}
|
||||
|
||||
/** Reads every metadata entry of a class, together with its property key. */
|
||||
export function getAllMemberMetadata(
|
||||
metadataKey: MetadataKey,
|
||||
target: object,
|
||||
): { propertyKey: MetadataKey; value: unknown }[] {
|
||||
const properties = propertyMetadataStore.get(target);
|
||||
if (!properties) return [];
|
||||
const entries: { propertyKey: MetadataKey; value: unknown }[] = [];
|
||||
for (const [propertyKey, map] of properties) {
|
||||
for (const value of map.get(metadataKey) ?? []) {
|
||||
entries.push({ propertyKey, value });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Installs the proposal API on `Reflect` when the runtime does not provide it. */
|
||||
export function installMetadataPolyfill(): void {
|
||||
const reflect = Reflect as unknown as Record<string, unknown>;
|
||||
if (typeof reflect["metadata"] !== "function") {
|
||||
reflect["metadata"] = (
|
||||
metadataKey: MetadataKey,
|
||||
metadataValue: unknown,
|
||||
) => {
|
||||
return (target: object, propertyKey?: MetadataKey): void => {
|
||||
defineMetadata(metadataKey, metadataValue, target, propertyKey);
|
||||
};
|
||||
};
|
||||
}
|
||||
if (typeof reflect["defineMetadata"] !== "function") {
|
||||
reflect["defineMetadata"] = defineMetadata;
|
||||
}
|
||||
if (typeof reflect["getOwnMetadata"] !== "function") {
|
||||
reflect["getOwnMetadata"] = getOwnMetadata;
|
||||
}
|
||||
if (typeof reflect["getMetadata"] !== "function") {
|
||||
reflect["getMetadata"] = getMetadata;
|
||||
}
|
||||
if (typeof reflect["hasMetadata"] !== "function") {
|
||||
reflect["hasMetadata"] = hasMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
installMetadataPolyfill();
|
||||
154
core/modules.ts
Normal file
154
core/modules.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import {
|
||||
Container,
|
||||
type AbstractType,
|
||||
type ProviderDefinition,
|
||||
type Token,
|
||||
type Type,
|
||||
} from "./container.ts";
|
||||
import {
|
||||
getInjectableOptions,
|
||||
getModuleOptions,
|
||||
} from "../decorators/Module.ts";
|
||||
|
||||
export interface ModuleContext {
|
||||
type: Type;
|
||||
container: Container;
|
||||
exports: Token[];
|
||||
controllers: Type[];
|
||||
imports: ModuleContext[];
|
||||
}
|
||||
|
||||
/** Per request container graph, one scoped container per module. */
|
||||
export class ScopeGraph {
|
||||
private readonly containers = new Map<ModuleContext, Container>();
|
||||
|
||||
constructor(private readonly registry: ModuleRegistry) {
|
||||
for (const context of registry.all()) {
|
||||
const scope = context.container.createScope();
|
||||
scopeGraphs.set(scope, this);
|
||||
this.containers.set(context, scope);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scoped container of a module. */
|
||||
containerFor(context: ModuleContext): Container {
|
||||
const container = this.containers.get(context);
|
||||
if (!container)
|
||||
throw new Error(`Module ${context.type.name} is not registered`);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
const scopeGraphs = new WeakMap<Container, ScopeGraph>();
|
||||
|
||||
export class ModuleRegistry {
|
||||
private readonly contexts = new Map<Type, ModuleContext>();
|
||||
private readonly order: ModuleContext[] = [];
|
||||
|
||||
/** Every registered module, in registration order. */
|
||||
all(): ModuleContext[] {
|
||||
return [...this.order];
|
||||
}
|
||||
|
||||
/** Registers a module and all of its imports. */
|
||||
register(moduleType: Type): ModuleContext {
|
||||
const existing = this.contexts.get(moduleType);
|
||||
if (existing) return existing;
|
||||
|
||||
const options = getModuleOptions(moduleType);
|
||||
const context: ModuleContext = {
|
||||
type: moduleType,
|
||||
container: new Container(),
|
||||
exports: [],
|
||||
controllers: [...(options.controllers ?? [])],
|
||||
imports: [],
|
||||
};
|
||||
this.contexts.set(moduleType, context);
|
||||
|
||||
for (const imported of options.imports ?? []) {
|
||||
context.imports.push(this.register(imported));
|
||||
}
|
||||
|
||||
for (const provider of options.providers ?? []) {
|
||||
if (typeof provider === "function") {
|
||||
const scope = getInjectableOptions(provider).scope;
|
||||
context.container.registerClass(provider as AbstractType, { scope });
|
||||
} else {
|
||||
context.container.registerProvider(provider as ProviderDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
for (const controller of context.controllers) {
|
||||
context.container.registerClass(controller);
|
||||
}
|
||||
|
||||
context.exports = [...(options.exports ?? [])];
|
||||
context.container.fallbackResolver = (token, scope) =>
|
||||
this.resolveFromImports(context, token, scope);
|
||||
|
||||
this.order.push(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Resolves a token from the exported providers of the imported modules. */
|
||||
resolveFromImports(
|
||||
context: ModuleContext,
|
||||
token: Token,
|
||||
scope?: Container,
|
||||
): unknown {
|
||||
for (const imported of context.imports) {
|
||||
const resolved = this.resolveExported(imported, token, scope);
|
||||
if (resolved !== undefined) return resolved;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolves a token that an imported module re-exports. */
|
||||
resolveExported(
|
||||
context: ModuleContext,
|
||||
token: Token,
|
||||
scope?: Container,
|
||||
): unknown {
|
||||
if (context.exports.includes(token)) {
|
||||
return context.container.resolve(token, scope);
|
||||
}
|
||||
for (const exported of context.exports) {
|
||||
if (typeof exported !== "function") continue;
|
||||
const exportedContext = this.contexts.get(exported as Type);
|
||||
if (!exportedContext) continue;
|
||||
const resolved = this.resolveExported(exportedContext, token, scope);
|
||||
if (resolved !== undefined) return resolved;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Controllers of every module together with the module they belong to. */
|
||||
controllers(): { context: ModuleContext; controller: Type }[] {
|
||||
const result: { context: ModuleContext; controller: Type }[] = [];
|
||||
for (const context of this.order) {
|
||||
for (const controller of context.controllers) {
|
||||
result.push({ context, controller });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Instantiates every controller, surfacing dependency errors at boot time. */
|
||||
bootstrap(): void {
|
||||
for (const { context, controller } of this.controllers()) {
|
||||
context.container.resolve(controller);
|
||||
}
|
||||
}
|
||||
|
||||
/** Instantiates every controller asynchronously. */
|
||||
async bootstrapAsync(): Promise<void> {
|
||||
for (const { context, controller } of this.controllers()) {
|
||||
await context.container.resolveAsync(controller);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the per request container graph. */
|
||||
createScope(): ScopeGraph {
|
||||
return new ScopeGraph(this);
|
||||
}
|
||||
}
|
||||
81
core/paths.ts
Normal file
81
core/paths.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
|
||||
/** Root directory of the framework package. */
|
||||
export const frameworkRoot = resolve(import.meta.dir, "..");
|
||||
|
||||
const INTERNAL_MARKERS = ["/bun:", "node:internal", "node_modules"];
|
||||
|
||||
export const frameworkSources = [
|
||||
"adapters",
|
||||
"contracts",
|
||||
"core",
|
||||
"decorators",
|
||||
"integrations",
|
||||
"orm",
|
||||
"views",
|
||||
].map((folder) => resolve(frameworkRoot, folder));
|
||||
|
||||
/** Extracts the file path of a stack frame. */
|
||||
function frameFile(frame: string): string | undefined {
|
||||
let text = frame.trim();
|
||||
text = text.replace(/^at\s+/, "");
|
||||
|
||||
const parenthesised = /\((.*)\)$/.exec(text);
|
||||
if (parenthesised) text = parenthesised[1] as string;
|
||||
|
||||
text = text.replace(/:\d+(?::\d+)?$/, "");
|
||||
if (text.length === 0) return undefined;
|
||||
if (text.startsWith("file://")) return text.slice(7);
|
||||
if (!text.startsWith("/")) return undefined;
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Returns the file that called the caller of this function. */
|
||||
export function callerFile(
|
||||
skipDirs: string[] = frameworkSources,
|
||||
skip = 0,
|
||||
): string | undefined {
|
||||
const stack = new Error("caller").stack;
|
||||
if (!stack) return undefined;
|
||||
const frames = stack.split("\n").slice(1);
|
||||
let seen = 0;
|
||||
for (const frame of frames) {
|
||||
const file = frameFile(frame);
|
||||
if (!file) continue;
|
||||
if (INTERNAL_MARKERS.some((marker) => file.startsWith(marker))) continue;
|
||||
if (skipDirs.some((dir) => file.startsWith(dir))) continue;
|
||||
if (seen < skip) {
|
||||
seen += 1;
|
||||
continue;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Directory of the file that called the caller of this function. */
|
||||
export function callerDirectory(
|
||||
skipDirs: string[] = frameworkSources,
|
||||
skip = 0,
|
||||
): string | undefined {
|
||||
const file = callerFile(skipDirs, skip + 1);
|
||||
return file ? dirname(file) : undefined;
|
||||
}
|
||||
|
||||
/** Resolves a path relative to the project root. */
|
||||
export function fromRoot(root: string, path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(root, path);
|
||||
}
|
||||
|
||||
/** Normalises a path to posix separators, relative to the project root. */
|
||||
export function relativeTo(root: string, path: string): string {
|
||||
const resolvedRoot = resolve(root);
|
||||
const resolvedPath = resolve(path);
|
||||
if (!resolvedPath.startsWith(resolvedRoot)) return resolvedPath;
|
||||
return resolvedPath.slice(resolvedRoot.length + 1).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/** Removes the extension of a path. */
|
||||
export function stripExtension(path: string): string {
|
||||
return path.replace(/\.[^./]+$/, "");
|
||||
}
|
||||
14
core/pipeline.ts
Normal file
14
core/pipeline.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { HttpResponse } from "../contracts/typegen/http/response.ts";
|
||||
import type { RequestContext } from "../contracts/typegen/http/request.ts";
|
||||
import type { RouteDefinition } from "./router.ts";
|
||||
|
||||
export type RouteGuard = (context: RequestContext, route: RouteDefinition) => Promise<void | HttpResponse> | void | HttpResponse;
|
||||
|
||||
export class RoutePipeline {
|
||||
private readonly guards: RouteGuard[] = [];
|
||||
use(guard: RouteGuard): this { this.guards.push(guard); 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;
|
||||
}
|
||||
}
|
||||
522
core/router.ts
Normal file
522
core/router.ts
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { getControllerOptions, getControllerPrefix } from "../decorators/Controller.ts";
|
||||
import { getRoot, resolveViewRoot } from "../decorators/Root.ts";
|
||||
import { getParamMetadata, type ParamMetadata } from "../decorators/Params.ts";
|
||||
import { getRoutes, type RouteOptions } from "../decorators/Route.ts";
|
||||
import { getViews, type ViewMetadata } from "../decorators/View.ts";
|
||||
import type {
|
||||
Headers,
|
||||
HttpMethod,
|
||||
HttpResponse,
|
||||
StatusCode,
|
||||
} from "../contracts/typegen/http/index.ts";
|
||||
import type { RenderedView } from "../contracts/typegen/http/response.ts";
|
||||
import type { RequestContext } from "../contracts/typegen/http/request.ts";
|
||||
import { serveStaticFile } from "./http.ts";
|
||||
import { existsSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts";
|
||||
import { RoutePipeline } from "./pipeline.ts";
|
||||
import { getAuth } from "../decorators/Auth.ts";
|
||||
|
||||
/** Template rendered by a controller, the props are checked by the typegen. */
|
||||
export interface ViewDescriptor<P extends string = string> {
|
||||
/** Template path, relative to the views directory or absolute. */
|
||||
template: P;
|
||||
/** Props handed to the template. */
|
||||
props?: P extends ViewPath ? ViewPropsOf<P> : Record<string, unknown>;
|
||||
/** Status code of the response. */
|
||||
status?: StatusCode;
|
||||
/** Headers of the response. */
|
||||
headers?: Headers;
|
||||
}
|
||||
|
||||
export interface RouterOptions {
|
||||
/** Renders a view descriptor; injected by the application. */
|
||||
renderView?: (
|
||||
descriptor: ViewDescriptor,
|
||||
context: RequestContext,
|
||||
) => Promise<string | RenderedView> | string | RenderedView;
|
||||
pipeline?: RoutePipeline;
|
||||
}
|
||||
|
||||
export interface RouteDefinition {
|
||||
method: HttpMethod;
|
||||
/** Full path, controller prefix included. */
|
||||
path: string;
|
||||
controller: object;
|
||||
methodName: string;
|
||||
options: RouteOptions;
|
||||
/** Template rendered by the route, declared with `@View()`. */
|
||||
view?: ResolvedView;
|
||||
/** Invokes the controller method with the resolved arguments. */
|
||||
invoke: (context: RequestContext) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export type RouteMatch =
|
||||
| { kind: "match"; route: RouteDefinition; params: Record<string, string> }
|
||||
| { kind: "method-not-allowed"; allowed: HttpMethod[] }
|
||||
| { kind: "not-found" };
|
||||
|
||||
/** Joins a controller prefix and a route path into a canonical path. */
|
||||
export function joinPath(prefix: string, path: string): string {
|
||||
const raw = `${prefix}/${path}`.replace(/\/{2,}/g, "/");
|
||||
const trimmed = raw.replace(/\/+$/, "");
|
||||
return trimmed.length === 0 ? "/" : trimmed;
|
||||
}
|
||||
|
||||
/** Splits a path into its segments. */
|
||||
export function pathSegments(path: string): string[] {
|
||||
return path.split("/").filter((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the segments of a route against the segments of a request path.
|
||||
*/
|
||||
export function matchSegments(
|
||||
routeSegments: string[],
|
||||
requestSegments: string[],
|
||||
): Record<string, string> | null {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (let index = 0; index < routeSegments.length; index++) {
|
||||
const segment = routeSegments[index] as string;
|
||||
const actual = requestSegments[index];
|
||||
|
||||
if (segment === "*") {
|
||||
params["*"] = requestSegments.slice(index).join("/");
|
||||
return params;
|
||||
}
|
||||
|
||||
if (segment.startsWith(":")) {
|
||||
const optional = segment.endsWith("?");
|
||||
const name = segment.slice(1, optional ? -1 : undefined);
|
||||
if (actual === undefined) {
|
||||
if (optional) continue;
|
||||
return null;
|
||||
}
|
||||
params[name] = actual;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (actual === undefined || segment !== actual) return null;
|
||||
}
|
||||
|
||||
if (requestSegments.length > routeSegments.length) return null;
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface RawRoute {
|
||||
method: string;
|
||||
path: string;
|
||||
handler: (
|
||||
request: Request,
|
||||
context: RequestContext,
|
||||
) => Promise<Response> | Response;
|
||||
}
|
||||
|
||||
export interface StaticRoot {
|
||||
/** Prefix of the controller the files are served below. */
|
||||
prefix: string;
|
||||
/** Absolute path of the directory. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** A view of a controller, resolved against the view folder. */
|
||||
export interface ResolvedView {
|
||||
/** Absolute path of the template. */
|
||||
path: string;
|
||||
/** Route of the template, derived from its path. */
|
||||
route: string;
|
||||
/** Name of the method the view belongs to. */
|
||||
propertyKey: string;
|
||||
method: HttpMethod;
|
||||
status?: StatusCode;
|
||||
name?: string;
|
||||
description?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the template of a view.
|
||||
*/
|
||||
export function resolveView(view: ViewMetadata, viewRoot: string): ResolvedView {
|
||||
const absolute = isAbsolute(view.path)
|
||||
? view.path
|
||||
: resolve(viewRoot, view.path);
|
||||
const path = existsSync(absolute)
|
||||
? absolute
|
||||
: existsSync(`${absolute}.spug`)
|
||||
? `${absolute}.spug`
|
||||
: absolute;
|
||||
|
||||
let relative = path.startsWith(`${viewRoot}/`)
|
||||
? path.slice(viewRoot.length + 1)
|
||||
: path.split("/").pop() ?? path;
|
||||
relative = relative.replace(/\.[^./]+$/, "");
|
||||
|
||||
const segments = relative.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length > 0 && segments[segments.length - 1] === "index") {
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
route: segments.length === 0 ? "/" : `/${segments.join("/")}`,
|
||||
propertyKey: view.propertyKey,
|
||||
method: view.method,
|
||||
status: view.status,
|
||||
name: view.name,
|
||||
description: view.description,
|
||||
params: view.params,
|
||||
};
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private readonly routes: RouteDefinition[] = [];
|
||||
private readonly rawRoutes: RawRoute[] = [];
|
||||
private readonly staticRoots: StaticRoot[] = [];
|
||||
|
||||
constructor(private readonly options: RouterOptions = {}) {}
|
||||
|
||||
/**
|
||||
* Adds a route that is not bound to a controller.
|
||||
*/
|
||||
raw(
|
||||
method: string,
|
||||
path: string,
|
||||
handler: RawRoute["handler"],
|
||||
): void {
|
||||
this.rawRoutes.push({ method: method.toUpperCase(), path, handler });
|
||||
}
|
||||
|
||||
/** Registers every route declared on a controller instance. */
|
||||
register(controllerType: object, instance: unknown): void {
|
||||
const prefix = getControllerPrefix(controllerType);
|
||||
const routes = getRoutes(controllerType);
|
||||
const views = getViews(controllerType);
|
||||
const viewRoot = resolveViewRoot(
|
||||
controllerType,
|
||||
getControllerOptions(controllerType).dir ?? process.cwd(),
|
||||
);
|
||||
|
||||
const root = getRoot(controllerType);
|
||||
if (root) {
|
||||
this.staticRoots.push({ prefix, path: root.path });
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
const view = views.find(
|
||||
(entry) => entry.propertyKey === route.propertyKey,
|
||||
);
|
||||
this.routes.push({
|
||||
method: route.method,
|
||||
path: joinPath(prefix, route.path),
|
||||
controller: controllerType,
|
||||
methodName: route.propertyKey,
|
||||
options: route.options,
|
||||
view: view ? resolveView(view, viewRoot) : undefined,
|
||||
invoke: (context) =>
|
||||
this.invokeHandler(
|
||||
instance,
|
||||
route.propertyKey,
|
||||
getParamMetadata(controllerType, route.propertyKey),
|
||||
context,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (const view of views) {
|
||||
if (routes.some((route) => route.propertyKey === view.propertyKey)) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveView(view, viewRoot);
|
||||
this.routes.push({
|
||||
method: view.method,
|
||||
path: joinPath(prefix, resolved.route ?? "/"),
|
||||
controller: controllerType,
|
||||
methodName: view.propertyKey,
|
||||
options: {},
|
||||
view: resolved,
|
||||
invoke: (context) =>
|
||||
this.invokeHandler(
|
||||
instance,
|
||||
view.propertyKey,
|
||||
getParamMetadata(controllerType, view.propertyKey),
|
||||
context,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Every registered route definition. */
|
||||
list(): RouteDefinition[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/** Every route that is not bound to a controller. */
|
||||
listRaw(): RawRoute[] {
|
||||
return [...this.rawRoutes];
|
||||
}
|
||||
|
||||
/** Public route metadata for client navigation and route-aware components. */
|
||||
clientRoutes(): { name: string; description?: string; path: string; method: HttpMethod; params: Record<string, string> }[] {
|
||||
return this.routes.map((route) => {
|
||||
const view = route.view;
|
||||
const params = Object.fromEntries(pathSegments(route.path).filter((part) => part.startsWith(":")).map((part) => {
|
||||
const key = part.slice(1).replace(/[?*]$/, "");
|
||||
return [key, 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 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds the route matching a method and path. */
|
||||
match(method: string, path: string): RouteMatch {
|
||||
const segments = pathSegments(path);
|
||||
const allowed = new Set<HttpMethod>();
|
||||
for (const route of this.routes) {
|
||||
const params = matchSegments(pathSegments(route.path), segments);
|
||||
if (!params) continue;
|
||||
if (route.method === method) {
|
||||
return { kind: "match", route, params };
|
||||
}
|
||||
allowed.add(route.method);
|
||||
}
|
||||
if (allowed.size > 0) {
|
||||
return { kind: "method-not-allowed", allowed: [...allowed] };
|
||||
}
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
|
||||
/** Executes a request and normalises the handler result into a response. */
|
||||
async handle(context: RequestContext): Promise<HttpResponse> {
|
||||
for (const route of this.rawRoutes) {
|
||||
if (route.method !== context.method) continue;
|
||||
if (route.path.endsWith("*") ? !context.path.startsWith(route.path.slice(0, -1)) : route.path !== context.path) continue;
|
||||
const response = await route.handler(
|
||||
context.raw as Request,
|
||||
context,
|
||||
);
|
||||
return {
|
||||
status: response.status as StatusCode,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: new Uint8Array(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
const match = this.match(context.method, context.path);
|
||||
if (match.kind === "not-found") {
|
||||
const asset = await this.serveStatic(context.path);
|
||||
if (asset) return asset;
|
||||
return {
|
||||
status: 404,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
body: "Not Found",
|
||||
};
|
||||
}
|
||||
if (match.kind === "method-not-allowed") {
|
||||
return {
|
||||
status: 405,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
allow: match.allowed.join(", "),
|
||||
},
|
||||
body: "Method Not Allowed",
|
||||
};
|
||||
}
|
||||
|
||||
context.params = match.params;
|
||||
context.locals.routes = this.clientRoutes();
|
||||
const blocked = await this.options.pipeline?.run(context, match.route);
|
||||
if (blocked) return blocked;
|
||||
const auth = getAuth(match.route.controller, match.route.methodName);
|
||||
if (auth) {
|
||||
const identity = context.locals.auth as { roles?: string[] } | undefined;
|
||||
if (!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);
|
||||
return this.toResponse(result, context, match.route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves a file of the static root of a controller.
|
||||
*/
|
||||
private async serveStatic(path: string): Promise<HttpResponse | undefined> {
|
||||
const candidates = this.staticRoots
|
||||
.filter((root) => path.startsWith(root.prefix))
|
||||
.sort((left, right) => right.prefix.length - left.prefix.length);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const relative = path.slice(candidate.prefix.length) || "/";
|
||||
const file = await serveStaticFile(candidate.path, relative);
|
||||
if (file) return file;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async invokeHandler(
|
||||
instance: unknown,
|
||||
methodName: string,
|
||||
paramMetadata: Map<number, ParamMetadata>,
|
||||
context: RequestContext,
|
||||
): Promise<unknown> {
|
||||
const handler = (instance as Record<string, unknown>)[methodName];
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error(`Controller method ${methodName} is not callable`);
|
||||
}
|
||||
|
||||
if (paramMetadata.size === 0) {
|
||||
return (handler as (ctx: RequestContext) => unknown).call(
|
||||
instance,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
const args: unknown[] = [];
|
||||
for (const [index, meta] of paramMetadata) {
|
||||
args[index] = resolveParam(meta, context);
|
||||
}
|
||||
return (handler as (...args: unknown[]) => unknown).apply(instance, args);
|
||||
}
|
||||
|
||||
private async toResponse(
|
||||
result: unknown,
|
||||
context: RequestContext,
|
||||
route: RouteDefinition,
|
||||
): Promise<HttpResponse> {
|
||||
const status = route.options.status ?? 200;
|
||||
|
||||
if (route.view) {
|
||||
const descriptor: ViewDescriptor = isViewDescriptor(result)
|
||||
? result
|
||||
: {
|
||||
template: route.view.path,
|
||||
props: (result ?? {}) as Record<string, unknown>,
|
||||
};
|
||||
const rendered = await this.renderView(descriptor, context);
|
||||
return {
|
||||
status: descriptor.status ?? status,
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...(descriptor.headers ?? {}),
|
||||
},
|
||||
body: rendered,
|
||||
};
|
||||
}
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return { status, headers: {}, body: result ?? undefined };
|
||||
}
|
||||
|
||||
if (isHttpResponse(result)) {
|
||||
return {
|
||||
status: result.status,
|
||||
headers: result.headers ?? {},
|
||||
body: result.body,
|
||||
};
|
||||
}
|
||||
|
||||
if (isViewDescriptor(result)) {
|
||||
const rendered = await this.renderView(result, context);
|
||||
return {
|
||||
status: result.status ?? status,
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...(result.headers ?? {}),
|
||||
},
|
||||
body: rendered,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
body: result,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
body: result,
|
||||
};
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
descriptor: ViewDescriptor,
|
||||
context: RequestContext,
|
||||
): Promise<string | ReadableStream<Uint8Array>> {
|
||||
const renderer = this.options.renderView;
|
||||
if (!renderer) {
|
||||
throw new Error(
|
||||
`Template "${descriptor.template}" cannot be rendered: no view renderer configured`,
|
||||
);
|
||||
}
|
||||
const rendered = await renderer(descriptor, context);
|
||||
if (typeof rendered === "string") return rendered;
|
||||
if (rendered.deferred && rendered.deferred.length > 0) {
|
||||
return toStream(rendered);
|
||||
}
|
||||
return rendered.html;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a stream that sends the shell first and the deferred chunks after. */
|
||||
export function toStream(rendered: RenderedView): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode(rendered.html));
|
||||
for (const chunk of rendered.deferred ?? []) {
|
||||
const content = await chunk.content;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`<div data-muddle-deferred="${chunk.id}">${content}</div>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveParam(meta: ParamMetadata, context: RequestContext): unknown {
|
||||
switch (meta.kind) {
|
||||
case "param":
|
||||
return meta.name
|
||||
? (context.params as Record<string, string>)[meta.name]
|
||||
: context.params;
|
||||
case "body":
|
||||
return context.body;
|
||||
case "form":
|
||||
return context.form;
|
||||
case "query":
|
||||
return meta.name ? context.query[meta.name] : context.query;
|
||||
case "request":
|
||||
return context;
|
||||
case "response":
|
||||
return context.respond;
|
||||
case "locals":
|
||||
return context.locals;
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpResponse(value: unknown): value is HttpResponse {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as HttpResponse).status === "number" &&
|
||||
"headers" in value
|
||||
);
|
||||
}
|
||||
|
||||
function isViewDescriptor(value: unknown): value is ViewDescriptor {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as ViewDescriptor).template === "string"
|
||||
);
|
||||
}
|
||||
28
core/sessions.ts
Normal file
28
core/sessions.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export interface SessionRecord {
|
||||
value: Record<string, unknown>;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
get(key: string): Promise<SessionRecord | undefined>;
|
||||
set(key: string, record: SessionRecord): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
/** Atomically removes and returns a transaction, preventing callback replay. */
|
||||
consume(key: string): Promise<SessionRecord | undefined>;
|
||||
}
|
||||
|
||||
export class MemorySessionStore implements SessionStore {
|
||||
private readonly records = new Map<string, SessionRecord>();
|
||||
async get(key: string): Promise<SessionRecord | undefined> {
|
||||
const record = this.records.get(key);
|
||||
if (!record || record.expires <= Date.now()) { this.records.delete(key); return undefined; }
|
||||
return structuredClone(record);
|
||||
}
|
||||
async set(key: string, record: SessionRecord): Promise<void> { this.records.set(key, structuredClone(record)); }
|
||||
async delete(key: string): Promise<void> { this.records.delete(key); }
|
||||
async consume(key: string): Promise<SessionRecord | undefined> {
|
||||
const record = this.records.get(key);
|
||||
this.records.delete(key);
|
||||
return record && record.expires > Date.now() ? structuredClone(record) : undefined;
|
||||
}
|
||||
}
|
||||
62
core/templates.ts
Normal file
62
core/templates.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export type MuddleTemplate = "docs" | "minimal";
|
||||
|
||||
export interface TemplateResult {
|
||||
name: MuddleTemplate;
|
||||
output: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
const sharedFiles: Record<string, string> = {
|
||||
"package.json": `${JSON.stringify({
|
||||
name: "my-muddle-site",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: { dev: "muddle dev .", build: "muddle build ." },
|
||||
dependencies: { muddle: "latest" },
|
||||
}, null, 2)}\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`,
|
||||
"modules/app/app.module.ts": `import { Module } from "muddle";\nimport { AppController } from "./app.controller.ts";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`,
|
||||
};
|
||||
|
||||
const minimalFiles: Record<string, string> = {
|
||||
"ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist" },\n});\n`,
|
||||
"index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist") });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle demo on \${serve(app, { port: 3000 }).url}\`);\n}\n`,
|
||||
"modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() {\n\t\treturn { title: "Muddle", message: "A small, fast Muddle project." };\n\t}\n}\n`,
|
||||
"modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n main.shell\n a.mark(href="/") muddle\n section.hero@demo.hero()\n p.kicker Vite-like starter\n h1= message\n p Edit the Spug view and STCSS stylesheet, then run <code>muddle dev .</code>.\n a.action(href="#next") Open the canvas\n section#next.card@demo.next()\n h2 Start with one route.\n p Build a static site with ordinary HTML, CSS, and server-side composition.\n`,
|
||||
"styles/app.stcss": `:root { --ink: #1f2937; --muted: #64748b; --surface: #ffffff; --accent: #646cff; }\n* { box-sizing: border-box; }\nbody { margin: 0; color: var(--ink); background: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.shell { width: min(100% - 2rem, 68rem); margin: 0 auto; padding: 1.5rem 0 5rem; }\n.mark { color: var(--ink); font-weight: 800; letter-spacing: -0.06em; text-decoration: none; }\n.hero { max-width: 48rem; padding: 8rem 0 5rem; }\n.kicker { margin: 0; color: var(--accent); font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }\nh1 { margin: .7rem 0 1rem; font-size: clamp(3.5rem, 9vw, 7.25rem); line-height: .94; letter-spacing: -.075em; }\n.hero p { max-width: 34rem; color: var(--muted); font-size: 1.1rem; line-height: 1.65; }\n.action { display: inline-block; margin-top: 1rem; padding: .8rem 1rem; border-radius: .55rem; background: var(--accent); color: white; font-weight: 700; text-decoration: none; }\n.card { max-width: 38rem; padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 1rem; background: var(--surface); }\n.card h2 { margin: 0; letter-spacing: -.04em; }\n.card p { color: var(--muted); line-height: 1.6; }\n`,
|
||||
"README.md": `# Minimal Muddle\n\nRun \`bun install\`, then \`bun run dev\`. Build the static output with \`bun run build\`.\n`,
|
||||
};
|
||||
|
||||
const docsFiles: Record<string, string> = {
|
||||
"ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist", predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } },\n});\n`,
|
||||
"index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist"), predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle docs on \${serve(app, { port: 3000 }).url}\`);\n}\n`,
|
||||
"modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\nconst sections = [\n\t{ id: "overview", title: "Overview", body: "Muddle composes static pages from Spug and STCSS." },\n\t{ id: "predict", title: "Reflective prediction", body: "Dotted anchors form groups, so the renderer discovers matching elements before it writes the page." },\n\t{ id: "deploy", title: "Deploy", body: "The finished bundle is HTML and CSS with precomputed responsive relations." },\n];\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() { return { title: "Muddle Docs", sections }; }\n}\n`,
|
||||
"modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n header.topbar\n a.brand(href="/") muddle\n span.topbar__tag Documentation\n div.docs\n aside.sidebar\n p.sidebar__title Guide\n nav.sidebar__nav(aria-label="Documentation")\n each section in sections\n a.sidebar__link@docs.{section.id}(href='#' + section.id)= section.title\n main.content\n p.eyebrow Muddle documentation\n h1 Static interfaces, with measured relationships.\n p.lede This template uses responsive layout prediction and reflective anchor groups. Resize it, then follow the highlighted section.\n each section in sections\n section.doc-section@docs.{section.id}(id=section.id)\n h2= section.title\n p= section.body\n if section.id === "predict"\n pre\n code .sidebar:reflect(@docs) { transition: background 180ms ease; }\n`,
|
||||
"styles/app.stcss": `:root { --ink: #20212a; --muted: #6d6f7c; --line: #e7e7ec; --paper: #fff; --wash: #f7f7fb; --accent: #7656df; }\n* { box-sizing: border-box; }\nhtml { scroll-behavior: smooth; }\nbody { margin: 0; color: var(--ink); background: var(--paper); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.topbar { position: sticky; z-index: 2; top: 0; display: flex; justify-content: space-between; align-items: center; height: 3.75rem; padding: 0 max(1rem, calc((100% - 78rem) / 2)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); }\n.brand { color: var(--ink); font-weight: 800; letter-spacing: -.055em; text-decoration: none; }\n.topbar__tag, .sidebar__title, .eyebrow { color: var(--muted); font-size: .72rem; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; }\n.docs { display: grid; grid-template-columns: 15rem minmax(0, 48rem); gap: clamp(2rem, 7vw, 7rem); width: min(100% - 2rem, 78rem); margin: 0 auto; }\n.sidebar { position: sticky; top: 5.5rem; align-self: start; padding: 2rem 0; }\n.sidebar__title { margin: 0 0 .65rem; }\n.sidebar__nav { display: grid; gap: .18rem; }\n.sidebar__link { position: relative; display: block; overflow: hidden; padding: .55rem .7rem; border-radius: .45rem; color: var(--muted); text-decoration: none; transition: color 180ms ease, transform 180ms ease; }\n.sidebar__link::before { position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: #ede8ff; content: ""; transform: scaleX(0); transform-origin: left; transition: transform 220ms cubic-bezier(.2,.8,.2,1); }\n.sidebar__link:hover, .sidebar__link:focus-visible { color: var(--accent); transform: translateX(.18rem); }\n.sidebar__link:hover::before, .sidebar__link:focus-visible::before { transform: scaleX(1); }\n.sidebar__link:target, .sidebar__link:target::before { color: var(--accent); transform: scaleX(1); }\n:reflect(@docs) { scroll-margin-top: 5.5rem; }\n.content { padding: clamp(3rem, 10vw, 7rem) 0 8rem; }\nh1 { max-width: 42rem; margin: .7rem 0 1.2rem; font-size: clamp(3rem, 6vw, 5.8rem); line-height: .95; letter-spacing: -.075em; }\n.lede { max-width: 42rem; color: var(--muted); font-size: 1.12rem; line-height: 1.7; }\n.doc-section { padding: 5rem 0; border-bottom: 1px solid var(--line); }\n.doc-section h2 { margin: 0 0 .8rem; font-size: 1.7rem; letter-spacing: -.045em; }\n.doc-section p { max-width: 38rem; color: var(--muted); line-height: 1.7; }\npre { overflow: auto; padding: 1rem; border: 1px solid var(--line); border-radius: .7rem; background: var(--wash); }\n@media (max-width: 48rem) { .docs { display: block; } .sidebar { position: sticky; top: 3.75rem; z-index: 1; display: block; margin: 0 -1rem; padding: .65rem 1rem; border-bottom: 1px solid var(--line); background: var(--paper); } .sidebar__title { display: none; } .sidebar__nav { display: flex; overflow: auto; } .sidebar__link { white-space: nowrap; } .content { padding-top: 3rem; } }\n`,
|
||||
"README.md": `# Muddle Docs\n\nRun \`bun install\`, then \`bun run dev\`. The docs shell has a responsive sidebar and uses the \`docs\` reflective anchor group. Build it with \`bun run build\`.\n`,
|
||||
};
|
||||
|
||||
export const muddleTemplates: readonly MuddleTemplate[] = ["docs", "minimal"];
|
||||
|
||||
export async function createTemplate(name: MuddleTemplate, destination: string): Promise<TemplateResult> {
|
||||
const output = resolve(destination);
|
||||
if (existsSync(output) && (await readdir(output)).length > 0) {
|
||||
throw new Error(`Refusing to overwrite non-empty directory ${output}`);
|
||||
}
|
||||
const files = { ...sharedFiles, ...(name === "docs" ? docsFiles : minimalFiles) };
|
||||
const written: string[] = [];
|
||||
for (const [relative, source] of Object.entries(files)) {
|
||||
const file = join(output, relative);
|
||||
await mkdir(join(file, ".."), { recursive: true });
|
||||
await writeFile(file, source);
|
||||
written.push(file);
|
||||
}
|
||||
return { name, output, files: written };
|
||||
}
|
||||
21
ct.config.ts
Normal file
21
ct.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from "./contracts";
|
||||
import { stcssIntegration } from "./views/integrations.ts";
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dir,
|
||||
entry: "samples/hello-world/index.ts",
|
||||
|
||||
views: "samples/hello-world/modules/app/views",
|
||||
components: "samples/hello-world/modules/app/views/components",
|
||||
viewRoots: ["samples/hello-world/modules/app/views"],
|
||||
|
||||
integrations: [
|
||||
stcssIntegration({ dir: "samples/hello-world/styles" }),
|
||||
],
|
||||
|
||||
prerender: {
|
||||
routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"],
|
||||
outDir: "samples/hello-world/dist",
|
||||
},
|
||||
|
||||
});
|
||||
8
decorators/Astro.ts
Normal file
8
decorators/Astro.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Route, type RouteOptions } from "./Route.ts";
|
||||
|
||||
export interface AstroOptions extends RouteOptions { partial?: boolean; action?: boolean }
|
||||
|
||||
/** Declares an Astro-compatible endpoint. */
|
||||
export function Astro(path: string, options: AstroOptions = {}): MethodDecorator {
|
||||
return Route(path, { ...options, method: options.action ? "POST" : options.method ?? "GET" });
|
||||
}
|
||||
12
decorators/Auth.ts
Normal file
12
decorators/Auth.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts";
|
||||
|
||||
const KEY = "muddle:auth";
|
||||
export interface AuthOptions { roles?: string[]; redirect?: string }
|
||||
|
||||
export function Auth(options: AuthOptions = {}): MethodDecorator {
|
||||
return (target, propertyKey) => defineMemberMetadata(KEY, options, target as object, propertyKey as string);
|
||||
}
|
||||
|
||||
export function getAuth(target: object, propertyKey: string): AuthOptions | undefined {
|
||||
return getMemberMetadata(KEY, target, propertyKey)[0] as AuthOptions | undefined;
|
||||
}
|
||||
51
decorators/Component.ts
Normal file
51
decorators/Component.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
|
||||
export const COMPONENT_METADATA_KEY = "muddle:component";
|
||||
|
||||
export interface ComponentOptions {
|
||||
/** Name used inside the templates, defaults to the class name. */
|
||||
name?: string;
|
||||
/**
|
||||
* Renders the component after the page, the response is streamed.
|
||||
*/
|
||||
deferred?: boolean;
|
||||
description?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Runtime description of a server component. */
|
||||
export interface ComponentMetadata {
|
||||
name: string;
|
||||
deferred: boolean;
|
||||
description?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Declares a server component. */
|
||||
export function Component(options: ComponentOptions = {}): ClassDecorator {
|
||||
return (target) => {
|
||||
const metadata: ComponentMetadata = {
|
||||
name: options.name ?? target.name,
|
||||
deferred: options.deferred === true,
|
||||
description: options.description,
|
||||
params: options.params,
|
||||
};
|
||||
defineMetadata(COMPONENT_METADATA_KEY, metadata, target as object);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the metadata of a server component. */
|
||||
export function getComponentMetadata(
|
||||
target: object,
|
||||
): ComponentMetadata | undefined {
|
||||
return getMetadata(COMPONENT_METADATA_KEY, target) as
|
||||
| ComponentMetadata
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** `true` when the class is a server component. */
|
||||
export function isComponent(target: object): boolean {
|
||||
return getComponentMetadata(target) !== undefined;
|
||||
}
|
||||
59
decorators/Controller.ts
Normal file
59
decorators/Controller.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
import { callerFile } from "../core/paths.ts";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
/**
|
||||
* `@Controller()` groups HTTP handlers under a path prefix.
|
||||
*/
|
||||
export const CONTROLLER_METADATA_KEY = "muddle:controller";
|
||||
|
||||
export interface ControllerOptions {
|
||||
/** Path prefix of every route of the controller, e.g. `/users`. */
|
||||
prefix?: string;
|
||||
/** Optional tag used by documentation adapters. */
|
||||
tag?: string;
|
||||
/** Directory of the file that declares the controller. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
/** Normalises a prefix to `/segment` shape (no trailing slash). */
|
||||
export function normalizePrefix(prefix: string): string {
|
||||
if (!prefix || prefix === "/") return "";
|
||||
const withLeading = prefix.startsWith("/") ? prefix : `/${prefix}`;
|
||||
return withLeading.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Declares a controller. */
|
||||
export function Controller(
|
||||
prefix?: string | ControllerOptions,
|
||||
): ClassDecorator {
|
||||
return (target) => {
|
||||
const options: ControllerOptions =
|
||||
typeof prefix === "string" ? { prefix } : (prefix ?? {});
|
||||
const caller = callerFile();
|
||||
defineMetadata(
|
||||
CONTROLLER_METADATA_KEY,
|
||||
{
|
||||
...options,
|
||||
dir: options.dir ?? (caller ? dirname(caller) : undefined),
|
||||
},
|
||||
target as object,
|
||||
);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the options of a `@Controller()` decorated class. */
|
||||
export function getControllerOptions(target: object): ControllerOptions {
|
||||
return (
|
||||
(getMetadata(CONTROLLER_METADATA_KEY, target) as
|
||||
| ControllerOptions
|
||||
| undefined) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads the normalised path prefix of a controller. */
|
||||
export function getControllerPrefix(target: object): string {
|
||||
return normalizePrefix(getControllerOptions(target).prefix ?? "");
|
||||
}
|
||||
35
decorators/Inject.ts
Normal file
35
decorators/Inject.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMemberMetadata, getLatestMetadata } from "../core/metadata.ts";
|
||||
import type { Token } from "../core/container.ts";
|
||||
import {
|
||||
INJECT_TOKENS_METADATA,
|
||||
OPTIONAL_PARAMS_METADATA,
|
||||
} from "../core/container.ts";
|
||||
|
||||
/**
|
||||
* `@Inject()` overrides the injection token of a constructor parameter.
|
||||
*/
|
||||
export function Inject(token: Token): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
const entries =
|
||||
(getLatestMetadata(INJECT_TOKENS_METADATA, target) as
|
||||
| { index: number; token: Token }[]
|
||||
| undefined) ?? [];
|
||||
entries.push({ index, token });
|
||||
defineMemberMetadata(INJECT_TOKENS_METADATA, entries, target as object);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `@Optional()` allows a dependency to be missing.
|
||||
*/
|
||||
export function Optional(): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
const entries =
|
||||
(getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as
|
||||
| number[]
|
||||
| undefined) ?? [];
|
||||
entries.push(index);
|
||||
defineMemberMetadata(OPTIONAL_PARAMS_METADATA, entries, target as object);
|
||||
};
|
||||
}
|
||||
69
decorators/Module.ts
Normal file
69
decorators/Module.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
import type {
|
||||
AbstractType,
|
||||
ProviderScope,
|
||||
Token,
|
||||
Type,
|
||||
} from "../core/container.ts";
|
||||
|
||||
export const MODULE_METADATA_KEY = "muddle:module";
|
||||
|
||||
export interface ModuleOptions {
|
||||
/** Controllers instantiated by this module. */
|
||||
controllers?: Type[];
|
||||
/** Providers available inside this module. */
|
||||
providers?: (Type | AbstractType | ProviderDefinitionLike)[];
|
||||
/** Modules whose exported providers are visible here. */
|
||||
imports?: Type[];
|
||||
/** Tokens (or modules) re-exported to importers of this module. */
|
||||
exports?: Token[];
|
||||
}
|
||||
|
||||
export interface ProviderDefinitionLike {
|
||||
provide?: Token;
|
||||
useValue?: unknown;
|
||||
useClass?: AbstractType;
|
||||
useFactory?: (...dependencies: unknown[]) => unknown;
|
||||
inject?: Token[];
|
||||
scope?: ProviderScope;
|
||||
}
|
||||
|
||||
/** Marks a class as injectable and therefore eligible for type based injection. */
|
||||
export const INJECTABLE_METADATA_KEY = "muddle:injectable";
|
||||
|
||||
export function Injectable(
|
||||
options: { scope?: ProviderScope } = {},
|
||||
): ClassDecorator {
|
||||
return (target) => {
|
||||
defineMetadata(INJECTABLE_METADATA_KEY, options, target as object);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Declares a module. */
|
||||
export function Module(options: ModuleOptions): ClassDecorator {
|
||||
return (target) => {
|
||||
defineMetadata(MODULE_METADATA_KEY, options, target as object);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the options of a `@Module()` decorated class. */
|
||||
export function getModuleOptions(target: object): ModuleOptions {
|
||||
return (
|
||||
(getMetadata(MODULE_METADATA_KEY, target) as ModuleOptions | undefined) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads the options of an `@Injectable()` decorated class. */
|
||||
export function getInjectableOptions(target: object): {
|
||||
scope?: ProviderScope;
|
||||
} {
|
||||
return (
|
||||
(getMetadata(INJECTABLE_METADATA_KEY, target) as
|
||||
| { scope?: ProviderScope }
|
||||
| undefined) ?? {}
|
||||
);
|
||||
}
|
||||
125
decorators/Params.ts
Normal file
125
decorators/Params.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts";
|
||||
|
||||
export const PARAM_METADATA_KEY = "muddle:params";
|
||||
|
||||
export type ParamKind =
|
||||
| "param"
|
||||
| "body"
|
||||
| "query"
|
||||
| "request"
|
||||
| "response"
|
||||
| "locals"
|
||||
| "form";
|
||||
|
||||
export interface ParamMetadata {
|
||||
index: number;
|
||||
kind: ParamKind;
|
||||
/** Name of the route parameter or query key. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** `@Param('id')`, a route parameter. */
|
||||
export function Param(name?: string): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "param", name } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** `@Body()`, the parsed request body. */
|
||||
export function Body(): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "body" } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** `@Form()`, the submitted form fields. */
|
||||
export function Form(): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "form" } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** `@Query('page')`, a query string entry. */
|
||||
export function Query(name?: string): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "query", name } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** `@Req()`, the full request context. */
|
||||
export function Req(): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "request" } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** `@Res()`, the response builder. */
|
||||
export function Res(): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "response" } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** `@Locals()`, the mutable per request scratch space. */
|
||||
export function Locals(): ParameterDecorator {
|
||||
return (target, _propertyKey, index) => {
|
||||
defineMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
{ index, kind: "locals" } satisfies ParamMetadata,
|
||||
target as object,
|
||||
_propertyKey as string,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the parameter descriptors of a controller method.
|
||||
*/
|
||||
export function getParamMetadata(
|
||||
target: object,
|
||||
propertyKey?: string,
|
||||
): Map<number, ParamMetadata> {
|
||||
const entries = propertyKey
|
||||
? (getMemberMetadata(
|
||||
PARAM_METADATA_KEY,
|
||||
target,
|
||||
propertyKey,
|
||||
) as ParamMetadata[])
|
||||
: [];
|
||||
const map = new Map<number, ParamMetadata>();
|
||||
for (const entry of entries) {
|
||||
map.set(entry.index, entry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
15
decorators/RenderAhead.ts
Normal file
15
decorators/RenderAhead.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
|
||||
const KEY = "muddle:render-ahead";
|
||||
|
||||
export type RenderAheadSource = string[] | (() => Promise<string[]> | string[]);
|
||||
|
||||
export function RenderAhead(routes: RenderAheadSource): MethodDecorator {
|
||||
return (target, propertyKey) => {
|
||||
defineMetadata(KEY, routes, target, propertyKey);
|
||||
};
|
||||
}
|
||||
|
||||
export function getRenderAhead(target: object, propertyKey: string): RenderAheadSource | undefined {
|
||||
return getMetadata(KEY, target, propertyKey) as RenderAheadSource | undefined;
|
||||
}
|
||||
26
decorators/RenderRoute.ts
Normal file
26
decorators/RenderRoute.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
|
||||
const KEY = "muddle:render-route";
|
||||
|
||||
export interface CollectionRenderRoute {
|
||||
collection: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type RenderRouteSource = string | string[] | CollectionRenderRoute;
|
||||
|
||||
export function routeFolder(...segments: string[]): string {
|
||||
return `/${segments.filter(Boolean).map((segment) => segment.replace(/^\/+|\/+$/g, "")).join("/")}`;
|
||||
}
|
||||
|
||||
export function collectionRoute(collection: string, path: string): CollectionRenderRoute {
|
||||
return { collection, path };
|
||||
}
|
||||
|
||||
export function RenderRoute(routes: RenderRouteSource[]): MethodDecorator {
|
||||
return (target, propertyKey) => defineMetadata(KEY, routes, target, propertyKey);
|
||||
}
|
||||
|
||||
export function getRenderRoutes(target: object, propertyKey: string): RenderRouteSource[] {
|
||||
return (getMetadata(KEY, target, propertyKey) as RenderRouteSource[] | undefined) ?? [];
|
||||
}
|
||||
75
decorators/Root.ts
Normal file
75
decorators/Root.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
import { callerFile } from "../core/paths.ts";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
export const ROOT_METADATA_KEY = "muddle:root";
|
||||
|
||||
export interface RootMetadata {
|
||||
/** Absolute path of the directory. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Declares the static root of a controller. */
|
||||
export function Root(path: string): ClassDecorator {
|
||||
return (target) => {
|
||||
const caller = callerFile();
|
||||
const base = caller ? dirname(caller) : process.cwd();
|
||||
const absolute = isAbsolute(path) ? path : resolve(base, path);
|
||||
defineMetadata(
|
||||
ROOT_METADATA_KEY,
|
||||
{ path: absolute } satisfies RootMetadata,
|
||||
target as object,
|
||||
);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the static root of a controller. */
|
||||
export function getRoot(target: object): RootMetadata | undefined {
|
||||
return getMetadata(ROOT_METADATA_KEY, target) as RootMetadata | undefined;
|
||||
}
|
||||
|
||||
export const VIEW_ROOT_METADATA_KEY = "muddle:view-root";
|
||||
|
||||
export interface ViewRootMetadata {
|
||||
/** Absolute path of the folder. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Declares the view folder of a controller. */
|
||||
export function ViewRoot(path: string): ClassDecorator {
|
||||
return (target) => {
|
||||
const caller = callerFile();
|
||||
const base = caller ? dirname(caller) : process.cwd();
|
||||
const absolute = isAbsolute(path) ? path : resolve(base, path);
|
||||
defineMetadata(
|
||||
VIEW_ROOT_METADATA_KEY,
|
||||
{ path: absolute } satisfies ViewRootMetadata,
|
||||
target as object,
|
||||
);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the view folder of a controller. */
|
||||
export function getViewRoot(target: object): ViewRootMetadata | undefined {
|
||||
return getMetadata(VIEW_ROOT_METADATA_KEY, target) as
|
||||
| ViewRootMetadata
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the folder of the templates of a controller.
|
||||
*/
|
||||
export function resolveViewRoot(target: object, fallback: string): string {
|
||||
const root = getViewRoot(target);
|
||||
if (root) return root.path;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** `true` when the directory exists. */
|
||||
export function isDirectory(path: string): boolean {
|
||||
return existsSync(path) && existsSync(join(path, "."));
|
||||
}
|
||||
80
decorators/Route.ts
Normal file
80
decorators/Route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import "../core/metadata.ts";
|
||||
import {
|
||||
defineMemberMetadata,
|
||||
getAllMemberMetadata,
|
||||
} from "../core/metadata.ts";
|
||||
import type { HttpMethod } from "../contracts/typegen/http/method.ts";
|
||||
import { normalizeMethod } from "../contracts/typegen/http/method.ts";
|
||||
import type { StatusCode } from "../contracts/typegen/http/respcode.ts";
|
||||
|
||||
/**
|
||||
* `@Route()` binds a controller method to an HTTP route.
|
||||
*/
|
||||
export const ROUTE_METADATA_KEY = "muddle:route";
|
||||
|
||||
export interface RouteOptions {
|
||||
/** HTTP method(s) handled by the route, defaults to `GET`. */
|
||||
method?: HttpMethod | HttpMethod[];
|
||||
/** Status code used when the handler returns a plain value. */
|
||||
status?: StatusCode;
|
||||
/** Route name, used by the fluent view helpers. */
|
||||
name?: string;
|
||||
/** Human-readable route description. */
|
||||
description?: string;
|
||||
/** Parameter description by parameter name. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RouteMetadata {
|
||||
/** Path relative to the controller prefix. */
|
||||
path: string;
|
||||
method: HttpMethod;
|
||||
options: RouteOptions;
|
||||
/** Name of the decorated method. */
|
||||
propertyKey: string;
|
||||
}
|
||||
|
||||
function normalizeMethods(method: RouteOptions["method"]): HttpMethod[] {
|
||||
if (method === undefined) return ["GET"];
|
||||
const list = Array.isArray(method) ? method : [method];
|
||||
return list.map((entry) => normalizeMethod(entry));
|
||||
}
|
||||
|
||||
/** Declares one or more routes for a controller method. */
|
||||
export function Route(
|
||||
path: string,
|
||||
options: RouteOptions = {},
|
||||
): MethodDecorator {
|
||||
return (target, propertyKey, descriptor) => {
|
||||
for (const httpMethod of normalizeMethods(options.method)) {
|
||||
defineMemberMetadata(
|
||||
ROUTE_METADATA_KEY,
|
||||
{
|
||||
path,
|
||||
method: httpMethod,
|
||||
options,
|
||||
propertyKey: String(propertyKey),
|
||||
} satisfies RouteMetadata,
|
||||
target as object,
|
||||
propertyKey as string,
|
||||
);
|
||||
}
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every route declared on a controller class.
|
||||
*
|
||||
* When a property key is given, only the routes of that method are returned.
|
||||
*/
|
||||
export function getRoutes(
|
||||
target: object,
|
||||
propertyKey?: string,
|
||||
): RouteMetadata[] {
|
||||
return getAllMemberMetadata(ROUTE_METADATA_KEY, target)
|
||||
.filter(
|
||||
(entry) => propertyKey === undefined || entry.propertyKey === propertyKey,
|
||||
)
|
||||
.map((entry) => entry.value as RouteMetadata);
|
||||
}
|
||||
26
decorators/Service.ts
Normal file
26
decorators/Service.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMetadata, getMetadata } from "../core/metadata.ts";
|
||||
import type { ProviderScope } from "../core/container.ts";
|
||||
|
||||
export const SERVICE_METADATA_KEY = "muddle:service";
|
||||
|
||||
export interface ServiceOptions {
|
||||
/** Lifetime of the service. */
|
||||
scope?: ProviderScope;
|
||||
}
|
||||
|
||||
/** Declares a service. */
|
||||
export function Service(options: ServiceOptions = {}): ClassDecorator {
|
||||
return (target) => {
|
||||
defineMetadata(SERVICE_METADATA_KEY, options, target as object);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the options of a `@Service()` decorated class. */
|
||||
export function getServiceOptions(target: object): ServiceOptions {
|
||||
return (
|
||||
(getMetadata(SERVICE_METADATA_KEY, target) as ServiceOptions | undefined) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
83
decorators/View.ts
Normal file
83
decorators/View.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import "../core/metadata.ts";
|
||||
import { defineMemberMetadata, getAllMemberMetadata } from "../core/metadata.ts";
|
||||
import { callerFile } from "../core/paths.ts";
|
||||
import { dirname } from "node:path";
|
||||
import type { HttpMethod } from "../contracts/typegen/http/method.ts";
|
||||
import { normalizeMethod } from "../contracts/typegen/http/method.ts";
|
||||
import type { StatusCode } from "../contracts/typegen/http/respcode.ts";
|
||||
|
||||
export const VIEW_METADATA_KEY = "muddle:view";
|
||||
|
||||
export interface ViewOptions {
|
||||
/** HTTP method of the route, defaults to `GET`. */
|
||||
method?: HttpMethod | HttpMethod[];
|
||||
/** Route path, derived from the template when omitted. */
|
||||
path?: string;
|
||||
/** Status code of the response. */
|
||||
status?: StatusCode;
|
||||
name?: string;
|
||||
description?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ViewMetadata {
|
||||
/** Name of the method the view belongs to. */
|
||||
propertyKey: string;
|
||||
/** Path of the template, relative to the view folder. */
|
||||
path: string;
|
||||
/** Directory of the file that declared the view. */
|
||||
dir: string;
|
||||
method: HttpMethod;
|
||||
status?: StatusCode;
|
||||
name?: string;
|
||||
description?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Declares the template rendered by a controller method. */
|
||||
export function View(path?: string, options: ViewOptions = {}): MethodDecorator {
|
||||
return (target, propertyKey, descriptor) => {
|
||||
const caller = callerFile();
|
||||
const method = String(propertyKey);
|
||||
const methods = options.method
|
||||
? Array.isArray(options.method)
|
||||
? options.method
|
||||
: [options.method]
|
||||
: ["GET" as HttpMethod];
|
||||
|
||||
for (const httpMethod of methods) {
|
||||
defineMemberMetadata(
|
||||
VIEW_METADATA_KEY,
|
||||
{
|
||||
propertyKey: method,
|
||||
path: path ?? `./${method}.spug`,
|
||||
dir: caller ? dirname(caller) : process.cwd(),
|
||||
method: normalizeMethod(httpMethod),
|
||||
status: options.status,
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
params: options.params,
|
||||
} satisfies ViewMetadata,
|
||||
target as object,
|
||||
propertyKey as string,
|
||||
);
|
||||
}
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the view metadata of a controller class.
|
||||
*
|
||||
* When a property key is given, only the views of that method are returned.
|
||||
*/
|
||||
export function getViews(
|
||||
target: object,
|
||||
propertyKey?: string,
|
||||
): ViewMetadata[] {
|
||||
return getAllMemberMetadata(VIEW_METADATA_KEY, target)
|
||||
.filter(
|
||||
(entry) => propertyKey === undefined || entry.propertyKey === propertyKey,
|
||||
)
|
||||
.map((entry) => entry.value as ViewMetadata);
|
||||
}
|
||||
13
decorators/index.ts
Normal file
13
decorators/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export * from "./Component.ts";
|
||||
export * from "./Controller.ts";
|
||||
export * from "./Inject.ts";
|
||||
export * from "./Module.ts";
|
||||
export * from "./Params.ts";
|
||||
export * from "./Root.ts";
|
||||
export * from "./Route.ts";
|
||||
export * from "./Service.ts";
|
||||
export * from "./View.ts";
|
||||
export * from "./RenderAhead.ts";
|
||||
export * from "./RenderRoute.ts";
|
||||
export * from "./Astro.ts";
|
||||
export * from "./Auth.ts";
|
||||
2
editors/vscode/.vscodeignore
Normal file
2
editors/vscode/.vscodeignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
bun.lock
|
||||
285
editors/vscode/bun.lock
Normal file
285
editors/vscode/bun.lock
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "muddle-language-tools",
|
||||
"dependencies": {
|
||||
"@vscode/vsce": "^4.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="],
|
||||
|
||||
"@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="],
|
||||
|
||||
"@azure/core-client": ["@azure/core-client@1.11.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ=="],
|
||||
|
||||
"@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="],
|
||||
|
||||
"@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="],
|
||||
|
||||
"@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="],
|
||||
|
||||
"@azure/core-util": ["@azure/core-util@1.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw=="],
|
||||
|
||||
"@azure/identity": ["@azure/identity@4.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^11.0.0", "tslib": "^2.2.0" } }, "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw=="],
|
||||
|
||||
"@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="],
|
||||
|
||||
"@azure/msal-browser": ["@azure/msal-browser@5.26.0", "", { "dependencies": { "@azure/msal-common": "16.14.3" } }, "sha512-GYKRquzDZmZggCdStWy2bNJKnPui1upssSuznJgP+/h6Mb0ldkUkaGvaY6uSGGmZOm0CGiBQ3b5BiGy43BpXSg=="],
|
||||
|
||||
"@azure/msal-common": ["@azure/msal-common@16.14.3", "", {}, "sha512-tz3GvMrR9V/plb2lDdW8qaO8CPpfZevdExBKjltzzbAtyhK0eB2UIvji1aU+gkyHj3fxQ2ie+LNHyMXdmo8wOg=="],
|
||||
|
||||
"@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="],
|
||||
|
||||
"@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="],
|
||||
|
||||
"@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="],
|
||||
|
||||
"@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="],
|
||||
|
||||
"@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="],
|
||||
|
||||
"@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="],
|
||||
|
||||
"@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="],
|
||||
|
||||
"@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="],
|
||||
|
||||
"@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="],
|
||||
|
||||
"@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="],
|
||||
|
||||
"@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="],
|
||||
|
||||
"@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="],
|
||||
|
||||
"@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="],
|
||||
|
||||
"@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="],
|
||||
|
||||
"@secretlint/core": ["@secretlint/core@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" } }, "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw=="],
|
||||
|
||||
"@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="],
|
||||
|
||||
"@secretlint/secretlint-rule-no-dotenv": ["@secretlint/secretlint-rule-no-dotenv@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg=="],
|
||||
|
||||
"@secretlint/secretlint-rule-preset-recommend": ["@secretlint/secretlint-rule-preset-recommend@10.2.2", "", {}, "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA=="],
|
||||
|
||||
"@secretlint/source-creator": ["@secretlint/source-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2", "istextorbinary": "^9.5.0" } }, "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw=="],
|
||||
|
||||
"@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="],
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.9", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A=="],
|
||||
|
||||
"@vscode/vsce": ["@vscode/vsce@4.0.0", "", { "dependencies": { "@azure/identity": "^4.13.2", "@napi-rs/keyring": "^1.3.0", "@secretlint/core": "^10.2.2", "@secretlint/secretlint-rule-no-dotenv": "^10.2.2", "@secretlint/secretlint-rule-preset-recommend": "^10.2.2", "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "@vscode/vsce-sign": "^2.1.0", "azure-devops-node-api": "^12.5.0", "cockatiel": "^3.2.1", "commander": "^12.1.0", "hosted-git-info": "^4.1.0", "jsonc-parser": "^3.3.1", "marked": "^18.0.11", "mime": "^1.6.0", "minimatch": "^10.2.6", "parse5": "^8.0.1", "proper-lockfile": "^4.1.2", "read": "^1.0.7", "semver": "^7.8.5", "tinyglobby": "^0.2.17", "typed-rest-client": "^1.8.11", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.4.0", "yazl": "^2.5.1" }, "bin": { "vsce": "vsce" } }, "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A=="],
|
||||
|
||||
"@vscode/vsce-sign": ["@vscode/vsce-sign@2.1.0", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g=="],
|
||||
|
||||
"@vscode/vsce-sign-alpine-arm64": ["@vscode/vsce-sign-alpine-arm64@2.0.6", "", { "os": "none", "cpu": "arm64" }, "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q=="],
|
||||
|
||||
"@vscode/vsce-sign-alpine-x64": ["@vscode/vsce-sign-alpine-x64@2.0.6", "", { "os": "none", "cpu": "x64" }, "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w=="],
|
||||
|
||||
"@vscode/vsce-sign-darwin-arm64": ["@vscode/vsce-sign-darwin-arm64@2.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ=="],
|
||||
|
||||
"@vscode/vsce-sign-darwin-x64": ["@vscode/vsce-sign-darwin-x64@2.0.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw=="],
|
||||
|
||||
"@vscode/vsce-sign-linux-arm": ["@vscode/vsce-sign-linux-arm@2.0.6", "", { "os": "linux", "cpu": "arm" }, "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA=="],
|
||||
|
||||
"@vscode/vsce-sign-linux-arm64": ["@vscode/vsce-sign-linux-arm64@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA=="],
|
||||
|
||||
"@vscode/vsce-sign-linux-x64": ["@vscode/vsce-sign-linux-x64@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA=="],
|
||||
|
||||
"@vscode/vsce-sign-win32-arm64": ["@vscode/vsce-sign-win32-arm64@2.0.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg=="],
|
||||
|
||||
"@vscode/vsce-sign-win32-x64": ["@vscode/vsce-sign-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="],
|
||||
|
||||
"boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"cockatiel": ["cockatiel@3.2.1", "", {}, "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q=="],
|
||||
|
||||
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="],
|
||||
|
||||
"default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"editions": ["editions@6.22.0", "", { "dependencies": { "version-range": "^4.15.0" } }, "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ=="],
|
||||
|
||||
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
|
||||
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
|
||||
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
|
||||
|
||||
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="],
|
||||
|
||||
"read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"structured-source": ["structured-source@4.0.0", "", { "dependencies": { "boundary": "^2.0.0" } }, "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA=="],
|
||||
|
||||
"textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
|
||||
|
||||
"typed-rest-client": ["typed-rest-client@1.8.11", "", { "dependencies": { "qs": "^6.9.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA=="],
|
||||
|
||||
"underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
|
||||
|
||||
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
|
||||
|
||||
"version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="],
|
||||
|
||||
"yazl": ["yazl@2.5.1", "", { "dependencies": { "buffer-crc32": "~0.2.3" } }, "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw=="],
|
||||
|
||||
"@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="],
|
||||
}
|
||||
}
|
||||
27
editors/vscode/language-configuration.json
Normal file
27
editors/vscode/language-configuration.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/microsoft/vscode/master/src/vscode/language-configuration/_languages.schema.json",
|
||||
"comments": { "lineComment": "//", "blockComment": ["/*", "*/"] },
|
||||
"brackets": [["{", "}"], ["(", ")"], ["[", "]"]],
|
||||
"autoClosingPairs": [
|
||||
{ "open": "{", "close": "}", "notIn": ["string", "comment"] },
|
||||
{ "open": "(", "close": ")", "notIn": ["string", "comment"] },
|
||||
{ "open": "[", "close": "]", "notIn": ["string"] },
|
||||
{ "open": "'", "close": "'", "notIn": ["string", "comment"] },
|
||||
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
|
||||
],
|
||||
"surroundingPairs": [
|
||||
["{", "}"], ["(", ")"], ["[", "]"], ["'", "'"], ["\"", "\""]
|
||||
],
|
||||
"indentationRules": {
|
||||
"increaseIndentPattern": "^\\s*(?:@\\w+[^;{]*)?[^{};]*\\{\\s*$|^(?:mixin|if|each|for|component)\\s+.*$",
|
||||
"decreaseIndentPattern": "^\\s*\\}"
|
||||
},
|
||||
"wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\s\\w\\{\\}\\(\\)\\[\\]:;,\"']+)",
|
||||
"onEnterRules": [
|
||||
{
|
||||
"beforeText": "\\{\\s*$",
|
||||
"afterText": "^(\\s*\\}).*$",
|
||||
"action": { "indent": "indentOutdent" }
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
editors/vscode/muddle-language-tools-0.1.0.vsix
Normal file
BIN
editors/vscode/muddle-language-tools-0.1.0.vsix
Normal file
Binary file not shown.
69
editors/vscode/package.json
Normal file
69
editors/vscode/package.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"name": "muddle-language-tools",
|
||||
"displayName": "Muddle Language Tools",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.crit.rip/mdl/muddle.git"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"description": "Language support for Muddle SPUG templates and STCSS stylesheets",
|
||||
"main": "./src/extension.js",
|
||||
"activationEvents": [
|
||||
"onLanguage:spug",
|
||||
"onLanguage:stcss"
|
||||
],
|
||||
"capabilities": {
|
||||
"untrustedWorkspaces": {
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Programming Languages"
|
||||
],
|
||||
"contributes": {
|
||||
"languages": [
|
||||
{
|
||||
"id": "spug",
|
||||
"extensions": [".spug", ".stpug", ".stug"],
|
||||
"configuration": "./language-configuration.json"
|
||||
},
|
||||
{
|
||||
"id": "stcss",
|
||||
"extensions": [".stcss"],
|
||||
"configuration": "./language-configuration.json"
|
||||
}
|
||||
],
|
||||
"grammars": [
|
||||
{
|
||||
"language": "spug",
|
||||
"scopeName": "source.spug",
|
||||
"path": "./syntaxes/spug.tmLanguage.json"
|
||||
},
|
||||
{
|
||||
"language": "stcss",
|
||||
"scopeName": "source.stcss",
|
||||
"path": "./syntaxes/stcss.tmLanguage.json"
|
||||
}
|
||||
],
|
||||
"snippets": [
|
||||
{
|
||||
"language": "spug",
|
||||
"path": "./snippets/spug.json"
|
||||
},
|
||||
{
|
||||
"language": "stcss",
|
||||
"path": "./snippets/stcss.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/vsce": "^4.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"package": "vsce package",
|
||||
"publish": "vsce publish"
|
||||
}
|
||||
}
|
||||
18
editors/vscode/snippets/spug.json
Normal file
18
editors/vscode/snippets/spug.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"Typed props": { "prefix": "props", "body": ["props", " ${1:name}: ${2:string}"], "description": "Declare typed SPUG props" },
|
||||
"Optional prop": { "prefix": "props?", "body": ["props", " ${1:name}: ${2:string}?", ""], "description": "Declare an optional SPUG prop" },
|
||||
"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" },
|
||||
"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" }
|
||||
}
|
||||
25
editors/vscode/snippets/stcss.json
Normal file
25
editors/vscode/snippets/stcss.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"Predicted geometry": { "prefix": "predict", "body": ["@predict max-height max-width;"], "description": "Transition prerendered geometry" },
|
||||
"Predicted transitions": { "prefix": "predict-full", "body": ["@predict left top width height opacity transform;"], "description": "Full prediction transition list (pill/ribbon style)" },
|
||||
"Measure prediction target": { "prefix": "measure", "body": ["@measure;"], "description": "Mark this selector for prerender geometry prefill (inline prediction source)" },
|
||||
"Measure anchors": { "prefix": "measure-anchor", "body": ["@measure @${1:anchor}, ${0:.selector};"], "description": "Measure elements by relation anchor or class" },
|
||||
"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" },
|
||||
"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" }
|
||||
}
|
||||
466
editors/vscode/src/extension.js
Normal file
466
editors/vscode/src/extension.js
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
const vscode = require("vscode");
|
||||
|
||||
const AT_RULES = new Set([
|
||||
"each", "for", "from", "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 AT_RULE_DOCS = new Map([
|
||||
["mixin", "Declare a reusable STCSS mixin: `@mixin name($param) { … }`"],
|
||||
["include", "Expand a mixin: `@include name(10%);`"],
|
||||
["each", "Repeat rules over values: `@each $item in a b c { … }`"],
|
||||
["for", "Count a range: `@for $i from 1 through 5 { … }`"],
|
||||
["responsive", "Wrap rules in a named breakpoint from the config."],
|
||||
["media", "Standard `@media` query wrapper."],
|
||||
["supports", "Standard `@supports` feature wrapper."],
|
||||
["concentric", "Emit concentric nested radii: `@concentric 0.75rem { … }`"],
|
||||
["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\"]`."],
|
||||
]);
|
||||
|
||||
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 CSS_PROPERTIES = [
|
||||
"background", "background-color", "background-image", "border", "border-color", "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",
|
||||
];
|
||||
|
||||
const HTML_ATTRIBUTES = [
|
||||
"alt", "aria-controls", "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 = {
|
||||
anchors: new Map(), // name -> [{ uri, line, kind }]
|
||||
variables: new Map(), // $name -> [{ uri, line }]
|
||||
spugFiles: [],
|
||||
styleFiles: [],
|
||||
};
|
||||
|
||||
let indexToken = 0;
|
||||
|
||||
function relativeToWorkspace(uri) {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(uri);
|
||||
if (!folder) return uri.fsPath.split(/[\\/]/).pop();
|
||||
return uri.fsPath.slice(folder.uri.fsPath.length + 1);
|
||||
}
|
||||
|
||||
function debounce(callback, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function readText(uri) {
|
||||
try {
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function posFromOffset(text, offset) {
|
||||
const before = text.slice(0, offset);
|
||||
const newline = before.lastIndexOf("\n");
|
||||
return new vscode.Position(before.split("\n").length - 1, offset - newline - 1);
|
||||
}
|
||||
|
||||
function rangeOf(text, offset, length) {
|
||||
return new vscode.Range(posFromOffset(text, offset), posFromOffset(text, offset + Math.max(length, 1)));
|
||||
}
|
||||
|
||||
function isStcss(document) {
|
||||
return document.languageId === "stcss";
|
||||
}
|
||||
|
||||
function rememberAnchor(name, uri, line, kind) {
|
||||
const list = INDEX.anchors.get(name) ?? [];
|
||||
list.push({ uri, line, kind });
|
||||
INDEX.anchors.set(name, list);
|
||||
}
|
||||
|
||||
function indexSpugDocument(uri, text) {
|
||||
const lines = text.split("\n");
|
||||
const anchorRegex = /@([A-Za-z_][\w-]*)(?:\(([^(){}]*)\))?/g;
|
||||
lines.forEach((line, index) => {
|
||||
let match;
|
||||
anchorRegex.lastIndex = 0;
|
||||
while ((match = anchorRegex.exec(line))) {
|
||||
if (AT_RULES.has(match[1]) || match[1] === "sample") continue;
|
||||
rememberAnchor(match[1], uri, index + 1, "declared in SPUG");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function indexStcssDocument(uri, text) {
|
||||
const variableRegex = /(\$[\w-]+)\s*:/g;
|
||||
let match;
|
||||
while ((match = variableRegex.exec(text))) {
|
||||
const position = posFromOffset(text, match.index);
|
||||
const list = INDEX.variables.get(match[1]) ?? [];
|
||||
list.push({ uri, line: position.line + 1 });
|
||||
INDEX.variables.set(match[1], list);
|
||||
}
|
||||
|
||||
const anchorRegex = /@([A-Za-z_][\w-]*)/g;
|
||||
while ((match = anchorRegex.exec(text))) {
|
||||
if (AT_RULES.has(match[1])) continue;
|
||||
rememberAnchor(match[1], uri, posFromOffset(text, match.index).line + 1, "referenced in STCSS");
|
||||
}
|
||||
}
|
||||
|
||||
async function indexWorkspace() {
|
||||
const token = ++indexToken;
|
||||
if (!vscode.workspace.workspaceFolders) return;
|
||||
const [spugFiles, styleFiles] = await Promise.all([
|
||||
vscode.workspace.findFiles("**/*.spug", "**/node_modules/**"),
|
||||
vscode.workspace.findFiles("**/*.stcss", "**/node_modules/**"),
|
||||
]);
|
||||
if (token !== indexToken) return;
|
||||
INDEX.anchors = new Map();
|
||||
INDEX.variables = new Map();
|
||||
INDEX.spugFiles = spugFiles;
|
||||
INDEX.styleFiles = styleFiles;
|
||||
await Promise.all([
|
||||
...spugFiles.map(async (uri) => indexSpugDocument(uri, await readText(uri))),
|
||||
...styleFiles.map(async (uri) => indexStcssDocument(uri, await readText(uri))),
|
||||
]);
|
||||
}
|
||||
|
||||
function anchorCompletions() {
|
||||
return [...INDEX.anchors.keys()].map((name) => {
|
||||
const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Keyword);
|
||||
item.detail = "relation anchor";
|
||||
const first = INDEX.anchors.get(name)[0];
|
||||
if (first) item.documentation = new vscode.MarkdownString(`${first.kind}: \`${relativeToWorkspace(first.uri)}:${first.line}\``);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function stcssCompletions() {
|
||||
const atRules = [...AT_RULE_DOCS.keys()].map((name) => {
|
||||
const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Struct);
|
||||
item.detail = "stcss at-rule";
|
||||
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));
|
||||
return item;
|
||||
});
|
||||
const metrics = ["left", "top", "width", "height"].map((metric) => {
|
||||
const item = new vscode.CompletionItem(`$refer[${metric}](@)`, vscode.CompletionItemKind.Function);
|
||||
item.insertText = new vscode.SnippetString(`$refer[${metric}](@\${1:anchor})`);
|
||||
item.documentation = new vscode.MarkdownString(REFERENCE_DOC);
|
||||
return item;
|
||||
});
|
||||
const relate = new vscode.CompletionItem("$relate(@)", vscode.CompletionItemKind.Function);
|
||||
relate.insertText = new vscode.SnippetString("$relate(@${1:anchor})");
|
||||
relate.documentation = new vscode.MarkdownString(RELATE_DOC);
|
||||
const states = [...STATE_WORDS].map((state) => {
|
||||
const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.EnumMember);
|
||||
item.detail = "anchor state";
|
||||
return item;
|
||||
});
|
||||
const properties = CSS_PROPERTIES.map((name) => {
|
||||
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property);
|
||||
item.insertText = new vscode.SnippetString(`${name}: $0;`);
|
||||
return item;
|
||||
});
|
||||
const variables = [...INDEX.variables.keys()].map((name) => {
|
||||
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable);
|
||||
item.detail = "stcss variable";
|
||||
return item;
|
||||
});
|
||||
return [...atRules, ...metrics, relate, ...states, ...properties, ...variables];
|
||||
}
|
||||
|
||||
function spugCompletions() {
|
||||
const keywords = ["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;
|
||||
});
|
||||
const javascript = new vscode.CompletionItem("javascript!", vscode.CompletionItemKind.Keyword);
|
||||
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 attributes = HTML_ATTRIBUTES.map((name) => {
|
||||
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property);
|
||||
item.insertText = new vscode.SnippetString(`${name}=$0`);
|
||||
return item;
|
||||
});
|
||||
return [...keywords, javascript, ...control, ...attributes, ...anchorCompletions()];
|
||||
}
|
||||
|
||||
function stcssHover(document, position) {
|
||||
const line = document.lineAt(position.line).text;
|
||||
const preceding = line.slice(0, position.character);
|
||||
const anchor = /(?:^|[^\w$])@([A-Za-z_][\w-]*)$/.exec(preceding)?.[1];
|
||||
if (anchor) return hoverAnchor(anchor);
|
||||
const variable = /\$[\w-]+$/.exec(preceding)?.[0];
|
||||
if (variable) {
|
||||
const hits = INDEX.variables.get(variable) ?? [];
|
||||
return new vscode.Hover(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];
|
||||
if (refer) {
|
||||
if (refer.startsWith("$refer")) return new vscode.Hover(new vscode.MarkdownString(REFERENCE_DOC));
|
||||
if (refer.startsWith("$relate")) return new vscode.Hover(new vscode.MarkdownString(RELATE_DOC));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hoverAnchor(name) {
|
||||
const hits = INDEX.anchors.get(name) ?? [];
|
||||
if (!hits.length) return null;
|
||||
const body = hits.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) {
|
||||
const range = document.getWordRangeAtPosition(position, /@?[A-Za-z_][\w-]*/);
|
||||
if (!range) return null;
|
||||
const word = document.getText(range);
|
||||
if (word.startsWith("@") || document.getText(new vscode.Range(range.start.translate({ characterDelta: -1 }), range.start)) === "@") {
|
||||
return hoverAnchor(word.replace(/^@/, ""));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function anchorDefinition(name) {
|
||||
return (INDEX.anchors.get(name) ?? [])
|
||||
.filter((hit) => hit.kind === "declared in SPUG")
|
||||
.map((hit) => new vscode.Location(hit.uri, new vscode.Position(hit.line - 1, 0)));
|
||||
}
|
||||
|
||||
function stcssDefinition(document, position) {
|
||||
const line = document.lineAt(position.line).text.slice(0, position.character + 1);
|
||||
const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(line)?.[1];
|
||||
if (anchor) return anchorDefinition(anchor);
|
||||
return null;
|
||||
}
|
||||
|
||||
function spugDefinition(document, position) {
|
||||
const before = document.lineAt(position.line).text.slice(0, position.character + 1);
|
||||
const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(before)?.[1];
|
||||
if (anchor) {
|
||||
// jump from anchor to its STCSS references
|
||||
const refs = (INDEX.anchors.get(anchor) ?? []).filter((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;
|
||||
}
|
||||
|
||||
function diagnosticCollection() {
|
||||
return vscode.languages.createDiagnosticCollection("muddle");
|
||||
}
|
||||
|
||||
function lintDocument(document, collection) {
|
||||
if (document.languageId !== "stcss" && document.languageId !== "spug") return [];
|
||||
const text = document.getText();
|
||||
const issues = [];
|
||||
|
||||
// balanced braces in both languages
|
||||
let depth = 0;
|
||||
let lastOpen = 0;
|
||||
for (let index = 0; index < text.length && issues.length < 40; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
lastOpen = index;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth < 0) {
|
||||
issues.push(new vscode.Diagnostic(rangeOf(text, index, 1), "Unexpected `}`.", vscode.DiagnosticSeverity.Error));
|
||||
depth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth > 0) {
|
||||
issues.push(new vscode.Diagnostic(rangeOf(text, lastOpen, 1), "Unclosed `{` block.", vscode.DiagnosticSeverity.Error));
|
||||
}
|
||||
|
||||
if (document.languageId === "stcss") {
|
||||
const anchorRegex = /@([A-Za-z_][\w-]*)/g;
|
||||
let match;
|
||||
while ((match = anchorRegex.exec(text))) {
|
||||
const name = match[1];
|
||||
const offset = match.index;
|
||||
const previous = text[offset - 1];
|
||||
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 (!INDEX.anchors.has(name)) {
|
||||
issues.push(new vscode.Diagnostic(
|
||||
rangeOf(text, offset, match[0].length),
|
||||
`No SPUG file declares relation anchor \`@${name}\`.`,
|
||||
vscode.DiagnosticSeverity.Warning,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collection.set(document.uri, issues);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function lintVisible() {
|
||||
for (const editor of vscode.window.visibleTextEditors) lintDocument(editor.document, diagnostics);
|
||||
}
|
||||
|
||||
function symbolKindFor(document) {
|
||||
return isStcss(document) ? vscode.SymbolKind.Class : vscode.SymbolKind.Module;
|
||||
}
|
||||
|
||||
function symbolsOf(document) {
|
||||
const flat = [];
|
||||
for (let line = 0; line < document.lineCount; line += 1) {
|
||||
const text = document.lineAt(line).text;
|
||||
const match = /^(\s*)(@?[A-Za-z][^\n{;]*?|@[\w-]+)\s*\{?\s*$/.exec(text);
|
||||
if (!match || !text.trim()) continue;
|
||||
if (isStcss(document) && !text.includes("{")) continue;
|
||||
if (!isStcss(document) && text.includes("{")) continue;
|
||||
const label = match[2].trim() || "root";
|
||||
const symbol = new vscode.DocumentSymbol(
|
||||
label,
|
||||
"",
|
||||
symbolKindFor(document),
|
||||
new vscode.Range(line, 0, line, text.length),
|
||||
new vscode.Range(line, match[1].length, line, match[1].length + label.length),
|
||||
);
|
||||
flat.push({ symbol, indent: match[1].length });
|
||||
}
|
||||
const roots = [];
|
||||
const stack = [];
|
||||
for (const entry of flat) {
|
||||
while (stack.length && entry.indent <= stack[stack.length - 1].indent) stack.pop();
|
||||
if (stack.length) stack[stack.length - 1].symbol.children.push(entry.symbol);
|
||||
else roots.push(entry.symbol);
|
||||
stack.push(entry);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function foldingOf(document) {
|
||||
const ranges = [];
|
||||
const stack = [];
|
||||
for (let line = 0; line < document.lineCount; line += 1) {
|
||||
const text = document.lineAt(line).text;
|
||||
if (isStcss(document)) {
|
||||
const opens = (text.match(/\{/g) ?? []).length;
|
||||
const closes = (text.match(/\}/g) ?? []).length;
|
||||
for (let i = 0; i < opens; i += 1) stack.push(line);
|
||||
for (let i = 0; i < closes && stack.length; i += 1) {
|
||||
const start = stack.pop();
|
||||
if (line > start) ranges.push(new vscode.FoldingRange(start, line));
|
||||
}
|
||||
} else {
|
||||
const indent = text.match(/^(\s*)/)[1].length;
|
||||
if (!text.trim()) continue;
|
||||
while (stack.length && indent <= stack[stack.length - 1].indent) {
|
||||
const start = stack.pop();
|
||||
if (line - 1 > start) ranges.push(new vscode.FoldingRange(start, line - 1));
|
||||
}
|
||||
stack.push({ indent, line });
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
const start = 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;
|
||||
}
|
||||
|
||||
let diagnostics;
|
||||
|
||||
function completionsFor(document, position) {
|
||||
const line = document.lineAt(position.line);
|
||||
const before = line.text.slice(0, position.character);
|
||||
if (line.isEmptyOrWhitespace) return [];
|
||||
const prefix = before.trim();
|
||||
if (prefix.length === 0 && position.character > 0) return [];
|
||||
// completions popped while just typing whitespace mid-line are noise
|
||||
if (before.length > 0 && /\s$/.test(before) && !/[$@:(\[=,{]\s*$/.test(before)) return [];
|
||||
return isStcss(document) ? stcssCompletions(before) : spugCompletions(before);
|
||||
}
|
||||
|
||||
function activate(context) {
|
||||
diagnostics = diagnosticCollection();
|
||||
|
||||
const refresh = debounce(async () => {
|
||||
await indexWorkspace();
|
||||
lintVisible();
|
||||
}, 500);
|
||||
|
||||
Promise.resolve(refresh());
|
||||
|
||||
context.subscriptions.push(
|
||||
diagnostics,
|
||||
|
||||
vscode.languages.registerCompletionItemProvider(
|
||||
["stcss", "spug"],
|
||||
{ provideCompletionItems(document, position) { return completionsFor(document, position); } },
|
||||
"@", "$", ".", "-", ":", "=",
|
||||
),
|
||||
|
||||
vscode.languages.registerHoverProvider(["stcss", "spug"], {
|
||||
provideHover(document, position) {
|
||||
const word = document.getWordRangeAtPosition(position, /[@$]?[\w-]+/);
|
||||
if (!word) return null;
|
||||
try {
|
||||
return isStcss(document) ? stcssHover(document, position) : spugHover(document, position);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
vscode.languages.registerDefinitionProvider(["stcss", "spug"], {
|
||||
provideDefinition(document, position) {
|
||||
try {
|
||||
return isStcss(document) ? stcssDefinition(document, position) : spugDefinition(document, position);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
vscode.languages.registerDocumentSymbolProvider(["stcss", "spug"], {
|
||||
provideDocumentSymbols(document) { return symbolsOf(document); },
|
||||
}),
|
||||
|
||||
vscode.languages.registerFoldingRangeProvider(["stcss", "spug"], {
|
||||
provideFoldingRanges(document) { return foldingOf(document); },
|
||||
}),
|
||||
|
||||
vscode.workspace.onDidOpenTextDocument((document) => lintDocument(document, diagnostics)),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
lintDocument(event.document, diagnostics);
|
||||
refresh();
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument(refresh),
|
||||
vscode.workspace.onDidCreateFiles(refresh),
|
||||
vscode.workspace.onDidDeleteFiles(refresh),
|
||||
);
|
||||
|
||||
lintVisible();
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
module.exports = { activate, deactivate };
|
||||
200
editors/vscode/syntaxes/spug.tmLanguage.json
Normal file
200
editors/vscode/syntaxes/spug.tmLanguage.json
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.schema.json",
|
||||
"name": "SPUG",
|
||||
"scopeName": "source.spug",
|
||||
"patterns": [
|
||||
{ "include": "#frontmatter" },
|
||||
{ "include": "#comment" },
|
||||
{ "include": "#javascript-block" },
|
||||
{ "include": "#doctype" },
|
||||
{ "include": "#control-line" },
|
||||
{ "include": "#keyword-line" },
|
||||
{ "include": "#element" }
|
||||
],
|
||||
"repository": {
|
||||
"comment": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "comment.block.spug",
|
||||
"begin": "/\\*",
|
||||
"end": "\\*/"
|
||||
},
|
||||
{
|
||||
"name": "comment.block.html.spug",
|
||||
"begin": "<!--",
|
||||
"end": "-->"
|
||||
}
|
||||
]
|
||||
},
|
||||
"frontmatter": {
|
||||
"name": "meta.embedded.block.ts.spug",
|
||||
"begin": "^---\\s*$",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.frontmatter.begin.spug" } },
|
||||
"end": "^---\\s*$",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.frontmatter.end.spug" } },
|
||||
"patterns": [{ "include": "source.ts" }]
|
||||
},
|
||||
"javascript-block": {
|
||||
"name": "meta.embedded.block.ts.spug",
|
||||
"begin": "^\\s*(javascript|typescript)!\\s*$",
|
||||
"beginCaptures": { "1": { "name": "keyword.control.spug" } },
|
||||
"end": "^\\s*end\\s*$",
|
||||
"endCaptures": { "0": { "name": "keyword.control.spug" } },
|
||||
"patterns": [{ "include": "source.ts" }]
|
||||
},
|
||||
"doctype": {
|
||||
"match": "^\\s*(doctype)\\b\\s*(\\S+)?",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.spug" },
|
||||
"2": { "name": "constant.other.doctype.spug" }
|
||||
}
|
||||
},
|
||||
"control-line": {
|
||||
"name": "meta.spug-control.spug",
|
||||
"begin": "^\\s*(each|if|unless|else if|else|while|case|when|default)\\b",
|
||||
"beginCaptures": { "1": { "name": "keyword.control.spug" } },
|
||||
"end": "$",
|
||||
"patterns": [
|
||||
{ "match": "\\bin\\b", "name": "keyword.operator.of.spug" },
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#expression" }
|
||||
]
|
||||
},
|
||||
"keyword-line": {
|
||||
"match": "^\\s*(extends|include|block|append|prepend|mixin|slot|yield|island|component|render|props)\\b\\s*([\\w./@-]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.spug" },
|
||||
"2": {
|
||||
"patterns": [
|
||||
"string.quoted.other.path.spug",
|
||||
{ "match": "[\\w./@-]+", "name": "variable.other.predefined.spug" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"anchor": {
|
||||
"match": "@(?!(?:each|if|unless|else|while|case|when|default|props|doctype|javascript|typescript|end)\\b)[A-Za-z_][\\w-]*(?:\\([^(){}]*\\))?",
|
||||
"name": "entity.other.relation-anchor.spug"
|
||||
},
|
||||
"element": {
|
||||
"name": "meta.element.spug",
|
||||
"begin": "^\\s*([A-Za-z][A-Za-z0-9:_.-]*)",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "entity.name.tag.spug" }
|
||||
},
|
||||
"end": "$",
|
||||
"patterns": [
|
||||
{ "include": "#class-id" },
|
||||
{ "include": "#anchor" },
|
||||
{ "include": "#attributes" },
|
||||
{ "include": "#output" },
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#text" }
|
||||
]
|
||||
},
|
||||
"class-id": {
|
||||
"patterns": [
|
||||
{
|
||||
"match": "(\\.)([A-Za-z_][\\w-]*)",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.definition.tag.spug" },
|
||||
"2": { "name": "entity.other.attribute-name.class.spug" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "(#)([A-Za-z_][\\w-]*)",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.definition.tag.spug" },
|
||||
"2": { "name": "entity.other.attribute-name.id.spug" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attributes": {
|
||||
"name": "meta.attributes.spug",
|
||||
"begin": "\\(",
|
||||
"end": "\\)",
|
||||
"patterns": [
|
||||
{ "include": "#anchor" },
|
||||
{ "include": "#attributes" },
|
||||
{
|
||||
"match": "([A-Za-z_][\\w:-]*)\\s*(?==)",
|
||||
"name": "entity.other.attribute-name.spug"
|
||||
},
|
||||
{ "include": "#d-string" },
|
||||
{ "include": "#expression" }
|
||||
]
|
||||
},
|
||||
"d-string": {
|
||||
"name": "string.quoted.double.stcss",
|
||||
"begin": "([dD])(\"|')",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "keyword.other.stcss" },
|
||||
"2": { "name": "punctuation.definition.string.begin.stcss" }
|
||||
},
|
||||
"end": "(?<!\\\\)\\2",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.string.end.stcss" } },
|
||||
"patterns": [
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#expression" }
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"contentName": "meta.embedded.expression.ts",
|
||||
"begin": "(!?=)",
|
||||
"beginCaptures": { "1": { "name": "punctuation.separator.filters.spug" } },
|
||||
"end": "$",
|
||||
"patterns": [{ "include": "#expression" }]
|
||||
},
|
||||
"interpolation": {
|
||||
"name": "meta.interpolation.spug",
|
||||
"begin": "([#!])\\{",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.interpolation.begin.spug" } },
|
||||
"end": "\\}",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.interpolation.end.spug" } },
|
||||
"contentName": "meta.embedded.expression.ts",
|
||||
"patterns": [{ "include": "#curly-nesting" }, { "include": "#expression" }]
|
||||
},
|
||||
"curly-nesting": {
|
||||
"begin": "\\{",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.brace.curly.spug" } },
|
||||
"end": "\\}",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.brace.curly.spug" } },
|
||||
"patterns": [
|
||||
{ "include": "#curly-nesting" },
|
||||
{ "include": "#expression" }
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"patterns": [
|
||||
{
|
||||
"match": "&|<|>|"|&#\\w+;",
|
||||
"name": "constant.character.escape.html.spug"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expression": {
|
||||
"patterns": [
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#d-string" },
|
||||
{
|
||||
"name": "string.quoted.js",
|
||||
"begin": "([\"'`])",
|
||||
"end": "(?<!\\\\)\\1",
|
||||
"patterns": [{ "include": "#interpolation" }]
|
||||
},
|
||||
{
|
||||
"match": "\\b(?:true|false|null|undefined|this|await|async|new|typeof|instanceof)\\b",
|
||||
"name": "constant.language.js"
|
||||
},
|
||||
{
|
||||
"match": "\\b(?:if|else|each|while|return|function|const|let|var|import|from|export)\\b",
|
||||
"name": "keyword.control.ts"
|
||||
},
|
||||
{ "match": "\\b\\d+(?:\\.\\d+)?\\b", "name": "constant.numeric.decimal.js" },
|
||||
{ "match": "[A-Za-z_$][\\w$]*", "name": "variable.other.js" },
|
||||
{ "match": "&&|\\|\\||[?:,+\\-*/%<>=!]=[?]?|[?:,+\\-*/%<>=!]", "name": "keyword.operator.js" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
251
editors/vscode/syntaxes/stcss.tmLanguage.json
Normal file
251
editors/vscode/syntaxes/stcss.tmLanguage.json
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.schema.json",
|
||||
"name": "Structured CSS (STCSS)",
|
||||
"scopeName": "source.stcss",
|
||||
"patterns": [
|
||||
{ "include": "#comment" },
|
||||
{ "include": "#javascript-block" },
|
||||
{ "include": "#variable-definition" },
|
||||
{ "include": "#mixin-declaration" },
|
||||
{ "include": "#rule" },
|
||||
{ "include": "#at-rule" },
|
||||
{ "include": "#value" }
|
||||
],
|
||||
"repository": {
|
||||
"comment": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "comment.block.stcss",
|
||||
"begin": "/\\*",
|
||||
"end": "\\*/"
|
||||
},
|
||||
{
|
||||
"name": "comment.line.double-slash.stcss",
|
||||
"match": "//[^{]*$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"javascript-block": {
|
||||
"name": "meta.embedded.block.ts.stcss",
|
||||
"begin": "^\\s*(javascript|typescript)!\\s*$",
|
||||
"beginCaptures": { "1": { "name": "keyword.control.stcss" } },
|
||||
"end": "^\\s*end\\s*$",
|
||||
"endCaptures": { "0": { "name": "keyword.control.stcss" } },
|
||||
"patterns": [{ "include": "source.ts" }]
|
||||
},
|
||||
"variable-definition": {
|
||||
"match": "^\\s*(\\$[\\w-]+)\\s*:",
|
||||
"captures": {
|
||||
"1": { "name": "variable.definition.stcss" }
|
||||
}
|
||||
},
|
||||
"mixin-declaration": {
|
||||
"match": "^\\s*(\\$[\\w-]+)\\s*\\(|^\\s*(mixin)\\s+([\\w-]+)\\s*(\\([^()]*\\))?",
|
||||
"captures": {
|
||||
"1": { "name": "variable.definition.stcss" },
|
||||
"2": { "name": "keyword.control.stcss" },
|
||||
"3": { "name": "entity.name.function.stcss" },
|
||||
"4": { "name": "meta.function.parameters.stcss" }
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"name": "meta.rule.stcss",
|
||||
"begin": "^([^\\n{}]*?)\\s*\\{",
|
||||
"end": "\\}",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "meta.selector.stcss", "patterns": [{ "include": "#selector-content" }] }
|
||||
},
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.block.end.bracket.curly.stcss" } },
|
||||
"patterns": [
|
||||
{ "include": "#comment" },
|
||||
{ "include": "#javascript-block" },
|
||||
{ "include": "#variable-definition" },
|
||||
{ "include": "#mixin-declaration" },
|
||||
{ "include": "#rule" },
|
||||
{ "include": "#at-rule" },
|
||||
{ "include": "#declaration" },
|
||||
{ "include": "#value" }
|
||||
]
|
||||
},
|
||||
"at-rule": {
|
||||
"match": "(@)(each|for|include|responsive|media|supports|concentric|view-transition|predict|measure|property|import|keyframes|layer|container|font-face|charset|document|page|scope|starting-style)(\\b[^;{\\n]*)?",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.definition.at-rule.stcss" },
|
||||
"2": {
|
||||
"patterns": [
|
||||
{
|
||||
"match": "^(?:each|for|include|responsive|concentric|predict|measure|property)$",
|
||||
"name": "keyword.other.at-rule.muddle"
|
||||
},
|
||||
{
|
||||
"match": "^.+$",
|
||||
"name": "keyword.control.at-rule.stcss"
|
||||
}
|
||||
]
|
||||
},
|
||||
"3": {
|
||||
"patterns": [
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#anchor" },
|
||||
{ "include": "#value" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector-content": {
|
||||
"patterns": [
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#anchor" },
|
||||
{ "include": "#variable" },
|
||||
{
|
||||
"match": "(\\.)([A-Za-z_][\\w-]*)",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.definition.class.stcss" },
|
||||
"2": { "name": "entity.other.attribute-name.class.stcss" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "(#)([A-Za-z_][\\w-]*)",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.definition.id.stcss" },
|
||||
"2": { "name": "entity.other.attribute-name.id.stcss" }
|
||||
}
|
||||
},
|
||||
{ "match": "(&)", "name": "keyword.operator.parent-reference.stcss" },
|
||||
{ "match": "[>+~]", "name": "keyword.operator.combinator.stcss" },
|
||||
{
|
||||
"match": "[\\w-]+(?=\\s*(?:$|[.#:&,*]|[\\[{]|\\s|,))",
|
||||
"name": "entity.name.tag.stcss"
|
||||
},
|
||||
{ "include": "#pseudoclass" },
|
||||
{ "include": "#attribute-selector" }
|
||||
]
|
||||
},
|
||||
"pseudoclass": {
|
||||
"patterns": [
|
||||
{
|
||||
"match": ":with|:when",
|
||||
"name": "keyword.other.relation-selector.stcss"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attribute-selector": {
|
||||
"name": "meta.attribute-selector.stcss",
|
||||
"begin": "\\[",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.attribute-selector.begin.stcss" } },
|
||||
"end": "\\]",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.attribute-selector.end.stcss" } },
|
||||
"patterns": [
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#variable" },
|
||||
{
|
||||
"match": "([a-zA-Z-][\\w-]*)\\s*(?=(?:[~^|$]?=)|\\])",
|
||||
"captures": {
|
||||
"1": { "name": "entity.other.attribute-name.attribute.stcss" }
|
||||
}
|
||||
},
|
||||
{ "match": "[~^|$]?=", "name": "keyword.operator.stcss" },
|
||||
{ "include": "#string" },
|
||||
{ "include": "#number" }
|
||||
]
|
||||
},
|
||||
"declaration": {
|
||||
"match": "^\\s*([-a-zA-Z][\\w-]*)\\s*:",
|
||||
"captures": {
|
||||
"1": { "name": "support.type.property-name.css" }
|
||||
}
|
||||
},
|
||||
"anchor": {
|
||||
"match": "@[A-Za-z_][\\w-]*(?:\\.computed\\.[\\w-]+)?(?::(?:active|clicked|focus|hover|open|closed|selected|loading|visible|hidden)\\b)?",
|
||||
"name": "entity.other.relation-anchor.stcss"
|
||||
},
|
||||
"refer": {
|
||||
"match": "\\$(?:refer(\\[[\\w-]+\\])?|relate)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.other.unit.reference-metric.stcss" }
|
||||
},
|
||||
"name": "support.function.refer.stcss"
|
||||
},
|
||||
"interpolation": {
|
||||
"name": "meta.interpolation.stcss",
|
||||
"begin": "#\\{",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.interpolation.begin.stcss" } },
|
||||
"end": "\\}",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.interpolation.end.stcss" } },
|
||||
"patterns": [{ "include": "#curly-nesting" }, { "include": "#expression" }]
|
||||
},
|
||||
"curly-nesting": {
|
||||
"begin": "\\{",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } },
|
||||
"end": "\\}",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } },
|
||||
"patterns": [
|
||||
{ "include": "#curly-nesting" },
|
||||
{ "include": "#expression" }
|
||||
]
|
||||
},
|
||||
"d-string": {
|
||||
"name": "string.quoted.stcss",
|
||||
"begin": "(?<!\\w)([dD])([\"'])",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "keyword.other.stcss" },
|
||||
"2": { "name": "punctuation.definition.string.begin.stcss" }
|
||||
},
|
||||
"end": "(?<!\\\\)\\2",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.string.end.stcss" } },
|
||||
"patterns": [{ "include": "#interpolation" }, { "include": "#expression" }]
|
||||
},
|
||||
"string": {
|
||||
"name": "string.quoted.stcss",
|
||||
"begin": "(['\"])",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.stcss" } },
|
||||
"end": "(?<!\\\\)\\1",
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.string.end.stcss" } }
|
||||
},
|
||||
"number": {
|
||||
"match": "(-)?(?<!\\w)(\\d+\\.?\\d*)(rem|em|px|vh|vw|%|ms|s|vmin|vmax|fr|deg)?\\b",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.operator.unary.stcss" },
|
||||
"2": { "name": "constant.numeric.decimal.stcss" },
|
||||
"3": { "name": "keyword.other.unit.stcss" }
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"match": "#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})(?![\\w-])",
|
||||
"name": "constant.other.color.rgb-value.stcss"
|
||||
},
|
||||
"variable": {
|
||||
"match": "\\$[\\w-]+",
|
||||
"name": "variable.other.stcss"
|
||||
},
|
||||
"function-call": {
|
||||
"captures": {
|
||||
"1": { "name": "support.function.stcss" }
|
||||
},
|
||||
"match": "(?<![@$.\\w-])([a-zA-Z][\\w-]*)(?=\\()"
|
||||
},
|
||||
"operator": {
|
||||
"match": "!important\\b|&&|\\|\\||<=?|>=?|[*/%+-]",
|
||||
"name": "keyword.operator.stcss"
|
||||
},
|
||||
"expression": {
|
||||
"patterns": [
|
||||
{ "include": "#comment" },
|
||||
{ "include": "#d-string" },
|
||||
{ "include": "#string" },
|
||||
{ "include": "#refer" },
|
||||
{ "include": "#interpolation" },
|
||||
{ "include": "#anchor" },
|
||||
{ "include": "#number" },
|
||||
{ "include": "#color" },
|
||||
{ "include": "#variable" },
|
||||
{ "match": "--[\\w-]+", "name": "variable.other.css.custom-property.stcss" },
|
||||
{ "include": "#function-call" },
|
||||
{ "include": "#operator" }
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"patterns": [{ "include": "#expression" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
eslint.config.js
Normal file
42
eslint.config.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "**/dist/**"],
|
||||
},
|
||||
{
|
||||
files: ["editors/vscode/src/**"],
|
||||
languageOptions: {
|
||||
sourceType: "commonjs",
|
||||
globals: {
|
||||
require: "readonly",
|
||||
module: "writable",
|
||||
setTimeout: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
TextDecoder: "readonly",
|
||||
console: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
237
index.ts
Normal file
237
index.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// Contracts
|
||||
export type {
|
||||
AdapterOptions,
|
||||
MuddleConfig,
|
||||
CollectionConfig,
|
||||
CollectionEntry,
|
||||
ContentSchema,
|
||||
DatabaseOptions,
|
||||
Integration,
|
||||
IntegrationContext,
|
||||
MountableViews,
|
||||
Mounts,
|
||||
Loader,
|
||||
LoaderFile,
|
||||
LoadedFile,
|
||||
PrerenderOptions as PrerenderConfig,
|
||||
ResolvedConfig,
|
||||
} from "./contracts/index.ts";
|
||||
export { defineConfig } from "./contracts/index.ts";
|
||||
export type {
|
||||
HttpMethod,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
RequestContext,
|
||||
ResponseBuilder,
|
||||
StatusCode,
|
||||
} from "./contracts/typegen/http/index.ts";
|
||||
export type {
|
||||
ColumnMapping,
|
||||
FieldOptions,
|
||||
FieldType,
|
||||
TableClass,
|
||||
TableOptions,
|
||||
WhereCondition,
|
||||
WhereOperator,
|
||||
} from "./contracts/typegen/orm/index.ts";
|
||||
export type {
|
||||
TableName,
|
||||
TableRowOf,
|
||||
TableRows,
|
||||
} from "./contracts/typegen/orm/tables.ts";
|
||||
export type {
|
||||
ViewPath,
|
||||
ViewProps,
|
||||
ViewPropsOf,
|
||||
} from "./contracts/typegen/views.ts";
|
||||
export type { Minify, Simplify } from "./contracts/typegen/minify.ts";
|
||||
|
||||
// Core
|
||||
export { App, type AppOptions, type ViewRenderer } from "./core/app.ts";
|
||||
export {
|
||||
Container,
|
||||
type AbstractType,
|
||||
type ProviderDefinition,
|
||||
type ProviderScope,
|
||||
type Token,
|
||||
type Type,
|
||||
} from "./core/container.ts";
|
||||
export { resolveConfig, loadConfig } from "./core/config.ts";
|
||||
export { ServerBuildContext, TemplateContext } from "./core/context.ts";
|
||||
export { MemorySessionStore, type SessionStore, type SessionRecord } from "./core/sessions.ts";
|
||||
export { RoutePipeline, type RouteGuard } from "./core/pipeline.ts";
|
||||
export { format } from "./core/format.ts";
|
||||
export { createTemplate, muddleTemplates, type MuddleTemplate, type TemplateResult } from "./core/templates.ts";
|
||||
export {
|
||||
MuddleError,
|
||||
RouteNotFoundError,
|
||||
SchemaError,
|
||||
TemplateError,
|
||||
UnknownProviderError,
|
||||
} from "./core/errors.ts";
|
||||
export {
|
||||
createRequestContext,
|
||||
html,
|
||||
json,
|
||||
notFound,
|
||||
redirect,
|
||||
parseQuery,
|
||||
parseRequestBody,
|
||||
text,
|
||||
} from "./core/http.ts";
|
||||
export {
|
||||
defineMetadata,
|
||||
getMetadata,
|
||||
getMemberMetadata,
|
||||
installMetadataPolyfill,
|
||||
} from "./core/metadata.ts";
|
||||
export { ModuleRegistry, type ModuleContext } from "./core/modules.ts";
|
||||
export {
|
||||
Router,
|
||||
type RouteDefinition,
|
||||
type RouteMatch,
|
||||
type ViewDescriptor,
|
||||
} from "./core/router.ts";
|
||||
export {
|
||||
redirectWithMessage,
|
||||
toFormFields,
|
||||
validate,
|
||||
type FormSchema,
|
||||
type ValidationResult,
|
||||
} from "./core/form.ts";
|
||||
export {
|
||||
ContentRegistry,
|
||||
ContentService,
|
||||
validateFrontmatter,
|
||||
} from "./core/content.ts";
|
||||
|
||||
// Decorators
|
||||
export { Component, getComponentMetadata } from "./decorators/Component.ts";
|
||||
export { Controller, getControllerPrefix } from "./decorators/Controller.ts";
|
||||
export { Inject, Optional } from "./decorators/Inject.ts";
|
||||
export { Injectable, Module, getModuleOptions } from "./decorators/Module.ts";
|
||||
export {
|
||||
Body,
|
||||
Form,
|
||||
Locals,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from "./decorators/Params.ts";
|
||||
export { Route, getRoutes } from "./decorators/Route.ts";
|
||||
export { Service } from "./decorators/Service.ts";
|
||||
export { View, getViews } from "./decorators/View.ts";
|
||||
export { 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 { Auth, getAuth, type AuthOptions } from "./decorators/Auth.ts";
|
||||
|
||||
// Views
|
||||
export {
|
||||
createViews,
|
||||
ViewBuilder,
|
||||
Views,
|
||||
type SpugComponentOptions,
|
||||
type RenderOptions,
|
||||
type RenderResult,
|
||||
type ViewsOptions,
|
||||
} from "./views/api.ts";
|
||||
export {
|
||||
defineComponent,
|
||||
type ComponentContext,
|
||||
type ComponentDefinition,
|
||||
type ComponentRegistry,
|
||||
type ServerComponent,
|
||||
type SpugComponentRender,
|
||||
} from "./views/components.ts";
|
||||
export { 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 {
|
||||
markdownLoader,
|
||||
parseFrontmatter,
|
||||
renderLatex,
|
||||
renderMarkdown,
|
||||
type Frontmatter,
|
||||
} from "./views/markdown.ts";
|
||||
export {
|
||||
TemplateEngine,
|
||||
helpers,
|
||||
mergeBlocks,
|
||||
type CompiledTemplate,
|
||||
type RenderContext,
|
||||
type TemplateRuntime,
|
||||
} from "./views/engine/runtime.ts";
|
||||
export { SPUG_COMPLETIONS, STCSS_COMPLETIONS, type LanguageCompletion } from "./views/language.ts";
|
||||
export { stage, StageManager, type KeyframeStep, type ScrollSceneOptions, type TransitionPortalOptions } from "./views/stage.ts";
|
||||
export {
|
||||
createHooks,
|
||||
defineHooks,
|
||||
MuddleHooks,
|
||||
type HookBuilder,
|
||||
type SpugExpansion,
|
||||
type SpugHookContext,
|
||||
type StcssAtRuleExpansion,
|
||||
type StcssAtRuleHookContext,
|
||||
type StcssCalculationExpansion,
|
||||
type StcssCalculationHookContext,
|
||||
type StcssDeclarationExpansion,
|
||||
type StcssDeclarationHookContext,
|
||||
type StcssValueExpansion,
|
||||
type StcssValueHookContext,
|
||||
} from "./views/hooks.ts";
|
||||
|
||||
export { filesystemCache, cloudflareCache, type Cache, type CloudflareCacheLike } from "./core/cache.ts";
|
||||
|
||||
// Adapters
|
||||
export { serve, type BunServerOptions } from "./adapters/server-bun.ts";
|
||||
export {
|
||||
createWorker,
|
||||
setupWrangler,
|
||||
type WorkerEnv,
|
||||
type WranglerSetupOptions,
|
||||
} from "./adapters/cloudflare-wrangler.ts";
|
||||
export { createHandler, setupVercel, type NodeHandler } from "./adapters/vercel-vercel.ts";
|
||||
export {
|
||||
prerender,
|
||||
type PrerenderOptions,
|
||||
type PrerenderResult,
|
||||
} from "./adapters/static.ts";
|
||||
export { predictLayout, type BreakpointAcc, type LayoutPrediction, type LayoutPredictionOptions, type ReflectiveAnchorGroup } from "./adapters/prediction.ts";
|
||||
|
||||
// Integrations
|
||||
export {
|
||||
checkboxMarkup,
|
||||
mouseTrackerMarkup,
|
||||
mouseTrackerCss,
|
||||
reactCss,
|
||||
reactIntegration,
|
||||
reactStylesheet,
|
||||
STATES,
|
||||
type ReactIntegrationOptions,
|
||||
type TrackerOptions,
|
||||
} from "./integrations/react.ts";
|
||||
export { concentric, layoutHooks, type Utility, type UtilityContext } from "./integrations/hooks.ts";
|
||||
export { googleFonts, type GoogleFontsOptions } from "./integrations/fonts.ts";
|
||||
export { responsivityIntegration, DEFAULT_BREAKPOINTS, type ResponsivityOptions } from "./integrations/responsivity.ts";
|
||||
export { authIntegration, authFromRequest, authGuard, type AuthIdentity, type OAuthProvider, type AuthIntegrationOptions } from "./integrations/auth.ts";
|
||||
|
||||
// Testing
|
||||
export {
|
||||
collectTemplateTests,
|
||||
readAssertions,
|
||||
runTemplateTests,
|
||||
type TemplateTest,
|
||||
type TemplateTestFailure,
|
||||
type TemplateTestOptions,
|
||||
type TemplateTestRun,
|
||||
} from "./views/testing.ts";
|
||||
|
||||
// Type generation
|
||||
export {
|
||||
generate,
|
||||
readConfig,
|
||||
type GeneratorOptions,
|
||||
type GeneratorResult,
|
||||
type ViewContract,
|
||||
} from "./contracts/typegen/generator.ts";
|
||||
95
integrations/auth.ts
Normal file
95
integrations/auth.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Integration, IntegrationContext } from "../contracts/index.ts";
|
||||
import type { RouteGuard } from "../core/pipeline.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
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 interface 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 {
|
||||
login?: (email: string, password: string) => Promise<AuthIdentity | undefined> | AuthIdentity | undefined;
|
||||
providers?: OAuthProvider[];
|
||||
route?: string;
|
||||
store?: SessionStore | ((context: IntegrationContext) => Promise<SessionStore> | SessionStore);
|
||||
sessionTtl?: number;
|
||||
}
|
||||
|
||||
const defaultStore = new MemorySessionStore();
|
||||
const sessionKey = (id: string) => `session:${createHash("sha256").update(id).digest("hex")}`;
|
||||
async function cookie(identity: AuthIdentity, store: SessionStore, ttl: number): Promise<string> {
|
||||
const id = crypto.randomUUID();
|
||||
await store.set(sessionKey(id), { value: { identity }, expires: Date.now() + ttl * 1000 });
|
||||
return `muddle_auth=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttl}`;
|
||||
}
|
||||
|
||||
/** Form and OAuth entry points. Token exchange remains provider-owned. */
|
||||
export function authIntegration(options: AuthIntegrationOptions = {}): Integration {
|
||||
const route = options.route ?? "/auth";
|
||||
let store: SessionStore = defaultStore;
|
||||
const ttl = options.sessionTtl ?? 3600;
|
||||
return {
|
||||
name: "auth",
|
||||
async request(context) { const identity = await authFromRequest(context.raw as Request, store); if (identity) context.locals.auth = identity; },
|
||||
async setup(context: IntegrationContext) {
|
||||
store = 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) => {
|
||||
if (request.headers.get("origin") !== new URL(request.url).origin) 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 identity = await options.login?.(String(data.email ?? ""), 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) => {
|
||||
if (request.headers.get("origin") !== new URL(request.url).origin) 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));
|
||||
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 ?? []) {
|
||||
context.route("GET", `${route}/${provider.name}`, async () => {
|
||||
const state = crypto.randomUUID();
|
||||
const verifier = Buffer.from(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);
|
||||
url.searchParams.set("client_id", provider.clientId);
|
||||
url.searchParams.set("redirect_uri", provider.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", (provider.scope ?? []).join(" "));
|
||||
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_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` } });
|
||||
});
|
||||
context.route("GET", `${route}/${provider.name}/callback`, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const state = url.searchParams.get("state") ?? "";
|
||||
const bound = request.headers.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 transaction = record?.value as { provider: string; verifier: string } | undefined;
|
||||
if (!transaction || record!.expires < Date.now() || transaction.provider !== provider.name) return new Response("Invalid OAuth state", { status: 400 });
|
||||
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 });
|
||||
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> {
|
||||
const value = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1];
|
||||
if (!value) return undefined;
|
||||
const session = await store.get(sessionKey(value));
|
||||
if (!session || session.expires < Date.now()) { await store.delete(sessionKey(value)); return undefined; }
|
||||
return session.value.identity as AuthIdentity;
|
||||
}
|
||||
65
integrations/fonts.ts
Normal file
65
integrations/fonts.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Cache } from "../core/cache.ts";
|
||||
import type { Integration, IntegrationContext } from "../contracts/index.ts";
|
||||
|
||||
export interface GoogleFontsOptions {
|
||||
families: string[];
|
||||
route?: string;
|
||||
cache?: Cache;
|
||||
display?: "auto" | "block" | "swap" | "fallback" | "optional";
|
||||
}
|
||||
|
||||
function memoryCache(): Cache {
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
return {
|
||||
async get(key) { return entries.get(key); },
|
||||
async put(key, value) { entries.set(key, value); },
|
||||
async delete(key) { entries.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
function text(value: Uint8Array): string {
|
||||
return new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
function googleUrl(options: GoogleFontsOptions): string {
|
||||
const family = options.families.map((value) => `family=${encodeURIComponent(value.replace(/\s+/g, "+"))}`).join("&");
|
||||
return `https://fonts.googleapis.com/css2?${family}&display=${options.display ?? "swap"}`;
|
||||
}
|
||||
|
||||
/** Serves Google Fonts through project routes and caches both CSS and font files. */
|
||||
export function googleFonts(options: GoogleFontsOptions): Integration {
|
||||
const route = options.route ?? "/_muddle/fonts.css";
|
||||
const proxyPrefix = `${route.replace(/\.css$/, "")}/`;
|
||||
const cache = options.cache ?? memoryCache();
|
||||
return {
|
||||
name: "google-fonts",
|
||||
setup(context: IntegrationContext) {
|
||||
context.route("GET", route, async () => {
|
||||
const key = `css:${googleUrl(options)}`;
|
||||
let css = await cache.get(key);
|
||||
if (!css) {
|
||||
const response = await fetch(googleUrl(options));
|
||||
if (!response.ok) return new Response("", { status: 502 });
|
||||
css = new Uint8Array(await response.arrayBuffer());
|
||||
await cache.put(key, css, { ttl: 86400 });
|
||||
}
|
||||
const proxied = text(css).replace(/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) => {
|
||||
const encoded = new URL(request.url).pathname.slice(proxyPrefix.length);
|
||||
const url = decodeURIComponent(encoded);
|
||||
if (!url.startsWith("https://fonts.gstatic.com/")) return new Response("Not Found", { status: 404 });
|
||||
const key = `font:${url}`;
|
||||
let font = await cache.get(key);
|
||||
if (!font) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return new Response("", { status: 502 });
|
||||
font = new Uint8Array(await response.arrayBuffer());
|
||||
await cache.put(key, font, { ttl: 31536000 });
|
||||
}
|
||||
return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable" } });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
48
integrations/hooks.ts
Normal file
48
integrations/hooks.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Integration, IntegrationContext } from "../contracts/index.ts";
|
||||
|
||||
export interface UtilityContext {
|
||||
name: string;
|
||||
value?: string;
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export type Utility = (context: UtilityContext) => string | undefined;
|
||||
|
||||
export interface LayoutHookContext {
|
||||
utilities: (name: string, utility: Utility) => void;
|
||||
concentric: (name?: string) => void;
|
||||
}
|
||||
|
||||
export function concentric(name = "concentric"): Utility {
|
||||
return ({ selector, value }) => {
|
||||
const radius = value ?? "1rem";
|
||||
return `${selector} { border-radius: ${radius}; }\n${selector} > * { border-radius: inherit; }`;
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutHooks(
|
||||
hook: (context: LayoutHookContext) => void,
|
||||
): Integration {
|
||||
return {
|
||||
name: "layout-hooks",
|
||||
setup(context: IntegrationContext) {
|
||||
const utilities = new Map<string, Utility>();
|
||||
hook({
|
||||
utilities: (name, utility) => utilities.set(name, utility),
|
||||
concentric: (name) => utilities.set(name ?? "concentric", concentric(name)),
|
||||
});
|
||||
context.loader("utilities", {
|
||||
name: "utilities",
|
||||
extensions: [".utilities.css"],
|
||||
load(file) {
|
||||
const css = file.source.split(/\s+/).flatMap((token) => {
|
||||
const [name, value] = token.split(":", 2);
|
||||
const utility = utilities.get(name ?? "");
|
||||
return utility?.({ name: name ?? "", value, selector: `.${token}` }) ?? [];
|
||||
});
|
||||
return { data: {}, content: css.join("\n") };
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
1
integrations/optimize.ts
Normal file
1
integrations/optimize.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "../modules/optimize/index.ts";
|
||||
192
integrations/react.ts
Normal file
192
integrations/react.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import type { Integration, IntegrationContext } from "../contracts/index.ts";
|
||||
import { compileCss } from "../views/css/index.ts";
|
||||
|
||||
/** The states the utilities know. */
|
||||
export const STATES = [
|
||||
"open",
|
||||
"closed",
|
||||
"active",
|
||||
"disabled",
|
||||
"loading",
|
||||
"checked",
|
||||
"selected",
|
||||
] as const;
|
||||
|
||||
/** The size of the grid that tracks the pointer. */
|
||||
export interface TrackerOptions {
|
||||
/** Columns of the grid. */
|
||||
columns?: number;
|
||||
/** Rows of the grid. */
|
||||
rows?: number;
|
||||
/** Extra grid detail, multiplied into each axis. */
|
||||
detail?: number;
|
||||
/** Enables the CSS grid to receive pointer events. Disabled by default. */
|
||||
capture?: boolean;
|
||||
/** Stable name for a tracker mounted inside one component. */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** The list of a range, used by the loops of the stylesheet. */
|
||||
function range(count: number): string {
|
||||
return Array.from({ length: count }, (_, index) => index + 1).join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The stylesheet of the integration.
|
||||
*
|
||||
* The mouse position is tracked by a grid of cells, the parent hack moves the
|
||||
* custom properties of the root element: every cell knows its position, the
|
||||
* `:has()` selector of the parent picks the hovered one.
|
||||
*/
|
||||
export function reactStylesheet(options: TrackerOptions = {}): string {
|
||||
const detail = Math.max(1, options.detail ?? 1);
|
||||
const columns = (options.columns ?? 10) * detail;
|
||||
const rows = (options.rows ?? 10) * detail;
|
||||
const states = STATES.join(" ");
|
||||
|
||||
return `
|
||||
$columns: ${columns};
|
||||
$rows: ${rows};
|
||||
|
||||
@each $state in ${states} {
|
||||
.state-#{$state} {
|
||||
--muddle-state: #{$state};
|
||||
}
|
||||
|
||||
[data-state="#{$state}"] {
|
||||
--muddle-state: #{$state};
|
||||
}
|
||||
}
|
||||
|
||||
[data-state] {
|
||||
--muddle-state-opacity: 1;
|
||||
--muddle-state-visibility: visible;
|
||||
}
|
||||
|
||||
[data-state="closed"] {
|
||||
--muddle-state-opacity: 0;
|
||||
--muddle-state-visibility: hidden;
|
||||
}
|
||||
|
||||
[data-state="disabled"] {
|
||||
--muddle-state-opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-state="loading"] {
|
||||
--muddle-state-opacity: 0.7;
|
||||
}
|
||||
|
||||
.state-open {
|
||||
--muddle-state-opacity: 1;
|
||||
--muddle-state-visibility: visible;
|
||||
}
|
||||
|
||||
.state-closed {
|
||||
--muddle-state-opacity: 0;
|
||||
--muddle-state-visibility: hidden;
|
||||
}
|
||||
|
||||
.peer:checked ~ [data-state="checked"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.peer:not(:checked) ~ [data-state="unchecked"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/** CSS for one explicitly mounted tracker. */
|
||||
export function mouseTrackerCss(options: TrackerOptions = {}): string {
|
||||
const detail = Math.max(1, options.detail ?? 1);
|
||||
const columns = (options.columns ?? 10) * detail;
|
||||
const rows = (options.rows ?? 10) * detail;
|
||||
const scope = options.scope ?? "default";
|
||||
const selector = `[data-muddle-tracker="${scope}"]`;
|
||||
const rules = Array.from({ length: columns * rows }, (_, index) => {
|
||||
const x = Math.floor(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} { 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")}`;
|
||||
}
|
||||
|
||||
/** The compiled stylesheet of the integration. */
|
||||
export function reactCss(options: TrackerOptions = {}): string {
|
||||
return compileCss(reactStylesheet(options));
|
||||
}
|
||||
|
||||
/** The markup of the grid that tracks the pointer. */
|
||||
export function mouseTrackerMarkup(options: TrackerOptions = {}): string {
|
||||
const detail = Math.max(1, options.detail ?? 1);
|
||||
const columns = (options.columns ?? 10) * detail;
|
||||
const rows = (options.rows ?? 10) * detail;
|
||||
const cells = Array.from(
|
||||
{ length: columns * rows },
|
||||
() => '<div class="mouse-tracker__cell"></div>',
|
||||
).join("");
|
||||
const scope = options.scope ?? "default";
|
||||
return `<div data-muddle-tracker="${scope}" data-muddle-capture="${options.capture === true}"><div class="mouse-tracker" aria-hidden="true">${cells}</div></div>`;
|
||||
}
|
||||
|
||||
/** The markup of a checkbox, the label is the control. */
|
||||
export function checkboxMarkup(options: {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}): string {
|
||||
const attributes = [
|
||||
`type="checkbox"`,
|
||||
`name="${options.name}"`,
|
||||
options.value ? `value="${options.value}"` : "",
|
||||
options.checked ? "checked" : "",
|
||||
options.disabled ? "disabled" : "",
|
||||
'class="checkbox__input"',
|
||||
]
|
||||
.filter((entry) => entry.length > 0)
|
||||
.join(" ");
|
||||
return `<label class="checkbox${options.class ? ` ${options.class}` : ""}"><input ${attributes}><span class="checkbox__control" aria-hidden="true"></span><span class="checkbox__label">${options.label}</span></label>`;
|
||||
}
|
||||
|
||||
export interface ReactIntegrationOptions extends TrackerOptions {
|
||||
/** Route the stylesheet is served at. */
|
||||
route?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivity integration.
|
||||
*/
|
||||
export function reactIntegration(
|
||||
options: ReactIntegrationOptions = {},
|
||||
): Integration {
|
||||
const route = options.route ?? "/_muddle/react.css";
|
||||
|
||||
return {
|
||||
name: "react",
|
||||
setup(context: IntegrationContext) {
|
||||
context.loader("react", {
|
||||
name: "react",
|
||||
extensions: [".react.css"],
|
||||
load(file) {
|
||||
return {
|
||||
data: {},
|
||||
content: compileCss(`${file.source}\n${reactStylesheet(options)}`),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
context.route("GET", route, () =>
|
||||
new Response(reactCss(options), {
|
||||
headers: {
|
||||
"content-type": "text/css; charset=utf-8",
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
24
integrations/responsivity.ts
Normal file
24
integrations/responsivity.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Integration, IntegrationContext } from "../contracts/index.ts";
|
||||
import { compileCss } from "../views/css/index.ts";
|
||||
|
||||
export interface ResponsivityOptions {
|
||||
breakpoints?: Record<string, string>;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_BREAKPOINTS = { sm: "40rem", md: "48rem", lg: "64rem", xl: "80rem" };
|
||||
|
||||
/** Adds mobile-first `@responsive name {}` blocks to STCSS. */
|
||||
export function responsivityIntegration(options: ResponsivityOptions = {}): Integration {
|
||||
const breakpoints = { ...DEFAULT_BREAKPOINTS, ...options.breakpoints };
|
||||
return {
|
||||
name: "responsivity",
|
||||
setup(context: IntegrationContext) {
|
||||
context.loader("responsive-stcss", {
|
||||
name: "responsive-stcss",
|
||||
extensions: [".responsive.stcss"],
|
||||
load(file) { return { data: { breakpoints }, content: compileCss(file.source, { breakpoints }) }; },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
60
integrations/shadcn.ts
Normal file
60
integrations/shadcn.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import type { Integration, IntegrationContext } from "../contracts/index.ts";
|
||||
import { cssToStcss, reactToSpug } from "../core/backport.ts";
|
||||
|
||||
export interface ShadcnIntegrationOptions {
|
||||
/** Registry component names, for example `button`, `card`, or `dialog`. */
|
||||
components: string[];
|
||||
/** Registry origin or `{name}` URL template. Defaults to ShadCN's New York registry. */
|
||||
registry?: string;
|
||||
/** Destination relative to the Muddle project root. */
|
||||
dir?: string;
|
||||
/** Injectable for offline registries and tests. */
|
||||
fetch?: (input: string) => Promise<Response>;
|
||||
}
|
||||
|
||||
interface RegistryItem {
|
||||
files?: { path: string; content: string; type?: string }[];
|
||||
registryDependencies?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls declaratively selected ShadCN React components and backports them
|
||||
* into SPUG and STCSS during application setup.
|
||||
*/
|
||||
export function shadcnIntegration(options: ShadcnIntegrationOptions): Integration {
|
||||
return {
|
||||
name: "shadcn",
|
||||
async setup(context: IntegrationContext) {
|
||||
const fetcher = options.fetch ?? globalThis.fetch;
|
||||
const destination = resolve(context.config.root, options.dir ?? "modules/app/views/components");
|
||||
const styles = resolve(context.config.root, "styles/shadcn");
|
||||
const seen = new Set<string>();
|
||||
const pull = async (name: string): Promise<void> => {
|
||||
if (seen.has(name)) return;
|
||||
seen.add(name);
|
||||
const base = 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);
|
||||
if (!response.ok) throw new Error(`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 ?? []) {
|
||||
const extension = extname(file.path).toLowerCase();
|
||||
const stem = basename(file.path).replace(/\.(tsx|jsx|css)$/i, "");
|
||||
if (extension === ".tsx" || extension === ".jsx") {
|
||||
await mkdir(destination, { recursive: true });
|
||||
await writeFile(join(destination, `${stem}.spug`), reactToSpug(file.content, file.path));
|
||||
} else if (extension === ".css") {
|
||||
await mkdir(styles, { recursive: true });
|
||||
await writeFile(join(styles, `${stem}.stcss`), cssToStcss(file.content, file.path));
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const component of options.components) await pull(component);
|
||||
},
|
||||
};
|
||||
}
|
||||
31
justfile
Normal file
31
justfile
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
set dotenv-load
|
||||
set positional-arguments
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
test:
|
||||
bun test ./tests
|
||||
|
||||
loc:
|
||||
@echo "Loc: counting source lines in the codebase..."
|
||||
@find . \
|
||||
\( -name '*.ts' -o -name '*.scss' -o -name '*.sass' -o -name '*.md' \
|
||||
-o -name '*.html' -o -name '*.toml' -o -name '*.js' -o -name '*.css' \) \
|
||||
-not -path '*/node_modules/*' \
|
||||
-not -path './editors/vscode/node_modules/*' \
|
||||
-not -path './dist/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path '*/bun.lock' \
|
||||
-not -path '*/.git/*' \
|
||||
-not -path '*/.cache/*' \
|
||||
-not -path '*/.next/*' \
|
||||
-not -path '*/dist/*' \
|
||||
-not -path '*/.vscode/*' \
|
||||
-not -path '*/.idea/*' \
|
||||
-not -path '*/ametrine/*' \
|
||||
-not -path '*/tests/*' \
|
||||
-not -path '*/README.md' \
|
||||
-not -path './modules/amezing/*' \
|
||||
-not -path './.opencode/*' \
|
||||
| xargs wc -l | tail -n +2
|
||||
3
modules/amezing/README.md
Normal file
3
modules/amezing/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# @muddle/amezing
|
||||
|
||||
Port of the Ametrine theme (by Daudix) as a muddle module.
|
||||
8
modules/amezing/components/alert.spug
Normal file
8
modules/amezing/components/alert.spug
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
props
|
||||
type: 'note' | 'tip' | 'important' | 'warning' | 'caution'
|
||||
content?: string | (() => string | Promise<string>)
|
||||
blockquote(class='markdown-alert markdown-alert-' + type)
|
||||
if typeof content === 'function'
|
||||
!= await content()
|
||||
else
|
||||
= content ?? ''
|
||||
29
modules/amezing/components/article-list.spug
Normal file
29
modules/amezing/components/article-list.spug
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
props
|
||||
pages: { title: string; href: string; description?: string; date?: string; updated?: string; banner?: string; bannerStyle?: string; articleClass?: string; draft?: boolean; categories?: { name: string; href: string; description?: string }[] }[]
|
||||
div.article-list
|
||||
each page in pages
|
||||
article(class=page.articleClass style=page.bannerStyle)
|
||||
if page.banner
|
||||
a.open-post(href=page.href aria-label=page.title)
|
||||
img.has-alpha.no-hover(src=page.banner decoding="async" loading="lazy")
|
||||
div
|
||||
a(href=page.href)
|
||||
h3= page.title
|
||||
if page.description
|
||||
div.description
|
||||
!= page.description
|
||||
div.article-details(data-slot="article-details")
|
||||
div.items
|
||||
if page.date
|
||||
div.item
|
||||
i.ph.ph-calendar(aria-hidden="true")
|
||||
time(datetime=page.date pubdate)= page.date
|
||||
if page.updated
|
||||
div.item
|
||||
i.ph.ph-clock-clockwise(aria-hidden="true")
|
||||
time(datetime=page.updated pubdate)= page.updated
|
||||
if page.categories
|
||||
div.item
|
||||
i.ph.ph-book-bookmark(aria-hidden="true")
|
||||
each category in page.categories
|
||||
a(href=category.href title=category.description)= category.name
|
||||
14
modules/amezing/components/article-nav.spug
Normal file
14
modules/amezing/components/article-nav.spug
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
props
|
||||
previousHref?: string
|
||||
previousTitle?: string
|
||||
nextHref?: string
|
||||
nextTitle?: string
|
||||
nav.article-nav
|
||||
if previousHref
|
||||
a.article-nav-item.prev(href=previousHref)
|
||||
div.nav-arrow Previous
|
||||
span.article-title= previousTitle
|
||||
if nextHref
|
||||
a.article-nav-item.next(href=nextHref)
|
||||
div.nav-arrow Next
|
||||
span.article-title= nextTitle
|
||||
31
modules/amezing/components/article.spug
Normal file
31
modules/amezing/components/article.spug
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
props
|
||||
title: string
|
||||
description?: string
|
||||
article
|
||||
h1
|
||||
= title
|
||||
div.article-details(data-slot="article-details")
|
||||
div.items
|
||||
if date
|
||||
div.item
|
||||
i.ph.ph-calendar(aria-hidden="true")
|
||||
time(datetime=isoDate pubdate)= date
|
||||
if updated
|
||||
div.item
|
||||
i.ph.ph-clock-clockwise(aria-hidden="true")
|
||||
time(datetime=isoUpdated pubdate)= updated
|
||||
if authors
|
||||
div.item
|
||||
i.ph.ph-users(aria-hidden="true")
|
||||
span= authors
|
||||
if readingTime
|
||||
div.item
|
||||
i.ph.ph-clock-countdown(aria-hidden="true")
|
||||
span= readingTime + ' min read'
|
||||
if body
|
||||
div.article-body
|
||||
!= body
|
||||
if notes
|
||||
ul.statements
|
||||
each statement in notes
|
||||
li= statement
|
||||
5
modules/amezing/components/audio-button.spug
Normal file
5
modules/amezing/components/audio-button.spug
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<button class="audio-button" {{ if accentStyle }}{{ accentStyle | safe }}{{ end }}>
|
||||
{%- set icon = load_data(path="icons/phosphor/" ~ "music" ~ ".svg", required=false) -%}
|
||||
{%- if icon -%}{{ icon | safe }}{{ else }}🎵{{ end }}
|
||||
<span class="audio-button-label">Audio</span>
|
||||
</button>
|
||||
7
modules/amezing/components/audio.spug
Normal file
7
modules/amezing/components/audio.spug
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
props
|
||||
url: string
|
||||
name?: string
|
||||
button.audio(type="button" data-audio=url)
|
||||
if name
|
||||
span= name
|
||||
i.ph.ph-speaker-high(aria-hidden="true")
|
||||
7
modules/amezing/components/banner.spug
Normal file
7
modules/amezing/components/banner.spug
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
props
|
||||
src: string
|
||||
alt?: string
|
||||
bannerPixels?: boolean
|
||||
div#banner(data-slot="banner" class=bannerPixels ? 'pixels' : '')
|
||||
div
|
||||
img.has-alpha.no-hover(src=src decoding="async" loading="lazy" alt=alt ?? '')
|
||||
133
modules/amezing/components/base.spug
Normal file
133
modules/amezing/components/base.spug
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
doctype html
|
||||
html(lang="en" style=accentStyle ?? '')
|
||||
head
|
||||
meta(charset="UTF-8")
|
||||
meta(name="viewport" content="width=device-width, initial-scale=1")
|
||||
meta(name="description" content=description ?? '')
|
||||
title= title
|
||||
link(rel="stylesheet" href="/_muddle/amezing.css")
|
||||
link(rel="stylesheet" href="/_muddle/styles.css")
|
||||
link(rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css")
|
||||
body
|
||||
a#skip-to-content.link(href="#main-content" tabindex="0") Skip to content
|
||||
header#site-header.firefox-unblur
|
||||
ul#site-sidebar-controls
|
||||
li#expand-sidebar-li
|
||||
input#expand-sidebar.visually-hidden(type="checkbox" name="expand-sidebar" autocomplete="off")
|
||||
label(for="expand-sidebar" title="Toggle sidebar")
|
||||
i.ph.ph-sidebar(aria-hidden="true")
|
||||
ul#site-sidebar-modes(role="radiogroup")
|
||||
li
|
||||
input#mode-nav.visually-hidden(type="radio" name="mode" checked)
|
||||
label(for="mode-nav" title="Navigation mode")
|
||||
i.ph.ph-compass(aria-hidden="true")
|
||||
li
|
||||
input#mode-meta.visually-hidden(type="radio" name="mode")
|
||||
label(for="mode-meta" title="Meta mode")
|
||||
i.ph.ph-info(aria-hidden="true")
|
||||
span.title= title
|
||||
ul.buttons
|
||||
li
|
||||
details.closable
|
||||
summary(title="Feed")
|
||||
i.ph.ph-rss-simple(aria-hidden="true")
|
||||
ul.dropdown
|
||||
li
|
||||
a(href="/atom.xml") Atom
|
||||
li
|
||||
a(href="/rss.xml") RSS
|
||||
if searchHref
|
||||
li
|
||||
button#search(title="Search" aria-haspopup="dialog")
|
||||
i.ph.ph-magnifying-glass(aria-hidden="true")
|
||||
div#site-sidebar
|
||||
a#site-sidebar-header(href="#top" class=logo ? 'has-icon' : '')
|
||||
if logo
|
||||
img(width="36" height="36" class="has-alpha no-hover" src=logo alt="")
|
||||
else
|
||||
i.ph.ph-arrow-line-up(aria-hidden="true")
|
||||
div
|
||||
span= siteTitle ?? title
|
||||
span Back to top
|
||||
div#site-sidebar-content
|
||||
nav#sidebar-nav.overshoot
|
||||
ul
|
||||
li
|
||||
a(href="/" aria-current=(currentPath ?? '/') === '/' ? 'page' : '')
|
||||
i.ph.ph-house(aria-hidden="true")
|
||||
= siteTitle ?? title
|
||||
each entry in nav
|
||||
if entry.children
|
||||
li
|
||||
details.closable(open=entry.open)
|
||||
summary
|
||||
= entry.name
|
||||
div.line
|
||||
ul.dropdown
|
||||
each child in entry.children
|
||||
li
|
||||
a(href=child.href aria-current=child.active ? 'page' : '' class=child.external ? 'external' : '')
|
||||
if child.icon
|
||||
i.ph.ph-(child.icon)(aria-hidden="true")
|
||||
if child.label
|
||||
span= child.label
|
||||
else
|
||||
span= child.name
|
||||
else
|
||||
li
|
||||
a(href=entry.href aria-current=entry.active ? 'page' : '' class=(entry.external ?? entry.href.indexOf('http') === 0) ? 'external' : '')
|
||||
if entry.icon
|
||||
i.ph.ph-(entry.icon)(aria-hidden="true")
|
||||
if entry.label
|
||||
span= entry.label
|
||||
else
|
||||
span= entry.name
|
||||
div#sidebar-meta.overshoot
|
||||
if metaTitle ?? note
|
||||
div.card
|
||||
div.card-title
|
||||
div.line
|
||||
span.title= metaTitle ?? 'Meta'
|
||||
div.line
|
||||
if note
|
||||
div.note= note
|
||||
main#main-content
|
||||
if content
|
||||
!= await content()
|
||||
footer#site-footer
|
||||
div
|
||||
if copyright
|
||||
p.copyright= copyright
|
||||
if poweredBy ?? undefined
|
||||
p= 'Powered by '
|
||||
a.link.external(href="https://www.getzola.org") Zola
|
||||
= ' and '
|
||||
a.link.external(href="https://ametrine.daudix.one") Ametrine
|
||||
if sourceHref ?? undefined
|
||||
p
|
||||
a.link.external(href=sourceHref) Source
|
||||
= ' | Updated ' + timestamp + ' UTC'
|
||||
if socials
|
||||
ul.socials
|
||||
each social in socials
|
||||
li
|
||||
a(href=social.href rel="me external")
|
||||
i.ph.ph-(social.name)(aria-hidden="true")
|
||||
if webrings
|
||||
ul.webrings
|
||||
each webring in webrings
|
||||
li.webring
|
||||
a.link.external(href=webring.href)= webring.name
|
||||
a.link.previous(href=webring.previous title="Previous")
|
||||
i.ph.ph-arrow-left
|
||||
if webring.random
|
||||
a.link.random(href=webring.random title="Random")
|
||||
i.ph.ph-dice-three
|
||||
a.link.next(href=webring.next title="Next")
|
||||
i.ph.ph-arrow-right
|
||||
if note
|
||||
div.note= note
|
||||
script(src="/_muddle/amezing/closable.js" defer)
|
||||
script(src="/_muddle/amezing/paginator.js" defer)
|
||||
script(src="/_muddle/amezing/deanchor.js" defer)
|
||||
script(src="/_muddle/amezing/marquee.js" defer)
|
||||
32
modules/amezing/components/comments.spug
Normal file
32
modules/amezing/components/comments.spug
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{%- set alert_icon = load_data(path="icons/phosphor/" ~ icon ~ ".svg", required=false) -%}
|
||||
{%- if not alert_icon -%}
|
||||
{%- set alert_icon = "info" -%}
|
||||
{%- endif -%}
|
||||
<div class="comments" {{ if accentStyle }}{{ accentStyle | safe }}{{ end }}>
|
||||
<div class="comments-header">
|
||||
<h3 class="comments-title">Comments</h3>
|
||||
<a class="comments-toggle" href="#">
|
||||
{%- set icon = load_data(path="icons/phosphor/" ~ "message-circle" ~ ".svg", required=false) -%}
|
||||
{%- if icon -%}{{ icon | safe }}{{ else }}💬{{ end }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="comments-list">
|
||||
{%- set comments = load_data(path="data/comments.toml", required=false) -%}
|
||||
{%- if comments -%}
|
||||
{%- for comment in comments -%}
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{ comment.author }}</span>
|
||||
<span class="comment-date">{{ comment.date }}</span>
|
||||
</div>
|
||||
<p class="comment-content">{{ comment.content }}</p>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
{%- else -%}
|
||||
<div class="comments-empty">
|
||||
<div class="comments-empty-icon">💭</div>
|
||||
<p>No comments yet. Be the first to share your thoughts!</p>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
11
modules/amezing/components/crt.spug
Normal file
11
modules/amezing/components/crt.spug
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
props
|
||||
fontSize?: string
|
||||
label?: string
|
||||
content?: string | (() => string | Promise<string>)
|
||||
pre.crt(style=fontSize ? 'font-size: ' + fontSize + ';' : '' role=label ? 'img' : false aria-label=label ? label : false)
|
||||
code(aria-hidden=label ? false : true)
|
||||
span
|
||||
if typeof content === 'function'
|
||||
!= await content()
|
||||
else
|
||||
!= content ?? ''
|
||||
1
modules/amezing/components/deanchor.spug
Normal file
1
modules/amezing/components/deanchor.spug
Normal file
|
|
@ -0,0 +1 @@
|
|||
<span class="deanchor">{{ text | striptags }}</span>
|
||||
5
modules/amezing/components/emoji.spug
Normal file
5
modules/amezing/components/emoji.spug
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
props
|
||||
path: string
|
||||
big?: boolean
|
||||
title?: string
|
||||
img.emoji(class=big ? 'big' : false src=path title=title ?? '' width="24" height="24")
|
||||
15
modules/amezing/components/footnotes.spug
Normal file
15
modules/amezing/components/footnotes.spug
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<div class="footnotes-container" {{ if accentStyle }}{{ accentStyle | safe }}{{ end }}>
|
||||
{%- set footnotes = load_data(path="data/footnotes.toml", required=false) -%}
|
||||
{%- if footnotes -%}
|
||||
<div class="footnotes-list">
|
||||
{%- for footnote in footnotes -%}
|
||||
<div class="footnote">
|
||||
<sup class="footnote-reference">
|
||||
<a href="#fn-{{ loop.index }}">{{ loop.index }}</a>
|
||||
</sup>
|
||||
<p id="fn-{{ loop.index }}" class="footnote-content">{{ footnote.content }}</p>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
4
modules/amezing/components/fuse.spug
Normal file
4
modules/amezing/components/fuse.spug
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<div class="fuse">
|
||||
<input type="text" class="fuse-input" placeholder="Search...">
|
||||
<div class="fuse-results"></div>
|
||||
</div>
|
||||
3
modules/amezing/components/icon.spug
Normal file
3
modules/amezing/components/icon.spug
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
props
|
||||
name: string
|
||||
i.ph.ph-(name)(aria-hidden="true")
|
||||
22
modules/amezing/components/image.spug
Normal file
22
modules/amezing/components/image.spug
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
props
|
||||
src: string
|
||||
alt?: string
|
||||
url?: string
|
||||
caption?: string
|
||||
width?: number
|
||||
height?: number
|
||||
lazy?: boolean
|
||||
if caption
|
||||
figure
|
||||
if url
|
||||
a(href=url)
|
||||
img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false)
|
||||
else
|
||||
img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false)
|
||||
figcaption!= caption
|
||||
else
|
||||
if url
|
||||
a(href=url)
|
||||
img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false)
|
||||
else
|
||||
img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false)
|
||||
14
modules/amezing/components/link.spug
Normal file
14
modules/amezing/components/link.spug
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
props
|
||||
name: string
|
||||
href: string
|
||||
icon?: string
|
||||
external?: boolean
|
||||
active?: boolean
|
||||
afterActive?: boolean
|
||||
li
|
||||
a(href=href + (!external && !active && afterActive ? '#↓' : !external && !active ? '#↑' : '') aria-current=active ? 'page' : false class=external ? 'external' : false)
|
||||
if icon
|
||||
i.ph.ph-(icon)(aria-hidden="true")
|
||||
else
|
||||
i.icon.fallback= name.slice(0, 2)
|
||||
= name
|
||||
5
modules/amezing/components/mastodon.spug
Normal file
5
modules/amezing/components/mastodon.spug
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
props
|
||||
host: string
|
||||
user: string
|
||||
id: string
|
||||
iframe.mastodon-embed(src='https://' + host + '/@' + user + '/' + id + '/embed')
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue