@hatchingpoint/point 0.0.12 → 0.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatchingpoint/point",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Point language compiler and CLI.",
@@ -24,7 +24,9 @@
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
26
  "./cli": "./src/cli.ts",
27
- "./core": "./src/core/index.ts"
27
+ "./core": "./src/core/index.ts",
28
+ "./std/json": "./src/std/json.ts",
29
+ "./std/http": "./src/std/http.ts"
28
30
  },
29
31
  "publishConfig": {
30
32
  "access": "public",
package/src/cli.ts CHANGED
File without changes
package/src/core/ast.ts CHANGED
@@ -85,11 +85,31 @@ export interface PointSemanticProgramMetadata {
85
85
  source: "semantic";
86
86
  }
87
87
 
88
+ export interface PointSemanticPageLayout {
89
+ title: PointCoreExpression;
90
+ description?: PointCoreExpression;
91
+ main: PointCoreExpression;
92
+ }
93
+
94
+ export interface PointSemanticViewCheckboxBinding {
95
+ label: string;
96
+ target: PointCoreExpression;
97
+ recordParam: string;
98
+ fieldName: string;
99
+ }
100
+
101
+ export interface PointSemanticViewControls {
102
+ changeCallback: string;
103
+ checkboxes: PointSemanticViewCheckboxBinding[];
104
+ }
105
+
88
106
  export interface PointSemanticDeclarationMetadata {
89
- kind: "record" | "calculation" | "rule" | "label" | "external" | "action" | "policy" | "view" | "route" | "workflow" | "command";
107
+ kind: "record" | "calculation" | "rule" | "label" | "external" | "action" | "policy" | "view" | "page" | "route" | "workflow" | "command";
90
108
  name: string;
91
109
  outputName?: string;
92
110
  effects?: string[];
111
+ pageLayout?: PointSemanticPageLayout;
112
+ viewControls?: PointSemanticViewControls;
93
113
  }
94
114
 
95
115
  export interface PointCoreTypeExpression {
package/src/core/check.ts CHANGED
@@ -28,7 +28,7 @@ type DiagnosticMetadata = Partial<Pick<PointCoreDiagnostic, "expected" | "actual
28
28
  type ScopeEntry = { type: PointCoreTypeExpression; mutable: boolean };
29
29
  type Scope = Map<string, ScopeEntry>;
30
30
 
31
- const PRIMITIVE_TYPES = new Set(["Text", "Int", "Float", "Bool", "Void", "List", "Maybe", "Error", "Or"]);
31
+ const PRIMITIVE_TYPES = new Set(["Text", "Int", "Float", "Bool", "Void", "List", "Maybe", "Error", "Or", "Page", "Handler"]);
32
32
 
33
33
  export function checkPointCore(program: PointCoreProgram): PointCoreDiagnostic[] {
34
34
  const checker = new CoreChecker(program);
@@ -125,6 +125,7 @@ class CoreChecker {
125
125
  }
126
126
  return;
127
127
  }
128
+ if (fn.semantic?.kind === "page" || fn.semantic?.kind === "view") return;
128
129
  this.checkExpressionAssignable(statement.value, fn.returnType, `fn.${fn.name}.return`, locals);
129
130
  return;
130
131
  }
@@ -531,7 +532,14 @@ class CoreChecker {
531
532
  repair: "Use syntax such as User or Error.",
532
533
  });
533
534
  }
534
- if (type.name !== "List" && type.name !== "Maybe" && type.name !== "Or" && type.args.length > 0 && !this.typeDeclarations.has(String(type.name))) {
535
+ if (type.name === "Handler" && type.args.length !== 1) {
536
+ this.push("invalid-type-arity", "Handler requires one type argument", path, type.span, {
537
+ expected: "Handler T",
538
+ actual: formatType(type),
539
+ repair: "Use Handler Listing Signals or another record type.",
540
+ });
541
+ }
542
+ if (type.name !== "List" && type.name !== "Maybe" && type.name !== "Or" && type.name !== "Handler" && type.args.length > 0 && !this.typeDeclarations.has(String(type.name))) {
535
543
  this.push("invalid-type-arity", `${type.name} does not accept type arguments`, path, type.span, {
536
544
  expected: String(type.name),
537
545
  actual: formatType(type),
package/src/core/cli.ts CHANGED
@@ -6,11 +6,12 @@ import { createPointCoreIndex, createPointCoreRepairPlan, explainPointCoreRef }
6
6
  import { createSemanticIndex, explainSemanticRef, mapPublicDiagnostics } from "../semantic/context.ts";
7
7
  import { emitPointCoreTypeScript } from "./emit-typescript.ts";
8
8
  import { emitPointCoreJavaScript } from "./emit-javascript.ts";
9
- import { emitPointCorePython } from "./emit-python.ts";
9
+ import { emitPointCorePython, isPureLogicProgram } from "./emit-python.ts";
10
10
  import { formatPointSource } from "./format.ts";
11
11
  import { isCacheHit, isIncrementalEnabled, readBuildCache, recordCacheEntry, writeBuildCache } from "./incremental.ts";
12
12
  import { parsePointSource } from "./parser.ts";
13
13
  import { runCheckDocs } from "./check-docs.ts";
14
+ import { addPointDependency, modulePathFromLock, POINT_LOCK, POINT_MANIFEST, readPointLock } from "./packages.ts";
14
15
  import { runPointLspServer } from "../lsp/server.ts";
15
16
 
16
17
  const DEFAULT_INPUT = "examples/math.point";
@@ -43,6 +44,18 @@ export async function main() {
43
44
  return;
44
45
  }
45
46
 
47
+ if (command === "add") {
48
+ const dependencyName = input;
49
+ const spec = output;
50
+ if (!dependencyName || !spec) {
51
+ throw new Error("Usage: point add <name> <spec> (spec: workspace:<path> | file:<path> | npm:<package>)");
52
+ }
53
+ const { manifest, lock } = await addPointDependency(dependencyName, spec);
54
+ console.log(`Point add updated ${POINT_MANIFEST} and ${POINT_LOCK}: ${dependencyName} -> ${spec}`);
55
+ console.log(JSON.stringify({ name: manifest.name, dependencies: manifest.dependencies, lockPackages: Object.keys(lock.packages) }, null, 2));
56
+ return;
57
+ }
58
+
46
59
  const inputPath = resolve(process.cwd(), input);
47
60
  const source = await Bun.file(inputPath).text();
48
61
  const program = parsePointSource(source);
@@ -198,8 +211,9 @@ export async function main() {
198
211
  async function runProjectCommand(command: string) {
199
212
  const inputs = await discoverInputs();
200
213
  if (inputs.length === 0) throw new Error(`No Point core files matched ${DEFAULT_PATTERNS.join(", ")}`);
201
- const results = await Promise.all(inputs.map((input) => loadCoreFile(input)));
202
- const graph = createModuleGraph(results);
214
+ const lock = await readPointLock();
215
+ const results = await Promise.all(inputs.map((input) => loadCoreFile(input, lock)));
216
+ const graph = createModuleGraph(results, lock);
203
217
  const orderedResults = orderByDependencies(results, graph);
204
218
 
205
219
  if (command === "fmt-all") {
@@ -298,6 +312,26 @@ async function runProjectCommand(command: string) {
298
312
  return;
299
313
  }
300
314
 
315
+ if (command === "build-py-all") {
316
+ const diagnostics = orderedResults.flatMap((result) =>
317
+ checkPointCore(programWithDependencyDeclarations(result, graph)).map((diagnostic) => ({ ...diagnostic, file: result.input })),
318
+ );
319
+ if (diagnostics.length > 0) {
320
+ console.error(JSON.stringify({ ok: false, diagnostics }, null, 2));
321
+ process.exit(1);
322
+ }
323
+ const pureLogicResults = orderedResults.filter((result) => isPureLogicProgram(result.program));
324
+ for (const result of pureLogicResults) {
325
+ const output = pyOutputFor(result.input);
326
+ const outputPath = resolve(process.cwd(), output);
327
+ await Bun.$`mkdir -p ${dirname(outputPath)}`.quiet();
328
+ await Bun.write(outputPath, emitPointCorePython(programWithTypeScriptImports(result, graph)));
329
+ }
330
+ const skipped = results.length - pureLogicResults.length;
331
+ console.log(`Point core Python build wrote ${pureLogicResults.length} files${skipped ? ` (${skipped} skipped)` : ""}`);
332
+ return;
333
+ }
334
+
301
335
  if (command === "test-all") {
302
336
  const diagnostics = orderedResults.flatMap((result) =>
303
337
  checkPointCore(programWithDependencyDeclarations(result, graph)).map((diagnostic) => ({ ...diagnostic, file: result.input })),
@@ -409,9 +443,9 @@ export function findRunEntryName(program: PointCoreProgram): string | null {
409
443
  return preferred?.name ?? null;
410
444
  }
411
445
 
412
- async function loadCoreFile(input: string) {
446
+ async function loadCoreFile(input: string, lock: Awaited<ReturnType<typeof readPointLock>>) {
413
447
  const source = await Bun.file(resolve(process.cwd(), input)).text();
414
- return { input, source, program: parsePointSource(source), uses: parseUseDeclarations(source, input) };
448
+ return { input, source, program: parsePointSource(source), uses: parseUseDeclarations(source, input, lock) };
415
449
  }
416
450
 
417
451
  type CoreFile = Awaited<ReturnType<typeof loadCoreFile>>;
@@ -423,15 +457,15 @@ interface UseDeclaration {
423
457
  input: string;
424
458
  }
425
459
 
426
- function parseUseDeclarations(source: string, input: string): UseDeclaration[] {
460
+ function parseUseDeclarations(source: string, input: string, lock: Awaited<ReturnType<typeof readPointLock>>): UseDeclaration[] {
427
461
  return source
428
462
  .split(/\r?\n/)
429
463
  .map((line) => line.trim().match(/^use\s+([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*)(?:\s+from\s+"([^"]+)")?$/))
430
464
  .filter((match): match is RegExpMatchArray => Boolean(match))
431
- .map((match) => ({ moduleName: match[1]!, from: match[2] ?? stdPathFor(match[1]!), input }));
465
+ .map((match) => ({ moduleName: match[1]!, from: match[2] ?? modulePathFromLock(lock, match[1]!), input }));
432
466
  }
433
467
 
434
- function createModuleGraph(results: CoreFile[]): ModuleGraph {
468
+ function createModuleGraph(results: CoreFile[], lock: Awaited<ReturnType<typeof readPointLock>>): ModuleGraph {
435
469
  const byInput = new Map(results.map((result) => [normalizeInput(result.input), result]));
436
470
  const graph: ModuleGraph = new Map();
437
471
  for (const result of results) {
@@ -495,11 +529,6 @@ function resolveDependencyInput(input: string, from: string): string {
495
529
  return resolve(base, from).replace(resolve(process.cwd()), "").replace(/^[/\\]/, "");
496
530
  }
497
531
 
498
- function stdPathFor(moduleName: string): string {
499
- if (!moduleName.startsWith("std.")) throw new Error(`Use declarations without from must target std modules: ${moduleName}`);
500
- return `${moduleName.replace(/^std\./, "std/").replaceAll(".", "/")}.point`;
501
- }
502
-
503
532
  function normalizeInput(input: string): string {
504
533
  return input.replaceAll("\\", "/");
505
534
  }
@@ -27,6 +27,7 @@ export type PointCoreSymbolKind =
27
27
  | "action"
28
28
  | "policy"
29
29
  | "view"
30
+ | "page"
30
31
  | "route"
31
32
  | "workflow"
32
33
  | "command";
@@ -325,6 +326,7 @@ function relatedRefsFor(symbol: PointCoreSymbol, index: PointCoreIndex): string[
325
326
  symbol.kind === "action" ||
326
327
  symbol.kind === "policy" ||
327
328
  symbol.kind === "view" ||
329
+ symbol.kind === "page" ||
328
330
  symbol.kind === "route" ||
329
331
  symbol.kind === "workflow" ||
330
332
  symbol.kind === "command"
@@ -351,6 +353,7 @@ function summaryFor(symbol: PointCoreSymbol): string {
351
353
  if (symbol.kind === "action") return `Semantic action ${symbol.name} returns ${symbol.type}; effects: ${(symbol.effects ?? []).join(", ") || "none"}.`;
352
354
  if (symbol.kind === "policy") return `Semantic policy ${symbol.name} returns ${symbol.type}.`;
353
355
  if (symbol.kind === "view") return `Semantic view ${symbol.name} returns React JSX.`;
356
+ if (symbol.kind === "page") return `Semantic page ${symbol.name} returns a Next.js page shell as React JSX.`;
354
357
  if (symbol.kind === "route") return `Semantic route ${symbol.name} returns ${symbol.type}.`;
355
358
  if (symbol.kind === "workflow") return `Semantic workflow ${symbol.name} returns ${symbol.type}.`;
356
359
  if (symbol.kind === "command") return `Semantic command ${symbol.name} returns ${symbol.type}.`;
@@ -52,9 +52,13 @@ function emitDeclaration(declaration: PointCoreDeclaration): string[] {
52
52
 
53
53
  function emitFunction(declaration: PointCoreFunctionDeclaration): string[] {
54
54
  const asyncPrefix = declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command" ? "async " : "";
55
+ const bodyLines =
56
+ declaration.semantic?.kind === "page" && declaration.semantic.pageLayout
57
+ ? [`return ${emitExpression(declaration.semantic.pageLayout.main)};`]
58
+ : declaration.body.flatMap((statement) => emitStatement(statement, declaration.semantic?.kind));
55
59
  return [
56
60
  `export ${asyncPrefix}function ${declaration.name}(${declaration.params.map(emitParam).join(", ")}) {`,
57
- ...indentLines(declaration.body.flatMap((statement) => emitStatement(statement, declaration.semantic?.kind))),
61
+ ...indentLines(bodyLines),
58
62
  "}",
59
63
  ];
60
64
  }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  PointCoreDeclaration,
3
3
  PointCoreExpression,
4
+ PointCoreExternalDeclaration,
4
5
  PointCoreFunctionDeclaration,
5
6
  PointCoreParameter,
6
7
  PointCorePrimitiveType,
@@ -16,7 +17,12 @@ const BINARY_OPERATORS: Record<string, string> = {
16
17
  or: "or",
17
18
  };
18
19
 
19
- const UNSUPPORTED_SEMANTIC_KINDS = new Set(["view", "route", "action", "workflow", "command"]);
20
+ const UNSUPPORTED_SEMANTIC_KINDS = new Set(["view", "page", "route", "workflow", "command"]);
21
+
22
+ /** True when every declaration is emit-able as Python (no views, routes, workflows, or commands). */
23
+ export function isPureLogicProgram(program: PointCoreProgram): boolean {
24
+ return !program.declarations.some((declaration) => declaration.semantic && UNSUPPORTED_SEMANTIC_KINDS.has(declaration.semantic.kind));
25
+ }
20
26
 
21
27
  /** Emit Python from a core AST program (pure logic modules). Production path: parsePointSource → check → emit. */
22
28
  export function emitPointCorePython(program: PointCoreProgram): string {
@@ -42,11 +48,7 @@ function emitDeclaration(declaration: PointCoreDeclaration): string[] {
42
48
  const moduleName = declaration.from.replace(/^\.\//, "").replace(/-/g, "_");
43
49
  return [`from ${toPythonModuleName(moduleName)} import ${declaration.names.join(", ")}`];
44
50
  }
45
- if (declaration.kind === "external") {
46
- const moduleName = declaration.from.replace(/^\.\//, "").replace(/-/g, "_");
47
- const imported = declaration.importName ?? declaration.name;
48
- return [`from ${toPythonModuleName(moduleName)} import ${imported} as ${declaration.name}`];
49
- }
51
+ if (declaration.kind === "external") return emitExternal(declaration);
50
52
  if (declaration.kind === "type") return emitType(declaration);
51
53
  if (declaration.kind === "value") return [emitValue(declaration)];
52
54
  if (declaration.semantic && UNSUPPORTED_SEMANTIC_KINDS.has(declaration.semantic.kind)) {
@@ -62,6 +64,19 @@ function emitType(declaration: PointCoreTypeDeclaration): string[] {
62
64
  ];
63
65
  }
64
66
 
67
+ function emitExternal(declaration: PointCoreExternalDeclaration): string[] {
68
+ if (declaration.from === "node:fs" && declaration.importName === "readFileSync") {
69
+ return [
70
+ `def ${declaration.name}(${declaration.params.map(emitParam).join(", ")}) -> ${emitTypeExpression(declaration.returnType)}:`,
71
+ " from pathlib import Path",
72
+ " return Path(path).read_text()",
73
+ ];
74
+ }
75
+ const moduleName = declaration.from.replace(/^\.\//, "").replace(/-/g, "_");
76
+ const imported = declaration.importName ?? declaration.name;
77
+ return [`from ${toPythonModuleName(moduleName)} import ${imported} as ${declaration.name}`];
78
+ }
79
+
65
80
  function emitFunction(declaration: PointCoreFunctionDeclaration): string[] {
66
81
  const asyncPrefix = declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command" ? "async " : "";
67
82
  return [
@@ -79,7 +94,7 @@ function emitReturnType(declaration: PointCoreFunctionDeclaration): string {
79
94
 
80
95
  function emitStatement(statement: PointCoreStatement, semanticKind?: string): string[] {
81
96
  if (statement.kind === "return") {
82
- if (semanticKind === "view") return ["# Point: view return values are not supported in Python emit yet", "return \"\""];
97
+ if (semanticKind === "view" || semanticKind === "page") return ["# Point: view/page return values are not supported in Python emit yet", "return \"\""];
83
98
  return [statement.value ? `return ${emitExpression(statement.value)}` : "return"];
84
99
  }
85
100
  if (statement.kind === "value") return [emitValue(statement)];
@@ -9,6 +9,9 @@ import type {
9
9
  PointCoreTypeDeclaration,
10
10
  PointCoreTypeExpression,
11
11
  PointCoreValueDeclaration,
12
+ PointSemanticPageLayout,
13
+ PointSemanticViewCheckboxBinding,
14
+ PointSemanticViewControls,
12
15
  } from "./ast.ts";
13
16
 
14
17
  const BINARY_OPERATORS: Record<string, string> = {
@@ -54,18 +57,86 @@ function emitFunction(declaration: PointCoreFunctionDeclaration): string[] {
54
57
  const returnType =
55
58
  declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command"
56
59
  ? `Promise<${emitTypeExpression(declaration.returnType)}>`
57
- : declaration.semantic?.kind === "view"
60
+ : declaration.semantic?.kind === "view" || declaration.semantic?.kind === "page"
58
61
  ? "JSX.Element"
59
62
  : declaration.semantic?.kind === "route"
60
63
  ? "Response | string"
61
64
  : emitTypeExpression(declaration.returnType);
65
+ const viewControls = declaration.semantic?.viewControls;
66
+ const bodyLines =
67
+ declaration.semantic?.kind === "page" && declaration.semantic.pageLayout
68
+ ? emitPageBody(declaration.semantic.pageLayout)
69
+ : declaration.semantic?.kind === "view" && viewControls
70
+ ? emitControlledViewBody(declaration.body, viewControls)
71
+ : declaration.body.flatMap((statement) => emitStatement(statement, declaration.semantic?.kind));
62
72
  return [
63
73
  `export ${asyncPrefix}function ${declaration.name}(${declaration.params.map(emitParam).join(", ")}): ${returnType} {`,
64
- ...indentLines(declaration.body.flatMap((statement) => emitStatement(statement, declaration.semantic?.kind))),
74
+ ...indentLines(bodyLines),
65
75
  "}",
66
76
  ];
67
77
  }
68
78
 
79
+ function emitControlledViewBody(body: PointCoreStatement[], controls: PointSemanticViewControls): string[] {
80
+ const checkboxLines = controls.checkboxes.map((binding) => emitCheckboxControl(binding, controls.changeCallback));
81
+ const contentJsx = emitViewContentExpression(body);
82
+ return ["return (", " <>", ...indentLines(checkboxLines), ` ${contentJsx}`, " </>", ");"];
83
+ }
84
+
85
+ function emitCheckboxControl(binding: PointSemanticViewCheckboxBinding, changeCallback: string): string {
86
+ const checked = emitExpression(binding.target);
87
+ return `<label><input type="checkbox" checked={${checked}} onChange={(e) => ${changeCallback}({ ...${binding.recordParam}, ${binding.fieldName}: e.target.checked })} />${escapeJsxText(binding.label)}</label>`;
88
+ }
89
+
90
+ function emitViewContentExpression(body: PointCoreStatement[]): string {
91
+ let expression = "null";
92
+ for (let index = body.length - 1; index >= 0; index -= 1) {
93
+ const statement = body[index];
94
+ if (!statement) continue;
95
+ if (statement.kind === "return" && statement.value) {
96
+ expression = emitViewRenderFragment(statement.value);
97
+ continue;
98
+ }
99
+ if (statement.kind === "if" && statement.thenBody.length === 1 && statement.thenBody[0]?.kind === "return" && statement.thenBody[0].value) {
100
+ const thenValue = emitViewRenderFragment(statement.thenBody[0].value);
101
+ expression = `${emitCondition(statement.condition)} ? ${thenValue} : ${expression}`;
102
+ }
103
+ }
104
+ return `{${expression}}`;
105
+ }
106
+
107
+ function emitViewRenderFragment(expression: PointCoreExpression): string {
108
+ if (expression.kind === "literal" && typeof expression.value === "string") {
109
+ return `<>${escapeJsxText(expression.value)}</>`;
110
+ }
111
+ return `<>{${emitExpression(expression)}}</>`;
112
+ }
113
+
114
+ function emitPageBody(layout: PointSemanticPageLayout): string[] {
115
+ const title = emitJsxChild(layout.title);
116
+ const description = layout.description ? `\n <p className="point-page-description">${emitJsxChild(layout.description)}</p>` : "";
117
+ const main = emitJsxChild(layout.main, true);
118
+ return [
119
+ `return (`,
120
+ ` <main className="point-page">`,
121
+ ` <header className="point-page-header">`,
122
+ ` <h1>${title}</h1>${description}`,
123
+ ` </header>`,
124
+ ` <section className="point-page-main">${main}</section>`,
125
+ ` </main>`,
126
+ `);`,
127
+ ];
128
+ }
129
+
130
+ function emitJsxChild(expression: PointCoreExpression, allowComponent = false): string {
131
+ if (expression.kind === "literal" && typeof expression.value === "string") {
132
+ return escapeJsxText(expression.value);
133
+ }
134
+ if (allowComponent) {
135
+ return `{${emitExpression(expression)}}`;
136
+ }
137
+ return `{${emitExpression(expression)}}`;
138
+ }
139
+
69
140
  function emitStatement(statement: PointCoreStatement, semanticKind?: string): string[] {
70
141
  if (statement.kind === "return") {
71
142
  if (semanticKind === "view" && statement.value) {
@@ -114,10 +185,20 @@ function emitValue(declaration: PointCoreValueDeclaration, exported: boolean): s
114
185
  }
115
186
 
116
187
  function emitParam(param: PointCoreParameter): string {
117
- return `${param.name}: ${emitTypeExpression(param.type)}`;
188
+ return `${param.name}: ${emitParamType(param.type)}`;
189
+ }
190
+
191
+ function emitParamType(type: PointCoreTypeExpression): string {
192
+ if (type.name === "Handler" && type.args.length === 1) {
193
+ return `(value: ${emitTypeExpression(type.args[0]!)}) => void`;
194
+ }
195
+ return emitTypeExpression(type);
118
196
  }
119
197
 
120
198
  function emitTypeExpression(type: PointCoreTypeExpression): string {
199
+ if (type.name === "Handler" && type.args.length === 1) {
200
+ return `(value: ${emitTypeExpression(type.args[0]!)}) => void`;
201
+ }
121
202
  if (type.name === "List") return `Array<${type.args[0] ? emitTypeExpression(type.args[0]) : "unknown"}>`;
122
203
  if (type.name === "Maybe") return `${type.args[0] ? emitTypeExpression(type.args[0]) : "unknown"} | null`;
123
204
  if (type.name === "Or") return type.args.map(emitTypeExpression).join(" | ");
package/src/core/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./emit-typescript.ts";
7
7
  export * from "./emit-javascript.ts";
8
8
  export * from "./emit-python.ts";
9
9
  export * from "./incremental.ts";
10
+ export * from "./packages.ts";
10
11
  export * from "./serialize.ts";
11
12
  export * from "../semantic/index.ts";
12
13
  export * from "./format.ts";
@@ -0,0 +1,138 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+
4
+ export const POINT_MANIFEST = "point.json";
5
+ export const POINT_LOCK = "point.lock";
6
+ export const LOCK_SCHEMA = "point.lock.v1";
7
+
8
+ export interface PointManifest {
9
+ name: string;
10
+ version: string;
11
+ dependencies?: Record<string, string>;
12
+ }
13
+
14
+ export interface PointLock {
15
+ schemaVersion: typeof LOCK_SCHEMA;
16
+ packages: Record<string, PointLockPackage>;
17
+ }
18
+
19
+ export interface PointLockPackage {
20
+ version: string;
21
+ path?: string;
22
+ dependencies?: Record<string, string>;
23
+ }
24
+
25
+ export type DependencySpecKind = "workspace" | "file" | "npm";
26
+
27
+ export interface ParsedDependencySpec {
28
+ kind: DependencySpecKind;
29
+ locator: string;
30
+ }
31
+
32
+ export function parseDependencySpec(spec: string): ParsedDependencySpec {
33
+ if (spec.startsWith("workspace:")) return { kind: "workspace", locator: spec.slice("workspace:".length) };
34
+ if (spec.startsWith("file:")) return { kind: "file", locator: spec.slice("file:".length) };
35
+ if (spec.startsWith("npm:")) return { kind: "npm", locator: spec.slice("npm:".length) };
36
+ throw new Error(`Invalid dependency spec "${spec}". Use workspace:<path>, file:<path>, or npm:<package> (npm not yet supported).`);
37
+ }
38
+
39
+ export function npmDependencyNotSupportedMessage(spec: string): string {
40
+ return `npm: registry dependencies are not supported yet (${spec}). Use workspace:<path> or file:<path> for local Point packages.`;
41
+ }
42
+
43
+ export async function readPointManifest(cwd = process.cwd()): Promise<PointManifest> {
44
+ const path = join(cwd, POINT_MANIFEST);
45
+ if (!existsSync(path)) throw new Error(`Missing ${POINT_MANIFEST} in ${cwd}`);
46
+ return Bun.file(path).json() as Promise<PointManifest>;
47
+ }
48
+
49
+ export async function writePointManifest(manifest: PointManifest, cwd = process.cwd()): Promise<void> {
50
+ const path = join(cwd, POINT_MANIFEST);
51
+ await Bun.write(path, `${JSON.stringify(manifest, null, 2)}\n`);
52
+ }
53
+
54
+ export async function readPointLock(cwd = process.cwd()): Promise<PointLock | null> {
55
+ const path = join(cwd, POINT_LOCK);
56
+ if (!existsSync(path)) return null;
57
+ const lock = (await Bun.file(path).json()) as PointLock;
58
+ if (lock.schemaVersion !== LOCK_SCHEMA) {
59
+ throw new Error(`Unsupported ${POINT_LOCK} schema: ${lock.schemaVersion}`);
60
+ }
61
+ return lock;
62
+ }
63
+
64
+ export async function writePointLock(lock: PointLock, cwd = process.cwd()): Promise<void> {
65
+ const path = join(cwd, POINT_LOCK);
66
+ await Bun.write(path, `${JSON.stringify(lock, null, 2)}\n`);
67
+ }
68
+
69
+ export function normalizePackagePath(cwd: string, rawPath: string): string {
70
+ const absolute = resolve(cwd, rawPath);
71
+ if (!existsSync(absolute)) {
72
+ throw new Error(`Dependency path does not exist: ${rawPath}`);
73
+ }
74
+ return relative(cwd, absolute).split("\\").join("/") || ".";
75
+ }
76
+
77
+ export function resolveDependencySpec(spec: string, cwd = process.cwd()): PointLockPackage {
78
+ const parsed = parseDependencySpec(spec);
79
+ if (parsed.kind === "npm") throw new Error(npmDependencyNotSupportedMessage(spec));
80
+ const path = normalizePackagePath(cwd, parsed.locator);
81
+ return { version: parsed.kind, path };
82
+ }
83
+
84
+ export async function resolveLockFromManifest(manifest: PointManifest, cwd = process.cwd()): Promise<PointLock> {
85
+ const packages: Record<string, PointLockPackage> = {
86
+ [manifest.name]: {
87
+ version: manifest.version,
88
+ dependencies: manifest.dependencies ?? {},
89
+ },
90
+ };
91
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
92
+ packages[name] = resolveDependencySpec(spec, cwd);
93
+ const entry = packages[name];
94
+ if (!entry.path) continue;
95
+ const nestedManifestPath = join(cwd, entry.path, POINT_MANIFEST);
96
+ if (!existsSync(nestedManifestPath)) continue;
97
+ const nested = (await Bun.file(nestedManifestPath).json()) as PointManifest;
98
+ packages[nested.name] = {
99
+ version: nested.version,
100
+ dependencies: nested.dependencies ?? {},
101
+ };
102
+ }
103
+ return { schemaVersion: LOCK_SCHEMA, packages };
104
+ }
105
+
106
+ export async function addPointDependency(name: string, spec: string, cwd = process.cwd()): Promise<{ manifest: PointManifest; lock: PointLock }> {
107
+ if (!name || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
108
+ throw new Error(`Invalid dependency name "${name}". Use an identifier like std or point-logic.`);
109
+ }
110
+ parseDependencySpec(spec);
111
+ const manifest = await readPointManifest(cwd);
112
+ manifest.dependencies = { ...(manifest.dependencies ?? {}), [name]: spec };
113
+ const lock = await resolveLockFromManifest(manifest, cwd);
114
+ await writePointManifest(manifest, cwd);
115
+ await writePointLock(lock, cwd);
116
+ return { manifest, lock };
117
+ }
118
+
119
+ export function packageRootFromLock(lock: PointLock | null, packageName: string): string | null {
120
+ const entry = lock?.packages[packageName];
121
+ if (entry?.path) return entry.path;
122
+ if (!lock && packageName === "std") return "std";
123
+ return null;
124
+ }
125
+
126
+ export function modulePathFromLock(lock: PointLock | null, moduleName: string): string {
127
+ const dot = moduleName.indexOf(".");
128
+ if (dot < 0) {
129
+ throw new Error(`Use declarations without from must target package modules: ${moduleName}`);
130
+ }
131
+ const packageName = moduleName.slice(0, dot);
132
+ const modulePath = moduleName.slice(dot + 1);
133
+ const root = packageRootFromLock(lock, packageName);
134
+ if (!root) {
135
+ throw new Error(`Unknown package "${packageName}" in ${moduleName}. Add it with: point add ${packageName} <spec>`);
136
+ }
137
+ return `${root}/${modulePath.replaceAll(".", "/")}.point`;
138
+ }
@@ -1,7 +1,7 @@
1
1
  export function isSemanticPointSyntax(source: string): boolean {
2
2
  return source
3
3
  .split(/\r?\n/)
4
- .some((line) => /^(use|record|calculation|rule|label|external|action|policy|view|route|workflow|command)\s+/.test(line.trim()));
4
+ .some((line) => /^(use|record|calculation|rule|label|external|action|policy|view|page|route|workflow|command)\s+/.test(line.trim()));
5
5
  }
6
6
 
7
7
  export function assertSemanticPointSource(source: string) {
@@ -12,7 +12,7 @@ export function assertSemanticPointSource(source: string) {
12
12
  for (const [index, line] of lines.entries()) {
13
13
  const trimmed = line.trim();
14
14
  if (!trimmed || trimmed.startsWith("//")) continue;
15
- if (/^(record|calculation|rule|label|external|action|policy|view|route|workflow|command)\s+/.test(trimmed)) hasSemanticDeclaration = true;
15
+ if (/^(record|calculation|rule|label|external|action|policy|view|page|route|workflow|command)\s+/.test(trimmed)) hasSemanticDeclaration = true;
16
16
  if (oldStyleTopLevel.test(trimmed)) {
17
17
  throw new Error(
18
18
  `Point source uses internal core syntax at ${index + 1}:1. Use record, calculation, rule, or label instead.`,
@@ -1,5 +1,7 @@
1
1
  import type { PointCoreProgram } from "../ast.ts";
2
2
  import { assertSemanticPointSource, isSemanticPointSyntax } from "../semantic-source.ts";
3
+ import { desugarSemanticProgram } from "../../semantic/desugar.ts";
4
+ import { parseSemanticSource } from "../../semantic/parse.ts";
3
5
  import { parsePointCore } from "./core-text-parser.ts";
4
6
 
5
7
  /** Legacy string-lowering path retained for migration parity tests only. */
@@ -11,10 +13,27 @@ export function parsePointSourceLegacy(source: string): PointCoreProgram {
11
13
  function parsePointSourceViaLowering(source: string): PointCoreProgram {
12
14
  const lowered = lowerSemanticPointSyntax(source);
13
15
  const program = parsePointCore(lowered);
14
- if (isSemanticPointSyntax(source)) attachSemanticMetadata(program, source);
16
+ if (isSemanticPointSyntax(source)) {
17
+ attachSemanticMetadata(program, source);
18
+ enrichPageLayouts(program, source);
19
+ }
15
20
  return program;
16
21
  }
17
22
 
23
+ function enrichPageLayouts(program: PointCoreProgram, source: string): void {
24
+ const desugared = desugarSemanticProgram(parseSemanticSource(source));
25
+ const pageFunctions = desugared.declarations.filter(
26
+ (declaration): declaration is Extract<(typeof desugared.declarations)[number], { kind: "function" }> =>
27
+ declaration.kind === "function" && declaration.semantic?.kind === "page",
28
+ );
29
+ for (const declaration of program.declarations) {
30
+ if (declaration.kind !== "function" || declaration.semantic?.kind !== "page") continue;
31
+ const match = pageFunctions.find((candidate) => candidate.name === declaration.name);
32
+ if (!match?.semantic?.pageLayout) continue;
33
+ declaration.semantic.pageLayout = match.semantic.pageLayout;
34
+ }
35
+ }
36
+
18
37
  function lowerSemanticPointSyntax(source: string): string {
19
38
  if (!isSemanticPointSyntax(source)) return source;
20
39
  const lines = source.split(/\r?\n/);
@@ -87,6 +106,12 @@ function lowerSemanticPointSyntax(source: string): string {
87
106
  index = lowered.next;
88
107
  continue;
89
108
  }
109
+ if (trimmed.startsWith("page ")) {
110
+ const lowered = lowerPage(lines, index, records, externalBindings);
111
+ output.push(...lowered.lines);
112
+ index = lowered.next;
113
+ continue;
114
+ }
90
115
  if (trimmed.startsWith("route ")) {
91
116
  const lowered = lowerRoute(lines, index, records, externalBindings);
92
117
  output.push(...lowered.lines);
@@ -120,7 +145,7 @@ function assertSemanticPointSource(source: string) {
120
145
  for (const [index, line] of lines.entries()) {
121
146
  const trimmed = line.trim();
122
147
  if (!trimmed || trimmed.startsWith("//")) continue;
123
- if (/^(record|calculation|rule|label|external|action|policy|view|route|workflow|command)\s+/.test(trimmed)) hasSemanticDeclaration = true;
148
+ if (/^(record|calculation|rule|label|external|action|policy|view|page|route|workflow|command)\s+/.test(trimmed)) hasSemanticDeclaration = true;
124
149
  if (oldStyleTopLevel.test(trimmed)) {
125
150
  throw new Error(
126
151
  `Point source uses internal core syntax at ${index + 1}:1. Use record, calculation, rule, or label instead.`,
@@ -534,6 +559,44 @@ function lowerView(
534
559
  };
535
560
  }
536
561
 
562
+ function lowerPage(
563
+ lines: string[],
564
+ start: number,
565
+ records: Map<string, Map<string, string>>,
566
+ externalBindings: Map<string, string> = new Map(),
567
+ ): { lines: string[]; next: number } {
568
+ const label = (lines[start] ?? "").trim().slice("page ".length).trim();
569
+ const body = collectSemanticBody(lines, start + 1);
570
+ const params: string[] = [];
571
+ const paramTypes = new Map<string, string>();
572
+ const bindings = new Map<string, string>(externalBindings);
573
+ let mainExpression: string | undefined;
574
+
575
+ for (const line of body.lines) {
576
+ if (line.startsWith("input ")) {
577
+ const param = parseTypedBinding(line.slice("input ".length));
578
+ const paramName = toIdentifier(param.name);
579
+ params.push(`${paramName}: ${param.type}`);
580
+ paramTypes.set(paramName, param.type);
581
+ bindings.set(param.name, paramName);
582
+ continue;
583
+ }
584
+ if (line.startsWith("title ") || line.startsWith("description ")) continue;
585
+ if (line.startsWith("main render ")) {
586
+ mainExpression = lowerExpression(line.slice("main render ".length), paramTypes, records, bindings);
587
+ continue;
588
+ }
589
+ throw new Error(`Unknown page statement: ${line}`);
590
+ }
591
+
592
+ if (!mainExpression) throw new Error(`Page ${label} requires main render`);
593
+
594
+ return {
595
+ lines: [`fn ${semanticFunctionName(label, "page", "page")}(${params.join(", ")}): Text {`, ` return ${mainExpression}`, "}", ""],
596
+ next: body.next,
597
+ };
598
+ }
599
+
537
600
  function lowerRoute(
538
601
  lines: string[],
539
602
  start: number,
@@ -745,6 +808,7 @@ function collectSemanticDeclarationInfo(source: string): SemanticDeclarationInfo
745
808
  trimmed.startsWith("action ") ||
746
809
  trimmed.startsWith("policy ") ||
747
810
  trimmed.startsWith("view ") ||
811
+ trimmed.startsWith("page ") ||
748
812
  trimmed.startsWith("route ") ||
749
813
  trimmed.startsWith("workflow ") ||
750
814
  trimmed.startsWith("command ")
@@ -761,7 +825,9 @@ function collectSemanticDeclarationInfo(source: string): SemanticDeclarationInfo
761
825
  ? "policy"
762
826
  : trimmed.startsWith("view ")
763
827
  ? "view"
764
- : trimmed.startsWith("route ")
828
+ : trimmed.startsWith("page ")
829
+ ? "page"
830
+ : trimmed.startsWith("route ")
765
831
  ? "route"
766
832
  : trimmed.startsWith("workflow ")
767
833
  ? "workflow"
@@ -771,7 +837,7 @@ function collectSemanticDeclarationInfo(source: string): SemanticDeclarationInfo
771
837
  const body = collectSemanticBody(lines, index + 1);
772
838
  const params = new Map<string, string>();
773
839
  let outputName =
774
- kind === "label" ? "label" : kind === "policy" ? "policy" : kind === "view" ? "view" : kind === "route" ? "route" : "result";
840
+ kind === "label" ? "label" : kind === "policy" ? "policy" : kind === "view" ? "view" : kind === "page" ? "page" : kind === "route" ? "route" : "result";
775
841
  const effects: string[] = [];
776
842
  for (const line of body.lines) {
777
843
  if (line.startsWith("input ")) {
@@ -840,6 +906,7 @@ function collectCallableBindings(source: string): Map<string, string> {
840
906
  declaration.kind === "action" ||
841
907
  declaration.kind === "policy" ||
842
908
  declaration.kind === "view" ||
909
+ declaration.kind === "page" ||
843
910
  declaration.kind === "route" ||
844
911
  declaration.kind === "workflow" ||
845
912
  declaration.kind === "command"
@@ -870,7 +937,7 @@ function collectSemanticBody(lines: string[], start: number): { lines: string[];
870
937
  }
871
938
 
872
939
  function isSemanticTopLevel(line: string): boolean {
873
- return /^(module|use|record|calculation|rule|label|external|action|policy|view|route|workflow|command|type|fn|let|var|import)\s+/.test(line);
940
+ return /^(module|use|record|calculation|rule|label|external|action|policy|view|page|route|workflow|command|type|fn|let|var|import)\s+/.test(line);
874
941
  }
875
942
 
876
943
  function isSemanticLoopBoundary(line: string): boolean {
@@ -973,7 +1040,7 @@ function toIdentifier(label: string): string {
973
1040
  function semanticFunctionName(
974
1041
  label: string,
975
1042
  outputName: string,
976
- kind: "calculation" | "rule" | "label" | "action" | "policy" | "view" | "route" | "workflow" | "command",
1043
+ kind: "calculation" | "rule" | "label" | "action" | "policy" | "view" | "page" | "route" | "workflow" | "command",
977
1044
  ): string {
978
1045
  const base = toIdentifier(label);
979
1046
  const suffix =
@@ -983,7 +1050,9 @@ function semanticFunctionName(
983
1050
  ? "Policy"
984
1051
  : kind === "view"
985
1052
  ? "View"
986
- : kind === "route"
1053
+ : kind === "page"
1054
+ ? "Page"
1055
+ : kind === "route"
987
1056
  ? "Route"
988
1057
  : kind === "workflow"
989
1058
  ? "Workflow"
@@ -24,6 +24,7 @@ export type PointSemanticDeclaration =
24
24
  | PointSemanticActionDeclaration
25
25
  | PointSemanticPolicyDeclaration
26
26
  | PointSemanticViewDeclaration
27
+ | PointSemanticPageDeclaration
27
28
  | PointSemanticRouteDeclaration
28
29
  | PointSemanticWorkflowDeclaration
29
30
  | PointSemanticCommandDeclaration;
@@ -123,6 +124,16 @@ export interface PointSemanticViewDeclaration {
123
124
  span?: PointSourceSpan;
124
125
  }
125
126
 
127
+ export interface PointSemanticPageDeclaration {
128
+ kind: "page";
129
+ name: string;
130
+ inputs: PointSemanticBinding[];
131
+ title: PointSemanticExpression;
132
+ description?: PointSemanticExpression;
133
+ main: PointSemanticExpression;
134
+ span?: PointSourceSpan;
135
+ }
136
+
126
137
  export interface PointSemanticRouteDeclaration {
127
138
  kind: "route";
128
139
  name: string;
@@ -182,7 +193,9 @@ export type PointSemanticPolicyStatement =
182
193
 
183
194
  export type PointSemanticViewStatement =
184
195
  | { kind: "render"; value: PointSemanticExpression; span?: PointSourceSpan }
185
- | { kind: "whenRender"; condition: PointSemanticExpression; value: PointSemanticExpression; span?: PointSourceSpan };
196
+ | { kind: "whenRender"; condition: PointSemanticExpression; value: PointSemanticExpression; span?: PointSourceSpan }
197
+ | { kind: "bindCheckbox"; label: string; target: PointSemanticExpression; span?: PointSourceSpan }
198
+ | { kind: "onChangeCall"; callback: string; span?: PointSourceSpan };
186
199
 
187
200
  export type PointSemanticRouteStatement = { kind: "return"; value: PointSemanticExpression; span?: PointSourceSpan };
188
201
 
@@ -4,6 +4,7 @@ const CALLABLE_KEYWORDS = [
4
4
  "label",
5
5
  "action",
6
6
  "view",
7
+ "page",
7
8
  "route",
8
9
  "workflow",
9
10
  "command",
@@ -47,5 +48,5 @@ function collectBody(lines: string[], start: number): { lines: string[]; next: n
47
48
  }
48
49
 
49
50
  function isTopLevel(line: string): boolean {
50
- return /^(module|use|record|calculation|rule|label|external|action|policy|view|route|workflow|command)\s+/.test(line);
51
+ return /^(module|use|record|calculation|rule|label|external|action|policy|view|page|route|workflow|command)\s+/.test(line);
51
52
  }
@@ -27,6 +27,7 @@ export type PointSemanticSymbolKind =
27
27
  | "action"
28
28
  | "policy"
29
29
  | "view"
30
+ | "page"
30
31
  | "route"
31
32
  | "workflow"
32
33
  | "command"
@@ -215,6 +216,15 @@ function callableDeclaration(declaration: PointSemanticDeclaration):
215
216
  if (declaration.kind === "view" || declaration.kind === "route" || declaration.kind === "workflow" || declaration.kind === "command") {
216
217
  return { kind: declaration.kind, name: declaration.name, inputs: declaration.inputs, output: declaration.output, declaration };
217
218
  }
219
+ if (declaration.kind === "page") {
220
+ return {
221
+ kind: "page",
222
+ name: declaration.name,
223
+ inputs: declaration.inputs,
224
+ output: { name: "page", type: { kind: "typeRef", name: "Page", args: [] } },
225
+ declaration,
226
+ };
227
+ }
218
228
  return null;
219
229
  }
220
230
 
@@ -313,7 +323,7 @@ function relatedRefsFor(symbol: PointSemanticSymbol, index: PointSemanticIndex):
313
323
  const ownerPath = symbol.path.split(".").slice(0, 2).join(".");
314
324
  return index.refs.filter((candidate) => candidate.path.startsWith(`${ownerPath}.`) && candidate.ref !== symbol.ref).map((candidate) => candidate.ref);
315
325
  }
316
- if (symbol.kind === "record" || symbol.kind === "calculation" || symbol.kind === "rule" || symbol.kind === "label" || symbol.kind === "action" || symbol.kind === "policy" || symbol.kind === "view" || symbol.kind === "route" || symbol.kind === "workflow" || symbol.kind === "command" || symbol.kind === "external") {
326
+ if (symbol.kind === "record" || symbol.kind === "calculation" || symbol.kind === "rule" || symbol.kind === "label" || symbol.kind === "action" || symbol.kind === "policy" || symbol.kind === "view" || symbol.kind === "page" || symbol.kind === "route" || symbol.kind === "workflow" || symbol.kind === "command" || symbol.kind === "external") {
317
327
  return index.refs.filter((candidate) => candidate.path.startsWith(`${symbol.path}.`)).map((candidate) => candidate.ref);
318
328
  }
319
329
  return [];
@@ -332,6 +342,7 @@ function summaryFor(symbol: PointSemanticSymbol): string {
332
342
  if (symbol.kind === "action") return `Semantic action ${symbol.name} returns ${symbol.type}; effects: ${(symbol.effects ?? []).join(", ") || "none"}.`;
333
343
  if (symbol.kind === "policy") return `Semantic policy ${symbol.name} returns ${symbol.type}.`;
334
344
  if (symbol.kind === "view") return `Semantic view ${symbol.name} returns ${symbol.type}.`;
345
+ if (symbol.kind === "page") return `Semantic page ${symbol.name} returns a Next.js page shell (${symbol.type}).`;
335
346
  if (symbol.kind === "route") return `Semantic route ${symbol.name} returns ${symbol.type}.`;
336
347
  if (symbol.kind === "workflow") return `Semantic workflow ${symbol.name} returns ${symbol.type}.`;
337
348
  if (symbol.kind === "command") return `Semantic command ${symbol.name} returns ${symbol.type}.`;
@@ -11,6 +11,7 @@ import type {
11
11
  PointCoreTypeExpression,
12
12
  PointCoreValueDeclaration,
13
13
  PointSourceSpan,
14
+ PointSemanticViewControls,
14
15
  } from "../core/ast.ts";
15
16
  import type {
16
17
  PointSemanticBinding,
@@ -35,6 +36,8 @@ import type {
35
36
  PointSemanticTypeExpression,
36
37
  PointSemanticUseDeclaration,
37
38
  PointSemanticViewDeclaration,
39
+ PointSemanticViewStatement,
40
+ PointSemanticPageDeclaration,
38
41
  PointSemanticWorkflowDeclaration,
39
42
  PointSemanticWorkflowStatement,
40
43
  PointSemanticActionDeclaration,
@@ -104,6 +107,7 @@ function buildCallableMap(
104
107
  declaration.kind === "action" ||
105
108
  declaration.kind === "policy" ||
106
109
  declaration.kind === "view" ||
110
+ declaration.kind === "page" ||
107
111
  declaration.kind === "route" ||
108
112
  declaration.kind === "workflow" ||
109
113
  declaration.kind === "command"
@@ -119,6 +123,7 @@ function defaultOutputName(declaration: PointSemanticDeclaration): string {
119
123
  if (declaration.kind === "label") return "label";
120
124
  if (declaration.kind === "policy") return "policy";
121
125
  if (declaration.kind === "view") return "view";
126
+ if (declaration.kind === "page") return "page";
122
127
  if (declaration.kind === "route") return "route";
123
128
  if ("output" in declaration) return toIdentifier(declaration.output.name);
124
129
  return "result";
@@ -152,6 +157,8 @@ function desugarDeclaration(
152
157
  return [desugarPolicy(declaration, records, callables)];
153
158
  case "view":
154
159
  return [desugarView(declaration, records, callables)];
160
+ case "page":
161
+ return [desugarPage(declaration, records, callables)];
155
162
  case "route":
156
163
  return [desugarRoute(declaration, records, callables)];
157
164
  case "workflow":
@@ -296,13 +303,85 @@ function desugarView(
296
303
  const outputType: PointCoreTypeExpression = { kind: "typeRef", name: "Text", args: [] };
297
304
  const { params, bindings } = collectBindings(declaration.inputs, declaration.output);
298
305
  const ctx: DesugarContext = { records, callables, bindings, outputName: "page", outputType };
306
+ const renderStatements = declaration.body.filter(
307
+ (statement): statement is Extract<PointSemanticViewStatement, { kind: "render" | "whenRender" }> =>
308
+ statement.kind === "render" || statement.kind === "whenRender",
309
+ );
310
+ const metadata = semanticDeclarationMetadata(declaration);
311
+ const viewControls = buildViewControls(declaration, ctx);
312
+ if (viewControls) metadata.viewControls = viewControls;
299
313
  return {
300
314
  kind: "function",
301
315
  name: semanticFunctionName(declaration.name, "view", "view"),
302
316
  params,
303
317
  returnType: outputType,
304
- body: desugarViewBody(declaration.body, ctx),
305
- semantic: semanticDeclarationMetadata(declaration),
318
+ body: desugarViewBody(renderStatements, ctx),
319
+ semantic: metadata,
320
+ span: declaration.span,
321
+ };
322
+ }
323
+
324
+ function buildViewControls(
325
+ declaration: PointSemanticViewDeclaration,
326
+ ctx: DesugarContext,
327
+ ): PointSemanticViewControls | undefined {
328
+ const bindStatements = declaration.body.filter(
329
+ (statement): statement is Extract<PointSemanticViewStatement, { kind: "bindCheckbox" }> => statement.kind === "bindCheckbox",
330
+ );
331
+ if (bindStatements.length === 0) return undefined;
332
+
333
+ const onChangeCall = declaration.body.find(
334
+ (statement): statement is Extract<PointSemanticViewStatement, { kind: "onChangeCall" }> => statement.kind === "onChangeCall",
335
+ );
336
+ const handlerInput = declaration.inputs.find((input) => input.type.name === "Handler" && input.type.args.length === 1);
337
+ const callbackLabel = onChangeCall?.callback ?? handlerInput?.label;
338
+ if (!callbackLabel) {
339
+ throw new Error(`View ${declaration.name} with bind checkbox requires input Handler T or on change call`);
340
+ }
341
+ const changeCallback = toIdentifier(callbackLabel);
342
+
343
+ return {
344
+ changeCallback,
345
+ checkboxes: bindStatements.map((statement) => {
346
+ const target = desugarExpression(statement.target, ctx);
347
+ if (target.kind !== "property") {
348
+ throw new Error(`bind checkbox target must be a record field access`);
349
+ }
350
+ if (target.target.kind !== "identifier") {
351
+ throw new Error(`bind checkbox target must start with an input record`);
352
+ }
353
+ return {
354
+ label: statement.label,
355
+ target,
356
+ recordParam: target.target.name,
357
+ fieldName: target.name,
358
+ };
359
+ }),
360
+ };
361
+ }
362
+
363
+ function desugarPage(
364
+ declaration: PointSemanticPageDeclaration,
365
+ records: Map<string, Map<string, string>>,
366
+ callables: Map<string, string>,
367
+ ): PointCoreFunctionDeclaration {
368
+ const outputType: PointCoreTypeExpression = { kind: "typeRef", name: "Text", args: [] };
369
+ const pageType: PointCoreTypeExpression = { kind: "typeRef", name: "Page", args: [] };
370
+ const { params, bindings } = collectBindings(declaration.inputs, { name: "page", type: pageType });
371
+ const ctx: DesugarContext = { records, callables, bindings, outputName: "page", outputType };
372
+ const metadata = semanticDeclarationMetadata(declaration);
373
+ metadata.pageLayout = {
374
+ title: desugarExpression(declaration.title, ctx),
375
+ description: declaration.description ? desugarExpression(declaration.description, ctx) : undefined,
376
+ main: desugarExpression(declaration.main, ctx),
377
+ };
378
+ return {
379
+ kind: "function",
380
+ name: semanticFunctionName(declaration.name, "page", "page"),
381
+ params,
382
+ returnType: outputType,
383
+ body: [{ kind: "return", value: metadata.pageLayout.main, span: declaration.span }],
384
+ semantic: metadata,
306
385
  span: declaration.span,
307
386
  };
308
387
  }
@@ -390,7 +469,7 @@ function desugarParameter(binding: PointSemanticBinding): PointCoreParameter {
390
469
  }
391
470
 
392
471
  function desugarType(type: PointSemanticTypeExpression): PointCoreTypeExpression {
393
- if (type.name === "List" || type.name === "Maybe" || type.name === "Or") {
472
+ if (type.name === "List" || type.name === "Maybe" || type.name === "Or" || type.name === "Handler") {
394
473
  return { kind: "typeRef", name: type.name, args: type.args.map(desugarType) };
395
474
  }
396
475
  const primitives = new Set(["Text", "Int", "Float", "Bool", "Void", "Error", "Page"]);
@@ -32,6 +32,14 @@ export function parseSemanticTypeExpression(source: string): PointSemanticTypeEx
32
32
  if (maybeMatch) {
33
33
  return { kind: "typeRef", name: "Maybe", args: [parseSemanticTypeExpression(maybeMatch[1] ?? "")] };
34
34
  }
35
+ const handlerMatch = trimmed.match(/^Handler\s+(.+)$/);
36
+ if (handlerMatch) {
37
+ return { kind: "typeRef", name: "Handler", args: [parseSemanticTypeExpression(handlerMatch[1] ?? "")] };
38
+ }
39
+ const handlerGenericMatch = trimmed.match(/^Handler<(.+)>$/);
40
+ if (handlerGenericMatch) {
41
+ return { kind: "typeRef", name: "Handler", args: [parseSemanticTypeExpression(handlerGenericMatch[1] ?? "")] };
42
+ }
35
43
  return { kind: "typeRef", name: trimmed, args: [] };
36
44
  }
37
45
 
@@ -83,6 +83,14 @@ function formatDeclaration(declaration: PointSemanticDeclaration): string[] {
83
83
  ...formatViewOutput(declaration.output),
84
84
  ...declaration.body.map((statement) => ` ${formatViewStatement(statement)}`),
85
85
  ];
86
+ case "page":
87
+ return [
88
+ `page ${declaration.name}`,
89
+ ...formatInputs(declaration.inputs),
90
+ ` title ${formatExpression(declaration.title)}`,
91
+ ...(declaration.description ? [` description ${formatExpression(declaration.description)}`] : []),
92
+ ` main render ${formatExpression(declaration.main)}`,
93
+ ];
86
94
  case "route":
87
95
  return [
88
96
  `route ${declaration.name}`,
@@ -176,6 +184,8 @@ function formatPolicyStatement(statement: PointSemanticPolicyStatement): string
176
184
 
177
185
  function formatViewStatement(statement: PointSemanticViewStatement): string {
178
186
  if (statement.kind === "whenRender") return `when ${formatExpression(statement.condition)} render ${formatExpression(statement.value)}`;
187
+ if (statement.kind === "bindCheckbox") return `bind checkbox "${statement.label}" to ${formatExpression(statement.target)}`;
188
+ if (statement.kind === "onChangeCall") return `on change call ${statement.callback}`;
179
189
  return `render ${formatExpression(statement.value)}`;
180
190
  }
181
191
 
@@ -30,6 +30,9 @@ export function semanticDeclarationMetadata(declaration: PointSemanticDeclaratio
30
30
  effects: [],
31
31
  };
32
32
  }
33
+ if (declaration.kind === "page") {
34
+ return { kind: "page", name: declaration.name, outputName: "page", effects: [] };
35
+ }
33
36
  if (declaration.kind === "route") {
34
37
  return { kind: "route", name: declaration.name, outputName: declaration.output.name, effects: [] };
35
38
  }
@@ -11,7 +11,7 @@ export function toIdentifier(label: string): string {
11
11
  export function semanticFunctionName(
12
12
  label: string,
13
13
  outputName: string,
14
- kind: "calculation" | "rule" | "label" | "action" | "policy" | "view" | "route" | "workflow" | "command",
14
+ kind: "calculation" | "rule" | "label" | "action" | "policy" | "view" | "page" | "route" | "workflow" | "command",
15
15
  ): string {
16
16
  const base = toIdentifier(label);
17
17
  const suffix =
@@ -21,7 +21,9 @@ export function semanticFunctionName(
21
21
  ? "Policy"
22
22
  : kind === "view"
23
23
  ? "View"
24
- : kind === "route"
24
+ : kind === "page"
25
+ ? "Page"
26
+ : kind === "route"
25
27
  ? "Route"
26
28
  : kind === "workflow"
27
29
  ? "Workflow"
@@ -25,6 +25,7 @@ import type {
25
25
  PointSemanticUseDeclaration,
26
26
  PointSemanticViewDeclaration,
27
27
  PointSemanticViewStatement,
28
+ PointSemanticPageDeclaration,
28
29
  PointSemanticWorkflowDeclaration,
29
30
  PointSemanticWorkflowStatement,
30
31
  PointSemanticBinding,
@@ -129,6 +130,12 @@ export function parseSemanticSource(source: string): PointSemanticProgram {
129
130
  index = parsed.next;
130
131
  continue;
131
132
  }
133
+ if (trimmed.startsWith("page ")) {
134
+ const parsed = parsePage(lines, index, source, records, callables);
135
+ declarations.push(parsed.declaration);
136
+ index = parsed.next;
137
+ continue;
138
+ }
132
139
  if (trimmed.startsWith("route ")) {
133
140
  const parsed = parseRoute(lines, index, source, records, callables);
134
141
  declarations.push(parsed.declaration);
@@ -586,6 +593,24 @@ function parseView(
586
593
  });
587
594
  continue;
588
595
  }
596
+ const bindCheckbox = line.match(/^bind checkbox "(.+)" to (.+)$/);
597
+ if (bindCheckbox) {
598
+ statements.push({
599
+ kind: "bindCheckbox",
600
+ label: bindCheckbox[1] ?? "",
601
+ target: parseLineExpression(bindCheckbox[2] ?? "", context, source, lineNumber),
602
+ span: lineSpan(source, lineNumber),
603
+ });
604
+ continue;
605
+ }
606
+ if (line.startsWith("on change call ")) {
607
+ statements.push({
608
+ kind: "onChangeCall",
609
+ callback: line.slice("on change call ".length).trim(),
610
+ span: lineSpan(source, lineNumber),
611
+ });
612
+ continue;
613
+ }
589
614
  throw new Error(`Unknown view statement: ${line}`);
590
615
  }
591
616
 
@@ -595,6 +620,57 @@ function parseView(
595
620
  };
596
621
  }
597
622
 
623
+ function parsePage(
624
+ lines: string[],
625
+ start: number,
626
+ source: string,
627
+ records: Map<string, Map<string, string>>,
628
+ callables: string[],
629
+ ): { declaration: PointSemanticPageDeclaration; next: number } {
630
+ const name = (lines[start] ?? "").trim().slice("page ".length).trim();
631
+ const body = collectSemanticBody(lines, start + 1);
632
+ const inputs: PointSemanticBinding[] = [];
633
+ const paramTypes = new Map<string, string>();
634
+ const bindings: string[] = [];
635
+ let title: PointSemanticPageDeclaration["title"] | undefined;
636
+ let description: PointSemanticPageDeclaration["description"];
637
+ let main: PointSemanticPageDeclaration["main"] | undefined;
638
+
639
+ for (let lineIndex = 0; lineIndex < body.lines.length; lineIndex += 1) {
640
+ const line = body.lines[lineIndex] ?? "";
641
+ const lineNumber = body.lineNumbers[lineIndex] ?? start + 2;
642
+ if (line.startsWith("input ")) {
643
+ const binding = parseInputBinding(line.slice("input ".length), source, lineNumber);
644
+ inputs.push(binding);
645
+ paramTypes.set(binding.label, typeLabel(binding.type));
646
+ bindings.push(binding.label);
647
+ continue;
648
+ }
649
+ const context = buildExpressionContext({ bindings, paramTypes, recordFields: records, callables });
650
+ if (line.startsWith("title ")) {
651
+ title = parseLineExpression(line.slice("title ".length), context, source, lineNumber);
652
+ continue;
653
+ }
654
+ if (line.startsWith("description ")) {
655
+ description = parseLineExpression(line.slice("description ".length), context, source, lineNumber);
656
+ continue;
657
+ }
658
+ if (line.startsWith("main render ")) {
659
+ main = parseLineExpression(line.slice("main render ".length), context, source, lineNumber);
660
+ continue;
661
+ }
662
+ throw new Error(`Unknown page statement: ${line}`);
663
+ }
664
+
665
+ if (!title) throw new Error(`Page ${name} requires a title`);
666
+ if (!main) throw new Error(`Page ${name} requires main render`);
667
+
668
+ return {
669
+ declaration: { kind: "page", name, inputs, title, description, main, span: lineSpan(source, start + 1) },
670
+ next: body.next,
671
+ };
672
+ }
673
+
598
674
  function parseRoute(
599
675
  lines: string[],
600
676
  start: number,
@@ -893,7 +969,7 @@ function collectSemanticBody(lines: string[], start: number): SemanticBody {
893
969
  }
894
970
 
895
971
  function isSemanticTopLevel(line: string): boolean {
896
- return /^(module|use|record|calculation|rule|label|external|action|policy|view|route|workflow|command)\s+/.test(line);
972
+ return /^(module|use|record|calculation|rule|label|external|action|policy|view|page|route|workflow|command)\s+/.test(line);
897
973
  }
898
974
 
899
975
  function isLoopBoundary(line: string): boolean {
@@ -914,7 +990,10 @@ function typeLabel(type: { kind: "typeRef"; name: string; args: unknown[] }): st
914
990
  if (type.name === "Maybe" && type.args[0]) {
915
991
  return `Maybe<${typeLabel(type.args[0] as { kind: "typeRef"; name: string; args: unknown[] })}>`;
916
992
  }
917
- const primitives = new Set(["Text", "Int", "Float", "Bool", "Void", "Maybe", "Or", "Error", "Page"]);
993
+ if (type.name === "Handler" && type.args[0]) {
994
+ return `Handler ${typeLabel(type.args[0] as { kind: "typeRef"; name: string; args: unknown[] })}`;
995
+ }
996
+ const primitives = new Set(["Text", "Int", "Float", "Bool", "Void", "Maybe", "Or", "Error", "Page", "Handler"]);
918
997
  if (primitives.has(type.name)) return type.name;
919
998
  return toPascalCase(type.name);
920
999
  }
@@ -0,0 +1,30 @@
1
+ type PointStdError = { message: string };
2
+
3
+ async function readResponseText(response: Response): Promise<string | PointStdError> {
4
+ if (!response.ok) {
5
+ return { message: `HTTP ${response.status}: ${response.statusText}` };
6
+ }
7
+ return response.text();
8
+ }
9
+
10
+ export async function httpGet(url: string): Promise<string | PointStdError> {
11
+ try {
12
+ return await readResponseText(await fetch(url));
13
+ } catch (error) {
14
+ return { message: error instanceof Error ? error.message : String(error) };
15
+ }
16
+ }
17
+
18
+ export async function httpPost(url: string, body: string): Promise<string | PointStdError> {
19
+ try {
20
+ return await readResponseText(
21
+ await fetch(url, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
24
+ body,
25
+ }),
26
+ );
27
+ } catch (error) {
28
+ return { message: error instanceof Error ? error.message : String(error) };
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ export type PointStdError = { message: string };
2
+
3
+ export function jsonParse(value: string): string | PointStdError {
4
+ try {
5
+ return JSON.stringify(JSON.parse(value));
6
+ } catch (error) {
7
+ return { message: error instanceof Error ? error.message : String(error) };
8
+ }
9
+ }
10
+
11
+ export function jsonStringify(value: string): string {
12
+ return JSON.stringify(JSON.parse(value));
13
+ }