@divlet/core 0.0.2 → 0.0.3

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.
Files changed (43) hide show
  1. package/AGENTS.md +3 -2
  2. package/BACKLOG.md +3 -4
  3. package/CHANGELOG.md +11 -2
  4. package/README.md +16 -6
  5. package/dist/core/lib/acceptance.d.ts +18 -0
  6. package/dist/core/lib/acceptance.js +29 -0
  7. package/dist/core/lib/assets.js +1 -1
  8. package/dist/core/lib/browser-board.d.ts +41 -0
  9. package/dist/core/lib/browser-board.js +203 -0
  10. package/dist/core/lib/browser-mcp.d.ts +131 -0
  11. package/dist/core/lib/browser-mcp.js +448 -0
  12. package/dist/core/lib/browser-media-type.d.ts +4 -0
  13. package/dist/core/lib/browser-media-type.js +7 -0
  14. package/dist/core/lib/browser-runtime.d.ts +598 -0
  15. package/dist/core/lib/browser-runtime.js +674 -0
  16. package/dist/core/lib/codec.js +2 -1
  17. package/dist/core/lib/contract.d.ts +1 -152
  18. package/dist/core/lib/contract.js +3 -359
  19. package/dist/core/lib/diff.js +1 -1
  20. package/dist/core/lib/prepare.d.ts +27 -0
  21. package/dist/core/lib/prepare.js +41 -0
  22. package/dist/core/lib/project.js +3 -3
  23. package/dist/core/lib/render.d.ts +2 -9
  24. package/dist/core/lib/render.js +10 -35
  25. package/dist/core/lib/runtime.d.ts +1 -1
  26. package/dist/core/lib/runtime.js +1 -21
  27. package/dist/core/lib/tools.d.ts +154 -0
  28. package/dist/core/lib/tools.js +364 -0
  29. package/dist/core/lib/validation.d.ts +5 -0
  30. package/dist/core/lib/validation.js +21 -0
  31. package/dist/skills/divlet/SKILL.md +7 -1
  32. package/docs/README.md +1 -0
  33. package/docs/agents.md +11 -1
  34. package/docs/architecture.md +15 -7
  35. package/docs/board.md +1 -1
  36. package/docs/browser-mcp.md +108 -0
  37. package/docs/browser-runtime.md +6 -4
  38. package/docs/publishing.md +6 -6
  39. package/docs/testing.md +8 -1
  40. package/examples/README.md +2 -2
  41. package/examples/landing/landing.divlet +1 -1
  42. package/package.json +24 -5
  43. package/skills/divlet/SKILL.md +7 -1
@@ -7,9 +7,9 @@
7
7
  * instance of it, with `descendants` overrides addressed by component node IDs (joined by
8
8
  * "/" to reach into nested instances), following the Pencil .pen conventions.
9
9
  */
10
- import path from 'node:path';
11
10
  import { readAssets, validateAssetPatch } from "./assets.js";
12
11
  import { DivletError, validateDocument, SOURCE_LIMITS, LIMITS, PROJECTION_LIMITS, documentLimits, validateTokens, validateCss, validateMetadata, string, merge, REF, } from "./model.js";
12
+ import { withinUtf8Budget } from "./validation.js";
13
13
  export const PROJECT_SCHEMA = 1;
14
14
  const MAX_FRAMES = SOURCE_LIMITS.roots;
15
15
  const PROJECT_KEYS = new Set(['schemaVersion', 'name', 'version', 'tokens', 'css', 'metadata', 'outputDir', 'assets', 'frames']);
@@ -43,7 +43,7 @@ export function validateSettings(source = {}) {
43
43
  if (!Number.isSafeInteger(version) || version < 1)
44
44
  fail('INVALID_PROJECT', 'Project version must be a positive integer');
45
45
  const outputDir = input.outputDir ?? null;
46
- if (outputDir !== null && (typeof outputDir !== 'string' || !outputDir.trim() || outputDir.length > 2048 || /[\\\u0000-\u001f]/.test(outputDir) || path.posix.isAbsolute(outputDir) || path.win32.isAbsolute(outputDir) || /^[a-z]:/i.test(outputDir)))
46
+ if (outputDir !== null && (typeof outputDir !== 'string' || !outputDir.trim() || outputDir.length > 2048 || /[\\\u0000-\u001f]/.test(outputDir) || outputDir.startsWith('/') || /^[a-z]:/i.test(outputDir)))
47
47
  fail('INVALID_OUTPUT_DIR', 'outputDir must be a relative directory path, or null for the default.');
48
48
  return {
49
49
  name,
@@ -115,7 +115,7 @@ export function writeProject(settings, frames) {
115
115
  fail('INVALID_PROJECT', `A project can hold at most ${MAX_FRAMES} frames`);
116
116
  const assets = readAssets(settings.assets ?? {}).tree;
117
117
  const text = JSON.stringify({ schemaVersion: PROJECT_SCHEMA, name: settings.name, version: settings.version, tokens: settings.tokens, css: settings.css, metadata: settings.metadata, ...(settings.outputDir !== null && settings.outputDir !== undefined ? { outputDir: settings.outputDir } : {}), ...(Object.keys(assets).length ? { assets } : {}), frames: frames.map(storedFrame) }, null, 2) + '\n';
118
- if (Buffer.byteLength(text) > SOURCE_LIMITS.bytes)
118
+ if (!withinUtf8Budget(text, SOURCE_LIMITS.bytes))
119
119
  fail('FILE_TOO_LARGE', 'The serialized project exceeds the source read limit.');
120
120
  return text;
121
121
  }
@@ -1,14 +1,7 @@
1
1
  import type { DocumentModel, Limits, NodeId } from './model.ts';
2
2
  import type { FrameLookup, ProjectSettings } from './project.ts';
3
- /** What preparing a frame produces: the validated document, the preview page, the generated file, its dependencies and the
4
- * classes that do nothing. Nothing here is state: a caller decides what to publish. */
5
- export interface PreparedFrame {
6
- document: DocumentModel;
7
- html: string;
8
- fileHtml: string;
9
- uses: string[];
10
- unknown: Record<string, string[]>;
11
- }
3
+ import type { PreparedFrame } from './prepare.ts';
4
+ export type { PreparedFrame, FrameCompiler } from './prepare.ts';
12
5
  export interface PrepareOptions {
13
6
  lookup?: FrameLookup;
14
7
  settings?: ProjectSettings | null;
@@ -2,44 +2,19 @@
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { createHash } from 'node:crypto';
5
- import { isDeepStrictEqual } from 'node:util';
6
- import postcss from 'postcss';
7
- import { validateDocument, DivletError, LIMITS, documentLimits } from "./model.js";
8
- import { expandFrame, instancesOf, storedFrame } from "./project.js";
9
- import { encodeHtml, decodeHtml, renderFile } from "./codec.js";
5
+ import { LIMITS } from "./model.js";
6
+ import { storedFrame } from "./project.js";
10
7
  import { compileClasses, unknownClasses } from "./engine.js";
8
+ import { prepareFrameWith } from "./prepare.js";
11
9
  const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
12
- const MAX_FILE_BYTES = 4 * 1024 * 1024;
13
10
  const digest = (value) => createHash('sha256').update(value).digest('hex');
14
- const sameDocument = (a, b) => isDeepStrictEqual({ ...a, version: 0 }, { ...b, version: 0 });
15
- function fail(code, message) { throw new DivletError(code, message); }
16
- function checkAtRules(css) {
17
- const tree = postcss.parse(css || '');
18
- const allowed = new Set(['media', 'supports', 'layer', 'keyframes', '-webkit-keyframes', 'font-face', 'container', 'property', 'starting-style', 'scope']);
19
- tree.walkAtRules(rule => {
20
- if (!allowed.has(rule.name.toLowerCase()))
21
- fail('UNSUPPORTED_CSS', `CSS @${rule.name} is not allowed in a divlet project. Build directives and imports are disabled.`);
22
- });
23
- if (/<\/style/i.test(css))
24
- fail('UNSUPPORTED_CSS', 'A style element closing sequence is not allowed in authored CSS.');
25
- }
26
- export async function prepareFrame(input, { lookup = () => undefined, settings = null, limits = LIMITS } = {}) {
27
- documentLimits(limits);
28
- const document = validateDocument(input, { limits });
29
- const expanded = expandFrame(document, lookup, settings, { limits });
30
- checkAtRules(expanded.document.css);
31
- const candidates = [...new Set(Object.values(expanded.document.nodes).flatMap(node => (node.type === 'element' ? node.className : '').split(/\s+/)).filter(Boolean))];
32
- const compiledCss = await compileClasses(expanded.document.tokens, candidates, PACKAGE_ROOT);
33
- const instances = new Map(instancesOf(document).map(item => [item.id, `${item.frame}/${item.node}`]));
34
- const html = encodeHtml(expanded.document, { compiledCss, parts: expanded.parts, instances, limits });
35
- if (Buffer.byteLength(html) > MAX_FILE_BYTES)
36
- fail('FILE_TOO_LARGE', 'The rendered frame exceeds the 4 MiB limit.');
37
- const decoded = decodeHtml(html, { previous: expanded.document, limits });
38
- if (!sameDocument(expanded.document, decoded))
39
- fail('UNREPRESENTABLE_HTML', 'The frame does not survive HTML parsing unchanged (check element nesting); the last valid preview was retained.');
40
- const own = Object.fromEntries(Object.values(document.nodes).flatMap(node => (node.type === 'element' && node.className ? [[node.id, node.className]] : [])));
41
- const unknown = await unknownClasses({ tokens: expanded.document.tokens, css: expanded.document.css, byNode: own, base: PACKAGE_ROOT });
42
- return { document, html, fileHtml: renderFile(expanded.document, { compiledCss, parts: expanded.parts, instances, limits }), uses: expanded.uses, unknown };
11
+ /** The Node composition: the installed style engine compiles each frame's classes. */
12
+ const engineCompiler = {
13
+ compile: (tokens, candidates) => compileClasses(tokens, candidates, PACKAGE_ROOT),
14
+ unknown: input => unknownClasses({ ...input, base: PACKAGE_ROOT }),
15
+ };
16
+ export function prepareFrame(input, { lookup = () => undefined, settings = null, limits = LIMITS } = {}) {
17
+ return prepareFrameWith(input, { lookup, settings, limits, compiler: engineCompiler });
43
18
  }
44
19
  /** The `meta.json` beside a generated page: what it was built from, what it depends on and the hashes that detect a rebuild. */
45
20
  export function frameMetadata(entry, entries, projectFile) {
@@ -323,7 +323,7 @@ export class DivletRuntime extends EventEmitter<[never]> {
323
323
  prepare(input: any, { frames, settings, limits }?: {
324
324
  settings?: import("./project.ts").ProjectSettings | undefined;
325
325
  limits?: Readonly<import("./model.ts").Limits> | undefined;
326
- }): Promise<import("./render.ts").PreparedFrame>;
326
+ }): Promise<import("./prepare.ts").PreparedFrame>;
327
327
  /** A board entry for a prepared frame; the render counter only moves when the preview changes. */
328
328
  entryFor(prepared: any, previous: any, outputDir?: string): any;
329
329
  /** Frames that must re-render when `ids` change (they use components from them). */
@@ -13,6 +13,7 @@ import { buildSite } from "./site.js";
13
13
  import { diffDocuments, diffValue, matchesPatch, summarizeChange, touchedNodeIds } from "./diff.js";
14
14
  import { operationPatch } from "./ops.js";
15
15
  import { HistoryLine } from "./history.js";
16
+ import { SETTINGS_FIELDS, sameDocument, sameSettings, settingsPatch, assertExpectedVersion, assertExpectedRender } from "./acceptance.js";
16
17
  import { expandPath, inspectPath, initProject, resolveProject, isProjectFile } from "./projects.js";
17
18
  import { OutputStore, safeDirectory } from "./output.js";
18
19
  import { buildStatic, inlineDeclaredAssets, readResourceBytes, normalizeExternalFiles } from './static.js';
@@ -35,7 +36,6 @@ const MAX_ASK = 4000;
35
36
  const MAX_REPLY = 6000;
36
37
  const HISTORY_LIMIT = 500;
37
38
  const UNDO_LIMIT = 200;
38
- const SETTINGS_FIELDS = ['name', 'tokens', 'css', 'metadata', 'outputDir', 'assets'];
39
39
  const capped = (list, limit = 50) => list.slice(0, limit);
40
40
  const digest = value => createHash('sha256').update(value).digest('hex');
41
41
  const hashOf = text => (text === null || text === undefined ? null : digest(text));
@@ -52,9 +52,6 @@ const imageUpload = (name, data, mediaType) => {
52
52
  fail('UNSUPPORTED_ASSET', 'The upload must declare a supported canonical image media type.');
53
53
  return { extension, mediaType: type, bytes: Buffer.from(data) };
54
54
  };
55
- const sameDocument = (a, b) => isDeepStrictEqual({ ...a, version: 0 }, { ...b, version: 0 });
56
- const sameSettings = (a, b) => SETTINGS_FIELDS.every(key => isDeepStrictEqual(a[key], b[key]));
57
- const settingsPatch = (from, to) => Object.fromEntries(SETTINGS_FIELDS.map(key => [key, diffValue(from[key], to[key])]).filter(([, value]) => value !== undefined));
58
55
  const inside = (root, target) => target === root || (!path.relative(root, target).startsWith('..' + path.sep) && path.relative(root, target) !== '..' && !path.isAbsolute(path.relative(root, target)));
59
56
  function fail(code, message, details) {
60
57
  throw new DivletError(code, message, details);
@@ -91,23 +88,6 @@ function styleWarnings({ unknown = {}, changed = [], css = null, where }) {
91
88
  }
92
89
  return out;
93
90
  }
94
- /**
95
- * A caller that names the state it read gets a refusal instead of silently overwriting a change it
96
- * never saw (AGENTS.md, "Validated commits"). Omitted means the caller accepted whatever is current.
97
- */
98
- function assertExpectedVersion(expected, target, what = 'frame') {
99
- if (expected === undefined)
100
- return;
101
- if (!Number.isInteger(expected) || expected !== target.version)
102
- fail('REVISION_CONFLICT', `Expected ${what} version ${target.version}; received ${expected}. Read it again and rebuild the change.`, { actualVersion: target.version });
103
- }
104
- /** The rendered page a gesture was made against; a dependent re-render advances it without a version bump. */
105
- function assertExpectedRender(expected, entry) {
106
- if (expected === undefined)
107
- return;
108
- if (!Number.isInteger(expected) || expected !== entry.render)
109
- fail('RENDER_CONFLICT', `Expected frame render ${entry.render}; received ${expected}. The page changed since it was read; reload the frame and repeat the change.`, { actualRender: entry.render, actualVersion: entry.document.version });
110
- }
111
91
  /*
112
92
  * Each .divlet file is an independent source of truth. Generated pages and metadata belong
113
93
  * to its OutputStore, by default .divlet/<source filename stem> beside that source file.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The host-neutral schema. A tool's parameters are written once in this form; each host renders it — Pi as TypeBox, MCP as
3
+ * JSON Schema — so a plugin or adapter that adds a tool describes it here and nowhere else.
4
+ */
5
+ interface SchemaBase {
6
+ optional?: boolean;
7
+ description?: string;
8
+ }
9
+ export type Schema = (SchemaBase & {
10
+ kind: 'string';
11
+ maxLength?: number;
12
+ minLength?: number;
13
+ }) | (SchemaBase & {
14
+ kind: 'integer';
15
+ minimum?: number;
16
+ maximum?: number;
17
+ }) | (SchemaBase & {
18
+ kind: 'number';
19
+ minimum?: number;
20
+ maximum?: number;
21
+ }) | (SchemaBase & {
22
+ kind: 'boolean';
23
+ }) | (SchemaBase & {
24
+ kind: 'oneOf';
25
+ values: readonly string[];
26
+ }) | (SchemaBase & {
27
+ kind: 'object';
28
+ properties: Record<string, Schema> | null;
29
+ }) | (SchemaBase & {
30
+ kind: 'array';
31
+ items: Schema;
32
+ maxItems?: number;
33
+ }) | (SchemaBase & {
34
+ kind: 'union';
35
+ variants: Schema[];
36
+ }) | (SchemaBase & {
37
+ kind: 'any';
38
+ }) | (SchemaBase & {
39
+ kind: 'null';
40
+ });
41
+ /** One tool's parameters, by name. */
42
+ export type ToolParameters = Record<string, Schema>;
43
+ /** JSON Schema as a host asks for it (the shape MCP `tools/list` answers). */
44
+ export type JsonSchema = Record<string, any>;
45
+ /** A host-neutral schema: the tool list is written once, Pi and MCP render it. */
46
+ export declare const S: {
47
+ string: (options?: Omit<Extract<Schema, {
48
+ kind: "string";
49
+ }>, "kind">) => Schema;
50
+ integer: (options?: Omit<Extract<Schema, {
51
+ kind: "integer";
52
+ }>, "kind">) => Schema;
53
+ number: (options?: Omit<Extract<Schema, {
54
+ kind: "number";
55
+ }>, "kind">) => Schema;
56
+ boolean: (options?: Omit<Extract<Schema, {
57
+ kind: "boolean";
58
+ }>, "kind">) => Schema;
59
+ /** One of a fixed set of strings. */
60
+ oneOf: (values: readonly string[], options?: Omit<Extract<Schema, {
61
+ kind: "oneOf";
62
+ }>, "kind" | "values">) => Schema;
63
+ /** An object with these properties (`null` shape: any object). */
64
+ object: (properties?: Record<string, Schema> | null, options?: Omit<Extract<Schema, {
65
+ kind: "object";
66
+ }>, "kind" | "properties">) => Schema;
67
+ array: (items: Schema, options?: Omit<Extract<Schema, {
68
+ kind: "array";
69
+ }>, "kind" | "items">) => Schema;
70
+ union: (variants: Schema[], options?: Omit<Extract<Schema, {
71
+ kind: "union";
72
+ }>, "kind" | "variants">) => Schema;
73
+ any: (options?: Omit<Extract<Schema, {
74
+ kind: "any";
75
+ }>, "kind">) => Schema;
76
+ };
77
+ export declare const optional: (schema: Schema) => Schema;
78
+ export declare const nullable: (schema: Schema) => Schema;
79
+ /** A JSON Schema property for one parameter (the shape MCP asks for). */
80
+ export declare function propertySchema(schema: Schema): JsonSchema;
81
+ /** The `inputSchema` of a tool for MCP (`tools/list`). */
82
+ export declare function inputSchema(tool: Tool): JsonSchema;
83
+ /** The subset of TypeBox a host needs to render the schema; the Pi adapter passes the real module. */
84
+ export interface TypeBoxApi {
85
+ String(options?: Record<string, unknown>): unknown;
86
+ Integer(options?: Record<string, unknown>): unknown;
87
+ Number(options?: Record<string, unknown>): unknown;
88
+ Boolean(options?: Record<string, unknown>): unknown;
89
+ Literal(value: string): unknown;
90
+ Union(items: unknown[]): unknown;
91
+ Object(properties: Record<string, unknown>, options?: Record<string, unknown>): unknown;
92
+ Record(key: unknown, value: unknown): unknown;
93
+ Array(items: unknown, options?: Record<string, unknown>): unknown;
94
+ Null(): unknown;
95
+ Unknown(): unknown;
96
+ Optional(item: unknown): unknown;
97
+ }
98
+ /** The same schema as TypeBox types, for the Pi extension. */
99
+ export declare function toTypeBox(schema: ToolParameters, Type: TypeBoxApi): unknown;
100
+ export declare const LARGE_FRAME_BYTES = 60000;
101
+ /** Rename document → frame in tool results, leaving user data (node maps, metadata…) untouched. */
102
+ export declare function forAgent(value: unknown, key?: string): unknown;
103
+ /** The project view for an agent: what it needs to choose a frame, without file paths and without one line per use. */
104
+ export declare function compactProject(info: any): any;
105
+ /** Components with their name and overridable texts, only the first few places used and a count. */
106
+ export declare function compactComponents(result: any): any;
107
+ /** An outline of a frame model: ids, tags, names and the start of text, three levels deep, with counts where it is cut. */
108
+ export declare function outlineOf(model: any, bytes: number): Record<string, unknown>;
109
+ /** What a tool may ask of the board it runs on. `any` results are the runtime's own shapes, shaped for agents below. */
110
+ export interface BoardRuntime {
111
+ info(): any;
112
+ read(query: Record<string, unknown>): any;
113
+ patch(args: Record<string, unknown>): Promise<any>;
114
+ edit(args: Record<string, unknown>): Promise<any>;
115
+ undo(args: Record<string, unknown>): Promise<any>;
116
+ redo(args: Record<string, unknown>): Promise<any>;
117
+ create(args: Record<string, unknown>): Promise<any>;
118
+ getEntry(documentId: string): any;
119
+ render(documentId: string): any;
120
+ createArtboard(args: Record<string, unknown>): Promise<any>;
121
+ removeDocument(args: Record<string, unknown>): Promise<any>;
122
+ frame(args: Record<string, unknown>): Promise<any>;
123
+ mark(args: Record<string, unknown>): any;
124
+ close(): Promise<void>;
125
+ [key: string]: any;
126
+ }
127
+ /** The context a tool runs with: the board that owns the project. */
128
+ export interface ToolContext {
129
+ runtime: BoardRuntime;
130
+ }
131
+ /** One capability: named, described and parameterised once, rendered by every host. */
132
+ export interface Tool {
133
+ name: string;
134
+ /** Short human title (absent on hidden tools). */
135
+ title?: string;
136
+ kind: 'open' | 'runtime';
137
+ description: string;
138
+ /** One line a host may advertise instead of the description. */
139
+ snippet?: string;
140
+ hidden?: boolean;
141
+ parameters: ToolParameters;
142
+ /** Absent for `open_divlet`, which the session handles itself. */
143
+ run?(context: ToolContext, params: any): any;
144
+ }
145
+ /** A runtime is required for every tool but `open_divlet`; the session resolves it before running one. */
146
+ export declare const TOOLS: Tool[];
147
+ export declare const toolByName: ReadonlyMap<string, Tool>;
148
+ export declare const toolList: ({ hidden }?: {
149
+ hidden?: boolean;
150
+ }) => Tool[];
151
+ export declare const toolNames: Set<string>;
152
+ /** Run one runtime tool against a board's runtime (used by the session and by the board's own api/tool route). */
153
+ export declare function runOnRuntime(name: string, args: unknown, runtime: BoardRuntime): Promise<any>;
154
+ export {};