Subproject update

This commit is contained in:
Hellings 2026-10-23 19:14:28 -04:00
parent 8d0d645487
commit 30d9ad05ac
11 changed files with 478 additions and 37 deletions

17
dbg.mjs Normal file
View file

@ -0,0 +1,17 @@
import { readFile } from "node:fs/promises";
import { lex } from "./views/engine/lexer.ts";
import { parse } from "./views/engine/parser.ts";
import { compileTemplate } from "./views/engine/compiler.ts";
const src = await readFile(
"./critdotrip/muddle/modules/app/views/index.spug",
"utf8",
);
const tokens = lex(src);
const parsed = parse(tokens);
console.log("frontmatter:", JSON.stringify(parsed.frontmatter));
const { source } = compileTemplate(parsed.nodes, {
path: "/x/index.spug",
dir: "/x",
frontmatter: "",
});
console.log(source.slice(0, 1200));

18
dbg2.mjs Normal file
View file

@ -0,0 +1,18 @@
import { readFile } from "node:fs/promises";
import { lex } from "./views/engine/lexer.ts";
import { parse } from "./views/engine/parser.ts";
import { compileTemplate } from "./views/engine/compiler.ts";
const src = await readFile(
"./critdotrip/muddle/modules/app/views/index.spug",
"utf8",
);
const tokens = lex(src);
const parsed = parse(tokens);
const { source } = compileTemplate(parsed.nodes, {
path: "/x/index.spug",
dir: "/x",
frontmatter: parsed.frontmatter || "",
});
const i = source.indexOf("hellings");
console.log("index", i);
if (i >= 0) console.log(source.slice(Math.max(0, i - 400), i + 200));

19
dbg3.mjs Normal file
View file

@ -0,0 +1,19 @@
import { readFile } from "node:fs/promises";
import { lex } from "./views/engine/lexer.ts";
import { parse } from "./views/engine/parser.ts";
import { compileTemplate } from "./views/engine/compiler.ts";
const src = await readFile(
"./critdotrip/muddle/modules/app/views/index.spug",
"utf8",
);
const parsed = parse(lex(src));
const { source } = compileTemplate(parsed.nodes, {
path: "/x/i.spug",
dir: "/x",
frontmatter: parsed.frontmatter || "",
});
const a = source.indexOf("with (__ctx) {");
const b = source.indexOf('return __out.join(""");', a);
console.log("TEMPLATE BODY START", a);
console.log(source.slice(a, b).slice(0, 400));
console.log("...LEN", b - a);

94
deno.lock Normal file
View file

@ -0,0 +1,94 @@
{
"version": "5",
"specifiers": {
"jsr:@std/fs@*": "1.0.24",
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/path@^1.1.5": "1.1.6"
},
"jsr": {
"@std/fs@1.0.24": {
"integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877",
"dependencies": [
"jsr:@std/path"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@1.1.6": {
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
"dependencies": [
"jsr:@std/internal"
]
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:@eslint/js@^10.0.1",
"npm:@types/bun@latest",
"npm:@types/pg@^8.15.5",
"npm:lightningcss@^1.33.0",
"npm:playwright@^1.64.1",
"npm:prettier@^3.9.10",
"npm:sass@^1.106.1",
"npm:typescript-eslint@^8.72.0"
]
},
"members": {
"modules/astro": {
"packageJson": {
"dependencies": [
"npm:@astrojs/compiler@2",
"npm:astro@5",
"npm:typescript@5.9.3"
]
}
},
"modules/database": {
"packageJson": {
"dependencies": [
"npm:pg@^8.23.1"
]
}
},
"modules/latex": {
"packageJson": {
"dependencies": [
"npm:katex@0.19"
]
}
},
"modules/mdx": {
"packageJson": {
"dependencies": [
"npm:@mdx-js/mdx@^3.1.1",
"npm:@types/react-dom@^19.3.0",
"npm:@types/react@^19.3.0",
"npm:react-dom@^19.3.1",
"npm:react@^19.3.1"
]
}
},
"modules/optimize": {
"packageJson": {
"dependencies": [
"npm:@types/csso@^5.0.5",
"npm:@types/html-minifier-terser@^7.0.2",
"npm:csso@^5.0.5",
"npm:html-minifier-terser@^7.2.0",
"npm:svgo@^4.1.0",
"npm:terser@^5.51.2"
]
}
},
"modules/tailwind": {
"packageJson": {
"dependencies": [
"npm:tailwindcss@^4.3.3"
]
}
}
}
}
}

View file

@ -10,32 +10,8 @@ bun add muddle@git+https://git.crit.rip/mdl/muddle.git
npm install muddle@git+https://git.crit.rip/mdl/muddle.git
```
Integrations are isolated `@muddle/*` modules. After their Forgejo repositories are provisioned, install one independently.
Integrations are `@muddle/*` modules.
```sh
bun add @muddle/shadcn@git+https://git.crit.rip/mdl/shadcn.git
```
Create a Forgejo personal access token with repository creation rights, then provision the namespace repositories. The script only reads `MUDDLE_FORGEJO_TOKEN` from the environment and never prints it.
```sh
MUDDLE_FORGEJO_TOKEN=token bun scripts/forgejo-modules.ts
```
Preview without network writes.
```sh
bun scripts/forgejo-modules.ts --dry-run
```
To commit changed integration module directories with generic `chore: update @muddle/<name> integration` messages and push their subtree history, preview the operation first. Unchanged modules are also synced so newly created Forgejo repositories receive their initial branch.
```sh
bun scripts/forgejo-push.ts
```
Push only after review. This requires the same token and creates local commits.
```sh
MUDDLE_FORGEJO_TOKEN=token bun scripts/forgejo-push.ts --push
```

View file

@ -53,6 +53,7 @@
"./contracts": "./contracts/index.ts",
"./contracts/*": "./contracts/*",
"./metadata": "./core/metadata.ts",
"./decorators": "./decorators/index.ts",
"./markdown": "./views/markdown.ts",
"./views": "./views/api.ts",
"./views/integrations": "./views/integrations.ts",

View file

@ -0,0 +1,72 @@
import { expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { compileStcssModule } from "../views/css/modules.ts";
import { createViews } from "../views/api.ts";
test("STCSS modules scope classes and inline relative imports", async () => {
const root = await mkdtemp(join(tmpdir(), "muddle-stcss-module-"));
try {
const imported = join(root, "tokens.stcss");
await writeFile(imported, ".token { color: red; }");
const result = await compileStcssModule(
'@import "./tokens.stcss"; .card { color: blue; }',
{ filename: join(root, "card.module.stcss"), root },
);
expect(result.classes.card).toBeDefined();
expect(result.classes.token).toBeDefined();
expect(result.css).toContain(`.${result.classes.card}`);
expect(result.css).toContain(`.${result.classes.token}`);
expect(result.css).not.toContain(".card {");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("a component automatically receives its adjacent STCSS module", async () => {
const root = await mkdtemp(join(tmpdir(), "muddle-component-module-"));
try {
const components = join(root, "components");
await mkdir(components);
await writeFile(join(components, "badge.spug"), "span.badge badge");
await writeFile(
join(components, "badge.module.stcss"),
".badge { color: rebeccapurple; }",
);
await writeFile(join(root, "index.spug"), "component badge");
const html = await createViews({
root,
viewsDir: root,
componentsDir: components,
}).render("index.spug", {});
expect(html).toContain("<style>");
expect(html).toMatch(/class="m[^"]+"/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("components resolve an installed package and its component entry", async () => {
const root = await mkdtemp(join(tmpdir(), "muddle-package-component-"));
try {
const packageRoot = join(root, "node_modules", "@muddle", "badge");
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(packageRoot, "package.json"),
'{"name":"@muddle/badge"}',
);
await writeFile(
join(packageRoot, "component.spug"),
"strong.badge package badge",
);
await writeFile(join(root, "index.spug"), "component @muddle/badge");
const html = await createViews({ root, viewsDir: root }).render(
"index.spug",
{},
);
expect(html).toContain("package badge");
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -13,6 +13,12 @@ export { compileCss, type CompileOptions } from "./compiler.ts";
export { evaluate, darken, lighten, rgba } from "./evaluate.ts";
export { parseCss } from "./parser.ts";
export { sassLoader, type SassLoaderOptions } from "./sass.ts";
export {
compileStcssModule,
resolveStyle,
type StcssModule,
type StcssModuleOptions,
} from "./modules.ts";
export interface StcssLoaderOptions extends CompileOptions {
/** Directory the loader scans, relative to the project root. */

138
views/css/modules.ts Normal file
View file

@ -0,0 +1,138 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { transform } from "lightningcss";
import { compileCss, type CompileOptions } from "./compiler.ts";
export interface StcssModule {
/** The minified, scoped stylesheet. */
css: string;
/** Original local class name to emitted class name. */
classes: Record<string, string>;
path: string;
}
export interface StcssModuleOptions extends CompileOptions {
/** Path used to resolve relative and package imports. */
filename?: string;
/** Project root used to find node_modules. */
root?: string;
/** Emits compact CSS, enabled by default. */
minify?: boolean;
}
const require = createRequire(import.meta.url);
/**
* Compiles a component stylesheet. Local classes are deterministically scoped
* and the resulting browser stylesheet is minified by Lightning CSS.
*/
export async function compileStcssModule(
source: string,
options: StcssModuleOptions = {},
): Promise<StcssModule> {
const filename = options.filename ?? "component.module.stcss";
const imported = await inlineImports(source, filename, options, new Set());
const names = localClasses(imported);
const classes = Object.fromEntries(
names.map((name, index) => [name, mangle(filename, index)]),
);
const scoped = imported.replace(/\.([_a-zA-Z][\w-]*)/g, (all, name) =>
classes[name] ? `.${classes[name]}` : all,
);
const structured = compileCss(scoped, options);
const css = transform({
filename,
code: Buffer.from(structured),
minify: options.minify !== false,
}).code.toString();
return { css, classes, path: filename };
}
function localClasses(source: string): string[] {
return [
...new Set(
[...source.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map(
(match) => match[1] as string,
),
),
]
.filter((name) => !name.startsWith("global-"))
.sort();
}
function mangle(filename: string, index: number): string {
let hash = 5381;
for (const char of filename) hash = (hash * 33) ^ char.charCodeAt(0);
return `m${(hash >>> 0).toString(36)}${index.toString(36)}`;
}
async function inlineImports(
source: string,
from: string,
options: StcssModuleOptions,
seen: Set<string>,
): Promise<string> {
const imports = [
...source.matchAll(/@import\s+(?:url\()?\s*["']([^"']+)["']\s*\)?\s*;?/g),
];
let output = source;
for (const match of imports) {
const specifier = match[1] as string;
if (/^(?:https?:|data:)/.test(specifier)) continue;
const path = resolveStyle(specifier, from, options.root);
if (!path || seen.has(path)) {
output = output.replace(match[0], "");
continue;
}
seen.add(path);
const nested = await inlineImports(
await readFile(path, "utf8"),
path,
options,
seen,
);
output = output.replace(match[0], nested);
}
return output;
}
/** Resolves relative styles and stylesheet files exposed by installed packages. */
export function resolveStyle(
specifier: string,
from: string,
root?: string,
): string | undefined {
const candidates: string[] = [];
if (specifier.startsWith(".")) {
candidates.push(resolve(dirname(from), specifier));
} else {
try {
candidates.push(
require.resolve(specifier, {
paths: [dirname(from), root].filter(Boolean) as string[],
}),
);
} catch {}
const packageName = specifier.startsWith("@")
? specifier.split("/").slice(0, 2).join("/")
: (specifier.split("/")[0] as string);
const rest = specifier.slice(packageName.length).replace(/^\//, "");
for (const base of [dirname(from), root].filter(Boolean) as string[]) {
candidates.push(join(base, "node_modules", packageName, rest));
}
}
for (const candidate of candidates) {
for (const path of [
candidate,
`${candidate}.stcss`,
`${candidate}.css`,
join(candidate, "index.stcss"),
join(candidate, "index.css"),
]) {
if (existsSync(path)) return path;
}
}
return undefined;
}

View file

@ -1,6 +1,7 @@
import { existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { compileTemplate } from "./compiler.ts";
import { lex } from "./lexer.ts";
import { collectProps, parse } from "./parser.ts";
@ -12,6 +13,7 @@ import {
type SpugComponentRender,
} from "../components.ts";
import { compileCss, styleTag } from "../css/index.ts";
import { compileStcssModule } from "../css/modules.ts";
import { isRaw, raw, styleValue } from "../raw.ts";
import type { DeferredChunk } from "../../contracts/typegen/http/response.ts";
import type { MuddleHooks } from "../hooks.ts";
@ -411,8 +413,9 @@ export class TemplateEngine {
name: string,
props: Record<string, unknown>,
context?: Partial<RenderContext>,
from?: string,
): Promise<string> {
const path = this.resolveComponent(name);
const path = this.resolveComponent(name, from);
const compiled = await this.load(path);
props = { ...props };
this.fillLocals(compiled, props);
@ -423,7 +426,8 @@ export class TemplateEngine {
context,
);
await this.runFrontmatter(compiled, props, runtime);
return compiled.template(runtime, props);
const html = await compiled.template(runtime, props);
return this.attachComponentModule(path, html);
}
/** Renders a component and marks it for client side hydration. */
@ -437,9 +441,12 @@ export class TemplateEngine {
return `${html}<script type="application/json" data-muddle-island="${name}">${payload}</script>`;
}
private resolveComponent(name: string): string {
private resolveComponent(name: string, from?: string): string {
if (isAbsolute(name) && existsSync(name)) return name;
const candidates = [
...(from && name.startsWith(".")
? [resolve(dirname(from), name), resolve(dirname(from), `${name}.spug`)]
: []),
join(this.componentsDir, `${name}.spug`),
join(this.componentsDir, name),
join(this.viewsDir, `${name}.spug`),
@ -450,9 +457,68 @@ export class TemplateEngine {
return candidate;
}
}
const packagePath = this.resolvePackageComponent(name, from);
if (packagePath) return packagePath;
throw new Error(`Component not found: ${name}`);
}
private resolvePackageComponent(
name: string,
from?: string,
): string | undefined {
if (name.startsWith(".")) return undefined;
const packageName = name.startsWith("@")
? name.split("/").slice(0, 2).join("/")
: (name.split("/")[0] as string);
const subpath = name.slice(packageName.length).replace(/^\//, "");
const require = createRequire(
from ?? join(this.options.root ?? this.viewsDir, "package.json"),
);
let packageJson: string;
try {
packageJson = require.resolve(`${packageName}/package.json`);
} catch {
return undefined;
}
const root = dirname(packageJson);
const candidates = [
subpath && join(root, subpath),
subpath && join(root, `${subpath}.spug`),
join(root, "component.spug"),
join(root, "index.spug"),
].filter(Boolean) as string[];
return candidates.find(
(candidate) => existsSync(candidate) && statSync(candidate).isFile(),
);
}
private async attachComponentModule(
path: string,
html: string,
): Promise<string> {
const modulePath = path.replace(/\.spug$/, ".module.stcss");
if (!existsSync(modulePath)) return html;
const module = await compileStcssModule(
await readFile(modulePath, "utf8"),
{
filename: modulePath,
root: this.options.root,
hooks: this.options.hooks,
},
);
const scopedHtml = html.replace(
/\bclass=(['"])([^'"]*)\1/g,
(_all, quote: string, value: string) => {
const classes = value
.split(/\s+/)
.map((name) => module.classes[name] ?? name)
.join(" ");
return `class=${quote}${classes}${quote}`;
},
);
return `${styleTag(module.css)}${scopedHtml}`;
}
private createRuntime(
compiled: CompiledTemplate,
locals: Record<string, unknown>,
@ -490,7 +556,12 @@ export class TemplateEngine {
renderComponent: async (name, props) => {
const definition = this.components.get(name);
if (!definition) {
return this.renderComponent(name, props, renderContext);
return this.renderComponent(
name,
props,
renderContext,
compiled.path,
);
}
if (definition.deferred) {
deferredCount += 1;
@ -552,9 +623,17 @@ export class TemplateEngine {
const css = await Promise.all(
styles.map(async (style) => {
const path = resolve(this.componentsDir, style);
return existsSync(path)
? compileCss(await readFile(path, "utf8"))
: compileCss(style);
if (!existsSync(path)) return compileCss(style);
const source = await readFile(path, "utf8");
return path.endsWith(".module.stcss")
? (
await compileStcssModule(source, {
filename: path,
root: this.options.root,
hooks: this.options.hooks,
})
).css
: compileCss(source, { hooks: this.options.hooks });
}),
);
return `${css.filter(Boolean).map(styleTag).join("")}${html}`;

View file

@ -7,6 +7,7 @@ import type {
IntegrationContext,
} from "../contracts/index.ts";
import { compileCss, type CompileOptions } from "./css/index.ts";
import { compileStcssModule } from "./css/modules.ts";
import { markdownLoader } from "./markdown.ts";
import { sassLoader, type SassLoaderOptions } from "./css/sass.ts";
@ -85,13 +86,22 @@ export function stcssIntegration(
const dir = resolve(context.config.root, options.dir ?? "styles");
if (!existsSync(dir)) return;
const files = (await readdir(dir)).filter((file) =>
file.endsWith(".stcss"),
);
const files = await stcssFiles(dir);
const parts: string[] = [];
for (const file of files.sort()) {
const source = await readFile(join(dir, file), "utf8");
parts.push(compileCss(source, options));
const path = join(dir, file);
const source = await readFile(path, "utf8");
parts.push(
file.endsWith(".module.stcss")
? (
await compileStcssModule(source, {
...options,
filename: path,
root: context.config.root,
})
).css
: compileCss(source, options),
);
}
const css = parts.join("\n");
@ -110,6 +120,17 @@ export function stcssIntegration(
};
}
async function stcssFiles(dir: string, prefix = ""): Promise<string[]> {
const files: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const relative = `${prefix}${entry.name}`;
if (entry.isDirectory())
files.push(...(await stcssFiles(join(dir, entry.name), `${relative}/`)));
else if (entry.name.endsWith(".stcss")) files.push(relative);
}
return files.sort();
}
/** Registers Sass files as loaders and serves their compiled CSS alongside STCSS. */
export function sassIntegration(options: SassLoaderOptions = {}): Integration {
const route = "/_muddle/sass.css";