@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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/bin/cli.js +62 -0
- package/docs/BACKEND_API_SPEC.md +281 -0
- package/docs/BUILD_LOG.md +193 -0
- package/docs/COMPONENT_PATTERNS.md +481 -0
- package/docs/CONVENTIONS.md +226 -0
- package/docs/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/docs/JSDOC_TYPING.md +86 -0
- package/docs/QUICKSTART.md +190 -0
- package/docs/SERVER_AND_DEPS.md +163 -0
- package/docs/STATE_AND_ROUTING.md +363 -0
- package/docs/TESTING.md +268 -0
- package/docs/app-spec/ENTITIES.md +37 -0
- package/docs/app-spec/README.md +19 -0
- package/lib/check.js +115 -0
- package/lib/copy.js +43 -0
- package/lib/init.js +73 -0
- package/lib/package-gen.js +83 -0
- package/lib/update.js +73 -0
- package/package.json +69 -0
- package/templates/fullstack/data/seed.json +1 -0
- package/templates/fullstack/src/api/db.js +75 -0
- package/templates/fullstack/src/api/entities.js +114 -0
- package/templates/fullstack/src/api/events.js +35 -0
- package/templates/fullstack/src/api/schemas.js +104 -0
- package/templates/fullstack/src/api/validate.js +52 -0
- package/templates/fullstack/src/pages/home/home-view.js +19 -0
- package/templates/fullstack/src/router/index.js +16 -0
- package/templates/fullstack/src/server.js +46 -0
- package/templates/fullstack/src/store/AppState.js +33 -0
- package/templates/fullstack/src/store/UserPrefs.js +31 -0
- package/templates/fullstack/src/store/realtimeSync.js +54 -0
- package/templates/shared/.configs/.prettierrc +8 -0
- package/templates/shared/.configs/eslint.config.js +64 -0
- package/templates/shared/.configs/jsconfig.json +24 -0
- package/templates/shared/.configs/web-test-runner.config.js +8 -0
- package/templates/shared/.env +9 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/bug_report.md +42 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/spec_correction.md +26 -0
- package/templates/shared/.github/pull_request_template.md +51 -0
- package/templates/shared/.github/workflows/spec.yml +46 -0
- package/templates/shared/README.md +22 -0
- package/templates/shared/docs/app-spec/README.md +40 -0
- package/templates/shared/docs/clearstack/BACKEND_API_SPEC.md +281 -0
- package/templates/shared/docs/clearstack/BUILD_LOG.md +193 -0
- package/templates/shared/docs/clearstack/COMPONENT_PATTERNS.md +481 -0
- package/templates/shared/docs/clearstack/CONVENTIONS.md +226 -0
- package/templates/shared/docs/clearstack/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/templates/shared/docs/clearstack/JSDOC_TYPING.md +86 -0
- package/templates/shared/docs/clearstack/QUICKSTART.md +190 -0
- package/templates/shared/docs/clearstack/SERVER_AND_DEPS.md +163 -0
- package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +363 -0
- package/templates/shared/docs/clearstack/TESTING.md +268 -0
- package/templates/shared/public/index.html +26 -0
- package/templates/shared/scripts/build-icons.js +86 -0
- package/templates/shared/scripts/vendor-deps.js +25 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.css +4 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.js +23 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.test.js +26 -0
- package/templates/shared/src/components/atoms/app-badge/index.js +1 -0
- package/templates/shared/src/components/atoms/app-button/app-button.css +3 -0
- package/templates/shared/src/components/atoms/app-button/app-button.js +41 -0
- package/templates/shared/src/components/atoms/app-button/app-button.test.js +43 -0
- package/templates/shared/src/components/atoms/app-button/index.js +1 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.css +4 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.js +57 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.test.js +30 -0
- package/templates/shared/src/components/atoms/app-icon/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.css +10 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.js +42 -0
- package/templates/shared/src/styles/buttons.css +79 -0
- package/templates/shared/src/styles/components.css +31 -0
- package/templates/shared/src/styles/forms.css +20 -0
- package/templates/shared/src/styles/reset.css +32 -0
- package/templates/shared/src/styles/shared.css +135 -0
- package/templates/shared/src/styles/tokens.css +65 -0
- package/templates/shared/src/utils/formatDate.js +41 -0
- package/templates/shared/src/utils/statusColors.js +60 -0
- package/templates/static/src/pages/home/home-view.js +38 -0
- package/templates/static/src/router/index.js +16 -0
- 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
|
+
}
|