@dreki-gg/pi-plan-mode 0.15.0 → 0.16.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 +17 -0
- package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +3 -3
- package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
- package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
- package/extensions/plan-mode/__tests__/phase-transitions.test.ts +14 -3
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +31 -5
- package/extensions/plan-mode/__tests__/prompts.test.ts +21 -3
- package/extensions/plan-mode/__tests__/task-storage.test.ts +23 -4
- package/extensions/plan-mode/__tests__/types.test.ts +3 -1
- package/extensions/plan-mode/__tests__/utils.test.ts +3 -1
- package/extensions/plan-mode/constants.ts +1 -0
- package/extensions/plan-mode/context-filter.ts +1 -4
- package/extensions/plan-mode/html/render.ts +10 -97
- package/extensions/plan-mode/html/templates/prototype.pug +25 -0
- package/extensions/plan-mode/index.ts +78 -17
- package/extensions/plan-mode/phase-transitions.ts +9 -5
- package/extensions/plan-mode/plan-storage.ts +6 -1
- package/extensions/plan-mode/prompts.ts +5 -6
- package/extensions/plan-mode/resume.ts +20 -5
- package/extensions/plan-mode/state.ts +1 -3
- package/extensions/plan-mode/storage/atomic-write.ts +5 -1
- package/extensions/plan-mode/storage/plan-storage.ts +3 -1
- package/extensions/plan-mode/storage/plans-manifest.ts +26 -6
- package/extensions/plan-mode/storage/task-storage.ts +29 -6
- package/extensions/plan-mode/tools/preview-prototype.ts +91 -0
- package/extensions/plan-mode/tools/submit-plan.ts +33 -13
- package/extensions/plan-mode/tools/update-task.ts +51 -11
- package/package.json +1 -1
- package/skills/planning-context/SKILL.md +42 -0
- package/skills/visual-prototype/SKILL.md +45 -0
- package/extensions/plan-mode/html/templates/plan.pug +0 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5c70b28: Reframe plan-mode HTML as a planning-phase visual aid instead of a finalization receipt.
|
|
8
|
+
|
|
9
|
+
- `submit_plan` no longer generates `plan.html`. It writes only `tasks.jsonl`, `HANDOFF.md`, and the manifest entry. The previous HTML duplicated the handoff and task list (already tracked elsewhere) and was never opened.
|
|
10
|
+
- Added a `preview_prototype` tool, available during planning. It renders self-contained Pug to a standalone HTML visual aid under `.plans/_prototypes/`, opens it, and notifies the path — so the user can react to a UI/component/style design _before_ the plan hardens.
|
|
11
|
+
- Added a bundled `visual-prototype` skill that routes UI/component/layout/style planning work to `preview_prototype` before `submit_plan`.
|
|
12
|
+
- Added a bundled `planning-context` skill that drives the living `context.md` deliberation discipline (intent, decisions, constraints, open questions, discarded options).
|
|
13
|
+
|
|
14
|
+
## 0.15.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Fix execution stopping after the final task is marked done. `update_task` no longer terminates the turn merely because the task queue is empty, so the agent can run its closing summary / validation pass before the `agent_end` completion handler takes over. The `blocked` branch still terminates to pause for user input.
|
|
19
|
+
|
|
3
20
|
## 0.15.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
|
@@ -102,9 +102,9 @@ describe('agent_end handler safety', () => {
|
|
|
102
102
|
if (violations.length > 0) {
|
|
103
103
|
throw new Error(
|
|
104
104
|
`Found sendUserMessage calls inside agent_end without deliverAs.\n` +
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
`The agent is still "processing" during agent_end, so deliverAs is required.\n\n` +
|
|
106
|
+
`Violations:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
|
|
107
|
+
`Fix: add { deliverAs: 'followUp' } to each call.`,
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
110
|
});
|
|
@@ -1,43 +1,34 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
},
|
|
19
|
-
],
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
describe('renderPlanHtml', () => {
|
|
23
|
-
test('renders title, task count, handoff, and tasks', () => {
|
|
24
|
-
const html = renderPlanHtml(plan);
|
|
25
|
-
|
|
26
|
-
expect(html).toContain('Refactor Auth');
|
|
27
|
-
expect(html).toContain('1 task');
|
|
28
|
-
expect(html).toContain('Ship carefully.');
|
|
29
|
-
expect(html).toContain('t-001');
|
|
30
|
-
expect(html).toContain('Add auth middleware');
|
|
2
|
+
import { renderPrototypeHtml } from '../html/render.js';
|
|
3
|
+
|
|
4
|
+
describe('renderPrototypeHtml', () => {
|
|
5
|
+
test('renders title and intent in a minimal header', () => {
|
|
6
|
+
const html = renderPrototypeHtml('Sidebar redesign', 'Left-aligned nav with icons', '.nav Nav');
|
|
7
|
+
|
|
8
|
+
expect(html).toContain('Sidebar redesign');
|
|
9
|
+
expect(html).toContain('Left-aligned nav with icons');
|
|
10
|
+
expect(html).toContain('Prototype');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('renders the Pug body markup', () => {
|
|
14
|
+
const html = renderPrototypeHtml('Card', 'A product card', '.card Product card');
|
|
15
|
+
|
|
16
|
+
expect(html).toContain('class="card"');
|
|
17
|
+
expect(html).toContain('Product card');
|
|
31
18
|
});
|
|
32
19
|
|
|
33
|
-
test('
|
|
34
|
-
|
|
20
|
+
test('does not render tasks or handoff sections', () => {
|
|
21
|
+
const html = renderPrototypeHtml('Card', 'A product card', '.card Hello');
|
|
22
|
+
|
|
23
|
+
expect(html).not.toContain('Handoff');
|
|
24
|
+
expect(html).not.toContain('Tasks');
|
|
25
|
+
expect(html).not.toContain('Depends on');
|
|
35
26
|
});
|
|
36
27
|
|
|
37
|
-
test('
|
|
38
|
-
const html =
|
|
28
|
+
test('handles an empty prototype body without throwing', () => {
|
|
29
|
+
const html = renderPrototypeHtml('Empty', 'Nothing yet', '');
|
|
39
30
|
|
|
40
|
-
expect(html).toContain('
|
|
41
|
-
expect(html).toContain('
|
|
31
|
+
expect(html).toContain('Empty');
|
|
32
|
+
expect(html).toContain('Nothing yet');
|
|
42
33
|
});
|
|
43
34
|
});
|
|
@@ -45,3 +45,54 @@ describe('bundled technical-options skill', () => {
|
|
|
45
45
|
expect(content).toMatch(/challenge.*framing|framing.*challenge/i);
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
describe('bundled visual-prototype skill', () => {
|
|
50
|
+
const skillDir = join(PKG_ROOT, 'skills', 'visual-prototype');
|
|
51
|
+
const skillFile = join(skillDir, 'SKILL.md');
|
|
52
|
+
|
|
53
|
+
test('SKILL.md exists', () => {
|
|
54
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('has valid frontmatter with name and description', () => {
|
|
58
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
59
|
+
expect(content).toMatch(/^---\n/);
|
|
60
|
+
expect(content).toMatch(/name:\s*visual-prototype/);
|
|
61
|
+
expect(content).toMatch(/description:/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('routes on visual work, not backend-only', () => {
|
|
65
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
66
|
+
expect(content).toMatch(/description:.*\b(UI|component|layout|style)\b/i);
|
|
67
|
+
expect(content).toMatch(/not for.*backend|backend-only/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('directs use of preview_prototype before submit_plan', () => {
|
|
71
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
72
|
+
expect(content).toContain('preview_prototype');
|
|
73
|
+
expect(content).toContain('submit_plan');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('bundled planning-context skill', () => {
|
|
78
|
+
const skillDir = join(PKG_ROOT, 'skills', 'planning-context');
|
|
79
|
+
const skillFile = join(skillDir, 'SKILL.md');
|
|
80
|
+
|
|
81
|
+
test('SKILL.md exists', () => {
|
|
82
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('has valid frontmatter with name and description', () => {
|
|
86
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
87
|
+
expect(content).toMatch(/^---\n/);
|
|
88
|
+
expect(content).toMatch(/name:\s*planning-context/);
|
|
89
|
+
expect(content).toMatch(/description:/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('covers context.md deliberation sections', () => {
|
|
93
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
94
|
+
expect(content).toContain('context.md');
|
|
95
|
+
expect(content).toMatch(/discarded options/i);
|
|
96
|
+
expect(content).toMatch(/open questions/i);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -4,10 +4,21 @@ import type { PlanData, TaskRecord } from '../types.js';
|
|
|
4
4
|
|
|
5
5
|
function makePlan(overrides?: Partial<PlanData>): PlanData {
|
|
6
6
|
const task: TaskRecord = {
|
|
7
|
-
_type: 'task',
|
|
8
|
-
|
|
7
|
+
_type: 'task',
|
|
8
|
+
id: 't-001',
|
|
9
|
+
description: 'Do work',
|
|
10
|
+
details: 'Details',
|
|
11
|
+
status: 'pending',
|
|
12
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
13
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
title: 'Test Plan',
|
|
17
|
+
planName: 'test-plan',
|
|
18
|
+
handoff: '# Handoff',
|
|
19
|
+
tasks: [task],
|
|
20
|
+
...overrides,
|
|
9
21
|
};
|
|
10
|
-
return { title: 'Test Plan', planName: 'test-plan', handoff: '# Handoff', tasks: [task], ...overrides };
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
describe('PlanModeState', () => {
|
|
@@ -3,17 +3,34 @@ import { chdir } from 'node:process';
|
|
|
3
3
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
readPlansManifest,
|
|
8
|
+
upsertPlanEntry,
|
|
9
|
+
writePlansManifest,
|
|
10
|
+
} from '../storage/plans-manifest.js';
|
|
7
11
|
|
|
8
12
|
const originalCwd = process.cwd();
|
|
9
13
|
let dir: string;
|
|
10
14
|
|
|
11
|
-
beforeEach(async () => {
|
|
12
|
-
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-'));
|
|
17
|
+
chdir(dir);
|
|
18
|
+
});
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
chdir(originalCwd);
|
|
21
|
+
await rm(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
13
23
|
|
|
14
24
|
describe('plans.jsonl manifest', () => {
|
|
15
25
|
test('round trips entries', async () => {
|
|
16
|
-
const entry = {
|
|
26
|
+
const entry = {
|
|
27
|
+
_type: 'plan' as const,
|
|
28
|
+
name: 'refactor',
|
|
29
|
+
status: 'in-progress' as const,
|
|
30
|
+
title: 'Refactor',
|
|
31
|
+
created_at: 'now',
|
|
32
|
+
completed_at: null,
|
|
33
|
+
};
|
|
17
34
|
await writePlansManifest([entry]);
|
|
18
35
|
await expect(readPlansManifest()).resolves.toEqual([entry]);
|
|
19
36
|
});
|
|
@@ -27,7 +44,16 @@ describe('plans.jsonl manifest', () => {
|
|
|
27
44
|
});
|
|
28
45
|
|
|
29
46
|
test('upserts existing entries without changing created_at', async () => {
|
|
30
|
-
await writePlansManifest([
|
|
47
|
+
await writePlansManifest([
|
|
48
|
+
{
|
|
49
|
+
_type: 'plan',
|
|
50
|
+
name: 'p',
|
|
51
|
+
status: 'in-progress',
|
|
52
|
+
title: 'Old',
|
|
53
|
+
created_at: 'created',
|
|
54
|
+
completed_at: null,
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
31
57
|
await upsertPlanEntry('p', { status: 'done', title: 'New' });
|
|
32
58
|
const [entry] = await readPlansManifest();
|
|
33
59
|
expect(entry.created_at).toBe('created');
|
|
@@ -5,7 +5,15 @@ import type { PlanData, TaskRecord } from '../types.js';
|
|
|
5
5
|
|
|
6
6
|
const now = '2026-01-01T00:00:00Z';
|
|
7
7
|
function makeTask(overrides?: Partial<TaskRecord>): TaskRecord {
|
|
8
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
_type: 'task',
|
|
10
|
+
id: 't-001',
|
|
11
|
+
description: 'Do work',
|
|
12
|
+
status: 'pending',
|
|
13
|
+
created_at: now,
|
|
14
|
+
updated_at: now,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
describe('buildPlanModePrompt', () => {
|
|
@@ -54,13 +62,23 @@ describe('buildExecutionPrompt', () => {
|
|
|
54
62
|
});
|
|
55
63
|
|
|
56
64
|
test('includes Details line when task has details', () => {
|
|
57
|
-
const plan: PlanData = {
|
|
65
|
+
const plan: PlanData = {
|
|
66
|
+
title: 'Test',
|
|
67
|
+
planName: 'test',
|
|
68
|
+
handoff: '# H',
|
|
69
|
+
tasks: [makeTask({ details: 'Full instructions here' })],
|
|
70
|
+
};
|
|
58
71
|
const prompt = buildExecutionPrompt(plan)!;
|
|
59
72
|
expect(prompt).toContain('Details: Full instructions here');
|
|
60
73
|
});
|
|
61
74
|
|
|
62
75
|
test('returns undefined when no pending tasks', () => {
|
|
63
|
-
const plan: PlanData = {
|
|
76
|
+
const plan: PlanData = {
|
|
77
|
+
title: 'Test',
|
|
78
|
+
planName: 'test',
|
|
79
|
+
handoff: '# H',
|
|
80
|
+
tasks: [makeTask({ status: 'done' })],
|
|
81
|
+
};
|
|
64
82
|
expect(buildExecutionPrompt(plan)).toBeUndefined();
|
|
65
83
|
});
|
|
66
84
|
});
|
|
@@ -8,10 +8,22 @@ import type { TaskMeta, TaskRecord } from '../types.js';
|
|
|
8
8
|
let dir: string;
|
|
9
9
|
const now = '2026-05-27T12:00:00.000Z';
|
|
10
10
|
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
11
|
-
const task: TaskRecord = {
|
|
11
|
+
const task: TaskRecord = {
|
|
12
|
+
_type: 'task',
|
|
13
|
+
id: 't-001',
|
|
14
|
+
description: 'Do work',
|
|
15
|
+
details: 'Details',
|
|
16
|
+
status: 'pending',
|
|
17
|
+
created_at: now,
|
|
18
|
+
updated_at: now,
|
|
19
|
+
};
|
|
12
20
|
|
|
13
|
-
beforeEach(async () => {
|
|
14
|
-
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-'));
|
|
23
|
+
});
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await rm(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
15
27
|
|
|
16
28
|
describe('tasks.jsonl storage', () => {
|
|
17
29
|
test('round trips meta and tasks', async () => {
|
|
@@ -20,7 +32,14 @@ describe('tasks.jsonl storage', () => {
|
|
|
20
32
|
});
|
|
21
33
|
|
|
22
34
|
test('round trips tasks without details (lightweight checklist)', async () => {
|
|
23
|
-
const lightweight: TaskRecord = {
|
|
35
|
+
const lightweight: TaskRecord = {
|
|
36
|
+
_type: 'task',
|
|
37
|
+
id: 't-002',
|
|
38
|
+
description: 'Quick fix',
|
|
39
|
+
status: 'pending',
|
|
40
|
+
created_at: now,
|
|
41
|
+
updated_at: now,
|
|
42
|
+
};
|
|
24
43
|
await writeTasksJsonl(dir, meta, [lightweight]);
|
|
25
44
|
const result = await readTasksJsonl(dir);
|
|
26
45
|
expect(result?.tasks[0]?.id).toBe('t-002');
|
|
@@ -63,6 +63,8 @@ describe('task meta type guard', () => {
|
|
|
63
63
|
|
|
64
64
|
test('rejects malformed meta records', () => {
|
|
65
65
|
expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
|
|
66
|
-
expect(
|
|
66
|
+
expect(
|
|
67
|
+
isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now }),
|
|
68
|
+
).toBe(false);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
@@ -14,7 +14,9 @@ describe('isSafeCommand', () => {
|
|
|
14
14
|
|
|
15
15
|
test('command with 2>/dev/null stderr redirect', () => {
|
|
16
16
|
expect(
|
|
17
|
-
isSafeCommand(
|
|
17
|
+
isSafeCommand(
|
|
18
|
+
'cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null',
|
|
19
|
+
),
|
|
18
20
|
).toBe(true);
|
|
19
21
|
});
|
|
20
22
|
|
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** Drop everything before the execution start index. */
|
|
7
|
-
export function filterExecutionMessages<T>(
|
|
8
|
-
messages: T[],
|
|
9
|
-
executionStartIdx: number,
|
|
10
|
-
): T[] {
|
|
7
|
+
export function filterExecutionMessages<T>(messages: T[], executionStartIdx: number): T[] {
|
|
11
8
|
return messages.filter((_m, i) => i >= executionStartIdx);
|
|
12
9
|
}
|
|
13
10
|
|
|
@@ -2,110 +2,23 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import pug from 'pug';
|
|
5
|
-
import type { PlanData } from '../types.js';
|
|
6
5
|
|
|
7
|
-
const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', '
|
|
6
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'prototype.pug');
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Renders a standalone prototype preview: a minimal header (title + one-line
|
|
10
|
+
* intent) wrapping the rendered Pug body. This is a planning-phase visual aid,
|
|
11
|
+
* not a plan dump — no tasks, no handoff.
|
|
12
|
+
*/
|
|
13
|
+
export function renderPrototypeHtml(title: string, intent: string, pugBody: string): string {
|
|
10
14
|
const template = readFileSync(templatePath, 'utf8');
|
|
11
|
-
const prototypeHtml =
|
|
15
|
+
const prototypeHtml = pugBody.trim() ? pug.render(pugBody) : '';
|
|
12
16
|
|
|
13
17
|
return pug.render(template, {
|
|
14
18
|
filename: templatePath,
|
|
15
|
-
|
|
19
|
+
title,
|
|
20
|
+
intent,
|
|
16
21
|
prototypeHtml,
|
|
17
|
-
handoffHtml: markdownToHtml(plan.handoff),
|
|
18
|
-
taskCountLabel: `${plan.tasks.length} ${plan.tasks.length === 1 ? 'task' : 'tasks'}`,
|
|
19
22
|
generatedAt: new Date().toISOString(),
|
|
20
23
|
});
|
|
21
24
|
}
|
|
22
|
-
|
|
23
|
-
function markdownToHtml(markdown: string): string {
|
|
24
|
-
const lines = markdown.split(/\r?\n/);
|
|
25
|
-
const html: string[] = [];
|
|
26
|
-
let inList = false;
|
|
27
|
-
let inCode = false;
|
|
28
|
-
let codeLang = '';
|
|
29
|
-
const codeLines: string[] = [];
|
|
30
|
-
|
|
31
|
-
for (const line of lines) {
|
|
32
|
-
// Fenced code blocks
|
|
33
|
-
if (line.startsWith('```')) {
|
|
34
|
-
if (!inCode) {
|
|
35
|
-
if (inList) { html.push('</ul>'); inList = false; }
|
|
36
|
-
inCode = true;
|
|
37
|
-
codeLang = line.slice(3).trim();
|
|
38
|
-
codeLines.length = 0;
|
|
39
|
-
} else {
|
|
40
|
-
const langAttr = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : '';
|
|
41
|
-
html.push(`<pre><code${langAttr}>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
|
|
42
|
-
inCode = false;
|
|
43
|
-
codeLang = '';
|
|
44
|
-
}
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (inCode) {
|
|
49
|
-
codeLines.push(line);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Headings
|
|
54
|
-
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
55
|
-
if (headingMatch) {
|
|
56
|
-
if (inList) { html.push('</ul>'); inList = false; }
|
|
57
|
-
const level = headingMatch[1].length;
|
|
58
|
-
html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// List items (- or *)
|
|
63
|
-
const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
|
64
|
-
if (listMatch) {
|
|
65
|
-
if (!inList) { html.push('<ul>'); inList = true; }
|
|
66
|
-
html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Blank line
|
|
71
|
-
if (!line.trim()) {
|
|
72
|
-
if (inList) { html.push('</ul>'); inList = false; }
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Paragraph
|
|
77
|
-
if (inList) { html.push('</ul>'); inList = false; }
|
|
78
|
-
html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Close unclosed blocks
|
|
82
|
-
if (inCode) {
|
|
83
|
-
html.push(`<pre><code>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
|
|
84
|
-
}
|
|
85
|
-
if (inList) html.push('</ul>');
|
|
86
|
-
return html.join('\n');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
|
|
90
|
-
function inlineMarkdown(text: string): string {
|
|
91
|
-
return text
|
|
92
|
-
// Inline code: `code`
|
|
93
|
-
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
94
|
-
// Bold: **text** or __text__
|
|
95
|
-
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
96
|
-
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
97
|
-
// Italic: *text* or _text_
|
|
98
|
-
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
99
|
-
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
100
|
-
// Links: [text](url)
|
|
101
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function escapeHtml(value: string): string {
|
|
105
|
-
return value
|
|
106
|
-
.replaceAll('&', '&')
|
|
107
|
-
.replaceAll('<', '<')
|
|
108
|
-
.replaceAll('>', '>')
|
|
109
|
-
.replaceAll('"', '"')
|
|
110
|
-
.replaceAll("'", ''');
|
|
111
|
-
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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")
|
|
6
|
+
title= title
|
|
7
|
+
style.
|
|
8
|
+
:root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
11
|
+
main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
|
|
12
|
+
header { margin-bottom:24px; }
|
|
13
|
+
h1 { font-size:30px; line-height:1.1; margin:0 0 8px; letter-spacing:-0.04em; }
|
|
14
|
+
.intent { color:var(--muted); margin:0; }
|
|
15
|
+
.badge { display:inline-block; border:1px solid var(--line); border-radius:999px; padding:5px 10px; background:rgba(255,255,255,0.03); color:var(--muted); font-size:12px; margin-bottom:14px; }
|
|
16
|
+
.prototype { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
|
|
17
|
+
body
|
|
18
|
+
main
|
|
19
|
+
header
|
|
20
|
+
span.badge Prototype · #{generatedAt}
|
|
21
|
+
h1= title
|
|
22
|
+
if intent
|
|
23
|
+
p.intent= intent
|
|
24
|
+
section.prototype
|
|
25
|
+
!= prototypeHtml
|