@techninja/clearstack 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/bin/cli.js +62 -0
  4. package/docs/BACKEND_API_SPEC.md +281 -0
  5. package/docs/BUILD_LOG.md +193 -0
  6. package/docs/COMPONENT_PATTERNS.md +481 -0
  7. package/docs/CONVENTIONS.md +226 -0
  8. package/docs/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
  9. package/docs/JSDOC_TYPING.md +86 -0
  10. package/docs/QUICKSTART.md +190 -0
  11. package/docs/SERVER_AND_DEPS.md +163 -0
  12. package/docs/STATE_AND_ROUTING.md +363 -0
  13. package/docs/TESTING.md +268 -0
  14. package/docs/app-spec/ENTITIES.md +37 -0
  15. package/docs/app-spec/README.md +19 -0
  16. package/lib/check.js +115 -0
  17. package/lib/copy.js +43 -0
  18. package/lib/init.js +73 -0
  19. package/lib/package-gen.js +83 -0
  20. package/lib/update.js +73 -0
  21. package/package.json +69 -0
  22. package/templates/fullstack/data/seed.json +1 -0
  23. package/templates/fullstack/src/api/db.js +75 -0
  24. package/templates/fullstack/src/api/entities.js +114 -0
  25. package/templates/fullstack/src/api/events.js +35 -0
  26. package/templates/fullstack/src/api/schemas.js +104 -0
  27. package/templates/fullstack/src/api/validate.js +52 -0
  28. package/templates/fullstack/src/pages/home/home-view.js +19 -0
  29. package/templates/fullstack/src/router/index.js +16 -0
  30. package/templates/fullstack/src/server.js +46 -0
  31. package/templates/fullstack/src/store/AppState.js +33 -0
  32. package/templates/fullstack/src/store/UserPrefs.js +31 -0
  33. package/templates/fullstack/src/store/realtimeSync.js +54 -0
  34. package/templates/shared/.configs/.prettierrc +8 -0
  35. package/templates/shared/.configs/eslint.config.js +64 -0
  36. package/templates/shared/.configs/jsconfig.json +24 -0
  37. package/templates/shared/.configs/web-test-runner.config.js +8 -0
  38. package/templates/shared/.env +9 -0
  39. package/templates/shared/.github/ISSUE_TEMPLATE/bug_report.md +42 -0
  40. package/templates/shared/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
  41. package/templates/shared/.github/ISSUE_TEMPLATE/spec_correction.md +26 -0
  42. package/templates/shared/.github/pull_request_template.md +51 -0
  43. package/templates/shared/.github/workflows/spec.yml +46 -0
  44. package/templates/shared/README.md +22 -0
  45. package/templates/shared/docs/app-spec/README.md +40 -0
  46. package/templates/shared/docs/clearstack/BACKEND_API_SPEC.md +281 -0
  47. package/templates/shared/docs/clearstack/BUILD_LOG.md +193 -0
  48. package/templates/shared/docs/clearstack/COMPONENT_PATTERNS.md +481 -0
  49. package/templates/shared/docs/clearstack/CONVENTIONS.md +226 -0
  50. package/templates/shared/docs/clearstack/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
  51. package/templates/shared/docs/clearstack/JSDOC_TYPING.md +86 -0
  52. package/templates/shared/docs/clearstack/QUICKSTART.md +190 -0
  53. package/templates/shared/docs/clearstack/SERVER_AND_DEPS.md +163 -0
  54. package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +363 -0
  55. package/templates/shared/docs/clearstack/TESTING.md +268 -0
  56. package/templates/shared/public/index.html +26 -0
  57. package/templates/shared/scripts/build-icons.js +86 -0
  58. package/templates/shared/scripts/vendor-deps.js +25 -0
  59. package/templates/shared/src/components/atoms/app-badge/app-badge.css +4 -0
  60. package/templates/shared/src/components/atoms/app-badge/app-badge.js +23 -0
  61. package/templates/shared/src/components/atoms/app-badge/app-badge.test.js +26 -0
  62. package/templates/shared/src/components/atoms/app-badge/index.js +1 -0
  63. package/templates/shared/src/components/atoms/app-button/app-button.css +3 -0
  64. package/templates/shared/src/components/atoms/app-button/app-button.js +41 -0
  65. package/templates/shared/src/components/atoms/app-button/app-button.test.js +43 -0
  66. package/templates/shared/src/components/atoms/app-button/index.js +1 -0
  67. package/templates/shared/src/components/atoms/app-icon/app-icon.css +4 -0
  68. package/templates/shared/src/components/atoms/app-icon/app-icon.js +57 -0
  69. package/templates/shared/src/components/atoms/app-icon/app-icon.test.js +30 -0
  70. package/templates/shared/src/components/atoms/app-icon/index.js +1 -0
  71. package/templates/shared/src/components/atoms/theme-toggle/index.js +1 -0
  72. package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.css +10 -0
  73. package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.js +42 -0
  74. package/templates/shared/src/styles/buttons.css +79 -0
  75. package/templates/shared/src/styles/components.css +31 -0
  76. package/templates/shared/src/styles/forms.css +20 -0
  77. package/templates/shared/src/styles/reset.css +32 -0
  78. package/templates/shared/src/styles/shared.css +135 -0
  79. package/templates/shared/src/styles/tokens.css +65 -0
  80. package/templates/shared/src/utils/formatDate.js +41 -0
  81. package/templates/shared/src/utils/statusColors.js +60 -0
  82. package/templates/static/src/pages/home/home-view.js +38 -0
  83. package/templates/static/src/router/index.js +16 -0
  84. package/templates/static/src/store/AppState.js +26 -0
package/lib/check.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Spec compliance checker — validates line counts, lint, format, types, tests.
3
+ * Reads thresholds from the project's .env file.
4
+ * @module lib/check
5
+ */
6
+
7
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
+ import { resolve, extname, relative } from 'node:path';
9
+ import { execSync } from 'node:child_process';
10
+
11
+ /**
12
+ * Load spec config from project .env.
13
+ * @param {string} projectDir
14
+ */
15
+ function loadConfig(projectDir) {
16
+ const envPath = resolve(projectDir, '.env');
17
+ const env = existsSync(envPath) ? parseEnv(readFileSync(envPath, 'utf-8')) : {};
18
+ return {
19
+ codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
20
+ docsMax: parseInt(env.SPEC_DOCS_MAX_LINES) || 500,
21
+ codeExt: (env.SPEC_CODE_EXTENSIONS || '.js,.css').split(','),
22
+ docsExt: (env.SPEC_DOCS_EXTENSIONS || '.md').split(','),
23
+ ignore: (env.SPEC_IGNORE_DIRS || 'node_modules,public/vendor,.git,.configs').split(','),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Run spec compliance checks on a project directory.
29
+ * @param {string} projectDir
30
+ * @param {string} [scope] - 'code', 'docs', or undefined for full check
31
+ */
32
+ export async function check(projectDir, scope) {
33
+ const cfg = loadConfig(projectDir);
34
+
35
+ if (scope === 'code') {
36
+ if (!checkFiles(projectDir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`))
37
+ process.exit(1);
38
+ return;
39
+ }
40
+ if (scope === 'docs') {
41
+ if (!checkFiles(projectDir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`))
42
+ process.exit(1);
43
+ return;
44
+ }
45
+
46
+ console.log('Running spec compliance check...\n');
47
+ const results = [
48
+ checkFiles(projectDir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`),
49
+ checkFiles(projectDir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`),
50
+ runCmd('ESLint', 'npx eslint --config .configs/eslint.config.js .', projectDir),
51
+ runCmd('Prettier', 'npx prettier --config .configs/.prettierrc --check src', projectDir),
52
+ runCmd('JSDoc types', 'npx tsc --project .configs/jsconfig.json', projectDir),
53
+ ];
54
+
55
+ const passed = results.filter(Boolean).length;
56
+ console.log(`\n${'='.repeat(40)}`);
57
+ console.log(`${passed}/${results.length} checks passed.`);
58
+ if (passed < results.length) process.exit(1);
59
+ }
60
+
61
+ /** @param {string} src */
62
+ function parseEnv(src) {
63
+ const env = {};
64
+ for (const line of src.split('\n')) {
65
+ const m = line.match(/^([^#=]+)=(.*)$/);
66
+ if (m) env[m[1].trim()] = m[2].trim();
67
+ }
68
+ return env;
69
+ }
70
+
71
+ /** Check file line counts. */
72
+ function checkFiles(root, extensions, max, ignoreDirs, label) {
73
+ const violations = [];
74
+ findFiles(root, extensions, ignoreDirs, root).forEach((file) => {
75
+ const lines = readFileSync(resolve(root, file), 'utf-8').trimEnd().split('\n').length;
76
+ if (lines > max) violations.push({ file, lines, max });
77
+ });
78
+ if (violations.length === 0) {
79
+ console.log(` ✅ ${label}`);
80
+ return true;
81
+ }
82
+ console.log(` ❌ ${label} — ${violations.length} violation(s):`);
83
+ violations.forEach((v) => console.log(` ${v.file}: ${v.lines} lines (max ${v.max})`));
84
+ return false;
85
+ }
86
+
87
+ /** Recursively find files. */
88
+ function findFiles(dir, extensions, ignoreDirs, root) {
89
+ const results = [];
90
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
91
+ const full = resolve(dir, entry.name);
92
+ const rel = relative(root, full);
93
+ if (entry.isDirectory()) {
94
+ if (ignoreDirs.some((ig) => entry.name === ig || rel === ig || rel.startsWith(ig + '/'))) continue;
95
+ results.push(...findFiles(full, extensions, ignoreDirs, root));
96
+ } else if (extensions.includes(extname(entry.name))) {
97
+ results.push(rel);
98
+ }
99
+ }
100
+ return results;
101
+ }
102
+
103
+ /** Run a shell command, report pass/fail. */
104
+ function runCmd(label, cmd, cwd) {
105
+ try {
106
+ execSync(cmd, { cwd, stdio: 'pipe' });
107
+ console.log(` ✅ ${label}`);
108
+ return true;
109
+ } catch (err) {
110
+ console.log(` ❌ ${label}`);
111
+ const out = (err.stdout || '') + (err.stderr || '');
112
+ if (out.trim()) out.trim().split('\n').forEach((l) => console.log(` ${l}`));
113
+ return false;
114
+ }
115
+ }
package/lib/copy.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Template file copier — copies files with {{variable}} replacement.
3
+ * @module lib/copy
4
+ */
5
+
6
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+
9
+ const TEMPLATE_RE = /\{\{(\w+)\}\}/g;
10
+
11
+ /**
12
+ * Recursively copy a template directory, replacing {{vars}} in file contents.
13
+ * @param {string} templateRoot - Root templates/ directory
14
+ * @param {string} templateName - 'shared', 'fullstack', or 'static'
15
+ * @param {string} dest - Destination project directory
16
+ * @param {Record<string, string|number>} vars - Template variables
17
+ */
18
+ export async function copyTemplates(templateRoot, templateName, dest, vars) {
19
+ const src = resolve(templateRoot, templateName);
20
+ await copyDir(src, dest, vars);
21
+ }
22
+
23
+ /**
24
+ * @param {string} src
25
+ * @param {string} dest
26
+ * @param {Record<string, string|number>} vars
27
+ */
28
+ async function copyDir(src, dest, vars) {
29
+ mkdirSync(dest, { recursive: true });
30
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
31
+ const srcPath = join(src, entry.name);
32
+ const destPath = join(dest, entry.name);
33
+ if (entry.isDirectory()) {
34
+ await copyDir(srcPath, destPath, vars);
35
+ } else {
36
+ const content = readFileSync(srcPath, 'utf-8');
37
+ const replaced = content.replace(TEMPLATE_RE, (_, key) =>
38
+ vars[key] !== undefined ? String(vars[key]) : `{{${key}}}`,
39
+ );
40
+ writeFileSync(destPath, replaced);
41
+ }
42
+ }
43
+ }
package/lib/init.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Project scaffolder — generates a spec-compliant project from templates.
3
+ * @module lib/init
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { basename, resolve } from 'node:path';
8
+ import { copyTemplates } from './copy.js';
9
+ import { writePackageJson } from './package-gen.js';
10
+
11
+ /**
12
+ * @typedef {Object} InitOptions
13
+ * @property {boolean} [yes] - Skip prompts, use defaults
14
+ * @property {string} [mode] - 'fullstack' or 'static'
15
+ * @property {string} [port] - Server port
16
+ */
17
+
18
+ /**
19
+ * Run the init flow.
20
+ * @param {string} pkgRoot - Root of the clearstack package
21
+ * @param {InitOptions} [opts]
22
+ */
23
+ export async function init(pkgRoot, opts = {}) {
24
+ console.log('\n🏗️ Clearstack project scaffolder\n');
25
+
26
+ const dest = process.cwd();
27
+ const pkgPath = resolve(dest, 'package.json');
28
+ const existingPkg = existsSync(pkgPath)
29
+ ? JSON.parse(readFileSync(pkgPath, 'utf-8'))
30
+ : null;
31
+
32
+ let name, description, mode, port;
33
+
34
+ if (opts.yes) {
35
+ name = existingPkg?.name || basename(dest);
36
+ description = existingPkg?.description || 'A Clearstack project';
37
+ mode = opts.mode || 'fullstack';
38
+ port = opts.port || '3000';
39
+ } else {
40
+ const { input, select } = await import('@inquirer/prompts');
41
+ name = await input({
42
+ message: 'Project name:',
43
+ default: existingPkg?.name || basename(dest),
44
+ });
45
+ description = await input({
46
+ message: 'Description:',
47
+ default: existingPkg?.description || 'A Clearstack project',
48
+ });
49
+ mode = await select({
50
+ message: 'Project mode:',
51
+ choices: [
52
+ { name: 'Full stack (Express + WebSocket + JSON DB + SSE)', value: 'fullstack' },
53
+ { name: 'Static / browser only (localStorage, no server)', value: 'static' },
54
+ ],
55
+ });
56
+ port = mode === 'fullstack'
57
+ ? await input({ message: 'Server port:', default: '3000' })
58
+ : '3000';
59
+ }
60
+
61
+ const templateDir = resolve(pkgRoot, 'templates');
62
+ const vars = { name, description, port, mode, year: new Date().getFullYear() };
63
+
64
+ console.log(`Scaffolding ${name} (${mode}) → ${dest}\n`);
65
+
66
+ await copyTemplates(templateDir, 'shared', dest, vars);
67
+ await copyTemplates(templateDir, mode, dest, vars);
68
+ await writePackageJson(dest, vars, existingPkg);
69
+
70
+ console.log(`\n✅ Project scaffolded at ${dest}`);
71
+ console.log(' npm install');
72
+ console.log(mode === 'fullstack' ? ' npm run dev\n' : ' npx serve public\n');
73
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Generates package.json for the scaffolded project.
3
+ * @module lib/package-gen
4
+ */
5
+
6
+ import { writeFileSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+
9
+ /**
10
+ * Write a package.json tailored to the project mode.
11
+ * Merges with existing package.json if provided.
12
+ * @param {string} dest - Project directory
13
+ * @param {{ name: string, description: string, port: string, mode: string }} vars
14
+ * @param {Record<string, unknown>} [existing] - Existing package.json to merge with
15
+ */
16
+ export async function writePackageJson(dest, vars, existing) {
17
+ const isFullstack = vars.mode === 'fullstack';
18
+
19
+ const specScripts = {
20
+ ...(isFullstack ? {
21
+ start: 'node src/server.js',
22
+ dev: 'node --watch --env-file=.env src/server.js',
23
+ } : {}),
24
+ postinstall: 'node scripts/vendor-deps.js && node scripts/build-icons.js',
25
+ test: 'npm run test:node && npm run test:browser',
26
+ 'test:node': 'node --test tests/*.test.js src/utils/*.test.js src/store/*.test.js',
27
+ 'test:browser': 'web-test-runner --config .configs/web-test-runner.config.js',
28
+ spec: 'clearstack check',
29
+ 'spec:code': 'clearstack check code',
30
+ 'spec:docs': 'clearstack check docs',
31
+ 'spec:update': 'clearstack update',
32
+ lint: 'eslint --config .configs/eslint.config.js .',
33
+ 'lint:fix': 'eslint --config .configs/eslint.config.js . --fix',
34
+ format: 'prettier --config .configs/.prettierrc --write src scripts tests',
35
+ typecheck: 'tsc --project .configs/jsconfig.json',
36
+ };
37
+
38
+ const specDeps = {
39
+ hybrids: '^9.1.22',
40
+ 'lucide-static': '^1.7.0',
41
+ ...(isFullstack ? { express: '^5.2.1', ws: '^8.0.0' } : {}),
42
+ };
43
+
44
+ const specDevDeps = {
45
+ '@techninja/clearstack': '^0.2.0',
46
+ '@open-wc/testing': '^4.0.0',
47
+ '@web/test-runner': '^0.20.0',
48
+ '@web/test-runner-playwright': '^0.11.0',
49
+ eslint: '^10.1.0',
50
+ 'eslint-config-prettier': '^10.1.8',
51
+ 'eslint-plugin-jsdoc': '^62.8.1',
52
+ playwright: '^1.50.0',
53
+ prettier: '^3.8.1',
54
+ typescript: '^6.0.2',
55
+ };
56
+
57
+ const pkg = existing
58
+ ? {
59
+ ...existing,
60
+ name: vars.name,
61
+ description: vars.description,
62
+ type: 'module',
63
+ ...(isFullstack ? { main: 'src/server.js' } : {}),
64
+ scripts: { ...existing.scripts, ...specScripts },
65
+ dependencies: { ...existing.dependencies, ...specDeps },
66
+ devDependencies: { ...existing.devDependencies, ...specDevDeps },
67
+ }
68
+ : {
69
+ name: vars.name,
70
+ version: '0.1.0',
71
+ type: 'module',
72
+ description: vars.description,
73
+ ...(isFullstack ? { main: 'src/server.js' } : {}),
74
+ scripts: specScripts,
75
+ keywords: ['hybrids', 'web-components', 'no-build'],
76
+ license: 'MIT',
77
+ dependencies: specDeps,
78
+ devDependencies: specDevDeps,
79
+ };
80
+
81
+ writeFileSync(resolve(dest, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
82
+ console.log(' ✓ package.json' + (existing ? ' (merged)' : ''));
83
+ }
package/lib/update.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Spec updater — syncs docs and configs from the clearstack package.
3
+ * Copies new versions so the user can review the git diff.
4
+ * @module lib/update
5
+ */
6
+
7
+ import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
8
+ import { resolve, join } from 'node:path';
9
+
10
+ /**
11
+ * Sync a source directory to a destination, comparing file contents.
12
+ * @param {string} src
13
+ * @param {string} dest
14
+ * @param {string} label
15
+ * @returns {number} count of updated files
16
+ */
17
+ function syncDir(src, dest, label) {
18
+ if (!existsSync(src)) return 0;
19
+ mkdirSync(dest, { recursive: true });
20
+
21
+ const files = readdirSync(src).filter((f) => !f.startsWith('.spec'));
22
+ let updated = 0;
23
+
24
+ for (const file of files) {
25
+ const srcContent = readFileSync(join(src, file), 'utf-8');
26
+ const destPath = join(dest, file);
27
+ const destContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
28
+
29
+ if (srcContent !== destContent) {
30
+ writeFileSync(destPath, srcContent);
31
+ console.log(` ✓ Updated: ${label}/${file}`);
32
+ updated++;
33
+ }
34
+ }
35
+ return updated;
36
+ }
37
+
38
+ /**
39
+ * Update spec docs and configs from the clearstack package to the local project.
40
+ * @param {string} pkgRoot - Root of the clearstack package
41
+ */
42
+ export async function update(pkgRoot) {
43
+ const templateShared = resolve(pkgRoot, 'templates/shared');
44
+ let total = 0;
45
+
46
+ console.log('');
47
+
48
+ total += syncDir(
49
+ resolve(templateShared, 'docs/clearstack'),
50
+ resolve(process.cwd(), 'docs/clearstack'),
51
+ 'docs/clearstack',
52
+ );
53
+
54
+ total += syncDir(
55
+ resolve(templateShared, '.configs'),
56
+ resolve(process.cwd(), '.configs'),
57
+ '.configs',
58
+ );
59
+
60
+ // Write spec version marker
61
+ const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf-8'));
62
+ const versionPath = resolve(process.cwd(), 'docs/clearstack/.specversion');
63
+ mkdirSync(resolve(process.cwd(), 'docs/clearstack'), { recursive: true });
64
+ writeFileSync(versionPath, pkg.version + '\n');
65
+
66
+ console.log(`\n docs/app-spec/ — untouched (your project specs are safe)`);
67
+
68
+ if (total === 0) {
69
+ console.log(' ✅ All docs and configs are up to date.\n');
70
+ } else {
71
+ console.log(`\n ${total} file(s) updated. Review with: git diff docs/ .configs/\n`);
72
+ }
73
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@techninja/clearstack",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
+ "bin": {
7
+ "clearstack": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "templates/",
13
+ "docs/"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/techninja/clearstack.git"
18
+ },
19
+ "scripts": {
20
+ "start": "node src/server.js",
21
+ "dev": "node --watch --env-file=.env src/server.js",
22
+ "postinstall": "node scripts/vendor-deps.js && node scripts/build-icons.js",
23
+ "test": "npm run test:node && npm run test:browser",
24
+ "test:node": "node --test tests/*.test.js src/utils/*.test.js src/store/*.test.js",
25
+ "test:browser": "web-test-runner --config .configs/web-test-runner.config.js",
26
+ "test:watch": "web-test-runner --config .configs/web-test-runner.config.js --watch",
27
+ "spec": "node --env-file=.env scripts/spec.js",
28
+ "spec:code": "node --env-file=.env scripts/spec.js code",
29
+ "spec:docs": "node --env-file=.env scripts/spec.js docs",
30
+ "lint": "eslint --config .configs/eslint.config.js .",
31
+ "lint:fix": "eslint --config .configs/eslint.config.js . --fix",
32
+ "format": "prettier --config .configs/.prettierrc --write src scripts tests",
33
+ "format:check": "prettier --config .configs/.prettierrc --check src scripts tests",
34
+ "typecheck": "tsc --project .configs/jsconfig.json",
35
+ "sync-docs": "node scripts/sync-docs.js",
36
+ "release": "node scripts/release.js",
37
+ "release:minor": "node scripts/release.js minor",
38
+ "release:major": "node scripts/release.js major",
39
+ "prepublishOnly": "node scripts/sync-docs.js"
40
+ },
41
+ "keywords": [
42
+ "web-components",
43
+ "no-build",
44
+ "es-modules",
45
+ "specification",
46
+ "scaffold",
47
+ "hybrids"
48
+ ],
49
+ "license": "MIT",
50
+ "dependencies": {
51
+ "@inquirer/prompts": "^8.3.2"
52
+ },
53
+ "devDependencies": {
54
+ "@open-wc/testing": "^4.0.0",
55
+ "@types/ws": "^8.18.1",
56
+ "@web/test-runner": "^0.20.0",
57
+ "@web/test-runner-playwright": "^0.11.0",
58
+ "eslint": "^10.1.0",
59
+ "eslint-config-prettier": "^10.1.8",
60
+ "eslint-plugin-jsdoc": "^62.8.1",
61
+ "express": "^5.2.1",
62
+ "hybrids": "^9.1.22",
63
+ "lucide-static": "^1.7.0",
64
+ "playwright": "^1.50.0",
65
+ "prettier": "^3.8.1",
66
+ "typescript": "^6.0.2",
67
+ "ws": "^8.20.0"
68
+ }
69
+ }
@@ -0,0 +1 @@
1
+ {"example": {}}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * JSON file-backed database. In-memory with disk persistence on writes.
3
+ * Call reload() to refresh from disk after external edits.
4
+ * @module api/db
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
8
+ import { resolve, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { randomUUID } from 'node:crypto';
11
+
12
+ export { schemas } from './schemas.js';
13
+
14
+ const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../data');
15
+ const DB_PATH = resolve(DIR, 'db.json');
16
+ const SEED_PATH = resolve(DIR, 'seed.json');
17
+
18
+ let data = loadFromDisk();
19
+
20
+ /** Read the database file from disk. Seeds if missing. */
21
+ function loadFromDisk() {
22
+ mkdirSync(DIR, { recursive: true });
23
+ if (!existsSync(DB_PATH)) {
24
+ const seed = existsSync(SEED_PATH) ? readFileSync(SEED_PATH, 'utf-8') : '{}';
25
+ writeFileSync(DB_PATH, seed);
26
+ }
27
+ return JSON.parse(readFileSync(DB_PATH, 'utf-8'));
28
+ }
29
+
30
+ /** Persist current state to disk. */
31
+ function save() {
32
+ writeFileSync(DB_PATH, JSON.stringify(data, null, 2));
33
+ }
34
+
35
+ /** Reload from disk — call after external edits to db.json. */
36
+ export function reload() {
37
+ data = loadFromDisk();
38
+ }
39
+
40
+ /** @type {{ get(entity: string): Record<string, object> | undefined }} */
41
+ export const db = {
42
+ get(entity) {
43
+ return data[entity];
44
+ },
45
+ };
46
+
47
+ /** @param {string} entity @param {string} id */
48
+ export function getRecord(entity, id) {
49
+ return data[entity]?.[id];
50
+ }
51
+
52
+ /** @param {string} entity @param {string} id @param {object} value */
53
+ export function setRecord(entity, id, value) {
54
+ if (!data[entity]) data[entity] = {};
55
+ data[entity][id] = value;
56
+ save();
57
+ }
58
+
59
+ /** @param {string} entity @param {string} id @returns {boolean} */
60
+ export function deleteRecord(entity, id) {
61
+ if (!data[entity]?.[id]) return false;
62
+ delete data[entity][id];
63
+ save();
64
+ return true;
65
+ }
66
+
67
+ /** @param {string} entity @returns {object[]} */
68
+ export function listRecords(entity) {
69
+ return data[entity] ? Object.values(data[entity]) : [];
70
+ }
71
+
72
+ /** @param {object} values @returns {object} */
73
+ export function createEntity(values) {
74
+ return { ...values, id: randomUUID(), createdAt: new Date().toISOString() };
75
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Generic CRUD router for any entity registered in the schema map.
3
+ * @module api/entities
4
+ */
5
+
6
+ import { Router } from 'express';
7
+ import { schemas, getRecord, setRecord, deleteRecord, listRecords, createEntity } from './db.js';
8
+ import { broadcast } from './events.js';
9
+ import { validate } from './validate.js';
10
+
11
+ export const entityRouter = Router();
12
+
13
+ /** @param {string} plural */
14
+ const singular = (plural) => plural.replace(/s$/, '');
15
+
16
+ // --- OPTIONS: schema + methods ---
17
+
18
+ entityRouter.options('/:entity', (req, res) => {
19
+ const entry = schemas.get(req.params.entity);
20
+ if (!entry) return res.status(404).json({ error: 'Unknown entity' });
21
+ res.set('Allow', 'OPTIONS, GET, POST');
22
+ res.json({ schema: entry.schema, layout: entry.layout, methods: ['OPTIONS', 'GET', 'POST'] });
23
+ });
24
+
25
+ entityRouter.options('/:entity/:id', (req, res) => {
26
+ const entry = schemas.get(req.params.entity);
27
+ if (!entry) return res.status(404).json({ error: 'Unknown entity' });
28
+ res.set('Allow', 'OPTIONS, GET, PUT, DELETE');
29
+ res.json({
30
+ schema: entry.schema,
31
+ layout: entry.layout,
32
+ methods: ['OPTIONS', 'GET', 'PUT', 'DELETE'],
33
+ });
34
+ });
35
+
36
+ // --- List ---
37
+
38
+ entityRouter.get('/:entity', (req, res) => {
39
+ const { entity } = req.params;
40
+
41
+ if (req.query.schema === 'true') {
42
+ const entry = schemas.get(entity);
43
+ return entry ? res.json(entry.schema) : res.status(404).json({ error: 'Unknown entity' });
44
+ }
45
+
46
+ let items = listRecords(entity);
47
+ if (!items.length && !schemas.has(entity)) {
48
+ return res.status(404).json({ error: 'Unknown entity' });
49
+ }
50
+
51
+ for (const [key, val] of Object.entries(req.query)) {
52
+ if (['limit', 'offset', 'sort', 'schema'].includes(key)) continue;
53
+ items = items.filter((item) => item[key] === val);
54
+ }
55
+
56
+ if (req.query.sort) {
57
+ const sortVal = /** @type {string} */ (req.query.sort);
58
+ const desc = sortVal.startsWith('-');
59
+ const field = desc ? sortVal.slice(1) : sortVal;
60
+ items.sort((a, b) => {
61
+ const av = a[field] ?? '';
62
+ const bv = b[field] ?? '';
63
+ const cmp = typeof av === 'number' ? av - bv : String(av).localeCompare(String(bv));
64
+ return desc ? -cmp : cmp;
65
+ });
66
+ }
67
+
68
+ const total = items.length;
69
+ const offset = parseInt(/** @type {string} */ (req.query.offset)) || 0;
70
+ const limit = parseInt(/** @type {string} */ (req.query.limit)) || 20;
71
+ res.json({ data: items.slice(offset, offset + limit), total, limit, offset });
72
+ });
73
+
74
+ // --- Read ---
75
+
76
+ entityRouter.get('/:entity/:id', (req, res) => {
77
+ const item = getRecord(req.params.entity, req.params.id);
78
+ return item ? res.json(item) : res.status(404).json({ error: 'Not found' });
79
+ });
80
+
81
+ // --- Create ---
82
+
83
+ entityRouter.post('/:entity', (req, res) => {
84
+ if (!schemas.has(req.params.entity)) return res.status(404).json({ error: 'Unknown entity' });
85
+ const { valid, fields } = validate(req.params.entity, req.body);
86
+ if (!valid) return res.status(422).json({ error: 'Validation failed', fields });
87
+ const entity = createEntity(req.body);
88
+ setRecord(req.params.entity, entity.id, entity);
89
+ broadcast(singular(req.params.entity), entity.id, 'created');
90
+ res.status(201).json(entity);
91
+ });
92
+
93
+ // --- Update ---
94
+
95
+ entityRouter.put('/:entity/:id', (req, res) => {
96
+ const existing = getRecord(req.params.entity, req.params.id);
97
+ if (!existing) return res.status(404).json({ error: 'Not found' });
98
+ const { valid, fields } = validate(req.params.entity, req.body, true);
99
+ if (!valid) return res.status(422).json({ error: 'Validation failed', fields });
100
+ const updated = { ...existing, ...req.body, id: existing.id, createdAt: existing.createdAt };
101
+ setRecord(req.params.entity, existing.id, updated);
102
+ broadcast(singular(req.params.entity), existing.id, 'updated');
103
+ res.json(updated);
104
+ });
105
+
106
+ // --- Delete ---
107
+
108
+ entityRouter.delete('/:entity/:id', (req, res) => {
109
+ if (!deleteRecord(req.params.entity, req.params.id)) {
110
+ return res.status(404).json({ error: 'Not found' });
111
+ }
112
+ broadcast(singular(req.params.entity), req.params.id, 'deleted');
113
+ res.status(204).end();
114
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Server-Sent Events endpoint and broadcast utility.
3
+ * @module api/events
4
+ */
5
+
6
+ import { Router } from 'express';
7
+
8
+ /** @type {Set<import('node:http').ServerResponse>} */
9
+ const clients = new Set();
10
+
11
+ export const eventsRouter = Router();
12
+
13
+ eventsRouter.get('/events', (req, res) => {
14
+ res.writeHead(200, {
15
+ 'Content-Type': 'text/event-stream',
16
+ 'Cache-Control': 'no-cache',
17
+ Connection: 'keep-alive',
18
+ });
19
+
20
+ clients.add(res);
21
+ req.on('close', () => clients.delete(res));
22
+ });
23
+
24
+ /**
25
+ * Broadcast an entity change to all connected SSE clients.
26
+ * @param {string} type - Entity name (singular), e.g. 'project'
27
+ * @param {string} id - Entity ID
28
+ * @param {'created'|'updated'|'deleted'} action
29
+ */
30
+ export function broadcast(type, id, action) {
31
+ const data = JSON.stringify({ type, id, action });
32
+ for (const res of clients) {
33
+ res.write(`event: update\ndata: ${data}\n\n`);
34
+ }
35
+ }