@dreki-gg/pi-plan-mode 0.15.1 → 0.17.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 +19 -0
- package/extensions/plan-mode/__tests__/add-task.test.ts +75 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +6 -4
- 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__/plan-storage.test.ts +57 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
- package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
- package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
- package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
- package/extensions/plan-mode/constants.ts +2 -1
- package/extensions/plan-mode/effects/filesystem.ts +65 -0
- package/extensions/plan-mode/effects/runtime.ts +24 -0
- package/extensions/plan-mode/errors.ts +105 -0
- package/extensions/plan-mode/html/render.ts +10 -114
- package/extensions/plan-mode/html/templates/prototype.pug +25 -0
- package/extensions/plan-mode/index.ts +151 -48
- package/extensions/plan-mode/prompts.ts +5 -3
- package/extensions/plan-mode/resume.ts +19 -15
- package/extensions/plan-mode/schema.ts +57 -0
- package/extensions/plan-mode/storage/atomic-write.ts +19 -1
- package/extensions/plan-mode/storage/plan-storage.ts +57 -29
- package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
- package/extensions/plan-mode/storage/task-storage.ts +83 -45
- package/extensions/plan-mode/task-status.ts +46 -0
- package/extensions/plan-mode/tools/add-task.ts +95 -0
- package/extensions/plan-mode/tools/preview-prototype.ts +98 -0
- package/extensions/plan-mode/tools/submit-plan.ts +18 -16
- package/extensions/plan-mode/types.ts +8 -38
- package/extensions/plan-mode/utils.ts +20 -0
- package/package.json +2 -1
- package/skills/planning-context/SKILL.md +42 -0
- package/skills/visual-prototype/SKILL.md +45 -0
- package/extensions/plan-mode/__tests__/types.test.ts +0 -70
- package/extensions/plan-mode/html/templates/plan.pug +0 -69
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Cause, Effect, Exit, Option } from 'effect';
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
6
|
+
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
5
7
|
import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
|
|
6
8
|
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
7
9
|
|
|
10
|
+
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
11
|
+
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
12
|
+
|
|
13
|
+
const runExit = <A, E>(program: Effect.Effect<A, E, FileSystem>) =>
|
|
14
|
+
Effect.runPromiseExit(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
15
|
+
|
|
16
|
+
const failureTag = <A, E>(exit: Exit.Exit<A, E>): string | undefined => {
|
|
17
|
+
if (!Exit.isFailure(exit)) return undefined;
|
|
18
|
+
const error = Option.getOrUndefined(Cause.failureOption(exit.cause)) as
|
|
19
|
+
| { _tag?: string }
|
|
20
|
+
| undefined;
|
|
21
|
+
return error?._tag;
|
|
22
|
+
};
|
|
23
|
+
|
|
8
24
|
let dir: string;
|
|
9
25
|
const now = '2026-05-27T12:00:00.000Z';
|
|
10
26
|
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
@@ -27,8 +43,8 @@ afterEach(async () => {
|
|
|
27
43
|
|
|
28
44
|
describe('tasks.jsonl storage', () => {
|
|
29
45
|
test('round trips meta and tasks', async () => {
|
|
30
|
-
await writeTasksJsonl(dir, meta, [task]);
|
|
31
|
-
await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
|
|
46
|
+
await run(writeTasksJsonl(dir, meta, [task]));
|
|
47
|
+
await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
|
|
32
48
|
});
|
|
33
49
|
|
|
34
50
|
test('round trips tasks without details (lightweight checklist)', async () => {
|
|
@@ -40,32 +56,39 @@ describe('tasks.jsonl storage', () => {
|
|
|
40
56
|
created_at: now,
|
|
41
57
|
updated_at: now,
|
|
42
58
|
};
|
|
43
|
-
await writeTasksJsonl(dir, meta, [lightweight]);
|
|
44
|
-
const result = await readTasksJsonl(dir);
|
|
59
|
+
await run(writeTasksJsonl(dir, meta, [lightweight]));
|
|
60
|
+
const result = await run(readTasksJsonl(dir));
|
|
45
61
|
expect(result?.tasks[0]?.id).toBe('t-002');
|
|
46
62
|
expect(result?.tasks[0]?.details).toBeUndefined();
|
|
47
63
|
});
|
|
48
64
|
|
|
49
65
|
test('missing file returns undefined', async () => {
|
|
50
|
-
await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
|
|
66
|
+
await expect(run(readTasksJsonl(dir))).resolves.toBeUndefined();
|
|
51
67
|
});
|
|
52
68
|
|
|
53
|
-
test('rejects corrupt lines', async () => {
|
|
69
|
+
test('rejects corrupt lines with JsonlParseError', async () => {
|
|
54
70
|
await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
|
|
55
|
-
await
|
|
71
|
+
expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('JsonlParseError');
|
|
56
72
|
});
|
|
57
73
|
|
|
58
|
-
test('rejects empty files', async () => {
|
|
74
|
+
test('rejects empty files with MissingMetaRecord', async () => {
|
|
59
75
|
await Bun.write(join(dir, 'tasks.jsonl'), '');
|
|
60
|
-
await
|
|
76
|
+
expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('MissingMetaRecord');
|
|
61
77
|
});
|
|
62
78
|
|
|
63
79
|
test('updates a task by id and rewrites the snapshot', async () => {
|
|
64
|
-
await writeTasksJsonl(dir, meta, [task]);
|
|
65
|
-
const updated = await updateTask(dir, 't-001', { status: 'done', notes: 'finished' });
|
|
80
|
+
await run(writeTasksJsonl(dir, meta, [task]));
|
|
81
|
+
const updated = await run(updateTask(dir, 't-001', { status: 'done', notes: 'finished' }));
|
|
66
82
|
|
|
67
83
|
expect(updated.status).toBe('done');
|
|
68
84
|
expect(updated.notes).toBe('finished');
|
|
69
|
-
expect((await readTasksJsonl(dir))?.tasks[0]?.status).toBe('done');
|
|
85
|
+
expect((await run(readTasksJsonl(dir)))?.tasks[0]?.status).toBe('done');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('fails with TaskNotFound for an unknown task id', async () => {
|
|
89
|
+
await run(writeTasksJsonl(dir, meta, [task]));
|
|
90
|
+
expect(failureTag(await runExit(updateTask(dir, 't-999', { status: 'done' })))).toBe(
|
|
91
|
+
'TaskNotFound',
|
|
92
|
+
);
|
|
70
93
|
});
|
|
71
94
|
});
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { isSafeCommand, isPlanPath } from '../utils.js';
|
|
2
|
+
import { isSafeCommand, isPlanPath, nextTaskId } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
describe('nextTaskId', () => {
|
|
5
|
+
test('increments the max numeric suffix', () => {
|
|
6
|
+
expect(nextTaskId(['t-001', 't-002', 't-003'])).toBe('t-004');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('uses the max even when ids are unordered or sparse', () => {
|
|
10
|
+
expect(nextTaskId(['t-003', 't-001', 't-010'])).toBe('t-011');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('starts at t-001 for an empty plan', () => {
|
|
14
|
+
expect(nextTaskId([])).toBe('t-001');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('falls back to count+1 when no ids match the pattern', () => {
|
|
18
|
+
expect(nextTaskId(['setup', 'cleanup'])).toBe('t-003');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
3
21
|
|
|
4
22
|
describe('isSafeCommand', () => {
|
|
5
23
|
// ── Commands that SHOULD be allowed ──────────────────────────────────────
|
|
@@ -10,13 +10,14 @@ export const PLAN_TOOLS = [
|
|
|
10
10
|
'find',
|
|
11
11
|
'ls',
|
|
12
12
|
'submit_plan',
|
|
13
|
+
'preview_prototype',
|
|
13
14
|
'write',
|
|
14
15
|
'questionnaire',
|
|
15
16
|
'search_skills',
|
|
16
17
|
'subagent',
|
|
17
18
|
];
|
|
18
19
|
|
|
19
|
-
export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task'];
|
|
20
|
+
export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task', 'add_task'];
|
|
20
21
|
|
|
21
22
|
// ── Model + thinking presets ─────────────────────────────────────────────────
|
|
22
23
|
export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileSystem service — the single seam for plan-mode disk I/O.
|
|
3
|
+
*
|
|
4
|
+
* Storage programs depend on this `Context.Tag` rather than touching
|
|
5
|
+
* `node:fs/promises` directly, which makes them trivially testable and keeps
|
|
6
|
+
* all failure modes typed (`PlanReadError` / `PlanWriteError`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Context, Effect } from 'effect';
|
|
10
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { PlanReadError, PlanWriteError } from '../errors.js';
|
|
12
|
+
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
13
|
+
|
|
14
|
+
export interface FileSystemService {
|
|
15
|
+
readonly readFileString: (path: string) => Effect.Effect<string, PlanReadError>;
|
|
16
|
+
readonly writeFileString: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
|
|
17
|
+
readonly writeFileAtomic: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
|
|
18
|
+
readonly makeDir: (path: string) => Effect.Effect<void, PlanWriteError>;
|
|
19
|
+
readonly listDirectories: (path: string) => Effect.Effect<string[], PlanReadError>;
|
|
20
|
+
readonly removeFile: (path: string) => Effect.Effect<void, PlanWriteError>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class FileSystem extends Context.Tag('PlanMode/FileSystem')<
|
|
24
|
+
FileSystem,
|
|
25
|
+
FileSystemService
|
|
26
|
+
>() {}
|
|
27
|
+
|
|
28
|
+
export const nodeFileSystemService: FileSystemService = {
|
|
29
|
+
readFileString: (path) =>
|
|
30
|
+
Effect.tryPromise({
|
|
31
|
+
try: () => readFile(path, 'utf-8'),
|
|
32
|
+
catch: (cause) => new PlanReadError({ path, cause }),
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
writeFileString: (path, data) =>
|
|
36
|
+
Effect.tryPromise({
|
|
37
|
+
try: () => writeFile(path, data, 'utf-8'),
|
|
38
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
writeFileAtomic: (path, data) => writeFileAtomic(path, data),
|
|
42
|
+
|
|
43
|
+
makeDir: (path) =>
|
|
44
|
+
Effect.tryPromise({
|
|
45
|
+
try: async () => {
|
|
46
|
+
await mkdir(path, { recursive: true });
|
|
47
|
+
},
|
|
48
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
listDirectories: (path) =>
|
|
52
|
+
Effect.tryPromise({
|
|
53
|
+
try: async () => {
|
|
54
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
55
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
56
|
+
},
|
|
57
|
+
catch: (cause) => new PlanReadError({ path, cause }),
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
removeFile: (path) =>
|
|
61
|
+
Effect.tryPromise({
|
|
62
|
+
try: () => unlink(path),
|
|
63
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Effect runtime for the plan-mode extension.
|
|
3
|
+
*
|
|
4
|
+
* Build the layer once inside the extension entry and run storage programs
|
|
5
|
+
* through the `runPlanIO` bridge so the imperative pi event handlers keep their
|
|
6
|
+
* `await fn(...)` shape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect, Layer } from 'effect';
|
|
10
|
+
import { FileSystem, nodeFileSystemService } from './filesystem.js';
|
|
11
|
+
|
|
12
|
+
export function makeRuntimeLayer(): Layer.Layer<FileSystem> {
|
|
13
|
+
return Layer.succeed(FileSystem, nodeFileSystemService);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Build a bridge that runs storage programs against the live filesystem layer. */
|
|
17
|
+
export function makePlanRuntime() {
|
|
18
|
+
const layer = makeRuntimeLayer();
|
|
19
|
+
return function runPlanIO<A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> {
|
|
20
|
+
return Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RunPlanIO = ReturnType<typeof makePlanRuntime>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged errors for plan-mode disk I/O and JSONL validation.
|
|
3
|
+
*
|
|
4
|
+
* These replace ad-hoc `throw new Error(...)` so storage programs surface
|
|
5
|
+
* typed, inspectable failures. They are mapped back to user-facing strings at
|
|
6
|
+
* the tool boundary via `errorMessage`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Data } from 'effect';
|
|
10
|
+
|
|
11
|
+
export class PlanReadError extends Data.TaggedError('PlanReadError')<{
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly cause: unknown;
|
|
14
|
+
}> {
|
|
15
|
+
get message(): string {
|
|
16
|
+
return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PlanWriteError extends Data.TaggedError('PlanWriteError')<{
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly cause: unknown;
|
|
23
|
+
}> {
|
|
24
|
+
get message(): string {
|
|
25
|
+
return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class JsonlParseError extends Data.TaggedError('JsonlParseError')<{
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly line: number;
|
|
32
|
+
readonly cause?: unknown;
|
|
33
|
+
}> {
|
|
34
|
+
get message(): string {
|
|
35
|
+
return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class JsonlValidationError extends Data.TaggedError('JsonlValidationError')<{
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly line: number;
|
|
42
|
+
readonly reason: string;
|
|
43
|
+
}> {
|
|
44
|
+
get message(): string {
|
|
45
|
+
return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class MissingMetaRecord extends Data.TaggedError('MissingMetaRecord')<{
|
|
50
|
+
readonly path: string;
|
|
51
|
+
}> {
|
|
52
|
+
get message(): string {
|
|
53
|
+
return `${this.path} is missing meta record`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{
|
|
58
|
+
readonly planDir: string;
|
|
59
|
+
readonly taskId: string;
|
|
60
|
+
}> {
|
|
61
|
+
get message(): string {
|
|
62
|
+
return `Task not found: ${this.taskId}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class TasksFileNotFound extends Data.TaggedError('TasksFileNotFound')<{
|
|
67
|
+
readonly planDir: string;
|
|
68
|
+
}> {
|
|
69
|
+
get message(): string {
|
|
70
|
+
return `No tasks.jsonl found in ${this.planDir}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PlanStorageError =
|
|
75
|
+
| PlanReadError
|
|
76
|
+
| PlanWriteError
|
|
77
|
+
| JsonlParseError
|
|
78
|
+
| JsonlValidationError
|
|
79
|
+
| MissingMetaRecord
|
|
80
|
+
| TaskNotFound
|
|
81
|
+
| TasksFileNotFound;
|
|
82
|
+
|
|
83
|
+
export function causeMessage(cause: unknown): string {
|
|
84
|
+
if (cause instanceof Error) return cause.message;
|
|
85
|
+
return String(cause);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function errorMessage(error: unknown): string {
|
|
89
|
+
if (error instanceof Error) return error.message;
|
|
90
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
91
|
+
const message = (error as { message?: unknown }).message;
|
|
92
|
+
if (typeof message === 'string') return message;
|
|
93
|
+
}
|
|
94
|
+
return String(error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Convert any error (including tagged errors) into a native Error for the tool boundary. */
|
|
98
|
+
export function toNativeError(error: unknown): Error {
|
|
99
|
+
if (error instanceof Error) return error;
|
|
100
|
+
const native = new Error(errorMessage(error));
|
|
101
|
+
if (typeof error === 'object' && error !== null && '_tag' in error) {
|
|
102
|
+
native.name = String((error as { _tag: unknown })._tag);
|
|
103
|
+
}
|
|
104
|
+
return native;
|
|
105
|
+
}
|
|
@@ -2,127 +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) {
|
|
36
|
-
html.push('</ul>');
|
|
37
|
-
inList = false;
|
|
38
|
-
}
|
|
39
|
-
inCode = true;
|
|
40
|
-
codeLang = line.slice(3).trim();
|
|
41
|
-
codeLines.length = 0;
|
|
42
|
-
} else {
|
|
43
|
-
const langAttr = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : '';
|
|
44
|
-
html.push(`<pre><code${langAttr}>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
|
|
45
|
-
inCode = false;
|
|
46
|
-
codeLang = '';
|
|
47
|
-
}
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (inCode) {
|
|
52
|
-
codeLines.push(line);
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Headings
|
|
57
|
-
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
58
|
-
if (headingMatch) {
|
|
59
|
-
if (inList) {
|
|
60
|
-
html.push('</ul>');
|
|
61
|
-
inList = false;
|
|
62
|
-
}
|
|
63
|
-
const level = headingMatch[1].length;
|
|
64
|
-
html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// List items (- or *)
|
|
69
|
-
const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
|
70
|
-
if (listMatch) {
|
|
71
|
-
if (!inList) {
|
|
72
|
-
html.push('<ul>');
|
|
73
|
-
inList = true;
|
|
74
|
-
}
|
|
75
|
-
html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Blank line
|
|
80
|
-
if (!line.trim()) {
|
|
81
|
-
if (inList) {
|
|
82
|
-
html.push('</ul>');
|
|
83
|
-
inList = false;
|
|
84
|
-
}
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Paragraph
|
|
89
|
-
if (inList) {
|
|
90
|
-
html.push('</ul>');
|
|
91
|
-
inList = false;
|
|
92
|
-
}
|
|
93
|
-
html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Close unclosed blocks
|
|
97
|
-
if (inCode) {
|
|
98
|
-
html.push(`<pre><code>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
|
|
99
|
-
}
|
|
100
|
-
if (inList) html.push('</ul>');
|
|
101
|
-
return html.join('\n');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
|
|
105
|
-
function inlineMarkdown(text: string): string {
|
|
106
|
-
return (
|
|
107
|
-
text
|
|
108
|
-
// Inline code: `code`
|
|
109
|
-
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
110
|
-
// Bold: **text** or __text__
|
|
111
|
-
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
112
|
-
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
113
|
-
// Italic: *text* or _text_
|
|
114
|
-
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
115
|
-
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
116
|
-
// Links: [text](url)
|
|
117
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function escapeHtml(value: string): string {
|
|
122
|
-
return value
|
|
123
|
-
.replaceAll('&', '&')
|
|
124
|
-
.replaceAll('<', '<')
|
|
125
|
-
.replaceAll('>', '>')
|
|
126
|
-
.replaceAll('"', '"')
|
|
127
|
-
.replaceAll("'", ''');
|
|
128
|
-
}
|
|
@@ -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
|