@mr_hugo/boredom
Version:
The LLM-First JavaScript Framework
542 lines (468 loc) ⢠16.7 kB
JavaScript
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline");
const { validateHtml } = require("../src/validator");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (query) =>
new Promise((resolve) => rl.question(query, resolve));
const resolveBooleanFlag = (args, truthyFlag, falsyFlag) => {
const hasTruthy = args.includes(truthyFlag);
const hasFalsy = args.includes(falsyFlag);
if (hasTruthy && hasFalsy) {
throw new Error(`Conflicting flags: ${truthyFlag} and ${falsyFlag}`);
}
if (hasTruthy) return true;
if (hasFalsy) return false;
return null;
};
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!["init", "validate", "component"].includes(command)) {
console.log("Usage: npx @mr_hugo/boredom init [directory]");
console.log(" options: --inline | --no-inline");
console.log(" --experimental-multi (enables Vite multi-file scaffold; experimental)");
console.log(" --vite | --no-vite (legacy flags, require --experimental-multi)");
console.log(" npx @mr_hugo/boredom validate [index.html]");
console.log(" npx @mr_hugo/boredom component <name> --experimental-multi");
process.exit(1);
}
if (command === "validate") {
const target = args[1] || path.join(process.cwd(), "index.html");
if (!fs.existsSync(target)) {
console.error(`File not found: ${target}`);
process.exit(1);
}
const html = fs.readFileSync(target, "utf8");
const warnings = validateHtml(html);
if (!warnings.length) {
console.log("No issues found.");
process.exit(0);
}
warnings.forEach((warning) => {
const loc = warning.line ? ` (line ${warning.line}:${warning.col})` : "";
const level = warning.severity ? warning.severity.toUpperCase() : "WARNING";
console.log(`${level} ${warning.code}: ${warning.message}${loc}`);
if (warning.suggestion) {
console.log(` Suggestion: ${warning.suggestion}`);
}
if (warning.example) {
console.log(` Example: ${warning.example}`);
}
});
process.exit(0);
}
if (command === "component") {
const componentArgs = args.slice(1);
const componentName = componentArgs.find((arg) => !arg.startsWith("--"));
const experimentalMulti = componentArgs.includes("--experimental-multi");
if (!componentName) {
console.error("Usage: npx @mr_hugo/boredom component <name> --experimental-multi");
process.exit(1);
}
if (!experimentalMulti) {
console.error(
"The component command is for the experimental multi-file workflow. Re-run with --experimental-multi.",
);
process.exit(1);
}
await generateComponent(componentName);
rl.close();
return;
}
const initArgs = args.slice(1);
const inlineFlag = resolveBooleanFlag(initArgs, "--inline", "--no-inline");
const viteFlag = resolveBooleanFlag(initArgs, "--vite", "--no-vite");
const experimentalMulti = initArgs.includes("--experimental-multi");
if (viteFlag === true && !experimentalMulti) {
console.error(
"The Vite scaffold is experimental. Re-run with --experimental-multi (or omit --vite for single-file).",
);
process.exit(1);
}
if (viteFlag === false && experimentalMulti) {
console.error("Conflicting flags: --experimental-multi cannot be combined with --no-vite.");
process.exit(1);
}
let targetDir = initArgs.find((arg) => !arg.startsWith("--"));
if (!targetDir) {
targetDir =
(await question("Project directory (default: my-boredom-app): ")) ||
"my-boredom-app";
}
const root = path.resolve(process.cwd(), targetDir);
if (fs.existsSync(root)) {
const overwrite = await question(
`Directory "${targetDir}" already exists. Overwrite? (y/N): `,
);
if (overwrite.toLowerCase() !== "y") {
console.log("Aborted.");
rl.close();
process.exit(0);
}
} else {
fs.mkdirSync(root, { recursive: true });
}
const doInline =
inlineFlag ??
(
await question("Inline boreDOM runtime into index.html? (y/N): ")
).toLowerCase() === "y";
const useVite = experimentalMulti || viteFlag === true;
console.log(`\nšļø Scaffolding boreDOM project in ${root}...`);
if (useVite) {
await setupViteProject(root, doInline);
} else {
await setupSingleFileProject(root, doInline);
}
console.log("\nā
Done! To get started:");
console.log(`\n cd ${targetDir}`);
if (useVite) {
console.log(" # Experimental multi-file development workflow");
console.log(" npm install");
console.log(" npm run dev");
} else {
console.log(" open index.html (or serve it with any static server)");
}
console.log();
rl.close();
}
async function setupViteProject(root, doInline) {
console.log("š¦ Setting up experimental Vite multi-file project...");
// Create directory structure
fs.mkdirSync(path.join(root, "components", "ui"), { recursive: true });
fs.mkdirSync(path.join(root, "components", "layout"), { recursive: true });
// Copy boreDOM runtime
const boreDomSrc = path.resolve(__dirname, "../src/boreDOM.js");
fs.copyFileSync(boreDomSrc, path.join(root, "boreDOM.js"));
// Create package.json
const packageJson = {
name: path.basename(root),
version: "1.0.0",
private: true,
scripts: {
dev: "vite",
build: "vite build",
preview: "vite preview"
},
devDependencies: {
"@mr_hugo/vite-plugin-boredom": "^0.1.0",
vite: "^5.0.0"
}
};
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify(packageJson, null, 2)
);
// Create vite.config.js
const viteConfig = `import { defineConfig } from 'vite';
import { boredomPlugin } from '@mr_hugo/vite-plugin-boredom';
export default defineConfig({
plugins: [
boredomPlugin({
inlineRuntime: ${doInline},
validateComponents: true,
optimizeStyles: true
})
],
build: {
rollupOptions: {
input: {
main: 'index.html'
},
output: {
manualChunks: undefined,
inlineDynamicImports: true
}
},
cssCodeSplit: false
},
server: {
port: 3000,
open: true
}
});`;
fs.writeFileSync(path.join(root, "vite.config.js"), viteConfig);
// Create example component
const buttonComponent = `export const metadata = {
name: 'ui-button',
version: '1.0.0',
dependencies: [],
props: ['variant', 'label'],
events: ['click']
};
export const style = \`
@layer components.ui-button {
ui-button button {
font: inherit;
border: none;
border-radius: 6px;
padding: 8px 16px;
cursor: pointer;
background: #007bff;
color: white;
}
ui-button button:hover {
background: #0056b3;
}
}
\`;
export const template = \`
<button
data-dispatch="click"
data-text="local.label || 'Button'"
></button>
\`;
export const logic = ({ on, local }) => {
local.label = local.label || 'Button';
local.variant = local.variant || 'primary';
on('click', ({ e }) => {
e.dispatcher.dispatchEvent(new CustomEvent('ui-button:click', {
bubbles: true,
detail: { variant: local.variant }
}));
});
};`;
fs.writeFileSync(
path.join(root, "components", "ui", "Button.js"),
buttonComponent
);
// Create main.js
const mainJs = `import { loadComponent } from '@mr_hugo/vite-plugin-boredom/component-loader';
async function initApp() {
const Button = await import('./components/ui/Button.js');
await loadComponent(Button);
console.log('Multi-file boreDOM app initialized');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}`;
fs.writeFileSync(path.join(root, "main.js"), mainJs);
// Create index.html
const indexHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>boreDOM Multi-File App</title>
<style>
:root {
font-family: system-ui, -apple-system, sans-serif;
background: #f8fafc;
color: #1e293b;
}
body {
margin: 0;
padding: 2rem;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<script id="initial-state" type="application/json">
{ "user": { "name": "Developer" } }
</script>
<div class="container">
<h1>Multi-File boreDOM App</h1>
<p>This app uses multi-file components that get bundled into a single HTML file.</p>
<ui-button label="Click me!"></ui-button>
</div>
<script type="module" src="./main.js"></script>
<script src="./boreDOM.js" data-state="#initial-state"></script>
</body>
</html>`;
fs.writeFileSync(path.join(root, "index.html"), indexHtml);
}
async function setupSingleFileProject(root, doInline) {
console.log("š¦ Setting up single-file project...");
const boreDomSrc = path.resolve(__dirname, "../src/boreDOM.js");
const scaffoldTemplatePath = path.resolve(__dirname, "../src/scaffold.html");
const scaffoldRuntimeTag = ` <script src="./boreDOM.js" data-state="#initial-state"></script>`;
let scriptTag;
if (doInline) {
console.log("š¦ Inlining Runtime...");
const runtimeContent = fs.readFileSync(boreDomSrc, "utf8").trimEnd();
scriptTag = [
` <script data-state="#initial-state">`,
runtimeContent,
` </script>`,
].join("\n");
} else {
console.log("š¦ Copying Runtime...");
fs.copyFileSync(boreDomSrc, path.join(root, "boreDOM.js"));
scriptTag = scaffoldRuntimeTag;
}
// 2. Create index.html (The Single File App)
console.log("š Creating index.html...");
const scaffoldTemplate = fs.readFileSync(scaffoldTemplatePath, "utf8");
if (!scaffoldTemplate.includes(scaffoldRuntimeTag)) {
throw new Error(
`Scaffold template is missing runtime marker: ${scaffoldTemplatePath}`,
);
}
const indexHtml = scaffoldTemplate.replace(scaffoldRuntimeTag, scriptTag);
fs.writeFileSync(path.join(root, "index.html"), indexHtml);
// 3. Create AGENTS.md (Context for LLMs)
console.log("š¤ Creating AGENTS.md...");
const q = String.fromCharCode(96);
const q3 = q + q + q;
const agentsGuide = [
"# boreDOM Lite Framework Guidelines",
"",
"boreDOM is a single-file, zero-build JavaScript runtime.",
doInline
? "The runtime is inlined in <script data-state=\"#initial-state\">. Do not remove it."
: "The runtime is loaded from ./boreDOM.js. Do not remove it.",
"",
"## Validation Loop (CLI)",
"",
"- Run: `npx @mr_hugo/boredom validate index.html`.",
"- Fix issues, then re-run until clean.",
"- Warnings are guidance; do not sacrifice semantics/UX to silence them.",
"",
"## Complexity Guardrails",
"",
"- Do NOT eject from boreDOM by default.",
"- For complex apps, use a hybrid: keep UI/state declarative, keep real-time engines imperative inside onMount.",
"- If the user explicitly wants no framework, ask before switching.",
"",
"## Incremental Delivery",
"",
"- Build the smallest working slice first.",
"- Verify behavior, then add features one at a time.",
"",
"## Non-Negotiables",
"",
"- All app code stays in index.html.",
"- No build tools or npm installs.",
"- Keep the boreDOM runtime script tag at the end of <body>.",
"",
"## Component Triad",
"",
"- <style data-component>, <template data-component>, <script type=\"text/boredom\" data-component>",
"",
"## Directives Cheatsheet",
"",
"- data-text=\"expr\"",
"- data-show=\"expr\"",
"- data-value=\"assignableExpr\" (auto two-way; use data-dispatch-input/change for side effects)",
"- data-checked=\"expr\"",
"- data-class=\"className:expr; other:expr\"",
"- data-ref=\"name\" -> refs.name",
"- data-dispatch / data-dispatch-<event>",
"- data-list=\"expr\" or \"alias in expr\" + <template data-item> + data-list-key (or data-list-once)",
"- data-arg-foo=\"expr\" -> e.args.foo",
"- data-attr-foo=\"expr\" (sets attribute \"foo\")",
"",
"## Events & Cleanup",
"",
"- No built-in event modifiers (no data-dispatch-stop). Use a handler:",
" on(\"stopEvent\", ({ e }) => e.event.stopPropagation())",
"- Global listeners are OK when necessary; always remove them in onCleanup.",
"- Action handlers receive { self } (component element).",
"- data-arg-* values are available in handlers as e.args.<name>.",
"- Guard keyboard shortcuts against editable targets (input/textarea/select/contenteditable).",
"",
"## Common Patterns",
"",
"- Async init: onMount(async () => { /* await load */ })",
"- Animation loops: keep rAF id and cancel in onCleanup.",
"",
"## Lists",
"",
"- <template data-item> must exist inside the list element (it may be nested).",
"- Nested lists are supported; use aliases (e.g. group in state.groups, item in group.items).",
"- Use data-list-key for stable DOM; use data-list-once for static lists.",
"",
"## Styles",
"",
"- No data-style directive. Prefer CSS classes + data-class.",
"- Use data-attr-style for simple dynamic inline styles.",
"- Prefix selectors with the component tag (e.g. app-shell .card).",
"- Prefer CSS Layers (@layer) to keep component styles reusable.",
"",
"## Debugging",
"",
"- window.__BOREDOM__.getState(), .inspect(el), .query(selector), window.__RESET_APP__()",
"- use the developer tools MCP when available",
"- Use browser tools MCP to check the console for errors.",
"- Open index.html in a browser. No build step required.",
"",
"## Example: Scoped Global Key Handler",
"",
q3 + "js",
"export default ({ onMount, onCleanup, self }) => {",
" const onKey = (e) => {",
" const path = e.composedPath ? e.composedPath() : [e.target];",
" const tag = e.target && e.target.tagName;",
" const isEditable = path.some(el => el && el.isContentEditable) ||",
" (tag && [\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(tag));",
" if (isEditable) return;",
" if (self && !path.includes(self)) return;",
" // handle key",
" };",
" document.addEventListener(\"keydown\", onKey);",
" onCleanup(() => document.removeEventListener(\"keydown\", onKey));",
"};",
q3,
];
fs.writeFileSync(path.join(root, "AGENTS.md"), agentsGuide.join("\n"));
fs.writeFileSync(path.join(root, "CLAUDE.md"), agentsGuide.join("\n"));
}
async function generateComponent(componentName) {
const componentDir = path.join(process.cwd(), "components");
if (!fs.existsSync(componentDir)) {
console.error(
"No components directory found. The component command only works in an experimental multi-file project.",
);
process.exit(1);
}
// Convert kebab-case to PascalCase for file name
const fileName = componentName.split('-').map(part =>
part.charAt(0).toUpperCase() + part.slice(1)
).join('');
const filePath = path.join(componentDir, "ui", `${fileName}.js`);
if (fs.existsSync(filePath)) {
const overwrite = await question(`Component ${fileName} already exists. Overwrite? (y/N): `);
if (overwrite.toLowerCase() !== "y") {
console.log("Aborted.");
return;
}
}
const componentTemplatePath = path.resolve(
__dirname,
"../src/component.template.js",
);
const componentTemplate = fs.readFileSync(componentTemplatePath, "utf8");
const token = "__COMPONENT_NAME__";
if (!componentTemplate.includes(token)) {
throw new Error(
`Component template is missing placeholder "${token}": ${componentTemplatePath}`,
);
}
const renderedComponent = componentTemplate.split(token).join(componentName);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, renderedComponent);
console.log(`ā
Component created: ${filePath}`);
console.log(`\nTo use it:`);
console.log(`1. Import: const ${fileName} = await import('./components/ui/${fileName}.js');`);
console.log(`2. Load: await loadComponent(${fileName});`);
console.log(`3. Use: <${componentName}></${componentName}>`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});