create-keboola-app 0.0.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/README.md +79 -0
- package/dist/blocks-DYU5Ke29.js +147 -0
- package/dist/cli.js +451 -0
- package/dist/extract.js +133 -0
- package/dist/regions-B3r5LhXl.js +95 -0
- package/dist/sanitize-CKs8pAzZ.js +158 -0
- package/dist/sanitize.js +2 -0
- package/dist/template/AGENTS.md +133 -0
- package/dist/template/README.md +56 -0
- package/dist/template/_env.example +6 -0
- package/dist/template/_gitignore +7 -0
- package/dist/template/_oxfmtrc.json +18 -0
- package/dist/template/index.html +13 -0
- package/dist/template/oxlint.config.ts +43 -0
- package/dist/template/package.json +53 -0
- package/dist/template/public/favicon.svg +10 -0
- package/dist/template/src/components/AppChrome.tsx +201 -0
- package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
- package/dist/template/src/components/AssistantPanel.tsx +96 -0
- package/dist/template/src/components/AssistantToggle.tsx +37 -0
- package/dist/template/src/components/ColorModeToggle.tsx +15 -0
- package/dist/template/src/components/HomePrompt.tsx +35 -0
- package/dist/template/src/components/KpiCard.test.tsx +20 -0
- package/dist/template/src/components/KpiCard.tsx +24 -0
- package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
- package/dist/template/src/components/SelectionFloater.tsx +88 -0
- package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
- package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
- package/dist/template/src/index.css +26 -0
- package/dist/template/src/main.tsx +57 -0
- package/dist/template/src/nav.test.ts +25 -0
- package/dist/template/src/nav.ts +38 -0
- package/dist/template/src/overview-sections.test.tsx +36 -0
- package/dist/template/src/overview-sections.tsx +40 -0
- package/dist/template/src/recipes/Chart.test.tsx +24 -0
- package/dist/template/src/recipes/Chart.tsx +56 -0
- package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
- package/dist/template/src/recipes/DataGrid.tsx +151 -0
- package/dist/template/src/routes/-fixtures.ts +113 -0
- package/dist/template/src/routes/-hooks.ts +15 -0
- package/dist/template/src/routes/-index.test.tsx +123 -0
- package/dist/template/src/routes/__root.tsx +20 -0
- package/dist/template/src/routes/index.tsx +95 -0
- package/dist/template/src/routes/tokens/$id.tsx +95 -0
- package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
- package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
- package/dist/template/src/routes/tokens/-hooks.ts +37 -0
- package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
- package/dist/template/src/routes/tokens/-nav.ts +17 -0
- package/dist/template/src/routes/tokens/-types.ts +22 -0
- package/dist/template/src/routes/tokens/index.tsx +182 -0
- package/dist/template/src/routes/tokens/new.tsx +163 -0
- package/dist/template/src/sections/chart.tsx +34 -0
- package/dist/template/src/sections/grid.tsx +21 -0
- package/dist/template/src/sections/kpi.tsx +36 -0
- package/dist/template/src/slices/assistant.ts +68 -0
- package/dist/template/src/slices/shell.ts +54 -0
- package/dist/template/src/store.test.tsx +92 -0
- package/dist/template/src/store.ts +67 -0
- package/dist/template/src/test-setup.ts +11 -0
- package/dist/template/src/vite-env.d.ts +1 -0
- package/dist/template/templates/agent-safety/README.md +14 -0
- package/dist/template/templates/agent-safety/aiexclude +25 -0
- package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
- package/dist/template/templates/agent-safety/cursorignore +25 -0
- package/dist/template/tsconfig.json +10 -0
- package/dist/template/vercel.json +4 -0
- package/dist/template/vite.config.ts +21 -0
- package/dist/template/vitest.config.ts +21 -0
- package/dist/template/workspace-deps.json +7 -0
- package/package.json +64 -0
package/dist/extract.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { a as isBlockId, i as SHELL_FILES, n as BLOCK_IDS, r as NOT_IN_TEMPLATE, t as BLOCKS } from "./blocks-DYU5Ke29.js";
|
|
2
|
+
import { i as renderRegions, r as blockIdOf } from "./regions-B3r5LhXl.js";
|
|
3
|
+
import { r as scan, t as formatFindings } from "./sanitize-CKs8pAzZ.js";
|
|
4
|
+
import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
//#region src/extract.ts
|
|
8
|
+
/** The scripts the emitted app keeps, keyed the way its `package.json` will hold them. */
|
|
9
|
+
const APP_SCRIPTS = {
|
|
10
|
+
build: "vite build",
|
|
11
|
+
dev: "vite",
|
|
12
|
+
lint: "oxfmt --check && oxlint .",
|
|
13
|
+
"lint:fix": "oxlint --fix . && oxfmt",
|
|
14
|
+
test: "vitest run",
|
|
15
|
+
"type-check": "tsc"
|
|
16
|
+
};
|
|
17
|
+
const DROPPED_DEPS = /* @__PURE__ */ new Set(["@keboola/ui-gen-bench"]);
|
|
18
|
+
const TEMPLATE_DOCS = [
|
|
19
|
+
"README.md",
|
|
20
|
+
"AGENTS.md",
|
|
21
|
+
"_gitignore",
|
|
22
|
+
"_env.example",
|
|
23
|
+
"_oxfmtrc.json"
|
|
24
|
+
];
|
|
25
|
+
/** Every workspace package's published version, by name. */
|
|
26
|
+
const workspaceVersions = (packagesDir) => new Map(readdirSync(packagesDir).flatMap((entry) => {
|
|
27
|
+
let manifest;
|
|
28
|
+
try {
|
|
29
|
+
manifest = JSON.parse(readFileSync(join(packagesDir, entry, "package.json"), "utf8"));
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const { name, version } = manifest;
|
|
34
|
+
return name !== void 0 && version !== void 0 ? [[name, version]] : [];
|
|
35
|
+
}));
|
|
36
|
+
/**
|
|
37
|
+
* The decision applied to every region while extracting.
|
|
38
|
+
*
|
|
39
|
+
* - `internal` is dropped: it is the marker for text that is true in the monorepo only.
|
|
40
|
+
* - `block:<id>` is kept **with** its markers — the CLI decides per scaffold.
|
|
41
|
+
* - Anything else throws, so a typo in a marker fails the build instead of shipping a region
|
|
42
|
+
* nobody will ever keep.
|
|
43
|
+
*/
|
|
44
|
+
const decideRegion = (regionId) => {
|
|
45
|
+
if (regionId === "internal") return "drop";
|
|
46
|
+
const id = blockIdOf(regionId);
|
|
47
|
+
if (id === null || !isBlockId(id)) throw new Error(`unknown region id "${regionId}" — expected "internal" or "block:<blockId>"`);
|
|
48
|
+
return "keep-with-markers";
|
|
49
|
+
};
|
|
50
|
+
/** Per-file rewrites that turn boilerplate identity into placeholders. */
|
|
51
|
+
const parameterise = (file, text) => {
|
|
52
|
+
if (file === "index.html") return text.replace(/<title>[^<]*<\/title>/, "<title>__APP_TITLE__</title>");
|
|
53
|
+
if (file === "src/store.ts") return text.replaceAll(/'boilerplate\/([^']+)'/g, "'__APP_NAME__/$1'");
|
|
54
|
+
return text;
|
|
55
|
+
};
|
|
56
|
+
const emittedManifest = (boilerplateDir, versions) => {
|
|
57
|
+
const { dependencies, devDependencies } = JSON.parse(readFileSync(join(boilerplateDir, "package.json"), "utf8"));
|
|
58
|
+
const workspaceDeps = [];
|
|
59
|
+
const resolve = (group) => Object.fromEntries(Object.entries(group).filter(([name]) => !DROPPED_DEPS.has(name)).map(([name, range]) => {
|
|
60
|
+
if (!range.startsWith("workspace:")) return [name, range];
|
|
61
|
+
const version = versions.get(name);
|
|
62
|
+
if (version === void 0) throw new Error(`${name} is a workspace dependency with no version on disk`);
|
|
63
|
+
workspaceDeps.push(name);
|
|
64
|
+
return [name, `^${version}`];
|
|
65
|
+
}).sort(([a], [b]) => a.localeCompare(b)));
|
|
66
|
+
return {
|
|
67
|
+
manifest: {
|
|
68
|
+
name: "__APP_NAME__",
|
|
69
|
+
version: "0.1.0",
|
|
70
|
+
private: true,
|
|
71
|
+
type: "module",
|
|
72
|
+
engines: { node: ">=22.12" },
|
|
73
|
+
scripts: APP_SCRIPTS,
|
|
74
|
+
dependencies: resolve(dependencies),
|
|
75
|
+
devDependencies: resolve(devDependencies)
|
|
76
|
+
},
|
|
77
|
+
workspaceDeps: [...new Set(workspaceDeps)].sort()
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const extractTemplate = (opts) => {
|
|
81
|
+
const { boilerplateDir, packagesDir, templatesDir, outDir } = opts;
|
|
82
|
+
const sources = [...SHELL_FILES, ...BLOCK_IDS.flatMap((id) => BLOCKS[id].files)].filter((file) => !NOT_IN_TEMPLATE.includes(file) && file !== "package.json").sort();
|
|
83
|
+
rmSync(outDir, {
|
|
84
|
+
recursive: true,
|
|
85
|
+
force: true
|
|
86
|
+
});
|
|
87
|
+
mkdirSync(outDir, { recursive: true });
|
|
88
|
+
const written = [];
|
|
89
|
+
for (const file of sources) {
|
|
90
|
+
const from = join(boilerplateDir, file);
|
|
91
|
+
const to = join(outDir, file);
|
|
92
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
93
|
+
const buffer = readFileSync(from);
|
|
94
|
+
if (buffer.subarray(0, 8e3).includes(0)) cpSync(from, to);
|
|
95
|
+
else writeFileSync(to, parameterise(file, renderRegions(buffer.toString("utf8"), decideRegion)));
|
|
96
|
+
written.push(file);
|
|
97
|
+
}
|
|
98
|
+
for (const doc of TEMPLATE_DOCS) {
|
|
99
|
+
const text = readFileSync(join(templatesDir, doc), "utf8");
|
|
100
|
+
writeFileSync(join(outDir, doc), renderRegions(text, decideRegion));
|
|
101
|
+
written.push(doc);
|
|
102
|
+
}
|
|
103
|
+
const { manifest, workspaceDeps } = emittedManifest(boilerplateDir, workspaceVersions(packagesDir));
|
|
104
|
+
writeFileSync(join(outDir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
105
|
+
written.push("package.json");
|
|
106
|
+
writeFileSync(join(outDir, "workspace-deps.json"), `${JSON.stringify(workspaceDeps, null, 2)}\n`);
|
|
107
|
+
written.push("workspace-deps.json");
|
|
108
|
+
return {
|
|
109
|
+
files: written.sort(),
|
|
110
|
+
workspaceDeps
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
114
|
+
const main = () => {
|
|
115
|
+
const outDir = fileURLToPath(new URL("../dist/template/", import.meta.url));
|
|
116
|
+
const { files, workspaceDeps } = extractTemplate({
|
|
117
|
+
boilerplateDir: join(REPO_ROOT, "apps", "boilerplate"),
|
|
118
|
+
packagesDir: join(REPO_ROOT, "packages"),
|
|
119
|
+
templatesDir: fileURLToPath(new URL("../templates/", import.meta.url)),
|
|
120
|
+
outDir
|
|
121
|
+
});
|
|
122
|
+
const findings = scan(outDir);
|
|
123
|
+
if (findings.length > 0) {
|
|
124
|
+
console.error(`✗ ${findings.length} sanitize finding(s) in the extracted template:`);
|
|
125
|
+
console.error(formatFindings(findings));
|
|
126
|
+
console.error(" Wrap the text in an `internal` region, or replace it.");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
console.log(`✓ extracted ${files.length} files into dist/template (${workspaceDeps.length} @keboola deps pinned to published ranges)`);
|
|
130
|
+
};
|
|
131
|
+
if (process.argv[1]?.endsWith("extract.js") === true) main();
|
|
132
|
+
//#endregion
|
|
133
|
+
export { extractTemplate };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//#region src/regions.ts
|
|
2
|
+
const MARKER = String.raw`#(region|endregion)\s+(\S+)`;
|
|
3
|
+
new RegExp(String.raw`^\s*(?://\s*|\{/\*\s*|<!--\s*)#region\s+(\S+)`);
|
|
4
|
+
const MARKER_LINE_RE = new RegExp(String.raw`^\s*(?://\s*|\{/\*\s*|<!--\s*)${MARKER}`);
|
|
5
|
+
/**
|
|
6
|
+
* The block name in a region id, or `null` for any other shape.
|
|
7
|
+
*
|
|
8
|
+
* - Only an exact `block:<name>` matches. `block:kpi:extra` is a typo, not the `kpi` block —
|
|
9
|
+
* resolving it to the block whose name it happens to start with would let a malformed
|
|
10
|
+
* marker decide a real region, which is how a marker leaks into an emitted app.
|
|
11
|
+
* - `internal` and every other id are the caller's business; this answers one question.
|
|
12
|
+
*/
|
|
13
|
+
const blockIdOf = (regionId) => /^block:([^:\s]+)$/.exec(regionId)?.[1] ?? null;
|
|
14
|
+
const markerOf = (line) => {
|
|
15
|
+
const match = MARKER_LINE_RE.exec(line);
|
|
16
|
+
if (!match) return null;
|
|
17
|
+
const [, kind, id] = match;
|
|
18
|
+
if (kind !== "region" && kind !== "endregion") return null;
|
|
19
|
+
return {
|
|
20
|
+
kind,
|
|
21
|
+
id: id ?? ""
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Removes the blank-line debris a dropped region leaves.
|
|
26
|
+
*
|
|
27
|
+
* - Runs of two or more blank lines collapse to one: dropping an import region between two
|
|
28
|
+
* groups otherwise leaves a double gap, and the emitted app's `oxfmt --check` fails on it.
|
|
29
|
+
* - A leading blank line is dropped for the same reason.
|
|
30
|
+
*/
|
|
31
|
+
const tidy = (lines) => {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (line.trim() === "" && (out.length === 0 || out.at(-1)?.trim() === "")) continue;
|
|
35
|
+
out.push(line);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
const renderRegions = (source, decide) => {
|
|
40
|
+
const lines = source.split("\n");
|
|
41
|
+
const out = [];
|
|
42
|
+
let open = null;
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const marker = markerOf(line);
|
|
45
|
+
if (marker?.kind === "region") {
|
|
46
|
+
if (open) throw new Error(`nested region "${marker.id}" inside "${open.id}"`);
|
|
47
|
+
const decision = decide(marker.id);
|
|
48
|
+
open = {
|
|
49
|
+
id: marker.id,
|
|
50
|
+
decision
|
|
51
|
+
};
|
|
52
|
+
if (decision === "keep-with-markers") out.push(line);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (marker?.kind === "endregion") {
|
|
56
|
+
if (!open) throw new Error(`#endregion "${marker.id}" with no open region`);
|
|
57
|
+
if (open.id !== marker.id) throw new Error(`#endregion closes "${marker.id}" while "${open.id}" is open`);
|
|
58
|
+
if (open.decision === "keep-with-markers") out.push(line);
|
|
59
|
+
open = null;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (open?.decision === "drop") continue;
|
|
63
|
+
out.push(line);
|
|
64
|
+
}
|
|
65
|
+
if (open) throw new Error(`unclosed region "${open.id}"`);
|
|
66
|
+
return tidy(out).join("\n");
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Any marker, in any dialect, anywhere on a line.
|
|
70
|
+
*
|
|
71
|
+
* - Deliberately looser than `markerOf`: this is the containment check, so an *unrecognised*
|
|
72
|
+
* marker — misspelt dialect, indented into a continuation line, left inside a string — has to
|
|
73
|
+
* match too. `markerOf` would skip exactly those and let them through.
|
|
74
|
+
*/
|
|
75
|
+
const MARKER_RE = /#(?:region|endregion)\b/;
|
|
76
|
+
/**
|
|
77
|
+
* Fails when rendered text still carries a marker.
|
|
78
|
+
*
|
|
79
|
+
* - The invariant: no `#region block:` may reach an emitted app. Markers are a build-time
|
|
80
|
+
* mechanism, and one surviving into generated source is how that mechanism fails silently —
|
|
81
|
+
* the app compiles, the region is dead text, and nobody finds out until a reader asks.
|
|
82
|
+
* - Called on every file the scaffolder writes, so a stray, mis-nested or unknown-block marker
|
|
83
|
+
* aborts the scaffold instead of shipping.
|
|
84
|
+
* - Not called on the extract's output: that keeps its markers on purpose, for the CLI to
|
|
85
|
+
* decide per scaffold.
|
|
86
|
+
*/
|
|
87
|
+
const assertNoMarkers = (file, text) => {
|
|
88
|
+
const line = text.split("\n").findIndex((candidate) => MARKER_RE.test(candidate));
|
|
89
|
+
if (line === -1) return;
|
|
90
|
+
throw new Error(`${file}:${line + 1} still carries a region marker after rendering — no "#region" may reach an emitted app.`);
|
|
91
|
+
};
|
|
92
|
+
/** Replaces `__KEY__` with `vars[KEY]`. An unlisted placeholder is left in place. */
|
|
93
|
+
const substitute = (source, vars) => Object.entries(vars).reduce((acc, [key, value]) => acc.replaceAll(`__${key}__`, value), source);
|
|
94
|
+
//#endregion
|
|
95
|
+
export { substitute as a, renderRegions as i, assertNoMarkers as n, blockIdOf as r, MARKER_RE as t };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { t as MARKER_RE } from "./regions-B3r5LhXl.js";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/sanitize.ts
|
|
6
|
+
const PATTERNS_PATH = fileURLToPath(new URL("../sanitize-patterns.json", import.meta.url));
|
|
7
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
8
|
+
"node_modules",
|
|
9
|
+
".turbo",
|
|
10
|
+
".git"
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* The pattern table, compiled.
|
|
14
|
+
*
|
|
15
|
+
* - Read from JSON on purpose: the table is not in `files[]`, so no published copy of this
|
|
16
|
+
* scanner carries the patterns it looks for.
|
|
17
|
+
* - Absent means the caller is running from an installed package, which the gates never do.
|
|
18
|
+
*/
|
|
19
|
+
const loadPatterns = () => {
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = readFileSync(PATTERNS_PATH, "utf8");
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error(`sanitize-patterns.json not found at ${PATTERNS_PATH} — the sanitize scan runs only inside the repo checkout (${String(error)})`);
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(raw).patterns.map((row) => ({
|
|
27
|
+
name: row.name,
|
|
28
|
+
regex: new RegExp(row.pattern, row.flags),
|
|
29
|
+
why: row.why,
|
|
30
|
+
hits: row.hits,
|
|
31
|
+
misses: row.misses
|
|
32
|
+
}));
|
|
33
|
+
};
|
|
34
|
+
/** Every pattern's first match on every line of `text`. */
|
|
35
|
+
const scanText = (patterns, file, text) => {
|
|
36
|
+
const findings = [];
|
|
37
|
+
text.split("\n").forEach((line, index) => {
|
|
38
|
+
for (const pattern of patterns) {
|
|
39
|
+
const match = pattern.regex.exec(line);
|
|
40
|
+
if (!match) continue;
|
|
41
|
+
findings.push({
|
|
42
|
+
file,
|
|
43
|
+
line: index + 1,
|
|
44
|
+
pattern: pattern.name,
|
|
45
|
+
match: match[0],
|
|
46
|
+
why: pattern.why
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return findings;
|
|
51
|
+
};
|
|
52
|
+
const isBinary = (buffer) => buffer.subarray(0, 8e3).includes(0);
|
|
53
|
+
const walk = (root, dir, out) => {
|
|
54
|
+
for (const entry of readdirSync(dir)) {
|
|
55
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
56
|
+
const path = join(dir, entry);
|
|
57
|
+
if (statSync(path).isDirectory()) walk(root, path, out);
|
|
58
|
+
else out.push(relative(root, path).split(sep).join("/"));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/** Scans every text file under `root`, reporting paths relative to it. */
|
|
62
|
+
const scanTree = (root, patterns = loadPatterns()) => {
|
|
63
|
+
const files = [];
|
|
64
|
+
walk(root, root, files);
|
|
65
|
+
return files.flatMap((file) => {
|
|
66
|
+
const buffer = readFileSync(join(root, file));
|
|
67
|
+
if (isBinary(buffer)) return [];
|
|
68
|
+
return scanText(patterns, file, buffer.toString("utf8"));
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Structural check on every `package.json` under `root`: no dependency may keep pnpm's
|
|
73
|
+
* `workspace:` protocol.
|
|
74
|
+
*
|
|
75
|
+
* - Parsed rather than grepped, so the rule is about specifiers and not about the string
|
|
76
|
+
* appearing anywhere in a file. The CLI legitimately holds that literal for `--workspace`.
|
|
77
|
+
*/
|
|
78
|
+
const scanManifests = (root) => {
|
|
79
|
+
const files = [];
|
|
80
|
+
walk(root, root, files);
|
|
81
|
+
const groups = [
|
|
82
|
+
"dependencies",
|
|
83
|
+
"devDependencies",
|
|
84
|
+
"peerDependencies",
|
|
85
|
+
"optionalDependencies"
|
|
86
|
+
];
|
|
87
|
+
return files.filter((file) => file === "package.json" || file.endsWith("/package.json")).flatMap((file) => {
|
|
88
|
+
const manifest = JSON.parse(readFileSync(join(root, file), "utf8"));
|
|
89
|
+
return groups.flatMap((group) => {
|
|
90
|
+
const deps = Reflect.get(manifest, group);
|
|
91
|
+
if (typeof deps !== "object" || deps === null) return [];
|
|
92
|
+
return Object.entries(deps).filter(([, range]) => typeof range === "string" && range.startsWith("workspace:")).map(([name, range]) => ({
|
|
93
|
+
file,
|
|
94
|
+
line: 1,
|
|
95
|
+
pattern: "workspace-specifier",
|
|
96
|
+
match: `${name}: ${String(range)}`,
|
|
97
|
+
why: "A workspace: specifier cannot resolve outside the monorepo — the emitted manifest must carry a published range."
|
|
98
|
+
}));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const scan = (root) => [...scanTree(root), ...scanManifests(root)];
|
|
103
|
+
/**
|
|
104
|
+
* Region markers surviving into emitted source.
|
|
105
|
+
*
|
|
106
|
+
* - Separate from `scan`, and never part of it: the tarball's own `dist/template/` carries
|
|
107
|
+
* markers on purpose, so a pattern-table rule for them would fail every run.
|
|
108
|
+
* - Run against an **emitted app** instead — the one place the invariant applies. A marker
|
|
109
|
+
* there is dead text that compiles, which is how the mechanism fails without telling anyone.
|
|
110
|
+
* - `assertNoMarkers` already aborts a scaffold in-process; this is the same rule applied to
|
|
111
|
+
* output on disk, so the gates can prove it about a real `pnpm pack` → scaffold run rather
|
|
112
|
+
* than trusting the code path.
|
|
113
|
+
*/
|
|
114
|
+
const scanMarkers = (root) => {
|
|
115
|
+
const files = [];
|
|
116
|
+
walk(root, root, files);
|
|
117
|
+
return files.flatMap((file) => {
|
|
118
|
+
const buffer = readFileSync(join(root, file));
|
|
119
|
+
if (isBinary(buffer)) return [];
|
|
120
|
+
return buffer.toString("utf8").split("\n").flatMap((line, index) => {
|
|
121
|
+
return MARKER_RE.exec(line) ? [{
|
|
122
|
+
file,
|
|
123
|
+
line: index + 1,
|
|
124
|
+
pattern: "region-marker",
|
|
125
|
+
match: line.trim(),
|
|
126
|
+
why: "No \"#region\" may reach an emitted app — a surviving marker is dead text that still compiles."
|
|
127
|
+
}] : [];
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
const formatFindings = (findings) => findings.map((f) => ` ${f.file}:${f.line} [${f.pattern}] ${f.match}\n ${f.why}`).join("\n");
|
|
132
|
+
/**
|
|
133
|
+
* Two modes, because the two things being proved are opposites.
|
|
134
|
+
*
|
|
135
|
+
* 1. Default — internal references and credential shapes under a directory. Run on `pnpm pack`
|
|
136
|
+
* output, which still holds the marked-up template.
|
|
137
|
+
* 2. `--markers` — region markers under a directory. Run on an **emitted app**, where a marker
|
|
138
|
+
* must never appear.
|
|
139
|
+
*/
|
|
140
|
+
const main = () => {
|
|
141
|
+
const args = process.argv.slice(2);
|
|
142
|
+
const markersOnly = args[0] === "--markers";
|
|
143
|
+
const root = markersOnly ? args[1] : args[0];
|
|
144
|
+
if (root === void 0) {
|
|
145
|
+
console.error("usage: node dist/sanitize.js [--markers] <directory>");
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const findings = markersOnly ? scanMarkers(root) : scan(root);
|
|
149
|
+
if (findings.length > 0) {
|
|
150
|
+
console.error(`✗ ${findings.length} ${markersOnly ? "marker" : "sanitize"} finding(s) under ${root}:`);
|
|
151
|
+
console.error(formatFindings(findings));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
console.log(markersOnly ? `✓ no region markers under ${root}` : `✓ no internal references or credential shapes under ${root}`);
|
|
155
|
+
};
|
|
156
|
+
if (process.argv[1]?.endsWith("sanitize.js") === true) main();
|
|
157
|
+
//#endregion
|
|
158
|
+
export { scanMarkers as a, scanManifests as i, loadPatterns as n, scanText as o, scan as r, scanTree as s, formatFindings as t };
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Generated by `create-keboola-app`. This file is yours to edit — nothing regenerates it.
|
|
4
|
+
|
|
5
|
+
## Read the installed package's contract first
|
|
6
|
+
|
|
7
|
+
Before writing UI, read the usage contract that ships with the version you actually have:
|
|
8
|
+
|
|
9
|
+
- `node_modules/@keboola/design/AGENTS.md` — the component decision tree and prop reference.
|
|
10
|
+
- `node_modules/@keboola/brand-registry/AGENTS.md` — brands, tokens and `BrandLogo`.
|
|
11
|
+
|
|
12
|
+
Both are pinned by your lockfile, so they match the components in your `node_modules` rather
|
|
13
|
+
than whatever the published docs track. Any `@keboola/*` package you add: check for its
|
|
14
|
+
`AGENTS.md` the same way.
|
|
15
|
+
|
|
16
|
+
## Conventions
|
|
17
|
+
|
|
18
|
+
- TypeScript only, `type` over `interface`, named exports only, no `any`.
|
|
19
|
+
- Import order: side effects → React/external → `@keboola/*` → relative.
|
|
20
|
+
- Tailwind v4 with the `tw:` variant prefix. Colours come from semantic tokens
|
|
21
|
+
(`tw:bg-background`, `tw:text-muted-foreground`), never literals and never a primitive
|
|
22
|
+
scale — that is what lets a customer brand re-theme the app.
|
|
23
|
+
- Dark mode is `[data-theme='dark']` on the root, written by `ThemeProvider`. A portalled
|
|
24
|
+
surface must set its own `tw:text-foreground` beside its `tw:bg-*`.
|
|
25
|
+
- Layout uses Tailwind classes on real elements. There is no `Stack`/`Flex`/`Box` wrapper.
|
|
26
|
+
- Per-module locality: everything about a feature lives under its route directory. A `-`
|
|
27
|
+
prefix keeps a file out of the generated route tree.
|
|
28
|
+
- `.oxfmtrc.json` is the formatter's config, and it excludes this file and `README.md`. Both
|
|
29
|
+
were generated for the block selection you picked, and `oxfmt` pads Markdown table columns to
|
|
30
|
+
the widest cell — so their canonical form differs per selection and no generated text can
|
|
31
|
+
satisfy it. Everything under `src/` is format-checked normally.
|
|
32
|
+
|
|
33
|
+
## Stack
|
|
34
|
+
|
|
35
|
+
| Concern | Tool |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| Build | Vite 8 + `@vitejs/plugin-react` |
|
|
38
|
+
| Routing | TanStack Router, file-based via `@tanstack/router-plugin/vite` |
|
|
39
|
+
| Server state | TanStack Query v5 |
|
|
40
|
+
| Client state | Zustand v5 with `devtools` + `persist` |
|
|
41
|
+
| UI | `@keboola/design` |
|
|
42
|
+
| Styling | Tailwind v4 + `@keboola/design/theme` tokens |
|
|
43
|
+
| Tests | Vitest + jsdom + `@testing-library/react` |
|
|
44
|
+
<!-- #region block:crud -->
|
|
45
|
+
| Forms | TanStack Form v1 + zod v4 |
|
|
46
|
+
<!-- #endregion block:crud -->
|
|
47
|
+
<!-- #region block:grid -->
|
|
48
|
+
| Data grid | TanStack Table v8 rendered through design's `Table` |
|
|
49
|
+
<!-- #endregion block:grid -->
|
|
50
|
+
<!-- #region block:chart -->
|
|
51
|
+
| Charts | `@keboola/design/charts` |
|
|
52
|
+
<!-- #endregion block:chart -->
|
|
53
|
+
|
|
54
|
+
## File map
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
src/
|
|
58
|
+
main.tsx # Mounts ThemeProvider + QueryClientProvider + RouterProvider
|
|
59
|
+
index.css # Tailwind v4 layer order + @keboola/design theme and styles
|
|
60
|
+
routeTree.gen.ts # Generated from src/routes/** by the Vite plugin
|
|
61
|
+
nav.ts # The rail's destinations and the active-match rule
|
|
62
|
+
overview-sections.tsx # The ordered registry the overview renders
|
|
63
|
+
store.ts # The Zustand store: storage key, partialize, merge guard
|
|
64
|
+
slices/shell.ts # Sidebar mode + help visibility
|
|
65
|
+
components/AppChrome.tsx # AppShell + Navbar + Sidebar
|
|
66
|
+
routes/__root.tsx # Root layout
|
|
67
|
+
routes/index.tsx # / — the overview
|
|
68
|
+
routes/-hooks.ts # useOverview, AbortSignal threaded
|
|
69
|
+
routes/-fixtures.ts # In-memory overview stub — swap for a real client call
|
|
70
|
+
```
|
|
71
|
+
<!-- #region block:kpi -->
|
|
72
|
+
- `components/KpiCard.tsx`, `sections/kpi.tsx` — the KPI row.
|
|
73
|
+
<!-- #endregion block:kpi -->
|
|
74
|
+
<!-- #region block:chart -->
|
|
75
|
+
- `recipes/Chart.tsx`, `sections/chart.tsx` — the line chart card.
|
|
76
|
+
<!-- #endregion block:chart -->
|
|
77
|
+
<!-- #region block:grid -->
|
|
78
|
+
- `recipes/DataGrid.tsx`, `sections/grid.tsx` — the editable grid.
|
|
79
|
+
<!-- #endregion block:grid -->
|
|
80
|
+
<!-- #region block:crud -->
|
|
81
|
+
- `routes/tokens/**` — the CRUD slice: `index.tsx`, `$id.tsx`, `new.tsx`, `-hooks.ts`,
|
|
82
|
+
`-fixtures.ts`, `-types.ts`, `-nav.ts`. **The entity is a placeholder.** Rename it to yours;
|
|
83
|
+
the fixture rows are labels only.
|
|
84
|
+
<!-- #endregion block:crud -->
|
|
85
|
+
<!-- #region block:assistant -->
|
|
86
|
+
- `components/AssistantPanel.tsx`, `AssistantToggle.tsx`, `HomePrompt.tsx`,
|
|
87
|
+
`SelectionFloater.tsx`, `slices/assistant.ts` — the assistant. `slices/assistant.ts`
|
|
88
|
+
answers with a canned reply; swap that branch for a real request.
|
|
89
|
+
<!-- #endregion block:assistant -->
|
|
90
|
+
|
|
91
|
+
## Adding a route
|
|
92
|
+
|
|
93
|
+
Drop a file in `src/routes/`. The Vite plugin regenerates `routeTree.gen.ts` on the next
|
|
94
|
+
`pnpm dev` or `pnpm build`; nothing needs registering by hand.
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { createFileRoute } from '@tanstack/react-router';
|
|
98
|
+
|
|
99
|
+
export const Route = createFileRoute('/reports/')({
|
|
100
|
+
component: ReportsPage,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function ReportsPage() {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Add it to the rail by appending to `NAV` in `src/nav.ts`, and to the overview by appending a
|
|
109
|
+
section to `OVERVIEW_SECTIONS` in `src/overview-sections.tsx`.
|
|
110
|
+
|
|
111
|
+
## Fetching data
|
|
112
|
+
|
|
113
|
+
One hook per query, in the route's `-hooks.ts`, always threading the signal:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
export const useReports = () =>
|
|
117
|
+
useQuery({
|
|
118
|
+
queryKey: ['reports'],
|
|
119
|
+
queryFn: ({ signal }) => api.reports.list(signal),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
TanStack Query aborts that signal on unmount and on refetch. A `queryFn` that ignores it
|
|
124
|
+
leaks a request per navigation.
|
|
125
|
+
|
|
126
|
+
## Secrets
|
|
127
|
+
|
|
128
|
+
`.env` is gitignored; `.env.example` is the checked-in list of what the app expects. Vite only
|
|
129
|
+
exposes `VITE_`-prefixed variables to the client bundle, so anything not prefixed stays out of
|
|
130
|
+
it — and nothing secret belongs in a client bundle at all.
|
|
131
|
+
|
|
132
|
+
`templates/agent-safety/` holds opt-in, per-runtime read-blocking stubs (Claude Code, Cursor,
|
|
133
|
+
Gemini CLI). Copy the one for your runtime as its README describes.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# __APP_TITLE__
|
|
2
|
+
|
|
3
|
+
A Keboola-shaped frontend app: Vite, React 19, TanStack Router/Query/Form, Zustand,
|
|
4
|
+
`@keboola/design` with `@keboola/brand-registry`.
|
|
5
|
+
|
|
6
|
+
## Quick start
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open `http://localhost:5173`.
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
| Command | What it does |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `pnpm dev` | Vite dev server. |
|
|
20
|
+
| `pnpm build` | Production build into `dist/`. |
|
|
21
|
+
| `pnpm test` | Vitest + jsdom. |
|
|
22
|
+
| `pnpm type-check` | `tsc`, no emit. |
|
|
23
|
+
| `pnpm lint` | `oxfmt --check` then `oxlint`. |
|
|
24
|
+
| `pnpm lint:fix` | `oxlint --fix` then `oxfmt`. |
|
|
25
|
+
| `pnpm gate:ui` | Scores the built app 0-100 in a headless browser. Needs `ANTHROPIC_API_KEY` and a `pnpm build` first — see below. |
|
|
26
|
+
|
|
27
|
+
`gate:ui` is deliberately not part of `pnpm test`: it needs a model key, and a first run that
|
|
28
|
+
fails for a missing credential is a worse welcome than an opt-in gate.
|
|
29
|
+
|
|
30
|
+
## What is here
|
|
31
|
+
|
|
32
|
+
- **`/`** — the overview route.
|
|
33
|
+
<!-- #region block:kpi -->
|
|
34
|
+
- KPI cards, reflowing on the content column's own width.
|
|
35
|
+
<!-- #endregion block:kpi -->
|
|
36
|
+
<!-- #region block:chart -->
|
|
37
|
+
- A credits used-vs-budgeted line chart, coloured from `--chart-N` palette slots.
|
|
38
|
+
<!-- #endregion block:chart -->
|
|
39
|
+
<!-- #region block:grid -->
|
|
40
|
+
- An editable per-component budget grid.
|
|
41
|
+
<!-- #endregion block:grid -->
|
|
42
|
+
<!-- #region block:crud -->
|
|
43
|
+
- **`/tokens`**, **`/tokens/:id`**, **`/tokens/new`** — a list → detail → form slice with zod
|
|
44
|
+
validation and toast feedback.
|
|
45
|
+
<!-- #endregion block:crud -->
|
|
46
|
+
<!-- #region block:assistant -->
|
|
47
|
+
- A docked assistant rail on every route, a composer on the overview, and
|
|
48
|
+
explain-on-selection. All three share one transcript.
|
|
49
|
+
<!-- #endregion block:assistant -->
|
|
50
|
+
|
|
51
|
+
Every data call is an in-memory fixture that honours an `AbortSignal`. Replacing a fixture with
|
|
52
|
+
a real `@keboola/api-client` call is a drop-in — the query hooks already thread `signal`.
|
|
53
|
+
|
|
54
|
+
## Next
|
|
55
|
+
|
|
56
|
+
Read [AGENTS.md](./AGENTS.md), then `node_modules/@keboola/design/AGENTS.md`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# What this app reads from the environment. `.env` is gitignored; this file is the list.
|
|
2
|
+
# Vite exposes only VITE_-prefixed variables to the client bundle.
|
|
3
|
+
|
|
4
|
+
# The Keboola stack this app talks to. Left empty on purpose: a default here would pin the
|
|
5
|
+
# app to one region.
|
|
6
|
+
# VITE_KBC_STACK_URL=
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"printWidth": 100,
|
|
3
|
+
"tabWidth": 2,
|
|
4
|
+
"singleQuote": true,
|
|
5
|
+
"arrowParens": "always",
|
|
6
|
+
"jsxSingleQuote": false,
|
|
7
|
+
"bracketSameLine": false,
|
|
8
|
+
"sortPackageJson": false,
|
|
9
|
+
"ignorePatterns": [
|
|
10
|
+
"dist",
|
|
11
|
+
"public",
|
|
12
|
+
"src/routeTree.gen.ts",
|
|
13
|
+
"pnpm-lock.yaml",
|
|
14
|
+
"pnpm-workspace.yaml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"AGENTS.md"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
|
+
<title>__APP_TITLE__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineConfig } from 'oxlint';
|
|
2
|
+
|
|
3
|
+
import keboolaShared from '@keboola/oxlint-config';
|
|
4
|
+
import keboolaTailwind from '@keboola/oxlint-config/tailwind';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
// `keboolaTailwind` opts into the v4-native Tailwind lint layer
|
|
8
|
+
// (eslint-plugin-better-tailwindcss). It carries the plugin + rules; the
|
|
9
|
+
// `entryPoint` below is required — oxlint only reads `settings` from the
|
|
10
|
+
// root config, so it can't live inside the shared fragment.
|
|
11
|
+
extends: [keboolaShared, keboolaTailwind],
|
|
12
|
+
env: {
|
|
13
|
+
node: true,
|
|
14
|
+
},
|
|
15
|
+
settings: {
|
|
16
|
+
'better-tailwindcss': {
|
|
17
|
+
// This app's Tailwind v4 CSS entry (the file that `@import`s "tailwindcss").
|
|
18
|
+
entryPoint: 'src/index.css',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
jsPlugins: [
|
|
22
|
+
// Local design-system + brand-safety lint rules — see
|
|
23
|
+
// packages/eslint-plugin-design. Aliased to `design` so rule IDs read
|
|
24
|
+
// `design/<rule>` in lint output. Boilerplate is the canonical scaffold
|
|
25
|
+
// cloned by `pnpm scaffold:app`, so every generated app inherits these.
|
|
26
|
+
{ name: 'design', specifier: '@keboola/eslint-plugin-design' },
|
|
27
|
+
],
|
|
28
|
+
rules: {
|
|
29
|
+
// Shipped at 'warn' for the first pass, matching the plugin convention —
|
|
30
|
+
// promote to 'error' once generated apps are consistently clean. See
|
|
31
|
+
// packages/eslint-plugin-design/README.md.
|
|
32
|
+
'design/no-layout-primitives': 'warn',
|
|
33
|
+
'design/no-inline-color': 'warn',
|
|
34
|
+
'design/no-primitive-tw-color': 'warn',
|
|
35
|
+
'design/no-native-form-controls': 'warn',
|
|
36
|
+
'design/no-raw-heading-on-page': 'warn',
|
|
37
|
+
'design/prefer-design-modal': 'warn',
|
|
38
|
+
|
|
39
|
+
// 'error' from the start: this app is the scaffold template, so a
|
|
40
|
+
// locale-implicit format here is copied into every generated app.
|
|
41
|
+
'design/no-implicit-locale': 'error',
|
|
42
|
+
},
|
|
43
|
+
});
|