@hatchingpoint/point 0.0.11 → 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.
@@ -0,0 +1,188 @@
1
+ import type {
2
+ PointCoreDeclaration,
3
+ PointCoreExpression,
4
+ PointCoreExternalDeclaration,
5
+ PointCoreFunctionDeclaration,
6
+ PointCoreParameter,
7
+ PointCorePrimitiveType,
8
+ PointCoreProgram,
9
+ PointCoreStatement,
10
+ PointCoreTypeDeclaration,
11
+ PointCoreTypeExpression,
12
+ PointCoreValueDeclaration,
13
+ } from "./ast.ts";
14
+
15
+ const BINARY_OPERATORS: Record<string, string> = {
16
+ and: "and",
17
+ or: "or",
18
+ };
19
+
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
+ }
26
+
27
+ /** Emit Python from a core AST program (pure logic modules). Production path: parsePointSource → check → emit. */
28
+ export function emitPointCorePython(program: PointCoreProgram): string {
29
+ const lines: string[] = [];
30
+ lines.push("# Generated by Point. Do not edit directly.");
31
+ if (program.module) lines.push(`# Point module: ${program.module}`);
32
+ lines.push("");
33
+ lines.push("from __future__ import annotations");
34
+ lines.push("");
35
+ if (program.declarations.some((declaration) => declaration.kind === "type")) {
36
+ lines.push("from typing import TypedDict");
37
+ lines.push("");
38
+ }
39
+ for (const declaration of program.declarations) {
40
+ const emitted = emitDeclaration(declaration);
41
+ if (emitted.length > 0) lines.push(...emitted, "");
42
+ }
43
+ return `${trimTrailingBlankLines(lines).join("\n")}\n`;
44
+ }
45
+
46
+ function emitDeclaration(declaration: PointCoreDeclaration): string[] {
47
+ if (declaration.kind === "import") {
48
+ const moduleName = declaration.from.replace(/^\.\//, "").replace(/-/g, "_");
49
+ return [`from ${toPythonModuleName(moduleName)} import ${declaration.names.join(", ")}`];
50
+ }
51
+ if (declaration.kind === "external") return emitExternal(declaration);
52
+ if (declaration.kind === "type") return emitType(declaration);
53
+ if (declaration.kind === "value") return [emitValue(declaration)];
54
+ if (declaration.semantic && UNSUPPORTED_SEMANTIC_KINDS.has(declaration.semantic.kind)) {
55
+ return [`# Point: ${declaration.semantic.kind} blocks are not supported in Python emit yet`];
56
+ }
57
+ return emitFunction(declaration);
58
+ }
59
+
60
+ function emitType(declaration: PointCoreTypeDeclaration): string[] {
61
+ return [
62
+ `class ${declaration.name}(TypedDict):`,
63
+ ...declaration.fields.map((field) => ` ${field.name}: ${emitTypeExpression(field.type)}`),
64
+ ];
65
+ }
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
+
80
+ function emitFunction(declaration: PointCoreFunctionDeclaration): string[] {
81
+ const asyncPrefix = declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command" ? "async " : "";
82
+ return [
83
+ `${asyncPrefix}def ${declaration.name}(${declaration.params.map(emitParam).join(", ")}) -> ${emitReturnType(declaration)}:`,
84
+ ...indentLines(declaration.body.flatMap((statement) => emitStatement(statement, declaration.semantic?.kind))),
85
+ ];
86
+ }
87
+
88
+ function emitReturnType(declaration: PointCoreFunctionDeclaration): string {
89
+ if (declaration.semantic?.kind === "action" || declaration.semantic?.kind === "workflow" || declaration.semantic?.kind === "command") {
90
+ return emitTypeExpression(declaration.returnType);
91
+ }
92
+ return emitTypeExpression(declaration.returnType);
93
+ }
94
+
95
+ function emitStatement(statement: PointCoreStatement, semanticKind?: string): string[] {
96
+ if (statement.kind === "return") {
97
+ if (semanticKind === "view" || semanticKind === "page") return ["# Point: view/page return values are not supported in Python emit yet", "return \"\""];
98
+ return [statement.value ? `return ${emitExpression(statement.value)}` : "return"];
99
+ }
100
+ if (statement.kind === "value") return [emitValue(statement)];
101
+ if (statement.kind === "assignment") return [`${statement.name} ${statement.operator} ${emitExpression(statement.value)}`];
102
+ if (statement.kind === "if") {
103
+ const lines = [`if ${emitCondition(statement.condition)}:`, ...indentLines(statement.thenBody.flatMap((child) => emitStatement(child, semanticKind)))];
104
+ if (statement.elseBody.length > 0) {
105
+ lines.push("else:", ...indentLines(statement.elseBody.flatMap((child) => emitStatement(child, semanticKind))));
106
+ }
107
+ return lines;
108
+ }
109
+ if (statement.kind === "for") {
110
+ return [`for ${statement.itemName} in ${emitExpression(statement.iterable)}:`, ...indentLines(statement.body.flatMap((child) => emitStatement(child, semanticKind)))];
111
+ }
112
+ return [emitExpression(statement.value)];
113
+ }
114
+
115
+ function emitValue(declaration: PointCoreValueDeclaration): string {
116
+ return `${declaration.name}: ${emitTypeExpression(declaration.type)} = ${emitExpression(declaration.value)}`;
117
+ }
118
+
119
+ function emitParam(param: PointCoreParameter): string {
120
+ return `${param.name}: ${emitTypeExpression(param.type)}`;
121
+ }
122
+
123
+ function emitTypeExpression(type: PointCoreTypeExpression): string {
124
+ if (type.name === "List") return `list[${type.args[0] ? emitTypeExpression(type.args[0]) : "object"}]`;
125
+ if (type.name === "Maybe") return `${type.args[0] ? emitTypeExpression(type.args[0]) : "object"} | None`;
126
+ if (type.name === "Or") return type.args.map(emitTypeExpression).join(" | ");
127
+ if (type.name === "Error") return "dict[str, str]";
128
+ if (isPrimitiveType(type.name)) return emitPrimitiveType(type.name);
129
+ return type.name;
130
+ }
131
+
132
+ function emitPrimitiveType(type: PointCorePrimitiveType): string {
133
+ if (type === "Text") return "str";
134
+ if (type === "Int") return "int";
135
+ if (type === "Float") return "float";
136
+ if (type === "Bool") return "bool";
137
+ return "None";
138
+ }
139
+
140
+ function emitExpression(expression: PointCoreExpression): string {
141
+ if (expression.kind === "literal") return emitLiteral(expression.value);
142
+ if (expression.kind === "identifier") return expression.name;
143
+ if (expression.kind === "list") return `[${expression.items.map(emitExpression).join(", ")}]`;
144
+ if (expression.kind === "record") {
145
+ return `{${expression.fields.map((field) => `"${field.name}": ${emitExpression(field.value)}`).join(", ")}}`;
146
+ }
147
+ if (expression.kind === "await") return `await ${emitExpression(expression.value)}`;
148
+ if (expression.kind === "property") return `${emitExpression(expression.target)}[${JSON.stringify(expression.name)}]`;
149
+ if (expression.kind === "call") {
150
+ if (expression.callee === "Error") {
151
+ const message = expression.args[0] ? emitExpression(expression.args[0]) : '""';
152
+ return `{"message": ${message}}`;
153
+ }
154
+ return `${expression.callee}(${expression.args.map(emitExpression).join(", ")})`;
155
+ }
156
+ const operator = BINARY_OPERATORS[expression.operator] ?? expression.operator;
157
+ return `(${emitExpression(expression.left)} ${operator} ${emitExpression(expression.right)})`;
158
+ }
159
+
160
+ function emitLiteral(value: unknown): string {
161
+ if (value === null) return "None";
162
+ if (value === true) return "True";
163
+ if (value === false) return "False";
164
+ if (typeof value === "string") return JSON.stringify(value);
165
+ return String(value);
166
+ }
167
+
168
+ function emitCondition(expression: PointCoreExpression): string {
169
+ const emitted = emitExpression(expression);
170
+ return emitted.startsWith("(") && emitted.endsWith(")") ? emitted.slice(1, -1) : emitted;
171
+ }
172
+
173
+ function toPythonModuleName(from: string): string {
174
+ return from.replace(/\.ts$/, "").replace(/\.js$/, "").replace(/\.py$/, "");
175
+ }
176
+
177
+ function isPrimitiveType(type: string): type is PointCorePrimitiveType {
178
+ return type === "Text" || type === "Int" || type === "Float" || type === "Bool" || type === "Void";
179
+ }
180
+
181
+ function indentLines(lines: string[]): string[] {
182
+ return lines.map((line) => ` ${line}`);
183
+ }
184
+
185
+ function trimTrailingBlankLines(lines: string[]): string[] {
186
+ while (lines.at(-1) === "") lines.pop();
187
+ return lines;
188
+ }
@@ -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,22 +57,93 @@ 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
- if (semanticKind === "view" && statement.value?.kind === "literal" && typeof statement.value.value === "string") {
72
- return [`return <>${escapeJsxText(statement.value.value)}</>;`];
142
+ if (semanticKind === "view" && statement.value) {
143
+ if (statement.value.kind === "literal" && typeof statement.value.value === "string") {
144
+ return [`return <>${escapeJsxText(statement.value.value)}</>;`];
145
+ }
146
+ return [`return <>{${emitExpression(statement.value)}}</>;`];
73
147
  }
74
148
  return [statement.value ? `return ${emitExpression(statement.value)};` : "return;"];
75
149
  }
@@ -111,10 +185,20 @@ function emitValue(declaration: PointCoreValueDeclaration, exported: boolean): s
111
185
  }
112
186
 
113
187
  function emitParam(param: PointCoreParameter): string {
114
- 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);
115
196
  }
116
197
 
117
198
  function emitTypeExpression(type: PointCoreTypeExpression): string {
199
+ if (type.name === "Handler" && type.args.length === 1) {
200
+ return `(value: ${emitTypeExpression(type.args[0]!)}) => void`;
201
+ }
118
202
  if (type.name === "List") return `Array<${type.args[0] ? emitTypeExpression(type.args[0]) : "unknown"}>`;
119
203
  if (type.name === "Maybe") return `${type.args[0] ? emitTypeExpression(type.args[0]) : "unknown"} | null`;
120
204
  if (type.name === "Or") return type.args.map(emitTypeExpression).join(" | ");
package/src/core/index.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  export * from "./ast.ts";
2
2
  export * from "./check.ts";
3
3
  export { findRunEntryName } from "./cli.ts";
4
+ export * from "./check-docs.ts";
4
5
  export * from "./context.ts";
5
6
  export * from "./emit-typescript.ts";
6
7
  export * from "./emit-javascript.ts";
8
+ export * from "./emit-python.ts";
7
9
  export * from "./incremental.ts";
10
+ export * from "./packages.ts";
8
11
  export * from "./serialize.ts";
9
12
  export * from "../semantic/index.ts";
10
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"