Repo automation and scrolltrans/viewtrans APIS

This commit is contained in:
Hellings 2026-10-23 09:04:18 -04:00
parent 743eb6a8e8
commit 6ce485f6d3
23 changed files with 780 additions and 603 deletions

View file

@ -1,7 +1,9 @@
# muddle
Forgejo installation and isolated integration module instructions are in [docs/install.md](docs/install.md).
> [!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.
Muddle is a Muddle Dark Wet Dub SSG.

29
docs/install.md Normal file
View file

@ -0,0 +1,29 @@
# Install Muddle from Forgejo
Install the framework directly from the Muddle Forgejo repository.
```sh
bun add muddle@git+https://git.crit.rip/mdl/muddle.git
```
```sh
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.
```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
```

View file

@ -1,60 +1,2 @@
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);
},
};
}
/** Compatibility export. The implementation lives in the isolated package. */
export { shadcnIntegration, type ShadcnIntegrationOptions } from "../modules/shadcn/src/integration.ts";

View file

@ -2,4 +2,4 @@
{%- 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>
</button>

View file

@ -29,4 +29,4 @@
</div>
{%- endif -%}
</div>
</div>
</div>

View file

@ -1 +1 @@
<span class="deanchor">{{ text | striptags }}</span>
<span class="deanchor">{{ text | striptags }}</span>

View file

@ -12,4 +12,4 @@
{%- endfor -%}
</div>
{%- endif -%}
</div>
</div>

View file

@ -1,4 +1,4 @@
<div class="fuse">
<input type="text" class="fuse-input" placeholder="Search...">
<div class="fuse-results"></div>
</div>
</div>

View file

@ -7,4 +7,4 @@
<input type="text" class="search-fuse-input" placeholder="Search..." autocomplete="off">
</div>
<div class="search-fuse-results"></div>
</div>
</div>

View file

@ -1,4 +1,4 @@
<a id="skip-to-content" class="skip-to-content-link" href="#main-content" tabindex="0">
{%- set icon = load_data(path="icons/phosphor/" ~ "arrow-up-right" ~ ".svg", required=false) -%}
{%- if icon -%}{{ icon | safe }}{{ else }}Skip to content{{ end }}
</a>
</a>

View file

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

View file

@ -0,0 +1,50 @@
import { mkdir, writeFile } from "node:fs/promises";
import { basename, extname, join, resolve } from "node:path";
import { cssToStcss, reactToSpug, type Integration, type IntegrationContext } from "muddle";
export interface ShadcnIntegrationOptions {
components: string[];
registry?: string;
dir?: string;
fetch?: (input: string) => Promise<Response>;
}
interface RegistryItem {
files?: { path: string; content: string; type?: string }[];
registryDependencies?: string[];
}
/** Pulls ShadCN registry entries and writes their Muddle ports into the host project. */
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);
},
};
}

View file

@ -13,7 +13,8 @@
"test": "bun test ./tests",
"lint": "bunx eslint . --ignore-pattern node_modules --ignore-pattern ametrine --ignore-pattern .opencode --ignore-pattern dist --ignore-pattern .git --ignore-pattern .cache --ignore-pattern .next --ignore-pattern .turbo --ignore-pattern .vercel --ignore-pattern .vscode --ignore-pattern .idea",
"format": "bun run cli.ts fmt .",
"muddle": "bun run cli.ts"
"muddle": "bun run cli.ts",
"forgejo:modules": "bun scripts/forgejo-modules.ts --dry-run"
},
"bin": {
"muddle": "./cli.ts"

View file

@ -1,10 +1,8 @@
extends ./layout.spug
props
title: string
nav: { label: string, href: string }[]
posts: { id: string, title: string, excerpt: string }[]
block content
section.primary-section@hero()
h1= title
@ -17,7 +15,6 @@ block content
div.line
component amezing-article-list
pages: posts.map((post) => ({ title: post.title, href: `/posts/${post.id}`, description: post.excerpt }))
$$$
assertExists("h1")
assertExists(".article-list")

View file

@ -2,7 +2,6 @@ props
title: string
nav: { label: string, href: string }[]
currentPath?: string
component amezing-base
title: title
siteTitle: title

View file

@ -1,10 +1,8 @@
extends ./layout.spug
props
title: string
nav: { label: string, href: string }[]
post: { id: string, title: string, excerpt: string } | null
block content
if post
article@post()
@ -24,7 +22,6 @@ block content
section.primary-section
h1 Not found
p The post does not exist.
$$$
assertExists("article .card-title")
assertText("article h1", "Test1")

View file

@ -1,28 +1,28 @@
main#main-content {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.25rem 3rem;
max-width: 72rem;
margin: 0 auto;
padding: 0 1.25rem 3rem;
}
section.primary-section {
max-width: 48rem;
padding: 4rem 0 2rem;
max-width: 48rem;
padding: 4rem 0 2rem;
h1 {
margin: 0.75rem 0 0;
}
h1 {
margin: 0.75rem 0 0;
}
p {
max-width: 36rem;
}
p {
max-width: 36rem;
}
@predict mobile max-width max-height transform;
@predict mobile max-width max-height transform;
}
section .card {
border-radius: concentric 0.75rem;
border-radius: concentric 0.75rem;
}
.hero-blob {
background-image: freeform-gradient(#7c3aed 10% 50%, #f00 70% 40%);
background-image: freeform-gradient(#7c3aed 10% 50%, #f00 70% 40%);
}

View file

@ -1,5 +1,4 @@
extends ./layout.spug
props
title: string
nav: { id: string, label: string, href: string, active?: boolean }[]
@ -9,13 +8,11 @@ props
prose: string | null
features: { label: string, href: string }[]
current?: string
block content
section.demo-feature
h1= label
if prose
p.prose= prose
nav(aria-label="Feature routes")
each feature in features
a(href=feature.href aria-current=feature.href === current ? 'page' : '')= feature.label

View file

@ -1,5 +1,4 @@
extends ./layout.spug
props
title: string
nav: { id: string, label: string, href: string, active?: boolean }[]
@ -7,12 +6,10 @@ props
ribbonStyle?: string
intro: { id: string, label: string, note: string, tint: string }[]
features: { label: string, href: string }[]
block content
section.demo-hero
h1= title
p
section.demo-intro(aria-label="Intro scene")
div.demo-intro__stage@introstage()
ul.demo-intro__topics(role="tablist")
@ -25,7 +22,6 @@ block content
each step in intro
p.demo-intro__note@intropane(step.id)()
= step.note
section.demo-meter.demo-meter--one
i
section.demo-meter.demo-meter--two

View file

@ -10,6 +10,5 @@ html(lang="en")
component navbar
nav: nav
albums: albums
main.page
block content

View file

@ -1,7 +1,6 @@
props
nav: { id: string, label: string, href: string, active?: boolean, detail: string }[]
albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[]
nav.navbar()
div.navbar__row@navbar()
a.navbar__brand(href="/") reactor

File diff suppressed because it is too large Load diff

113
scripts/forgejo-modules.ts Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bun
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const moduleNames = [
"amezing",
"analysis",
"astro",
"database",
"latex",
"mdx",
"optimize",
"prettier-plugin",
"shadcn",
"tailwind",
] as const;
const root = resolve(import.meta.dir, "..");
const api = (process.env.MUDDLE_FORGEJO_URL ?? "https://git.crit.rip").replace(
/\/$/,
"",
);
const owner = process.env.MUDDLE_FORGEJO_OWNER ?? "mdl";
const dryRun = process.argv.includes("--dry-run");
const token = process.env.MUDDLE_FORGEJO_TOKEN;
if (!dryRun && !token) {
throw new Error(
"Set MUDDLE_FORGEJO_TOKEN or use --dry-run.",
);
}
async function forgejo(
path: string,
init: RequestInit = {},
): Promise<Response> {
if (dryRun) return new Response(null, { status: 204 });
return fetch(`${api}/api/v1${path}`, {
...init,
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
...init.headers,
},
});
}
async function ensureWorkspaceModule(name: string): Promise<void> {
const dir = join(root, "modules", name);
await mkdir(dir, { recursive: true });
const manifest = join(dir, "package.json");
if (!existsSync(manifest)) {
await writeFile(
manifest,
`${
JSON.stringify(
{
name: `@muddle/${name}`,
version: "0.1.0",
type: "module",
exports: "./index.ts",
peerDependencies: { muddle: "*" },
},
null,
2,
)
}\n`,
);
await writeFile(join(dir, "index.ts"), "export {};\n");
}
const parsed = JSON.parse(await readFile(manifest, "utf8")) as {
name?: string;
};
if (parsed.name !== `@muddle/${name}`) {
throw new Error(`${manifest} must declare @muddle/${name}`);
}
}
async function ensureRemoteRepository(name: string): Promise<void> {
const response = await forgejo(`/repos/${owner}/${name}`);
if (response.status === 200 || response.status === 204) return;
if (response.status !== 404) {
throw new Error(
`Unable to inspect ${owner}/${name}, Forgejo returned ${response.status}`,
);
}
const created = await forgejo(`/orgs/${owner}/repos`, {
method: "POST",
body: JSON.stringify({
name,
private: false,
description: `@muddle/${name} integration module`,
}),
});
if (!created.ok) {
throw new Error(
`Unable to create ${owner}/${name}, Forgejo returned ${created.status}`,
);
}
}
for (const name of moduleNames) {
await ensureWorkspaceModule(name);
await ensureRemoteRepository(name);
console.log(
`${
dryRun
? "would provision"
: "provisioned"
} @muddle/${name} at ${api}/${owner}/${name}`,
);
}