Subproject update
This commit is contained in:
parent
8d0d645487
commit
30d9ad05ac
11 changed files with 478 additions and 37 deletions
17
dbg.mjs
Normal file
17
dbg.mjs
Normal 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
18
dbg2.mjs
Normal 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
19
dbg3.mjs
Normal 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
94
deno.lock
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
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
|
```sh
|
||||||
bun add @muddle/shadcn@git+https://git.crit.rip/mdl/shadcn.git
|
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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"./contracts": "./contracts/index.ts",
|
"./contracts": "./contracts/index.ts",
|
||||||
"./contracts/*": "./contracts/*",
|
"./contracts/*": "./contracts/*",
|
||||||
"./metadata": "./core/metadata.ts",
|
"./metadata": "./core/metadata.ts",
|
||||||
|
"./decorators": "./decorators/index.ts",
|
||||||
"./markdown": "./views/markdown.ts",
|
"./markdown": "./views/markdown.ts",
|
||||||
"./views": "./views/api.ts",
|
"./views": "./views/api.ts",
|
||||||
"./views/integrations": "./views/integrations.ts",
|
"./views/integrations": "./views/integrations.ts",
|
||||||
|
|
|
||||||
72
tests/stcss-modules.test.ts
Normal file
72
tests/stcss-modules.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,12 @@ export { compileCss, type CompileOptions } from "./compiler.ts";
|
||||||
export { evaluate, darken, lighten, rgba } from "./evaluate.ts";
|
export { evaluate, darken, lighten, rgba } from "./evaluate.ts";
|
||||||
export { parseCss } from "./parser.ts";
|
export { parseCss } from "./parser.ts";
|
||||||
export { sassLoader, type SassLoaderOptions } from "./sass.ts";
|
export { sassLoader, type SassLoaderOptions } from "./sass.ts";
|
||||||
|
export {
|
||||||
|
compileStcssModule,
|
||||||
|
resolveStyle,
|
||||||
|
type StcssModule,
|
||||||
|
type StcssModuleOptions,
|
||||||
|
} from "./modules.ts";
|
||||||
|
|
||||||
export interface StcssLoaderOptions extends CompileOptions {
|
export interface StcssLoaderOptions extends CompileOptions {
|
||||||
/** Directory the loader scans, relative to the project root. */
|
/** Directory the loader scans, relative to the project root. */
|
||||||
|
|
|
||||||
138
views/css/modules.ts
Normal file
138
views/css/modules.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { compileTemplate } from "./compiler.ts";
|
import { compileTemplate } from "./compiler.ts";
|
||||||
import { lex } from "./lexer.ts";
|
import { lex } from "./lexer.ts";
|
||||||
import { collectProps, parse } from "./parser.ts";
|
import { collectProps, parse } from "./parser.ts";
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
type SpugComponentRender,
|
type SpugComponentRender,
|
||||||
} from "../components.ts";
|
} from "../components.ts";
|
||||||
import { compileCss, styleTag } from "../css/index.ts";
|
import { compileCss, styleTag } from "../css/index.ts";
|
||||||
|
import { compileStcssModule } from "../css/modules.ts";
|
||||||
import { isRaw, raw, styleValue } from "../raw.ts";
|
import { isRaw, raw, styleValue } from "../raw.ts";
|
||||||
import type { DeferredChunk } from "../../contracts/typegen/http/response.ts";
|
import type { DeferredChunk } from "../../contracts/typegen/http/response.ts";
|
||||||
import type { MuddleHooks } from "../hooks.ts";
|
import type { MuddleHooks } from "../hooks.ts";
|
||||||
|
|
@ -411,8 +413,9 @@ export class TemplateEngine {
|
||||||
name: string,
|
name: string,
|
||||||
props: Record<string, unknown>,
|
props: Record<string, unknown>,
|
||||||
context?: Partial<RenderContext>,
|
context?: Partial<RenderContext>,
|
||||||
|
from?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const path = this.resolveComponent(name);
|
const path = this.resolveComponent(name, from);
|
||||||
const compiled = await this.load(path);
|
const compiled = await this.load(path);
|
||||||
props = { ...props };
|
props = { ...props };
|
||||||
this.fillLocals(compiled, props);
|
this.fillLocals(compiled, props);
|
||||||
|
|
@ -423,7 +426,8 @@ export class TemplateEngine {
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
await this.runFrontmatter(compiled, props, runtime);
|
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. */
|
/** 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>`;
|
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;
|
if (isAbsolute(name) && existsSync(name)) return name;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
...(from && name.startsWith(".")
|
||||||
|
? [resolve(dirname(from), name), resolve(dirname(from), `${name}.spug`)]
|
||||||
|
: []),
|
||||||
join(this.componentsDir, `${name}.spug`),
|
join(this.componentsDir, `${name}.spug`),
|
||||||
join(this.componentsDir, name),
|
join(this.componentsDir, name),
|
||||||
join(this.viewsDir, `${name}.spug`),
|
join(this.viewsDir, `${name}.spug`),
|
||||||
|
|
@ -450,9 +457,68 @@ export class TemplateEngine {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const packagePath = this.resolvePackageComponent(name, from);
|
||||||
|
if (packagePath) return packagePath;
|
||||||
throw new Error(`Component not found: ${name}`);
|
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(
|
private createRuntime(
|
||||||
compiled: CompiledTemplate,
|
compiled: CompiledTemplate,
|
||||||
locals: Record<string, unknown>,
|
locals: Record<string, unknown>,
|
||||||
|
|
@ -490,7 +556,12 @@ export class TemplateEngine {
|
||||||
renderComponent: async (name, props) => {
|
renderComponent: async (name, props) => {
|
||||||
const definition = this.components.get(name);
|
const definition = this.components.get(name);
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
return this.renderComponent(name, props, renderContext);
|
return this.renderComponent(
|
||||||
|
name,
|
||||||
|
props,
|
||||||
|
renderContext,
|
||||||
|
compiled.path,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (definition.deferred) {
|
if (definition.deferred) {
|
||||||
deferredCount += 1;
|
deferredCount += 1;
|
||||||
|
|
@ -552,9 +623,17 @@ export class TemplateEngine {
|
||||||
const css = await Promise.all(
|
const css = await Promise.all(
|
||||||
styles.map(async (style) => {
|
styles.map(async (style) => {
|
||||||
const path = resolve(this.componentsDir, style);
|
const path = resolve(this.componentsDir, style);
|
||||||
return existsSync(path)
|
if (!existsSync(path)) return compileCss(style);
|
||||||
? compileCss(await readFile(path, "utf8"))
|
const source = await readFile(path, "utf8");
|
||||||
: compileCss(style);
|
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}`;
|
return `${css.filter(Boolean).map(styleTag).join("")}${html}`;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
IntegrationContext,
|
IntegrationContext,
|
||||||
} from "../contracts/index.ts";
|
} from "../contracts/index.ts";
|
||||||
import { compileCss, type CompileOptions } from "./css/index.ts";
|
import { compileCss, type CompileOptions } from "./css/index.ts";
|
||||||
|
import { compileStcssModule } from "./css/modules.ts";
|
||||||
import { markdownLoader } from "./markdown.ts";
|
import { markdownLoader } from "./markdown.ts";
|
||||||
import { sassLoader, type SassLoaderOptions } from "./css/sass.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");
|
const dir = resolve(context.config.root, options.dir ?? "styles");
|
||||||
if (!existsSync(dir)) return;
|
if (!existsSync(dir)) return;
|
||||||
|
|
||||||
const files = (await readdir(dir)).filter((file) =>
|
const files = await stcssFiles(dir);
|
||||||
file.endsWith(".stcss"),
|
|
||||||
);
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const file of files.sort()) {
|
for (const file of files.sort()) {
|
||||||
const source = await readFile(join(dir, file), "utf8");
|
const path = join(dir, file);
|
||||||
parts.push(compileCss(source, options));
|
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");
|
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. */
|
/** Registers Sass files as loaders and serves their compiled CSS alongside STCSS. */
|
||||||
export function sassIntegration(options: SassLoaderOptions = {}): Integration {
|
export function sassIntegration(options: SassLoaderOptions = {}): Integration {
|
||||||
const route = "/_muddle/sass.css";
|
const route = "/_muddle/sass.css";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue