@hatchingpoint/point 0.0.12 → 0.0.14

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,230 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+
5
+ export const POINT_MANIFEST = "point.json";
6
+ export const POINT_LOCK = "point.lock";
7
+ export const LOCK_SCHEMA = "point.lock.v1";
8
+
9
+ export interface PointManifest {
10
+ name: string;
11
+ version: string;
12
+ dependencies?: Record<string, string>;
13
+ }
14
+
15
+ export interface PointLock {
16
+ schemaVersion: typeof LOCK_SCHEMA;
17
+ packages: Record<string, PointLockPackage>;
18
+ }
19
+
20
+ export interface PointLockPackage {
21
+ version: string;
22
+ path?: string;
23
+ dependencies?: Record<string, string>;
24
+ }
25
+
26
+ export type DependencySpecKind = "workspace" | "file" | "npm";
27
+
28
+ export interface ParsedDependencySpec {
29
+ kind: DependencySpecKind;
30
+ locator: string;
31
+ }
32
+
33
+ export interface ParsedNpmLocator {
34
+ name: string;
35
+ version?: string;
36
+ }
37
+
38
+ export function parseNpmLocator(locator: string): ParsedNpmLocator {
39
+ if (locator.startsWith("@")) {
40
+ const slash = locator.indexOf("/");
41
+ if (slash < 0) {
42
+ throw new Error(`Invalid npm package name "${locator}". Scoped packages use @scope/name.`);
43
+ }
44
+ const rest = locator.slice(slash + 1);
45
+ const versionAt = rest.indexOf("@");
46
+ if (versionAt >= 0) {
47
+ return { name: `${locator.slice(0, slash + 1 + versionAt)}`, version: rest.slice(versionAt + 1) };
48
+ }
49
+ return { name: locator };
50
+ }
51
+ const versionAt = locator.lastIndexOf("@");
52
+ if (versionAt > 0) {
53
+ return { name: locator.slice(0, versionAt), version: locator.slice(versionAt + 1) };
54
+ }
55
+ return { name: locator };
56
+ }
57
+
58
+ export function parseDependencySpec(spec: string): ParsedDependencySpec {
59
+ if (spec.startsWith("workspace:")) return { kind: "workspace", locator: spec.slice("workspace:".length) };
60
+ if (spec.startsWith("file:")) return { kind: "file", locator: spec.slice("file:".length) };
61
+ if (spec.startsWith("npm:")) return { kind: "npm", locator: spec.slice("npm:".length) };
62
+ throw new Error(`Invalid dependency spec "${spec}". Use workspace:<path>, file:<path>, or npm:<package>[@version].`);
63
+ }
64
+
65
+ export async function readPointManifest(cwd = process.cwd()): Promise<PointManifest> {
66
+ const path = join(cwd, POINT_MANIFEST);
67
+ if (!existsSync(path)) throw new Error(`Missing ${POINT_MANIFEST} in ${cwd}`);
68
+ return Bun.file(path).json() as Promise<PointManifest>;
69
+ }
70
+
71
+ export async function writePointManifest(manifest: PointManifest, cwd = process.cwd()): Promise<void> {
72
+ const path = join(cwd, POINT_MANIFEST);
73
+ await Bun.write(path, `${JSON.stringify(manifest, null, 2)}\n`);
74
+ }
75
+
76
+ export async function readPointLock(cwd = process.cwd()): Promise<PointLock | null> {
77
+ const path = join(cwd, POINT_LOCK);
78
+ if (!existsSync(path)) return null;
79
+ const lock = (await Bun.file(path).json()) as PointLock;
80
+ if (lock.schemaVersion !== LOCK_SCHEMA) {
81
+ throw new Error(`Unsupported ${POINT_LOCK} schema: ${lock.schemaVersion}`);
82
+ }
83
+ return lock;
84
+ }
85
+
86
+ export async function writePointLock(lock: PointLock, cwd = process.cwd()): Promise<void> {
87
+ const path = join(cwd, POINT_LOCK);
88
+ await Bun.write(path, `${JSON.stringify(lock, null, 2)}\n`);
89
+ }
90
+
91
+ export function normalizePackagePath(cwd: string, rawPath: string): string {
92
+ const absolute = resolve(cwd, rawPath);
93
+ if (!existsSync(absolute)) {
94
+ throw new Error(`Dependency path does not exist: ${rawPath}`);
95
+ }
96
+ return relative(cwd, absolute).split("\\").join("/") || ".";
97
+ }
98
+
99
+ export function locatePointPackageRoot(pkgDir: string): string {
100
+ if (existsSync(join(pkgDir, POINT_MANIFEST))) return pkgDir;
101
+ const srcDir = join(pkgDir, "src");
102
+ if (existsSync(srcDir) && readdirSync(srcDir).some((name) => name.endsWith(".point"))) return pkgDir;
103
+ if (readdirSync(pkgDir).some((name) => name.endsWith(".point"))) return pkgDir;
104
+ throw new Error(`Point package at ${pkgDir} has no ${POINT_MANIFEST} or src/*.point modules`);
105
+ }
106
+
107
+ export function resolveNpmPackagePath(cwd: string, packageName: string): string | null {
108
+ const direct = join(cwd, "node_modules", ...packageName.split("/"));
109
+ if (existsSync(join(direct, "package.json"))) return direct;
110
+ try {
111
+ const req = createRequire(join(cwd, "package.json"));
112
+ const pkgJson = req.resolve(`${packageName}/package.json`);
113
+ return resolve(pkgJson, "..");
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ async function readInstalledNpmVersion(pkgDir: string): Promise<string | null> {
120
+ const pkgJsonPath = join(pkgDir, "package.json");
121
+ if (!existsSync(pkgJsonPath)) return null;
122
+ const pkg = (await Bun.file(pkgJsonPath).json()) as { version?: string };
123
+ return pkg.version ?? null;
124
+ }
125
+
126
+ export async function ensureNpmPackage(cwd: string, packageName: string, version?: string): Promise<string> {
127
+ const installed = resolveNpmPackagePath(cwd, packageName);
128
+ if (installed) {
129
+ const installedVersion = await readInstalledNpmVersion(installed);
130
+ if (!version || installedVersion === version) return installed;
131
+ }
132
+ const installSpec = version ? `${packageName}@${version}` : packageName;
133
+ const result = await Bun.$`npm install ${installSpec} --prefix ${cwd} --no-save --no-package-lock`.quiet().nothrow();
134
+ if (result.exitCode !== 0) {
135
+ const detail = result.stderr.toString().trim() || result.stdout.toString().trim();
136
+ throw new Error(`npm install failed for ${installSpec}: ${detail || "unknown error"}`);
137
+ }
138
+ const resolved = resolveNpmPackagePath(cwd, packageName);
139
+ if (!resolved) {
140
+ throw new Error(`npm package "${packageName}" was not found under node_modules/ after install`);
141
+ }
142
+ if (version) {
143
+ const installedVersion = await readInstalledNpmVersion(resolved);
144
+ if (installedVersion && installedVersion !== version) {
145
+ throw new Error(`npm package "${packageName}" resolved to ${installedVersion}, expected ${version}`);
146
+ }
147
+ }
148
+ return resolved;
149
+ }
150
+
151
+ export async function resolveNpmDependencySpec(spec: string, cwd = process.cwd()): Promise<PointLockPackage> {
152
+ const parsed = parseDependencySpec(spec);
153
+ if (parsed.kind !== "npm") throw new Error(`Expected npm: spec, got ${spec}`);
154
+ const { name, version } = parseNpmLocator(parsed.locator);
155
+ const pkgDir = await ensureNpmPackage(cwd, name, version);
156
+ locatePointPackageRoot(pkgDir);
157
+ const npmVersion = (await readInstalledNpmVersion(pkgDir)) ?? version ?? "npm";
158
+ return { version: npmVersion, path: normalizePackagePath(cwd, pkgDir) };
159
+ }
160
+
161
+ export async function resolveDependencySpec(spec: string, cwd = process.cwd()): Promise<PointLockPackage> {
162
+ const parsed = parseDependencySpec(spec);
163
+ if (parsed.kind === "npm") return resolveNpmDependencySpec(spec, cwd);
164
+ const path = normalizePackagePath(cwd, parsed.locator);
165
+ return { version: parsed.kind, path };
166
+ }
167
+
168
+ export async function resolveLockFromManifest(manifest: PointManifest, cwd = process.cwd()): Promise<PointLock> {
169
+ const packages: Record<string, PointLockPackage> = {
170
+ [manifest.name]: {
171
+ version: manifest.version,
172
+ dependencies: manifest.dependencies ?? {},
173
+ },
174
+ };
175
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
176
+ packages[name] = await resolveDependencySpec(spec, cwd);
177
+ const entry = packages[name];
178
+ if (!entry.path) continue;
179
+ const nestedManifestPath = join(cwd, entry.path, POINT_MANIFEST);
180
+ if (!existsSync(nestedManifestPath)) continue;
181
+ const nested = (await Bun.file(nestedManifestPath).json()) as PointManifest;
182
+ packages[nested.name] = {
183
+ version: nested.version,
184
+ dependencies: nested.dependencies ?? {},
185
+ };
186
+ }
187
+ return { schemaVersion: LOCK_SCHEMA, packages };
188
+ }
189
+
190
+ export async function addPointDependency(name: string, spec: string, cwd = process.cwd()): Promise<{ manifest: PointManifest; lock: PointLock }> {
191
+ if (!name || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
192
+ throw new Error(`Invalid dependency name "${name}". Use an identifier like std or point-logic.`);
193
+ }
194
+ parseDependencySpec(spec);
195
+ const manifest = await readPointManifest(cwd);
196
+ manifest.dependencies = { ...(manifest.dependencies ?? {}), [name]: spec };
197
+ const lock = await resolveLockFromManifest(manifest, cwd);
198
+ await writePointManifest(manifest, cwd);
199
+ await writePointLock(lock, cwd);
200
+ return { manifest, lock };
201
+ }
202
+
203
+ export function packageRootFromLock(lock: PointLock | null, packageName: string): string | null {
204
+ const entry = lock?.packages[packageName];
205
+ if (entry?.path) return entry.path;
206
+ if (!lock && packageName === "std") return "std";
207
+ return null;
208
+ }
209
+
210
+ function modulePathCandidates(root: string, modulePath: string): string[] {
211
+ const segments = modulePath.replaceAll(".", "/");
212
+ return [`${root}/${segments}.point`, `${root}/src/${segments}.point`];
213
+ }
214
+
215
+ export function modulePathFromLock(lock: PointLock | null, moduleName: string, cwd = process.cwd()): string {
216
+ const dot = moduleName.indexOf(".");
217
+ if (dot < 0) {
218
+ throw new Error(`Use declarations without from must target package modules: ${moduleName}`);
219
+ }
220
+ const packageName = moduleName.slice(0, dot);
221
+ const modulePath = moduleName.slice(dot + 1);
222
+ const root = packageRootFromLock(lock, packageName);
223
+ if (!root) {
224
+ throw new Error(`Unknown package "${packageName}" in ${moduleName}. Add it with: point add ${packageName} <spec>`);
225
+ }
226
+ for (const candidate of modulePathCandidates(root, modulePath)) {
227
+ if (existsSync(join(cwd, candidate))) return candidate;
228
+ }
229
+ return modulePathCandidates(root, modulePath)[0]!;
230
+ }
@@ -0,0 +1,93 @@
1
+ import type { PointCoreProgram } from "./ast.ts";
2
+ import { emitPointCoreJavaScript } from "./emit-javascript.ts";
3
+ import { isPureLogicProgram } from "./emit-python.ts";
4
+
5
+ /** Pure logic with no imports or externals can run in-memory without a temp module file. */
6
+ export function canBundleRunInMemory(program: PointCoreProgram): boolean {
7
+ if (!isPureLogicProgram(program)) return false;
8
+ if (program.declarations.some((declaration) => declaration.kind === "import" || declaration.kind === "external")) return false;
9
+ if (program.semanticSource?.uses?.length) return false;
10
+ const emitted = emitPointCoreJavaScript(program);
11
+ if (/^\s*import\s/m.test(emitted)) return false;
12
+ return !hasUnresolvedCallTargets(emitted);
13
+ }
14
+
15
+ const BUILTIN_CALL_TARGETS = new Set([
16
+ "Array",
17
+ "Boolean",
18
+ "JSON",
19
+ "Math",
20
+ "Number",
21
+ "Object",
22
+ "Promise",
23
+ "String",
24
+ "console",
25
+ "fetch",
26
+ "parseInt",
27
+ "parseFloat",
28
+ ]);
29
+
30
+ const NON_CALL_KEYWORDS = new Set([
31
+ "async",
32
+ "await",
33
+ "catch",
34
+ "delete",
35
+ "export",
36
+ "for",
37
+ "function",
38
+ "if",
39
+ "import",
40
+ "instanceof",
41
+ "new",
42
+ "return",
43
+ "switch",
44
+ "throw",
45
+ "typeof",
46
+ "void",
47
+ "while",
48
+ "with",
49
+ "yield",
50
+ ]);
51
+
52
+ function hasUnresolvedCallTargets(source: string): boolean {
53
+ const defined = new Set<string>();
54
+ for (const match of source.matchAll(/(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) defined.add(match[1]!);
55
+ for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) {
56
+ const name = match[1]!;
57
+ if (NON_CALL_KEYWORDS.has(name) || BUILTIN_CALL_TARGETS.has(name) || defined.has(name)) continue;
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ export function bundleJavaScriptForEval(source: string): { body: string; exports: string[] } {
64
+ const exports: string[] = [];
65
+ const bodyLines: string[] = [];
66
+ for (const line of source.split(/\r?\n/)) {
67
+ if (/^\s*import\s/.test(line)) continue;
68
+ const fnMatch = line.match(/^export\s+(async\s+)?function\s+([A-Za-z_$][\w$]*)/);
69
+ if (fnMatch) {
70
+ exports.push(fnMatch[2]!);
71
+ bodyLines.push(line.replace(/^export\s+/, ""));
72
+ continue;
73
+ }
74
+ const bindingMatch = line.match(/^export\s+(const|let|var)\s+([A-Za-z_$][\w$]*)/);
75
+ if (bindingMatch) {
76
+ exports.push(bindingMatch[2]!);
77
+ bodyLines.push(line.replace(/^export\s+/, ""));
78
+ continue;
79
+ }
80
+ bodyLines.push(line);
81
+ }
82
+ return { body: bodyLines.join("\n"), exports };
83
+ }
84
+
85
+ export async function executeBundledEntry(program: PointCoreProgram, entryName: string): Promise<unknown> {
86
+ const { body, exports } = bundleJavaScriptForEval(emitPointCoreJavaScript(program));
87
+ if (!exports.includes(entryName)) throw new Error(`Entrypoint ${entryName} was not emitted.`);
88
+ const factory = new Function(`"use strict";\n${body}\nreturn { ${exports.join(", ")} };`);
89
+ const mod = factory() as Record<string, unknown>;
90
+ const entry = mod[entryName];
91
+ if (typeof entry !== "function") throw new Error(`Entrypoint ${entryName} was not a function.`);
92
+ return await (entry as () => unknown)();
93
+ }
@@ -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}.`;