@polderlabs/openkan 0.4.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/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// OpenKan — tagging, categorization, priority, and effort inference.
|
|
2
|
+
// ─── Tag keyword map (in priority order — first match wins) ──────────────────
|
|
3
|
+
const TAG_KEYWORDS = [
|
|
4
|
+
{ keywords: ["fix", "bug", "broken", "regression", "crash", "outage"], tag: "bug" },
|
|
5
|
+
{ keywords: ["feature", "add support", "implement"], tag: "feature" },
|
|
6
|
+
{ keywords: ["refactor", "cleanup", "clean up"], tag: "refactor" },
|
|
7
|
+
{ keywords: ["doc", "docs", "documentation", "readme"], tag: "docs" },
|
|
8
|
+
{ keywords: ["test", "spec", "e2e", "coverage"], tag: "test" },
|
|
9
|
+
{ keywords: ["perf", "performance", "slow", "optimi"], tag: "perf" },
|
|
10
|
+
{ keywords: ["security", "vuln", "cve", "xss"], tag: "security" },
|
|
11
|
+
{ keywords: ["a11y", "accessibility"], tag: "a11y" },
|
|
12
|
+
{ keywords: ["ux", "design"], tag: "ux" },
|
|
13
|
+
{ keywords: ["i18n", "l10n", "locale"], tag: "i18n" },
|
|
14
|
+
{ keywords: ["migration", "migrate"], tag: "migration" },
|
|
15
|
+
{ keywords: ["deprecat"], tag: "deprecation" },
|
|
16
|
+
];
|
|
17
|
+
// ─── Category rules (first match wins) ───────────────────────────────────────
|
|
18
|
+
const CATEGORY_PATTERNS = [
|
|
19
|
+
{ pattern: /\.tsx|\.jsx|\.css|\.scss|component|page\/|ui\/|button|modal|style(?!s)|css[\/-]/i, category: "frontend" },
|
|
20
|
+
{ pattern: /\.py|\.go|\.ts\b(?!x)|\/api|endpoint|route|handler|server|db|query|sql\b/i, category: "backend" },
|
|
21
|
+
{ pattern: /k8s|kubernetes|docker|terraform|tf\b|helm|deploy|ci|cd|pipeline/i, category: "infra" },
|
|
22
|
+
{ pattern: /readme|changelog|\bdocs\/|documentation|comment|jsdoc/i, category: "docs" },
|
|
23
|
+
{ pattern: /playwright|vitest|jest|mocha|cypress|e2e|unit test/i, category: "test" },
|
|
24
|
+
{ pattern: /figma|sketch|wireframe|mockup|style guide/i, category: "design" },
|
|
25
|
+
{ pattern: /sql\b|postgres|mysql|migration|schema|index|query\b/i, category: "data" },
|
|
26
|
+
{ pattern: /xss|csp|cve|auth|oauth|jwt|vuln|rbac/i, category: "security" },
|
|
27
|
+
];
|
|
28
|
+
// ─── Priority rules (first match wins) ───────────────────────────────────────
|
|
29
|
+
const PRIORITY_PATTERNS = [
|
|
30
|
+
{ pattern: /P0|urgent|asap|critical|blocker|outage/i, priority: "urgent" },
|
|
31
|
+
{ pattern: /P1|high priority|high\b(?! priority)|important/i, priority: "high" },
|
|
32
|
+
{ pattern: /P2|low priority/i, priority: "low" },
|
|
33
|
+
{ pattern: /P3|backlog/i, priority: "normal" },
|
|
34
|
+
];
|
|
35
|
+
// ─── Effort rules (first match wins; null if no signal) ─────────────────────
|
|
36
|
+
const EFFORT_PATTERNS = [
|
|
37
|
+
{ pattern: /\b(?:xs|trivial|1[\s_-]?line|one[\s_-]?liner|typo)\b/i, effort: "xs" },
|
|
38
|
+
{ pattern: /\b(?:small|quick|minutes?)\b/i, effort: "s" },
|
|
39
|
+
{ pattern: /\b(?:medium|afternoon|half[\s_-]?day)\b/i, effort: "m" },
|
|
40
|
+
// xl pattern first (tries multi-week before week in the l rule below)
|
|
41
|
+
{ pattern: /\b(?:xl|epic|multi[\s_-]?week|quarter)\b/i, effort: "xl" },
|
|
42
|
+
// "week" must NOT be preceded by a letter/digit/hyphen (prevents matching inside "multi-week")
|
|
43
|
+
{ pattern: /\b(?:large|big|(?<![a-zA-Z0-9-])week)\b/i, effort: "l" },
|
|
44
|
+
];
|
|
45
|
+
// ─── Explicit tag extraction ─────────────────────────────────────────────────
|
|
46
|
+
const HASHTAG_PATTERN = /#([a-zA-Z][a-zA-Z0-9_-]*)/g;
|
|
47
|
+
/** Extract #word tokens from a string. Returns lowercased tag names without the #. */
|
|
48
|
+
function extractExplicitTags(text) {
|
|
49
|
+
const tags = [];
|
|
50
|
+
let match;
|
|
51
|
+
const re = new RegExp(HASHTAG_PATTERN.source, "g");
|
|
52
|
+
while ((match = re.exec(text)) !== null) {
|
|
53
|
+
tags.push(match[1].toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
return tags;
|
|
56
|
+
}
|
|
57
|
+
// ─── Core inference ───────────────────────────────────────────────────────────
|
|
58
|
+
function matchFirst(text, rules) {
|
|
59
|
+
for (const rule of rules) {
|
|
60
|
+
if (rule.pattern.test(text))
|
|
61
|
+
return rule.value;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function deriveTags(title, description) {
|
|
66
|
+
const combined = `${title} ${description}`.toLowerCase();
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const { keywords, tag } of TAG_KEYWORDS) {
|
|
69
|
+
if (keywords.some(kw => combined.includes(kw))) {
|
|
70
|
+
found.push(tag);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
function deriveCategory(title, description) {
|
|
76
|
+
const combined = `${title} ${description}`;
|
|
77
|
+
const matched = matchFirst(combined, CATEGORY_PATTERNS.map(r => ({ pattern: r.pattern, value: r.category })));
|
|
78
|
+
return matched ?? "task";
|
|
79
|
+
}
|
|
80
|
+
function derivePriority(title, description) {
|
|
81
|
+
const combined = `${title} ${description}`;
|
|
82
|
+
const matched = matchFirst(combined, PRIORITY_PATTERNS.map(r => ({ pattern: r.pattern, value: r.priority })));
|
|
83
|
+
return matched ?? "normal";
|
|
84
|
+
}
|
|
85
|
+
function deriveEffort(title, description) {
|
|
86
|
+
const combined = `${title} ${description}`;
|
|
87
|
+
const matched = matchFirst(combined, EFFORT_PATTERNS.map(r => ({ pattern: r.pattern, value: r.effort })));
|
|
88
|
+
return matched ?? null;
|
|
89
|
+
}
|
|
90
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* Infer tags, category, priority, and effort from a task's title and description.
|
|
93
|
+
* Tags are deduplicated and sorted: keyword-derived tags first (in table order),
|
|
94
|
+
* then explicit #tag tokens (alphabetical). The category is always included as a tag.
|
|
95
|
+
*/
|
|
96
|
+
export function extractMetadata(input) {
|
|
97
|
+
const title = input.title ?? "";
|
|
98
|
+
const description = input.description ?? "";
|
|
99
|
+
const explicitTags = extractExplicitTags(`${title} ${description}`);
|
|
100
|
+
const keywordTags = deriveTags(title, description);
|
|
101
|
+
// Merge: keyword tags first (preserving table order), then explicit tags alphabetical
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const tags = [];
|
|
104
|
+
for (const tag of keywordTags) {
|
|
105
|
+
if (!seen.has(tag)) {
|
|
106
|
+
seen.add(tag);
|
|
107
|
+
tags.push(tag);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const tag of [...explicitTags].sort()) {
|
|
111
|
+
if (!seen.has(tag)) {
|
|
112
|
+
seen.add(tag);
|
|
113
|
+
tags.push(tag);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const category = deriveCategory(title, description);
|
|
117
|
+
// Always include category as a tag
|
|
118
|
+
if (!seen.has(category)) {
|
|
119
|
+
tags.push(category);
|
|
120
|
+
}
|
|
121
|
+
const priority = derivePriority(title, description);
|
|
122
|
+
const effort = deriveEffort(title, description);
|
|
123
|
+
return { tags, category, priority, effort, explicitTags };
|
|
124
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// OpenKan — canonical task MDX template + helpers.
|
|
2
|
+
// ─── Template ────────────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* The canonical task MDX template.
|
|
5
|
+
* Agents fill this in when creating a new task via kanban_add without a description.
|
|
6
|
+
*
|
|
7
|
+
* The template uses placeholder comments so agents can detect
|
|
8
|
+
* unfilled sections via extractTemplate().
|
|
9
|
+
*/
|
|
10
|
+
export const TASK_MDX_TEMPLATE = `---
|
|
11
|
+
title: {/* Task title — replace with a short, descriptive name */}
|
|
12
|
+
column: todo
|
|
13
|
+
order: 0
|
|
14
|
+
state: idle
|
|
15
|
+
status: idle
|
|
16
|
+
agent: {/* Bizar agent ID, e.g. "mike" */}
|
|
17
|
+
model: {/* optional provider/model override */}
|
|
18
|
+
createdAt: {/* ISO timestamp */}
|
|
19
|
+
updatedAt: {/* ISO timestamp */}
|
|
20
|
+
tags: []
|
|
21
|
+
category: task
|
|
22
|
+
priority: normal
|
|
23
|
+
effort: null
|
|
24
|
+
archived: false
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# {/\* Task title \*/}
|
|
28
|
+
|
|
29
|
+
{/* Add the task title above, then fill in the sections below. */}
|
|
30
|
+
|
|
31
|
+
## Goal
|
|
32
|
+
|
|
33
|
+
{/* What does "done" look like? Describe the end state in 1-3 sentences.
|
|
34
|
+
Example: "Users can reset their password via a secure email link." */}
|
|
35
|
+
|
|
36
|
+
## Context
|
|
37
|
+
|
|
38
|
+
{/* Add background context, links to relevant issues/files, and why this task exists.
|
|
39
|
+
Example: "Related to #42 — the current reset flow has no rate limiting." */}
|
|
40
|
+
|
|
41
|
+
## Acceptance criteria
|
|
42
|
+
|
|
43
|
+
{/* Replace this checklist with concrete, testable conditions. */}
|
|
44
|
+
- [ ] {/* Criterion 1 */}
|
|
45
|
+
- [ ] {/* Criterion 2 */}
|
|
46
|
+
- [ ] {/* Criterion 3 */}
|
|
47
|
+
|
|
48
|
+
## Files to touch
|
|
49
|
+
|
|
50
|
+
{/* List specific files/functions/UI components to change. */}
|
|
51
|
+
{/* Example: src/auth/reset-password.ts, web/components/PasswordReset.tsx */}
|
|
52
|
+
|
|
53
|
+
## Safety
|
|
54
|
+
|
|
55
|
+
{/* Any non-functional requirements: performance, security, backwards compat, rollback plan. */}
|
|
56
|
+
{/* Example: "Database migration must be reversible. Rate-limit to 5 attempts per IP." */}
|
|
57
|
+
|
|
58
|
+
## Agent progress
|
|
59
|
+
|
|
60
|
+
{/* Agents append timestamped one-liners here as they work.
|
|
61
|
+
Format: "- [YYYY-MM-DD HH:MM:SS] <tool or action description>" */}
|
|
62
|
+
|
|
63
|
+
## MDX components
|
|
64
|
+
|
|
65
|
+
{/* Available interactive components — embed in the task MDX when needed: */}
|
|
66
|
+
|
|
67
|
+
{/* <Ask question="Your question here" /> */}
|
|
68
|
+
{/* <Choice question="Pick one:" options={[{id:"a",label:"Option A"},{id:"b",label:"Option B"}]} /> */}
|
|
69
|
+
{/* <Input label="Enter value" placeholder="e.g. foo" /> */}
|
|
70
|
+
{/* <Confirm question="Proceed?" /> */}
|
|
71
|
+
{/* <Preview tsx="<button>Click me</button>" /> */}
|
|
72
|
+
`;
|
|
73
|
+
/**
|
|
74
|
+
* Render the task template with field values filled in.
|
|
75
|
+
* Fields not provided retain their placeholder comments.
|
|
76
|
+
*/
|
|
77
|
+
export function renderTaskTemplate(fields) {
|
|
78
|
+
const { title, goal = "{/* Describe the goal of this task */}", context = "{/* Add context: why this task exists, links, background */}", acceptance = ["{/* Acceptance criterion */}"], safety = [], tags = [], } = fields;
|
|
79
|
+
const acceptanceLines = acceptance
|
|
80
|
+
.map(c => (c.startsWith("- [ ]") ? c : `- [ ] ${c}`))
|
|
81
|
+
.join("\n");
|
|
82
|
+
const safetyLines = safety.length > 0
|
|
83
|
+
? safety.map(s => (s.startsWith("-") ? s : `- ${s}`)).join("\n")
|
|
84
|
+
: "{/* Safety considerations (optional) */}";
|
|
85
|
+
return `---
|
|
86
|
+
title: ${title}
|
|
87
|
+
column: todo
|
|
88
|
+
order: 0
|
|
89
|
+
state: idle
|
|
90
|
+
status: idle
|
|
91
|
+
agent:
|
|
92
|
+
model:
|
|
93
|
+
createdAt: ${new Date().toISOString()}
|
|
94
|
+
updatedAt: ${new Date().toISOString()}
|
|
95
|
+
tags: [${tags.map(t => `"${t}"`).join(", ")}]
|
|
96
|
+
category: task
|
|
97
|
+
priority: normal
|
|
98
|
+
effort: null
|
|
99
|
+
archived: false
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
# ${title}
|
|
103
|
+
|
|
104
|
+
## Goal
|
|
105
|
+
|
|
106
|
+
${goal}
|
|
107
|
+
|
|
108
|
+
## Context
|
|
109
|
+
|
|
110
|
+
${context}
|
|
111
|
+
|
|
112
|
+
## Acceptance criteria
|
|
113
|
+
|
|
114
|
+
${acceptanceLines}
|
|
115
|
+
|
|
116
|
+
## Files to touch
|
|
117
|
+
|
|
118
|
+
{/* List files to change */}
|
|
119
|
+
|
|
120
|
+
## Safety
|
|
121
|
+
|
|
122
|
+
${safetyLines}
|
|
123
|
+
|
|
124
|
+
## Agent progress
|
|
125
|
+
|
|
126
|
+
{/* Agents append progress notes here */}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
// ─── Placeholder detection ──────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Returns true if the MDX still contains unfilled placeholder comments.
|
|
132
|
+
* Used by kanban_add to detect "empty template" state.
|
|
133
|
+
*/
|
|
134
|
+
export function extractTemplate(mdx) {
|
|
135
|
+
return /\{\/\*.*?\*\/\}/s.test(mdx);
|
|
136
|
+
}
|
|
137
|
+
// ─── Parse hints (for frontend help panel) ─────────────────────────────────
|
|
138
|
+
/** Hints shown in the frontend template help panel. */
|
|
139
|
+
export const TEMPLATE_PARSE_HINTS = [
|
|
140
|
+
"<Ask> — Ask the user a single structured question; blocks until answered",
|
|
141
|
+
"<Choice> — Present radio options; user picks one",
|
|
142
|
+
"<Input> — Free-form text input from the user",
|
|
143
|
+
"<Confirm> — Yes/no confirmation from the user",
|
|
144
|
+
"<Preview> — Inline interactive TSX preview (live in the browser)",
|
|
145
|
+
];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// OpenKan — TSX compile + sandbox iframe HTML builder.
|
|
2
|
+
// ─── Hook detection (compile-time safety) ────────────────────────────────────
|
|
3
|
+
const HOOK_PATTERN = /\buse[A-Z][a-zA-Z0-9_]*/;
|
|
4
|
+
const BUILTIN_FUNCTIONS = new Set([
|
|
5
|
+
"Button", "Card", "Row", "Column", "Text", "Heading", "Image", "ColorSwatch", "Code",
|
|
6
|
+
"h", "render", "respond",
|
|
7
|
+
]);
|
|
8
|
+
function checkHooks(source) {
|
|
9
|
+
// Find all useXXX identifiers that aren't in BUILTIN_FUNCTIONS
|
|
10
|
+
const matches = source.matchAll(/\buse[A-Z][a-zA-Z0-9_]*/g);
|
|
11
|
+
for (const m of matches) {
|
|
12
|
+
const name = m[0];
|
|
13
|
+
if (!BUILTIN_FUNCTIONS.has(name)) {
|
|
14
|
+
return `Hooks (${name}) are not supported in sandbox TSX. Use plain functions instead.`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// ─── Compile TSX ─────────────────────────────────────────────────────────────
|
|
20
|
+
const MAX_BYTES_DEFAULT = 32768;
|
|
21
|
+
export async function compileTsx(source, opts) {
|
|
22
|
+
const maxBytes = opts?.maxBytes ?? MAX_BYTES_DEFAULT;
|
|
23
|
+
if (Buffer.byteLength(source, "utf-8") > maxBytes) {
|
|
24
|
+
return { error: `TSX source exceeds ${maxBytes} bytes limit.` };
|
|
25
|
+
}
|
|
26
|
+
const hookError = checkHooks(source);
|
|
27
|
+
if (hookError)
|
|
28
|
+
return { error: hookError };
|
|
29
|
+
try {
|
|
30
|
+
// Lazy import sucrase
|
|
31
|
+
const { default: sucrase } = await import("sucrase");
|
|
32
|
+
const result = sucrase.transform(source, {
|
|
33
|
+
transforms: ["typescript", "jsx"],
|
|
34
|
+
production: false,
|
|
35
|
+
// Map JSX to the runtime's h() function so we don't need React.
|
|
36
|
+
jsxPragma: "h",
|
|
37
|
+
jsxFragmentPragma: "Fragment",
|
|
38
|
+
});
|
|
39
|
+
let js = result.code;
|
|
40
|
+
// Defensive: if any React.createElement leaked through, replace it.
|
|
41
|
+
js = js.replace(/React\.createElement\b/g, "h")
|
|
42
|
+
.replace(/React\.Fragment\b/g, "Fragment");
|
|
43
|
+
if (Buffer.byteLength(js, "utf-8") > maxBytes * 2) {
|
|
44
|
+
return { error: `Compiled JS exceeds ${maxBytes * 2} bytes after transpilation.` };
|
|
45
|
+
}
|
|
46
|
+
return { js };
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
return { error: `Compile error: ${e?.message ?? String(e)}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ─── BUILTIN_LIBRARY ─────────────────────────────────────────────────────────
|
|
53
|
+
const BUILTIN_LIBRARY = `
|
|
54
|
+
const Button = (props) => h('button', { onClick: props.onClick, class: 'ok-btn', type: 'button' }, props.label ?? props.children);
|
|
55
|
+
const Card = (props) => h('div', { class: 'ok-card' }, props.children);
|
|
56
|
+
const Row = (props) => h('div', { class: 'ok-row', style: 'display:flex;gap:8px;align-items:center' }, props.children);
|
|
57
|
+
const Column = (props) => h('div', { class: 'ok-col', style: 'display:flex;flex-direction:column;gap:8px' }, props.children);
|
|
58
|
+
const Text = (props) => h('span', { class: 'ok-text' }, props.children ?? props.text);
|
|
59
|
+
const Heading = (props) => h('h3', { class: 'ok-heading' }, props.children ?? props.text);
|
|
60
|
+
const Image = (props) => h('img', { class: 'ok-img', src: props.src, alt: props.alt ?? '' });
|
|
61
|
+
const ColorSwatch = (props) => h('div', { class: 'ok-swatch', style: 'width:32px;height:32px;border-radius:4px;background:' + (props.color || '#ccc') });
|
|
62
|
+
const Code = (props) => h('pre', { class: 'ok-code' }, h('code', {}, props.children ?? props.text));
|
|
63
|
+
`;
|
|
64
|
+
// ─── Runtime (tiny React-like, no hooks) ─────────────────────────────────────
|
|
65
|
+
const RUNTIME = `
|
|
66
|
+
// Minimal h() and render() runtime — no hooks, no reconciliation.
|
|
67
|
+
// h(type, props, ...children) => vnode
|
|
68
|
+
// render(vnode, root) => void
|
|
69
|
+
function Fragment(props) { return props.children; }
|
|
70
|
+
function h(type, props, ...children) {
|
|
71
|
+
return { type, props: props || {}, children: children.flat().filter(c => c != null && c !== false && c !== '') };
|
|
72
|
+
}
|
|
73
|
+
function render(vnode, root) {
|
|
74
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') {
|
|
75
|
+
root.textContent = String(vnode);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!vnode || !vnode.type) { root.textContent = ''; return; }
|
|
79
|
+
const el = document.createElement(vnode.type);
|
|
80
|
+
const { props, children } = vnode;
|
|
81
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
82
|
+
if (k === 'style' && typeof v === 'string') {
|
|
83
|
+
v.split(';').forEach(s => { const [pk, pv] = s.split(':'); if (pk && pv) el.style[pk.trim()] = pv.trim(); });
|
|
84
|
+
} else if (k.startsWith('on') && typeof v === 'function') {
|
|
85
|
+
el.addEventListener(k.slice(2).toLowerCase(), v);
|
|
86
|
+
} else if (k !== 'class') {
|
|
87
|
+
el.setAttribute(k, String(v));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (props && props.class) el.className = props.class;
|
|
91
|
+
const childRoot = document.createDocumentFragment();
|
|
92
|
+
for (const child of children || []) {
|
|
93
|
+
render(child, childRoot);
|
|
94
|
+
el.appendChild(childRoot);
|
|
95
|
+
}
|
|
96
|
+
root.appendChild(el);
|
|
97
|
+
}
|
|
98
|
+
function respond(value) {
|
|
99
|
+
parent.postMessage({ type: 'openkan:respond', version: 1, value }, '*');
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
// ─── Sandbox HTML builder ─────────────────────────────────────────────────────
|
|
103
|
+
function esc(s) {
|
|
104
|
+
return s
|
|
105
|
+
.replace(/<\/script>/gi, "<\\/script>")
|
|
106
|
+
.replace(/<\/iframe>/gi, "<\\/iframe>")
|
|
107
|
+
.replace(/`/g, "\\`")
|
|
108
|
+
.replace(/\\/g, "\\\\");
|
|
109
|
+
}
|
|
110
|
+
export function buildSandboxHtml(js, propsJson) {
|
|
111
|
+
const escapedJs = esc(js);
|
|
112
|
+
const escapedProps = esc(propsJson);
|
|
113
|
+
// Wrap user code: the user's component is assigned to window
|
|
114
|
+
const wrappedJs = `\
|
|
115
|
+
const App = (function() {
|
|
116
|
+
${escapedJs}
|
|
117
|
+
})();
|
|
118
|
+
window.render = function(props) {
|
|
119
|
+
const root = document.getElementById('root');
|
|
120
|
+
root.innerHTML = '';
|
|
121
|
+
const v = App(typeof props === 'string' ? JSON.parse(props) : props);
|
|
122
|
+
render(v, root);
|
|
123
|
+
};
|
|
124
|
+
window.addEventListener('message', function(e) {
|
|
125
|
+
if (e.data && e.data.type === 'openkan:props' && e.data.version === 1) {
|
|
126
|
+
try {
|
|
127
|
+
window.render(e.data.props);
|
|
128
|
+
} catch(err) {
|
|
129
|
+
document.body.textContent = 'Render error: ' + err.message;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
`;
|
|
134
|
+
return `<!doctype html>
|
|
135
|
+
<html>
|
|
136
|
+
<head>
|
|
137
|
+
<meta charset="utf-8">
|
|
138
|
+
<title>preview</title>
|
|
139
|
+
<style>
|
|
140
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
141
|
+
body{font-family:system-ui,sans-serif;padding:16px;background:#fafafa}
|
|
142
|
+
.ok-btn{background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:4px;cursor:pointer;font-size:14px}
|
|
143
|
+
.ok-btn:hover{background:#1d4ed8}
|
|
144
|
+
.ok-card{border:1px solid #e5e7eb;border-radius:8px;padding:16px;background:#fff}
|
|
145
|
+
.ok-row{display:flex;gap:8px;align-items:center}
|
|
146
|
+
.ok-col{display:flex;flex-direction:column;gap:8px}
|
|
147
|
+
.ok-text{font-size:14px;color:#374151}
|
|
148
|
+
.ok-heading{font-size:18px;font-weight:600;color:#111}
|
|
149
|
+
.ok-img{max-width:100%;border-radius:4px}
|
|
150
|
+
.ok-swatch{border-radius:4px}
|
|
151
|
+
.ok-code{background:#1f2937;color:#f9fafb;padding:12px;border-radius:6px;overflow-x:auto;font-size:13px}
|
|
152
|
+
</style>
|
|
153
|
+
<script>${BUILTIN_LIBRARY}</script>
|
|
154
|
+
<script>${RUNTIME}</script>
|
|
155
|
+
<script>
|
|
156
|
+
window.ok = { Button, Card, Row, Column, Text, Heading, Image, ColorSwatch, Code, respond, h, render };
|
|
157
|
+
try {
|
|
158
|
+
${wrappedJs}
|
|
159
|
+
} catch(e) {
|
|
160
|
+
document.body.textContent = 'Preview error: ' + e.message;
|
|
161
|
+
}
|
|
162
|
+
</script>
|
|
163
|
+
</head>
|
|
164
|
+
<body>
|
|
165
|
+
<div id="root"></div>
|
|
166
|
+
<script>
|
|
167
|
+
// Initial props from server
|
|
168
|
+
try { window.render && window.render(${escapedProps || '{}'}); } catch(e) {}
|
|
169
|
+
</script>
|
|
170
|
+
</body>
|
|
171
|
+
</html>`;
|
|
172
|
+
}
|
|
173
|
+
// ─── Full preview pipeline ─────────────────────────────────────────────────────
|
|
174
|
+
export async function buildPreview(source, props) {
|
|
175
|
+
const compileResult = await compileTsx(source);
|
|
176
|
+
if (compileResult.error)
|
|
177
|
+
return { error: compileResult.error };
|
|
178
|
+
const js = compileResult.js;
|
|
179
|
+
const propsJson = JSON.stringify(props ?? {});
|
|
180
|
+
try {
|
|
181
|
+
const sandboxHtml = buildSandboxHtml(js, propsJson);
|
|
182
|
+
return { js, sandboxHtml };
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
return { error: `Sandbox error: ${e?.message ?? String(e)}` };
|
|
186
|
+
}
|
|
187
|
+
}
|