@jxsuite/compiler 0.35.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +182 -161
- package/dist/compiler.js +116 -112
- package/package.json +11 -7
- package/src/cli.ts +102 -9
- package/src/site/context-injection.ts +0 -6
- package/src/site/db-push.ts +178 -0
- package/src/site/format-host.ts +128 -13
- package/src/site/pages-discovery.ts +61 -49
- package/src/site/project-sections.ts +48 -0
- package/src/site/prototype-resolver.ts +55 -11
- package/src/site/schema-command.ts +242 -0
- package/src/site/site-build.ts +162 -26
- package/src/site/site-loader.ts +3 -5
- package/src/targets/compile-server.ts +83 -5
- package/src/site/content-loader.ts +0 -385
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,15 +19,17 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"exports": {
|
|
21
21
|
".": "./src/compiler.ts",
|
|
22
|
-
"./content-loader": "./src/site/content-loader.ts",
|
|
23
22
|
"./format-host": "./src/site/format-host.ts",
|
|
23
|
+
"./project-sections": "./src/site/project-sections.ts",
|
|
24
|
+
"./schema-command": "./src/site/schema-command.ts",
|
|
24
25
|
"./context-injection": "./src/site/context-injection.ts",
|
|
25
26
|
"./head-merger": "./src/site/head-merger.ts",
|
|
26
27
|
"./layout-resolver": "./src/site/layout-resolver.ts",
|
|
27
28
|
"./pages-discovery": "./src/site/pages-discovery.ts",
|
|
28
29
|
"./site": "./src/site/site-build.ts",
|
|
29
30
|
"./site-loader": "./src/site/site-loader.ts",
|
|
30
|
-
"./types": "./src/types.ts"
|
|
31
|
+
"./types": "./src/types.ts",
|
|
32
|
+
"./db-push": "./src/site/db-push.ts"
|
|
31
33
|
},
|
|
32
34
|
"publishConfig": {
|
|
33
35
|
"provenance": true
|
|
@@ -42,14 +44,16 @@
|
|
|
42
44
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
43
45
|
},
|
|
44
46
|
"dependencies": {
|
|
45
|
-
"@apidevtools/json-schema-ref-parser": "^15.
|
|
46
|
-
"@jxsuite/
|
|
47
|
-
"@jxsuite/runtime": "^0.
|
|
48
|
-
"@jxsuite/schema": "^0.
|
|
47
|
+
"@apidevtools/json-schema-ref-parser": "^15.5.0",
|
|
48
|
+
"@jxsuite/create": "^1.0.0",
|
|
49
|
+
"@jxsuite/runtime": "^1.0.0",
|
|
50
|
+
"@jxsuite/schema": "^1.0.0",
|
|
49
51
|
"sharp": "^0.35.3"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
55
|
+
"@jxsuite/connector": "^0.2.0",
|
|
56
|
+
"@jxsuite/parser": "^1.0.0",
|
|
53
57
|
"@types/mdast": "^4.0.4"
|
|
54
58
|
}
|
|
55
59
|
}
|
package/src/cli.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Jx — Unified CLI for the Jx platform
|
|
4
4
|
*
|
|
5
|
-
* Commands: jx build [project-root] [--verbose] [--no-clean]
|
|
5
|
+
* Commands: jx build [project-root] [--verbose] [--no-clean] jx schema [project-root] jx validate
|
|
6
|
+
* [project-root]
|
|
6
7
|
*
|
|
7
8
|
* @module jx-cli
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { resolve } from "node:path";
|
|
11
|
+
import { relative, resolve } from "node:path";
|
|
11
12
|
|
|
12
13
|
// The CLI body runs inside an async function rather than via top-level await: when a test pulls this
|
|
13
14
|
// Entry in with a dynamic import(), Bun's test runtime drops the continuation after a top-level await
|
|
@@ -21,20 +22,79 @@ async function main() {
|
|
|
21
22
|
console.log(`Usage: jx <command> [options]
|
|
22
23
|
|
|
23
24
|
Commands:
|
|
24
|
-
build [root]
|
|
25
|
+
build [root] Build a Jx site to dist/
|
|
26
|
+
schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
|
|
27
|
+
validate [root] Validate project.json against its generated project.schema.json
|
|
28
|
+
db push [root] Sync the data section's tables to their connections (additive-only)
|
|
25
29
|
|
|
26
30
|
Options:
|
|
27
31
|
--verbose Print detailed build progress
|
|
28
|
-
--no-clean Don't clean outDir before building
|
|
32
|
+
--no-clean Don't clean outDir before building
|
|
33
|
+
--dry-run db push: print the statements without executing them
|
|
34
|
+
--connection db push: restrict to one connection name`);
|
|
29
35
|
process.exit(0);
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
// `db push` is a two-word command; normalize it before the flag/positional split.
|
|
39
|
+
const isDb = command === "db";
|
|
40
|
+
const dbSubcommand = isDb ? args[1] : null;
|
|
41
|
+
const rest = args.slice(isDb ? 2 : 1);
|
|
42
|
+
const flags = new Set<string>();
|
|
43
|
+
const positionals: string[] = [];
|
|
44
|
+
let connectionArg: string | undefined;
|
|
45
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
46
|
+
const arg = rest[index]!;
|
|
47
|
+
if (arg === "--connection") {
|
|
48
|
+
index += 1;
|
|
49
|
+
connectionArg = rest[index];
|
|
50
|
+
} else if (arg.startsWith("--")) {
|
|
51
|
+
flags.add(arg);
|
|
52
|
+
} else {
|
|
53
|
+
positionals.push(arg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const [positional] = positionals;
|
|
57
|
+
const projectRoot = resolve(positional ?? ".");
|
|
58
|
+
|
|
59
|
+
if (isDb) {
|
|
60
|
+
if (dbSubcommand !== "push") {
|
|
61
|
+
console.error(
|
|
62
|
+
`Unknown db subcommand: ${dbSubcommand ?? "(none)"}\nUsage: jx db push [root] [--dry-run] [--connection <name>]`,
|
|
63
|
+
);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const dryRun = flags.has("--dry-run");
|
|
67
|
+
try {
|
|
68
|
+
const { dbPush } = await import("./site/db-push.ts");
|
|
69
|
+
const { results, bindingsPatched, wranglerPath } = await dbPush(projectRoot, {
|
|
70
|
+
dryRun,
|
|
71
|
+
...(connectionArg === undefined ? {} : { connection: connectionArg }),
|
|
72
|
+
});
|
|
73
|
+
for (const result of results) {
|
|
74
|
+
const mode = result.applied ? "applied" : "dry-run";
|
|
75
|
+
console.log(
|
|
76
|
+
`${result.connection} (${result.provider}) — ${result.tables.length} table(s), ` +
|
|
77
|
+
`${result.statements.length} statement(s) [${mode}]`,
|
|
78
|
+
);
|
|
79
|
+
for (const statement of result.statements) {
|
|
80
|
+
console.log(` ${statement}`);
|
|
81
|
+
}
|
|
82
|
+
for (const warning of result.warnings) {
|
|
83
|
+
console.warn(` warning: ${warning}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (bindingsPatched && wranglerPath) {
|
|
87
|
+
console.log(`Updated bindings in ${relative(projectRoot, wranglerPath)}`);
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const err = error as Error;
|
|
91
|
+
console.error(`db push failed: ${err.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
36
96
|
|
|
37
|
-
|
|
97
|
+
if (command === "build") {
|
|
38
98
|
const verbose = flags.has("--verbose");
|
|
39
99
|
const clean = !flags.has("--no-clean");
|
|
40
100
|
|
|
@@ -58,6 +118,39 @@ Options:
|
|
|
58
118
|
console.error(`Build failed: ${err.message}`);
|
|
59
119
|
process.exit(1);
|
|
60
120
|
}
|
|
121
|
+
} else if (command === "schema") {
|
|
122
|
+
try {
|
|
123
|
+
const { writeProjectSchemas } = await import("./site/schema-command.ts");
|
|
124
|
+
const { projectSchemaPath, documentSchemaPath } = await writeProjectSchemas(projectRoot);
|
|
125
|
+
console.log(`Wrote ${relative(projectRoot, projectSchemaPath)}`);
|
|
126
|
+
console.log(`Wrote ${relative(projectRoot, documentSchemaPath)}`);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const err = error as Error;
|
|
129
|
+
console.error(`Schema generation failed: ${err.message}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
} else if (command === "validate") {
|
|
133
|
+
try {
|
|
134
|
+
const { validateProjectFile } = await import("@jxsuite/schema/validate-project");
|
|
135
|
+
const { valid, errors } = await validateProjectFile(projectRoot);
|
|
136
|
+
if (valid) {
|
|
137
|
+
console.log(`project.json is valid (${projectRoot})`);
|
|
138
|
+
} else {
|
|
139
|
+
console.error(`project.json is INVALID (${projectRoot}):`);
|
|
140
|
+
for (const error of errors ?? []) {
|
|
141
|
+
const { instancePath, message } = (error ?? {}) as {
|
|
142
|
+
instancePath?: string;
|
|
143
|
+
message?: string;
|
|
144
|
+
};
|
|
145
|
+
console.error(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
|
|
146
|
+
}
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const err = error as Error;
|
|
151
|
+
console.error(`Validation failed: ${err.message}`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
61
154
|
} else {
|
|
62
155
|
console.error(`Unknown command: ${command}\nRun "jx --help" for usage.`);
|
|
63
156
|
process.exit(1);
|
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
* Injects project-level and page-level context variables into a page's state before compilation.
|
|
5
5
|
* These are available as $site.* and $page.* in template expressions.
|
|
6
6
|
*
|
|
7
|
-
* Also resolves ContentCollection and ContentEntry $prototype entries against loaded content types
|
|
8
|
-
* (Phase 2, spec §6.4).
|
|
9
|
-
*
|
|
10
7
|
* Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
|
|
11
8
|
* url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
|
|
12
9
|
* title $page.params — dynamic route parameters (if any)
|
|
@@ -19,7 +16,6 @@ import type {
|
|
|
19
16
|
JxStateDefinition,
|
|
20
17
|
ProjectConfig,
|
|
21
18
|
} from "@jxsuite/schema/types";
|
|
22
|
-
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
23
19
|
import type { SiteRoute } from "../types.ts";
|
|
24
20
|
|
|
25
21
|
/**
|
|
@@ -28,7 +24,6 @@ import type { SiteRoute } from "../types.ts";
|
|
|
28
24
|
* @param {JxDocument} doc - The page document (mutated)
|
|
29
25
|
* @param {ProjectConfig} projectConfig - Loaded project configuration
|
|
30
26
|
* @param {SiteRoute} route - The resolved route for this page
|
|
31
|
-
* @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types
|
|
32
27
|
* @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
|
|
33
28
|
* @returns {JxDocument} The mutated document
|
|
34
29
|
*/
|
|
@@ -36,7 +31,6 @@ export function injectContext(
|
|
|
36
31
|
doc: JxDocument,
|
|
37
32
|
projectConfig: ProjectConfig,
|
|
38
33
|
route: SiteRoute,
|
|
39
|
-
_contentTypes = new Map<string, ContentLoaderEntry[]>(),
|
|
40
34
|
projectRoot: string | null = null,
|
|
41
35
|
) {
|
|
42
36
|
if (!doc.state) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Db-push — `jx db push [root] [--dry-run] [--connection <name>]` (plan Part 4a).
|
|
3
|
+
*
|
|
4
|
+
* Registry-driven: every `connections` entry resolves to its connector class (by
|
|
5
|
+
* `connector.provider`) and the additive schema sync runs through the class's `deploySchema`
|
|
6
|
+
* capability with the connection's tables from the `data` section. The environment is process.env
|
|
7
|
+
* merged under `<root>/.dev.vars` (wrangler convention, specs/extensions.md §13). After a non-dry
|
|
8
|
+
* apply, each connector's `bindings` fragment is deep-merged into wrangler.jsonc via
|
|
9
|
+
* `applyBindingFragments` (user keys preserved).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { applyBindingFragments } from "@jxsuite/create/scaffold";
|
|
15
|
+
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
16
|
+
import { loadProjectConfig } from "./site-loader.ts";
|
|
17
|
+
import type { FormatEntry } from "@jxsuite/schema/format-registry";
|
|
18
|
+
|
|
19
|
+
export interface DbPushOptions {
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
/** Restrict the push to one connection name. */
|
|
22
|
+
connection?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConnectionPushResult {
|
|
26
|
+
connection: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
tables: string[];
|
|
29
|
+
statements: string[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
applied: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DbPushResult {
|
|
35
|
+
results: ConnectionPushResult[];
|
|
36
|
+
/** True when wrangler.jsonc was rewritten with binding fragments. */
|
|
37
|
+
bindingsPatched: boolean;
|
|
38
|
+
wranglerPath: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DeployResultLike {
|
|
42
|
+
statements?: string[];
|
|
43
|
+
warnings?: string[];
|
|
44
|
+
applied?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Push the project's table schemas to their connections' databases.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
51
|
+
* @param {DbPushOptions} [options]
|
|
52
|
+
* @returns {Promise<DbPushResult>}
|
|
53
|
+
*/
|
|
54
|
+
export async function dbPush(
|
|
55
|
+
projectRoot: string,
|
|
56
|
+
options: DbPushOptions = {},
|
|
57
|
+
): Promise<DbPushResult> {
|
|
58
|
+
const { dryRun = false, connection: only } = options;
|
|
59
|
+
const { config } = loadProjectConfig(projectRoot);
|
|
60
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
61
|
+
|
|
62
|
+
const connections = (config.connections ?? {}) as Record<string, Record<string, unknown>>;
|
|
63
|
+
const tables = (config.data ?? {}) as Record<string, { connection?: string }>;
|
|
64
|
+
|
|
65
|
+
const names = Object.keys(connections).filter((name) => !only || name === only);
|
|
66
|
+
if (only && names.length === 0) {
|
|
67
|
+
throw new Error(`Unknown connection "${only}" (no such connections entry in project.json)`);
|
|
68
|
+
}
|
|
69
|
+
if (names.length === 0) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`project.json has no "connections" section — nothing to push. Declare a connection and ` +
|
|
72
|
+
`its tables in the "data" section first.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const env: Record<string, unknown> = {
|
|
77
|
+
...process.env,
|
|
78
|
+
...readDevVars(projectRoot),
|
|
79
|
+
JX_PROJECT_ROOT: projectRoot,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const results: ConnectionPushResult[] = [];
|
|
83
|
+
const fragments: Record<string, unknown>[] = [];
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
const connection: Record<string, unknown> = { ...connections[name], $name: name };
|
|
86
|
+
const provider = String(connection.provider ?? "");
|
|
87
|
+
const entry = connectorByProvider(registry.connectors(), provider);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Connection "${name}" names provider "${provider}", but no enabled extension provides ` +
|
|
91
|
+
`it (enable the extension in project.json "extensions")`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const connectionTables: Record<string, unknown> = {};
|
|
95
|
+
for (const [tableName, def] of Object.entries(tables)) {
|
|
96
|
+
if (def.connection === name) {
|
|
97
|
+
connectionTables[tableName] = def;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const deployed = (await entry.call("deploySchema", connectionTables, connection, {
|
|
102
|
+
dryRun,
|
|
103
|
+
env,
|
|
104
|
+
})) as DeployResultLike;
|
|
105
|
+
results.push({
|
|
106
|
+
applied: deployed.applied ?? !dryRun,
|
|
107
|
+
connection: name,
|
|
108
|
+
provider,
|
|
109
|
+
statements: deployed.statements ?? [],
|
|
110
|
+
tables: Object.keys(connectionTables),
|
|
111
|
+
warnings: deployed.warnings ?? [],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (entry.capabilities.bindings) {
|
|
115
|
+
const fragment = (await entry.call("bindings", connection)) as Record<string, unknown>;
|
|
116
|
+
if (fragment && Object.keys(fragment).length > 0) {
|
|
117
|
+
fragments.push(fragment);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Apply binding fragments to wrangler.jsonc (skipped on dry runs and non-wrangler projects).
|
|
123
|
+
const wranglerPath = resolve(projectRoot, "wrangler.jsonc");
|
|
124
|
+
let bindingsPatched = false;
|
|
125
|
+
if (!dryRun && fragments.length > 0 && existsSync(wranglerPath)) {
|
|
126
|
+
const existing = readFileSync(wranglerPath, "utf8");
|
|
127
|
+
const { content, patched } = applyBindingFragments(existing, fragments);
|
|
128
|
+
if (patched && content !== existing) {
|
|
129
|
+
writeFileSync(wranglerPath, content, "utf8");
|
|
130
|
+
bindingsPatched = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { bindingsPatched, results, wranglerPath: existsSync(wranglerPath) ? wranglerPath : null };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Find the connector class serving a provider id. */
|
|
138
|
+
function connectorByProvider(entries: FormatEntry[], provider: string): FormatEntry | undefined {
|
|
139
|
+
return entries.find((entry) => entry.connector?.provider === provider);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse `<root>/.dev.vars` (wrangler convention): KEY=VALUE lines, `#` comments, optional
|
|
144
|
+
* single/double quotes. Kept deliberately tiny; the dev server has its own copy in
|
|
145
|
+
* `@jxsuite/server` (dev-vars.ts) — the compiler cannot depend on the server package.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} projectRoot
|
|
148
|
+
* @returns {Record<string, string>}
|
|
149
|
+
*/
|
|
150
|
+
export function readDevVars(projectRoot: string): Record<string, string> {
|
|
151
|
+
const path = resolve(projectRoot, ".dev.vars");
|
|
152
|
+
if (!existsSync(path)) {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
const out: Record<string, string> = {};
|
|
156
|
+
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
|
|
157
|
+
const line = rawLine.trim();
|
|
158
|
+
if (line === "" || line.startsWith("#")) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const eq = line.indexOf("=");
|
|
162
|
+
if (eq === -1) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const key = line.slice(0, eq).trim();
|
|
166
|
+
let value = line.slice(eq + 1).trim();
|
|
167
|
+
if (
|
|
168
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
169
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
170
|
+
) {
|
|
171
|
+
value = value.slice(1, -1);
|
|
172
|
+
}
|
|
173
|
+
if (key !== "") {
|
|
174
|
+
out[key] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
package/src/site/format-host.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Format-host — node-side FormatHostIO and project registry construction
|
|
3
3
|
*
|
|
4
|
-
* Provides the filesystem/module I/O the shared
|
|
5
|
-
* (and dev server).
|
|
6
|
-
* specs/extensions.md.
|
|
4
|
+
* Provides the filesystem/module I/O the shared extension registry needs to run inside the compiler
|
|
5
|
+
* (and dev server). Extensions are declared explicitly in project.json's `extensions` array and
|
|
6
|
+
* discovered through their jx-extension.json manifests; see specs/extensions.md.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import {
|
|
14
|
-
import type {
|
|
13
|
+
import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
14
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
15
|
+
import type { FormatHostIO, FormatRegistry, ProjectBlock } from "@jxsuite/schema/format-registry";
|
|
15
16
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
16
17
|
|
|
18
|
+
export type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
17
19
|
export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -73,23 +75,136 @@ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
|
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
/** Build the
|
|
77
|
-
export async function
|
|
78
|
+
/** Build the extension registry for a project from its project.json `extensions` array. */
|
|
79
|
+
export async function buildProjectExtensionRegistry(
|
|
78
80
|
projectRoot: string,
|
|
79
81
|
projectConfig?: ProjectConfig,
|
|
80
|
-
): Promise<
|
|
81
|
-
return
|
|
82
|
-
projectConfig?.
|
|
82
|
+
): Promise<ExtensionRegistry> {
|
|
83
|
+
return buildExtensionRegistry(
|
|
84
|
+
projectConfig?.extensions,
|
|
83
85
|
createNodeFormatIO(projectRoot),
|
|
84
86
|
resolve(projectRoot, "project.json"),
|
|
85
87
|
);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Format-dispatch view of the project's extension registry.
|
|
92
|
+
*
|
|
93
|
+
* @deprecated Part-3 cleanup: the desktop app still imports this; use
|
|
94
|
+
* `buildProjectExtensionRegistry(...).then((r) => r.formats)` instead.
|
|
95
|
+
*/
|
|
96
|
+
export async function buildProjectFormatRegistry(
|
|
97
|
+
projectRoot: string,
|
|
98
|
+
projectConfig?: ProjectConfig,
|
|
99
|
+
): Promise<FormatRegistry> {
|
|
100
|
+
const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
|
|
101
|
+
return registry.formats;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Studio extensions payload ────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** One project-section contribution on the studio wire (specs/extensions.md §9/§9.1). */
|
|
107
|
+
export interface ExtensionsPayloadContribution {
|
|
108
|
+
/** The $prototype-visible class name declaring the `project` block. */
|
|
109
|
+
className: string;
|
|
110
|
+
/** The class descriptor's `project` admission block. */
|
|
111
|
+
project: ProjectBlock;
|
|
112
|
+
/** The class descriptor's `$studio` block (settings section vocabulary), when declared. */
|
|
113
|
+
studio: Record<string, unknown> | null;
|
|
114
|
+
/**
|
|
115
|
+
* The section's value schema — `properties[<key>]` of the extension's project fragment — or null
|
|
116
|
+
* when the extension ships no project fragment (or it lacks the key).
|
|
117
|
+
*/
|
|
118
|
+
entrySchema: Record<string, unknown> | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One extension package on the studio wire (the formats route's `extensions` sibling array). */
|
|
122
|
+
export interface ExtensionsPayloadEntry {
|
|
123
|
+
specifier: string;
|
|
124
|
+
name: string;
|
|
125
|
+
title?: string;
|
|
126
|
+
description?: string;
|
|
127
|
+
contributions: ExtensionsPayloadContribution[];
|
|
128
|
+
/**
|
|
129
|
+
* Every manifest class of the extension with its resolved descriptor path, in declaration order
|
|
130
|
+
* (additive; lets the studio address a class by `$src` without hardcoding its package layout).
|
|
131
|
+
* Plain state classes (no admission blocks — `$prototype` targets like TableQuery or Session)
|
|
132
|
+
* carry `state: true` plus their `$studio.stateDefaults` hint (specs/extensions.md §10), so the
|
|
133
|
+
* studio can offer them in its add-state picker and seed new defs (e.g. `timing: "client"`).
|
|
134
|
+
*/
|
|
135
|
+
classes: {
|
|
136
|
+
name: string;
|
|
137
|
+
path: string;
|
|
138
|
+
state?: boolean;
|
|
139
|
+
stateDefaults?: Record<string, unknown>;
|
|
140
|
+
}[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the studio-facing extensions payload from a project's extension registry: per extension,
|
|
145
|
+
* its manifest identity plus every project-section contribution paired with the entry schema read
|
|
146
|
+
* from the extension's shipped project fragment. Unreadable fragments degrade to a null entrySchema
|
|
147
|
+
* (the studio renders the section without field metadata).
|
|
148
|
+
*
|
|
149
|
+
* @param {ExtensionRegistry} registry
|
|
150
|
+
* @returns {ExtensionsPayloadEntry[]}
|
|
151
|
+
*/
|
|
152
|
+
export function buildExtensionsPayload(registry: ExtensionRegistry): ExtensionsPayloadEntry[] {
|
|
153
|
+
const payload: ExtensionsPayloadEntry[] = [];
|
|
154
|
+
for (const ext of registry.extensions) {
|
|
155
|
+
let fragmentProperties: Record<string, Record<string, unknown>> = {};
|
|
156
|
+
if (ext.schemas.project) {
|
|
157
|
+
try {
|
|
158
|
+
const fragment = JSON.parse(readFileSync(ext.schemas.project, "utf8")) as {
|
|
159
|
+
properties?: Record<string, Record<string, unknown>>;
|
|
160
|
+
};
|
|
161
|
+
fragmentProperties = fragment.properties ?? {};
|
|
162
|
+
} catch {
|
|
163
|
+
fragmentProperties = {};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const contributions: ExtensionsPayloadContribution[] = [];
|
|
167
|
+
for (const cls of ext.classes) {
|
|
168
|
+
if (!cls.project) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
contributions.push({
|
|
172
|
+
className: cls.name,
|
|
173
|
+
entrySchema: fragmentProperties[cls.project.key] ?? null,
|
|
174
|
+
project: cls.project,
|
|
175
|
+
studio: cls.studio,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
payload.push({
|
|
179
|
+
classes: ext.classes.map((cls) => {
|
|
180
|
+
const stateDefaults = cls.studio?.stateDefaults as Record<string, unknown> | undefined;
|
|
181
|
+
const isState =
|
|
182
|
+
cls.project === null &&
|
|
183
|
+
cls.server === null &&
|
|
184
|
+
cls.connector === null &&
|
|
185
|
+
cls.extensions.length === 0;
|
|
186
|
+
return {
|
|
187
|
+
name: cls.name,
|
|
188
|
+
path: cls.classPath,
|
|
189
|
+
...(isState ? { state: true } : {}),
|
|
190
|
+
...(stateDefaults && typeof stateDefaults === "object" ? { stateDefaults } : {}),
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
contributions,
|
|
194
|
+
name: ext.manifest.name,
|
|
195
|
+
specifier: ext.specifier,
|
|
196
|
+
...(ext.manifest.title === undefined ? {} : { title: ext.manifest.title }),
|
|
197
|
+
...(ext.manifest.description === undefined ? {} : { description: ext.manifest.description }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return payload;
|
|
201
|
+
}
|
|
202
|
+
|
|
88
203
|
/** Error message for an unregistered non-JSON extension, naming the fix. */
|
|
89
204
|
export function unknownFormatError(path: string, ext: string): Error {
|
|
90
205
|
return new Error(
|
|
91
|
-
`No format class
|
|
92
|
-
`
|
|
93
|
-
`
|
|
206
|
+
`No format class registered for "${ext}" (${path}). ` +
|
|
207
|
+
`Enable an extension providing this format in project.json "extensions", ` +
|
|
208
|
+
`e.g. "@jxsuite/parser".`,
|
|
94
209
|
);
|
|
95
210
|
}
|