@nanobpm/urban 0.47.0 → 0.49.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/README.md +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
package/src/toolkit/gen.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
import type { DerivedArtifact } from "./artifact.ts";
|
|
7
7
|
import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.ts";
|
|
8
8
|
import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
|
|
9
|
-
import {
|
|
10
|
-
import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
|
|
9
|
+
import { emitDomainModel, registryFromManifest, sourcesFromManifest } from "./derivers/domain.ts";
|
|
10
|
+
import { byModelPath, deriveWorkerBindings, detectEnvelopeConflicts, DOMAIN_DTS, formatEnvelopeConflict, type ModelSource, scanModelWorkers } from "./derivers/worker-io.ts";
|
|
11
11
|
import { deriveMeta } from "./derivers/meta.ts";
|
|
12
12
|
import { deriveMessageBindings } from "./derivers/messages.ts";
|
|
13
|
+
import { resolveShapes, scanModelShapes } from "./derivers/shapes.ts";
|
|
13
14
|
import { deriveApi } from "./derivers/api.ts";
|
|
14
15
|
import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.ts";
|
|
15
16
|
import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
|
|
@@ -20,6 +21,12 @@ export interface GenIO {
|
|
|
20
21
|
writeText(path: string, content: string): Promise<void>;
|
|
21
22
|
/** File names (not paths) in a directory; empty if it does not exist. */
|
|
22
23
|
listDir(path: string): Promise<string[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Sub-directory names (not files) in a directory; empty if it does not exist. Optional: the
|
|
26
|
+
* deploy-by-convention model scan uses it to descend one level into `resources/<subdir>/`. When
|
|
27
|
+
* absent, only files directly under `resources/` are scanned.
|
|
28
|
+
*/
|
|
29
|
+
listSubdirs?(path: string): Promise<string[]>;
|
|
23
30
|
exists(path: string): Promise<boolean>;
|
|
24
31
|
/**
|
|
25
32
|
* Import an app module and return its exports. Optional: only present when the caller runs on
|
|
@@ -98,19 +105,66 @@ export async function expandPattern(root: string, io: GenIO, pattern: string): P
|
|
|
98
105
|
.sort();
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
/**
|
|
108
|
+
/** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
|
|
109
|
+
* runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
|
|
110
|
+
* deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
|
|
111
|
+
export const RESOURCES_DIR = "resources";
|
|
112
|
+
|
|
113
|
+
/** Model file extensions the `nano:shape`/code-first scan reads by convention (BPMN + DMN). */
|
|
114
|
+
const MODEL_EXTS = [".bpmn", ".dmn"];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
|
|
118
|
+
* and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
|
|
119
|
+
* and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
|
|
120
|
+
* `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
|
|
121
|
+
*/
|
|
122
|
+
export async function discoverResourceModels(root: string, io: GenIO): Promise<string[]> {
|
|
123
|
+
const isModel = (n: string): boolean => MODEL_EXTS.some((e) => n.endsWith(e));
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
for (const n of await io.listDir(join(root, RESOURCES_DIR))) {
|
|
126
|
+
if (isModel(n)) out.push(`${RESOURCES_DIR}/${n}`);
|
|
127
|
+
}
|
|
128
|
+
const subdirs = io.listSubdirs ? await io.listSubdirs(join(root, RESOURCES_DIR)) : [];
|
|
129
|
+
for (const sub of subdirs.slice().sort()) {
|
|
130
|
+
for (const n of await io.listDir(join(root, `${RESOURCES_DIR}/${sub}`))) {
|
|
131
|
+
if (isModel(n)) out.push(`${RESOURCES_DIR}/${sub}/${n}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out.sort();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
|
|
139
|
+
* `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
|
|
140
|
+
* discovered by convention under `resources/`; with a `models` block declared, `models.processes`
|
|
141
|
+
* is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
|
|
142
|
+
* alone would fall back to convention even when a `models` block is present for other overrides
|
|
143
|
+
* (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
|
|
144
|
+
* gen/runtime drift.
|
|
145
|
+
*/
|
|
102
146
|
export async function readModels(
|
|
103
147
|
root: string,
|
|
104
148
|
io: GenIO,
|
|
105
149
|
manifest: { models?: { processes?: string[] } },
|
|
106
150
|
): Promise<ModelSource[]> {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
151
|
+
const byConvention = manifest.models === undefined;
|
|
152
|
+
let rels: string[];
|
|
153
|
+
if (byConvention) {
|
|
154
|
+
rels = await discoverResourceModels(root, io);
|
|
155
|
+
} else {
|
|
156
|
+
rels = [];
|
|
157
|
+
for (const pat of manifest.models?.processes ?? []) {
|
|
158
|
+
rels.push(...(await expandPattern(root, io, pat)));
|
|
112
159
|
}
|
|
113
160
|
}
|
|
161
|
+
const models: ModelSource[] = [];
|
|
162
|
+
const seen = new Set<string>();
|
|
163
|
+
for (const rel of rels) {
|
|
164
|
+
if (seen.has(rel)) continue;
|
|
165
|
+
seen.add(rel);
|
|
166
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
167
|
+
}
|
|
114
168
|
return models;
|
|
115
169
|
}
|
|
116
170
|
|
|
@@ -133,29 +187,62 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
|
|
|
133
187
|
const artifacts: DerivedArtifact[] = [];
|
|
134
188
|
const warnings: string[] = [];
|
|
135
189
|
|
|
136
|
-
// 1.
|
|
190
|
+
// 1. Resolve the app's models first (code-first `workflows/*.ts` → BPMN, plus authored on-disk
|
|
191
|
+
// `.bpmn`): the fused domain model (below) lifts `nano:shape` envelopes *from* the models, so
|
|
192
|
+
// they must be in hand before the domain + worker/message type-contracts are derived.
|
|
193
|
+
const derived = await deriveModels(root, io, manifest);
|
|
194
|
+
// The derived `.bpmn` are emitted only when `emitModels` (default), but always fed to the
|
|
195
|
+
// type-contract derivers so worker I/O works for a code-first app even on a `--no-models` run.
|
|
196
|
+
if (emitModels) artifacts.push(...derived.artifacts);
|
|
197
|
+
const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
|
|
198
|
+
const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
|
|
199
|
+
const models = [...diskModels, ...derived.models];
|
|
200
|
+
|
|
201
|
+
// 2. Fused domain model (ADR 0040): the `DomainTypes` registry is the union of the manifest
|
|
202
|
+
// `types` registry and the model-authored `nano:shape` envelopes, resolved through the same
|
|
203
|
+
// fuse the console reifier (`envelope_scan.rs`) uses. Without this the standalone `urban gen`
|
|
204
|
+
// would honour only `manifest.types` and silently ignore every model-authored data envelope,
|
|
205
|
+
// so worker/message I/O against those envelopes would fall back to the untyped `WorkerVars`.
|
|
206
|
+
const manifestRegistry = registryFromManifest(manifest);
|
|
207
|
+
const sources = sourcesFromManifest(manifest);
|
|
208
|
+
const shapeDecls = models.flatMap((m) => scanModelShapes(m.xml));
|
|
209
|
+
const shapeResolution = resolveShapes(shapeDecls, manifestRegistry, sources);
|
|
210
|
+
const fusedTypes = { ...manifestRegistry, ...shapeResolution.types };
|
|
211
|
+
for (const d of shapeResolution.diagnostics) {
|
|
212
|
+
warnings.push(`data-envelope shape "${d.shape}" (${d.kind}): ${d.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. types → migrations (persisted `manifest.types` only — shapes are transient wire envelopes,
|
|
216
|
+
// never tables) + the fused domain row types (`DomainTables` spine + `DomainTypes` registry).
|
|
137
217
|
if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
|
|
138
218
|
artifacts.push(...deriveMigrations(manifest));
|
|
139
|
-
|
|
219
|
+
}
|
|
220
|
+
if (!opts.modelsOnly && Object.keys(fusedTypes).length > 0) {
|
|
221
|
+
artifacts.push({
|
|
222
|
+
path: `${GENERATED_DIR}/${DOMAIN_DTS}`,
|
|
223
|
+
content: emitDomainModel(sources, manifest.data?.default ?? "app", fusedTypes),
|
|
224
|
+
});
|
|
140
225
|
}
|
|
141
226
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
const derived = await deriveModels(root, io, manifest);
|
|
146
|
-
if (emitModels) artifacts.push(...derived.artifacts);
|
|
147
|
-
|
|
148
|
-
// 3. models (authored on-disk + derived) → worker I/O index + meta accessor + message map.
|
|
149
|
-
// Drop any on-disk model whose path a derived model already covers, to avoid double-counting.
|
|
227
|
+
// 4. models (authored on-disk + derived) → worker I/O index + meta accessor + message map. The
|
|
228
|
+
// declared type ids include the fused shapes, so an envelope ref to a model-authored shape
|
|
229
|
+
// resolves to its `DomainTypes[...]` entry instead of degrading to the untyped fallback.
|
|
150
230
|
if (!opts.modelsOnly) {
|
|
151
|
-
const derivedPaths = new Set(derived.artifacts.map((a) => a.path));
|
|
152
|
-
const diskModels = (await readModels(root, io, manifest)).filter((m) => !derivedPaths.has(m.path));
|
|
153
|
-
const models = [...diskModels, ...derived.models];
|
|
154
231
|
if (models.length > 0) {
|
|
155
|
-
const declaredTypeIds = Object.keys(
|
|
232
|
+
const declaredTypeIds = Object.keys(fusedTypes);
|
|
156
233
|
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
|
157
234
|
artifacts.push(...deriveMeta(models));
|
|
158
235
|
artifacts.push(...deriveMessageBindings(models, declaredTypeIds));
|
|
236
|
+
// Surface genuine data-envelope conflicts: a taskType whose variants disagree on a field's
|
|
237
|
+
// TYPE. The emitted contract is the union of the variants (sound for a shared handler), so a
|
|
238
|
+
// type clash would silently widen to `A | B` — warn instead so the author unifies the envelope.
|
|
239
|
+
const scannedWorkers = [...models]
|
|
240
|
+
.sort(byModelPath)
|
|
241
|
+
.flatMap((m) => scanModelWorkers(m.xml))
|
|
242
|
+
.map((w) => ({ taskType: w.taskType, inputType: w.in, outputType: w.out }));
|
|
243
|
+
for (const c of detectEnvelopeConflicts(scannedWorkers, fusedTypes)) {
|
|
244
|
+
warnings.push(formatEnvelopeConflict(c));
|
|
245
|
+
}
|
|
159
246
|
}
|
|
160
247
|
}
|
|
161
248
|
|
|
@@ -64,13 +64,13 @@ test("bpmnFilename sanitizes ids into safe .bpmn filenames", () => {
|
|
|
64
64
|
assert.equal(bpmnFilename("a b:c"), "a-b-c.bpmn");
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test("processesOutDir defaults to processes/, or the dir of a models.processes glob", () => {
|
|
68
|
-
assert.equal(processesOutDir({}), "processes");
|
|
67
|
+
test("processesOutDir defaults to resources/processes/, or the dir of a models.processes glob", () => {
|
|
68
|
+
assert.equal(processesOutDir({}), "resources/processes");
|
|
69
69
|
assert.equal(processesOutDir({ models: { processes: ["resources/processes/*.bpmn"] } }), "resources/processes");
|
|
70
70
|
assert.equal(processesOutDir({ models: { processes: ["src/models/*.bpmn"] } }), "src/models");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>.bpmn", async () => {
|
|
73
|
+
test("deriveModels turns workflows/*.ts into a provenance-stamped resources/processes/<id>.bpmn", async () => {
|
|
74
74
|
const io = memIO(
|
|
75
75
|
{ "/app/workflows/greet.ts": "// source" },
|
|
76
76
|
{ "workflows/greet.ts": { greet } },
|
|
@@ -81,12 +81,12 @@ test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>
|
|
|
81
81
|
assert.equal(d.incomplete, false);
|
|
82
82
|
assert.deepEqual(d.ids, ["greet"]);
|
|
83
83
|
assert.equal(d.artifacts.length, 1);
|
|
84
|
-
assert.equal(d.artifacts[0].path, "processes/greet.bpmn");
|
|
84
|
+
assert.equal(d.artifacts[0].path, "resources/processes/greet.bpmn");
|
|
85
85
|
assert.match(d.artifacts[0].content, /<!--.*@nanobpm\/urban/);
|
|
86
86
|
assert.ok(d.artifacts[0].content.includes(MODEL_PROVENANCE));
|
|
87
87
|
assert.match(d.artifacts[0].content, /greet/);
|
|
88
88
|
// the in-memory ModelSource is fed to the type-contract derivers
|
|
89
|
-
assert.deepEqual(d.models.map((m) => m.path), ["processes/greet.bpmn"]);
|
|
89
|
+
assert.deepEqual(d.models.map((m) => m.path), ["resources/processes/greet.bpmn"]);
|
|
90
90
|
assert.equal(d.list[0].id, "greet");
|
|
91
91
|
assert.equal(d.list[0].kind, "declarative");
|
|
92
92
|
});
|
package/src/toolkit/models.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
|
|
4
4
|
// imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
|
|
5
|
-
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn
|
|
5
|
+
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `resources/processes/<id>.bpmn`
|
|
6
|
+
// (the deploy-by-convention `resources/` tree, ADR 0062 — see `PROCESSES_DIR`).
|
|
6
7
|
// Executing the app's TypeScript needs a runtime module loader, so this is gated on the
|
|
7
8
|
// optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
|
|
8
9
|
// loader is absent — or the app authors its models directly (model-first) — derivation is a
|
|
@@ -19,9 +20,11 @@ import { isRecord } from "../runtime/core/guards.ts";
|
|
|
19
20
|
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
20
21
|
export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
|
|
21
22
|
|
|
22
|
-
/** Default output dir for derived models
|
|
23
|
-
*
|
|
24
|
-
|
|
23
|
+
/** Default output dir for derived models when the manifest declares no `models.processes`: under
|
|
24
|
+
* the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
|
|
25
|
+
* exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
|
|
26
|
+
* `processesOutDir` derives the dir from that glob instead (the model-first override). */
|
|
27
|
+
export const PROCESSES_DIR = "resources/processes";
|
|
25
28
|
|
|
26
29
|
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
27
30
|
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
@@ -68,7 +71,7 @@ export interface ModelError {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export interface DerivedModels {
|
|
71
|
-
/** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
74
|
+
/** `resources/processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
72
75
|
artifacts: DerivedArtifact[];
|
|
73
76
|
/** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
|
|
74
77
|
models: ModelSource[];
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { HostContext } from "../host.ts";
|
|
2
|
-
/** A template source: a list of globs/directories/files (name = file stem), or an explicit
|
|
3
|
-
* `name → content` map. */
|
|
4
|
-
export type TemplateSource = string[] | Record<string, string>;
|
|
5
|
-
/** Build the `name → content` template map from the given sources in order (later sources win on
|
|
6
|
-
* a name collision). Array sources are resolved to files (stem = name) and read; map sources are
|
|
7
|
-
* merged verbatim. */
|
|
8
|
-
export declare function resolveTemplates(host: HostContext, root: string, sources: ReadonlyArray<TemplateSource | undefined>): Promise<Record<string, string>>;
|
|
9
|
-
export interface TemplateApplication {
|
|
10
|
-
content: string;
|
|
11
|
-
/** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
|
|
12
|
-
unresolved: string[];
|
|
13
|
-
}
|
|
14
|
-
/** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
|
|
15
|
-
* for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
|
|
16
|
-
* not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
|
|
17
|
-
* dropped) and reported in `unresolved`. */
|
|
18
|
-
export declare function applyTemplates(content: string, contentType: string, templates: Record<string, string>): TemplateApplication;
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
// Template substitution for deployed model resources. A model file (`.bpmn`/`.dmn`/`.form`) may
|
|
2
|
-
// carry `{{ name }}` placeholders — e.g. an agent service task whose prompt lives in a
|
|
3
|
-
// `zeebe:header` value. Before deploy we replace each placeholder with the named template's
|
|
4
|
-
// content, escaped for the resource's content-type, so a large asset (a prompt) is authored once
|
|
5
|
-
// in a file and inlined into the model at deploy time rather than hand-pasted into XML or shipped
|
|
6
|
-
// as a bulky per-instance process variable.
|
|
7
|
-
//
|
|
8
|
-
// Templates come from two sources, merged in order (later wins on a name collision):
|
|
9
|
-
// 1. the manifest `models.templates` (globs, or a bare directory that is scanned), and
|
|
10
|
-
// 2. a programmatic `templates` deploy option (globs, or an explicit `name → content` map).
|
|
11
|
-
// A template's name is its file's stem (basename without extension): `prompts/review-round.md`
|
|
12
|
-
// resolves the placeholder `{{review-round}}`.
|
|
13
|
-
import { expandPatterns } from "../glob.js";
|
|
14
|
-
/** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
|
|
15
|
-
function stem(path) {
|
|
16
|
-
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
17
|
-
const dot = base.lastIndexOf(".");
|
|
18
|
-
return dot > 0 ? base.slice(0, dot) : base;
|
|
19
|
-
}
|
|
20
|
-
// A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
|
|
21
|
-
// `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
|
|
22
|
-
// referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
|
|
23
|
-
const VALID_NAME = /^[\w.-]+$/;
|
|
24
|
-
/** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
|
|
25
|
-
* or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
|
|
26
|
-
async function filesFor(host, root, entry) {
|
|
27
|
-
if (entry.includes("*"))
|
|
28
|
-
return expandPatterns(host, root, [entry]);
|
|
29
|
-
const trimmed = entry.replace(/\/+$/, "");
|
|
30
|
-
// A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
|
|
31
|
-
// a non-empty directory resolves here.
|
|
32
|
-
const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
|
|
33
|
-
if (scanned.length > 0)
|
|
34
|
-
return scanned;
|
|
35
|
-
// No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
|
|
36
|
-
// missing. A directory never appears in its parent's `listDir` (which yields file names only),
|
|
37
|
-
// so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
|
|
38
|
-
// empty template directory that contributes nothing — and, crucially, emit no misleading
|
|
39
|
-
// "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
|
|
40
|
-
const slash = trimmed.lastIndexOf("/");
|
|
41
|
-
const base = trimmed.slice(slash + 1);
|
|
42
|
-
const parent = slash > 0 ? trimmed.slice(0, slash) : "";
|
|
43
|
-
const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
|
|
44
|
-
const siblings = await host.listDir(parentDir);
|
|
45
|
-
return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
|
|
46
|
-
}
|
|
47
|
-
/** Build the `name → content` template map from the given sources in order (later sources win on
|
|
48
|
-
* a name collision). Array sources are resolved to files (stem = name) and read; map sources are
|
|
49
|
-
* merged verbatim. */
|
|
50
|
-
export async function resolveTemplates(host, root, sources) {
|
|
51
|
-
// A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
|
|
52
|
-
// untrusted, so a name like `__proto__` must set a plain own property rather than trip the
|
|
53
|
-
// magic prototype setter that a normal `{}` inherits from `Object.prototype`.
|
|
54
|
-
const map = Object.create(null);
|
|
55
|
-
for (const source of sources) {
|
|
56
|
-
if (!source)
|
|
57
|
-
continue;
|
|
58
|
-
if (Array.isArray(source)) {
|
|
59
|
-
const seen = new Set();
|
|
60
|
-
for (const entry of source) {
|
|
61
|
-
for (const file of await filesFor(host, root, entry)) {
|
|
62
|
-
if (seen.has(file))
|
|
63
|
-
continue;
|
|
64
|
-
seen.add(file);
|
|
65
|
-
const name = stem(file);
|
|
66
|
-
if (!VALID_NAME.test(name)) {
|
|
67
|
-
// Loud failure for a misnamed template file rather than a silently unreferenceable entry.
|
|
68
|
-
host.log("warn", "template: skipped unsupported name", { file, name });
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
try {
|
|
72
|
-
map[name] = await host.readTextFile(file);
|
|
73
|
-
}
|
|
74
|
-
catch (err) {
|
|
75
|
-
// A directory path that slipped through the file fallback, or an unreadable file.
|
|
76
|
-
host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
Object.assign(map, source);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return map;
|
|
86
|
-
}
|
|
87
|
-
// `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
|
|
88
|
-
// with FEEL's single-brace context syntax used elsewhere in BPMN.
|
|
89
|
-
const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
|
|
90
|
-
/** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
|
|
91
|
-
* the markup-significant characters, newlines and tabs MUST become character references:
|
|
92
|
-
* XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
|
|
93
|
-
* destroy a multi-line prompt. Character references are exempt from that normalization, so
|
|
94
|
-
* ` ` round-trips as a newline. This escaping is also valid for XML element text, so it is
|
|
95
|
-
* the safe default for any `.bpmn`/`.dmn` placeholder position. */
|
|
96
|
-
function xmlAttrEscape(s) {
|
|
97
|
-
return s
|
|
98
|
-
.replace(/&/g, "&")
|
|
99
|
-
.replace(/</g, "<")
|
|
100
|
-
.replace(/>/g, ">")
|
|
101
|
-
.replace(/"/g, """)
|
|
102
|
-
.replace(/'/g, "'")
|
|
103
|
-
.replace(/\r/g, " ")
|
|
104
|
-
.replace(/\n/g, " ")
|
|
105
|
-
.replace(/\t/g, "	");
|
|
106
|
-
}
|
|
107
|
-
/** Escape template content for embedding inside a JSON string literal (a `.form` value). */
|
|
108
|
-
function jsonStringEscape(s) {
|
|
109
|
-
// Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
|
|
110
|
-
const quoted = JSON.stringify(s);
|
|
111
|
-
return quoted.slice(1, quoted.length - 1);
|
|
112
|
-
}
|
|
113
|
-
function escaperFor(contentType) {
|
|
114
|
-
if (contentType === "application/json")
|
|
115
|
-
return jsonStringEscape;
|
|
116
|
-
return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
|
|
117
|
-
}
|
|
118
|
-
/** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
|
|
119
|
-
* for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
|
|
120
|
-
* not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
|
|
121
|
-
* dropped) and reported in `unresolved`. */
|
|
122
|
-
export function applyTemplates(content, contentType, templates) {
|
|
123
|
-
const escape = escaperFor(contentType);
|
|
124
|
-
const unresolved = [];
|
|
125
|
-
const out = content.replace(PLACEHOLDER, (whole, name) => {
|
|
126
|
-
if (Object.prototype.hasOwnProperty.call(templates, name))
|
|
127
|
-
return escape(templates[name]);
|
|
128
|
-
if (!unresolved.includes(name))
|
|
129
|
-
unresolved.push(name);
|
|
130
|
-
return whole;
|
|
131
|
-
});
|
|
132
|
-
return { content: out, unresolved };
|
|
133
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
// Template substitution for deployed model resources. A model file (`.bpmn`/`.dmn`/`.form`) may
|
|
2
|
-
// carry `{{ name }}` placeholders — e.g. an agent service task whose prompt lives in a
|
|
3
|
-
// `zeebe:header` value. Before deploy we replace each placeholder with the named template's
|
|
4
|
-
// content, escaped for the resource's content-type, so a large asset (a prompt) is authored once
|
|
5
|
-
// in a file and inlined into the model at deploy time rather than hand-pasted into XML or shipped
|
|
6
|
-
// as a bulky per-instance process variable.
|
|
7
|
-
//
|
|
8
|
-
// Templates come from two sources, merged in order (later wins on a name collision):
|
|
9
|
-
// 1. the manifest `models.templates` (globs, or a bare directory that is scanned), and
|
|
10
|
-
// 2. a programmatic `templates` deploy option (globs, or an explicit `name → content` map).
|
|
11
|
-
// A template's name is its file's stem (basename without extension): `prompts/review-round.md`
|
|
12
|
-
// resolves the placeholder `{{review-round}}`.
|
|
13
|
-
|
|
14
|
-
import type { HostContext } from "../host.ts";
|
|
15
|
-
import { expandPatterns } from "../glob.ts";
|
|
16
|
-
|
|
17
|
-
/** A template source: a list of globs/directories/files (name = file stem), or an explicit
|
|
18
|
-
* `name → content` map. */
|
|
19
|
-
export type TemplateSource = string[] | Record<string, string>;
|
|
20
|
-
|
|
21
|
-
/** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
|
|
22
|
-
function stem(path: string): string {
|
|
23
|
-
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
24
|
-
const dot = base.lastIndexOf(".");
|
|
25
|
-
return dot > 0 ? base.slice(0, dot) : base;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
|
|
29
|
-
// `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
|
|
30
|
-
// referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
|
|
31
|
-
const VALID_NAME = /^[\w.-]+$/;
|
|
32
|
-
|
|
33
|
-
/** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
|
|
34
|
-
* or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
|
|
35
|
-
async function filesFor(host: HostContext, root: string, entry: string): Promise<string[]> {
|
|
36
|
-
if (entry.includes("*")) return expandPatterns(host, root, [entry]);
|
|
37
|
-
const trimmed = entry.replace(/\/+$/, "");
|
|
38
|
-
// A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
|
|
39
|
-
// a non-empty directory resolves here.
|
|
40
|
-
const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
|
|
41
|
-
if (scanned.length > 0) return scanned;
|
|
42
|
-
// No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
|
|
43
|
-
// missing. A directory never appears in its parent's `listDir` (which yields file names only),
|
|
44
|
-
// so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
|
|
45
|
-
// empty template directory that contributes nothing — and, crucially, emit no misleading
|
|
46
|
-
// "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
|
|
47
|
-
const slash = trimmed.lastIndexOf("/");
|
|
48
|
-
const base = trimmed.slice(slash + 1);
|
|
49
|
-
const parent = slash > 0 ? trimmed.slice(0, slash) : "";
|
|
50
|
-
const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
|
|
51
|
-
const siblings = await host.listDir(parentDir);
|
|
52
|
-
return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Build the `name → content` template map from the given sources in order (later sources win on
|
|
56
|
-
* a name collision). Array sources are resolved to files (stem = name) and read; map sources are
|
|
57
|
-
* merged verbatim. */
|
|
58
|
-
export async function resolveTemplates(
|
|
59
|
-
host: HostContext,
|
|
60
|
-
root: string,
|
|
61
|
-
sources: ReadonlyArray<TemplateSource | undefined>,
|
|
62
|
-
): Promise<Record<string, string>> {
|
|
63
|
-
// A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
|
|
64
|
-
// untrusted, so a name like `__proto__` must set a plain own property rather than trip the
|
|
65
|
-
// magic prototype setter that a normal `{}` inherits from `Object.prototype`.
|
|
66
|
-
const map: Record<string, string> = Object.create(null);
|
|
67
|
-
for (const source of sources) {
|
|
68
|
-
if (!source) continue;
|
|
69
|
-
if (Array.isArray(source)) {
|
|
70
|
-
const seen = new Set<string>();
|
|
71
|
-
for (const entry of source) {
|
|
72
|
-
for (const file of await filesFor(host, root, entry)) {
|
|
73
|
-
if (seen.has(file)) continue;
|
|
74
|
-
seen.add(file);
|
|
75
|
-
const name = stem(file);
|
|
76
|
-
if (!VALID_NAME.test(name)) {
|
|
77
|
-
// Loud failure for a misnamed template file rather than a silently unreferenceable entry.
|
|
78
|
-
host.log("warn", "template: skipped unsupported name", { file, name });
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
try {
|
|
82
|
-
map[name] = await host.readTextFile(file);
|
|
83
|
-
} catch (err) {
|
|
84
|
-
// A directory path that slipped through the file fallback, or an unreadable file.
|
|
85
|
-
host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
Object.assign(map, source);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return map;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
|
|
97
|
-
// with FEEL's single-brace context syntax used elsewhere in BPMN.
|
|
98
|
-
const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
|
|
99
|
-
|
|
100
|
-
/** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
|
|
101
|
-
* the markup-significant characters, newlines and tabs MUST become character references:
|
|
102
|
-
* XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
|
|
103
|
-
* destroy a multi-line prompt. Character references are exempt from that normalization, so
|
|
104
|
-
* ` ` round-trips as a newline. This escaping is also valid for XML element text, so it is
|
|
105
|
-
* the safe default for any `.bpmn`/`.dmn` placeholder position. */
|
|
106
|
-
function xmlAttrEscape(s: string): string {
|
|
107
|
-
return s
|
|
108
|
-
.replace(/&/g, "&")
|
|
109
|
-
.replace(/</g, "<")
|
|
110
|
-
.replace(/>/g, ">")
|
|
111
|
-
.replace(/"/g, """)
|
|
112
|
-
.replace(/'/g, "'")
|
|
113
|
-
.replace(/\r/g, " ")
|
|
114
|
-
.replace(/\n/g, " ")
|
|
115
|
-
.replace(/\t/g, "	");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Escape template content for embedding inside a JSON string literal (a `.form` value). */
|
|
119
|
-
function jsonStringEscape(s: string): string {
|
|
120
|
-
// Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
|
|
121
|
-
const quoted = JSON.stringify(s);
|
|
122
|
-
return quoted.slice(1, quoted.length - 1);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function escaperFor(contentType: string): (s: string) => string {
|
|
126
|
-
if (contentType === "application/json") return jsonStringEscape;
|
|
127
|
-
return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export interface TemplateApplication {
|
|
131
|
-
content: string;
|
|
132
|
-
/** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
|
|
133
|
-
unresolved: string[];
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
|
|
137
|
-
* for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
|
|
138
|
-
* not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
|
|
139
|
-
* dropped) and reported in `unresolved`. */
|
|
140
|
-
export function applyTemplates(
|
|
141
|
-
content: string,
|
|
142
|
-
contentType: string,
|
|
143
|
-
templates: Record<string, string>,
|
|
144
|
-
): TemplateApplication {
|
|
145
|
-
const escape = escaperFor(contentType);
|
|
146
|
-
const unresolved: string[] = [];
|
|
147
|
-
const out = content.replace(PLACEHOLDER, (whole: string, name: string) => {
|
|
148
|
-
if (Object.prototype.hasOwnProperty.call(templates, name)) return escape(templates[name]);
|
|
149
|
-
if (!unresolved.includes(name)) unresolved.push(name);
|
|
150
|
-
return whole;
|
|
151
|
-
});
|
|
152
|
-
return { content: out, unresolved };
|
|
153
|
-
}
|