@wp-typia/project-tools 0.16.7 → 0.16.8

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 CHANGED
@@ -6,7 +6,8 @@ Package roles:
6
6
 
7
7
  - `wp-typia` owns the CLI, help, TUI, completions, skills, MCP, and bin entry.
8
8
  - `@wp-typia/project-tools` owns scaffold, add-block, migrate, template, doctor, and schema project helpers.
9
- It also owns the typed generator boundary via `BlockSpec` and `BlockGeneratorService`,
9
+ It also owns the typed generator boundary via `BlockSpec`, `BlockGeneratorService`,
10
+ and `inspectBlockGeneration(...)`,
10
11
  plus the emitter-owned built-in structural/code path where built-in
11
12
  templates no longer ship structural, TS/TSX, style, or block-local `render.php`
12
13
  Mustache files.
@@ -50,4 +51,18 @@ files and starter `typia.manifest.json` now come from the emitter path, while
50
51
  project bootstrap/package-manager files, sync scripts, shared REST helpers, and
51
52
  the remaining non-block assets still come from Mustache-backed template copy.
52
53
 
54
+ The higher-level generator architecture record, including the current phase map
55
+ and the non-mutating `plan -> validate -> render -> apply` tool-facing usage
56
+ model, lives in
57
+ [`docs/block-generator-architecture.md`](../../docs/block-generator-architecture.md).
58
+ The public non-mutating controller/tool contract now lives in
59
+ [`docs/block-generator-tool-contract.md`](../../docs/block-generator-tool-contract.md).
60
+
61
+ Reusable external layer packages on top of the built-in shared scaffold model
62
+ are now available programmatically through `scaffoldProject(...)`,
63
+ `BlockGeneratorService`, and `inspectBlockGeneration(...)` via
64
+ `externalLayerSource` and optional `externalLayerId`. The RFC/CLI UX record
65
+ still lives in
66
+ [`docs/external-template-layer-composition.md`](../../docs/external-template-layer-composition.md).
67
+
53
68
  If you need metadata sync, editor helpers, validation helpers, or other generated-project runtime utilities, import them directly from `@wp-typia/block-runtime/*`.
@@ -41,6 +41,8 @@ export interface BlockSpec {
41
41
  export interface BlockGenerationTarget {
42
42
  allowExistingDir: boolean;
43
43
  cwd: string;
44
+ externalLayerId?: string;
45
+ externalLayerSource?: string;
44
46
  noInstall: boolean;
45
47
  packageManager: PackageManagerId;
46
48
  projectDir: string;
@@ -51,6 +53,8 @@ export interface PlanBlockInput {
51
53
  answers: ScaffoldAnswers;
52
54
  cwd?: string;
53
55
  dataStorageMode?: DataStorageMode;
56
+ externalLayerId?: string;
57
+ externalLayerSource?: string;
54
58
  noInstall?: boolean;
55
59
  packageManager: PackageManagerId;
56
60
  persistencePolicy?: PersistencePolicy;
@@ -95,7 +99,7 @@ export interface ApplyBlockInput {
95
99
  export declare function createBuiltInBlockSpec({ answers, dataStorageMode, persistencePolicy, templateId, withMigrationUi, withTestPreset, withWpEnv, }: Omit<PlanBlockInput, "allowExistingDir" | "cwd" | "noInstall" | "packageManager" | "projectDir" | "variant">): BlockSpec;
96
100
  export declare function buildTemplateVariablesFromBlockSpec(spec: BlockSpec): ScaffoldTemplateVariables;
97
101
  export declare class BlockGeneratorService {
98
- plan({ allowExistingDir, answers, cwd, dataStorageMode, noInstall, packageManager, persistencePolicy, projectDir, templateId, variant, withMigrationUi, withTestPreset, withWpEnv, }: PlanBlockInput): Promise<PlanBlockResult>;
102
+ plan({ allowExistingDir, answers, cwd, dataStorageMode, externalLayerId, externalLayerSource, noInstall, packageManager, persistencePolicy, projectDir, templateId, variant, withMigrationUi, withTestPreset, withWpEnv, }: PlanBlockInput): Promise<PlanBlockResult>;
99
103
  validate({ plan }: ValidateBlockInput): Promise<ValidateBlockResult>;
100
104
  render({ validated }: RenderBlockInput): Promise<RenderBlockResult>;
101
105
  apply({ rendered, installDependencies, }: ApplyBlockInput): Promise<ScaffoldProjectResult>;
@@ -7,10 +7,62 @@ import { applyBuiltInScaffoldProjectFiles, buildGitignore, buildReadme, } from "
7
7
  import { buildBlockCssClassName, buildFrontendCssClassName, resolveScaffoldIdentifiers, } from "./scaffold-identifiers.js";
8
8
  import { buildBuiltInBlockArtifacts, } from "./built-in-block-artifacts.js";
9
9
  import { buildBuiltInCodeArtifacts, } from "./built-in-block-code-artifacts.js";
10
+ import { getStarterManifestFiles } from "./starter-manifests.js";
11
+ import { resolveTemplateSeed, parseTemplateLocator } from "./template-source.js";
12
+ import { assertExternalTemplateLayersDoNotWriteProtectedOutputs, resolveExternalTemplateLayers, } from "./template-layers.js";
13
+ import { getBuiltInTemplateOverlayDir, getBuiltInTemplateSharedLayerDirs, resolveBuiltInTemplateSourceFromLayerDirs, } from "./template-builtins.js";
10
14
  const renderedArtifactCache = new WeakMap();
11
15
  function createVariablesFingerprint(variables) {
12
16
  return JSON.stringify(variables);
13
17
  }
18
+ function buildProtectedTemplateOutputPaths({ codeArtifacts, spec, variables, artifacts, }) {
19
+ const protectedOutputs = new Set([
20
+ ".gitignore",
21
+ "package.json",
22
+ "scripts/add-compound-child.ts",
23
+ "scripts/block-config.ts",
24
+ "scripts/sync-project.ts",
25
+ "scripts/sync-rest-contracts.ts",
26
+ "scripts/sync-types-to-block-json.ts",
27
+ "tsconfig.json",
28
+ "webpack.config.js",
29
+ `${variables.slugKebabCase}.php`,
30
+ ]);
31
+ for (const artifact of codeArtifacts) {
32
+ protectedOutputs.add(artifact.relativePath);
33
+ }
34
+ for (const artifact of artifacts) {
35
+ protectedOutputs.add(`${artifact.relativeDir}/block.json`);
36
+ protectedOutputs.add(`${artifact.relativeDir}/types.ts`);
37
+ }
38
+ for (const manifest of getStarterManifestFiles(spec.template.family, variables)) {
39
+ protectedOutputs.add(manifest.relativePath);
40
+ }
41
+ return protectedOutputs;
42
+ }
43
+ function buildCombinedTemplateLayerDirs({ baseLayerDirs, externalEntries, templateId, }) {
44
+ const orderedLayerDirs = [];
45
+ const seenLayerDirs = new Set();
46
+ for (const layerDir of baseLayerDirs) {
47
+ if (seenLayerDirs.has(layerDir)) {
48
+ continue;
49
+ }
50
+ orderedLayerDirs.push(layerDir);
51
+ seenLayerDirs.add(layerDir);
52
+ }
53
+ for (const entry of externalEntries) {
54
+ if (seenLayerDirs.has(entry.dir)) {
55
+ continue;
56
+ }
57
+ orderedLayerDirs.push(entry.dir);
58
+ seenLayerDirs.add(entry.dir);
59
+ }
60
+ const overlayDir = getBuiltInTemplateOverlayDir(templateId);
61
+ if (!seenLayerDirs.has(overlayDir)) {
62
+ orderedLayerDirs.push(overlayDir);
63
+ }
64
+ return orderedLayerDirs;
65
+ }
14
66
  function getBuiltInPersistenceSpec({ templateId, dataStorageMode, persistencePolicy, }) {
15
67
  if (templateId === "persistence") {
16
68
  return {
@@ -146,7 +198,7 @@ export function buildTemplateVariablesFromBlockSpec(spec) {
146
198
  };
147
199
  }
148
200
  export class BlockGeneratorService {
149
- async plan({ allowExistingDir = false, answers, cwd = process.cwd(), dataStorageMode, noInstall = false, packageManager, persistencePolicy, projectDir, templateId, variant, withMigrationUi = false, withTestPreset = false, withWpEnv = false, }) {
201
+ async plan({ allowExistingDir = false, answers, cwd = process.cwd(), dataStorageMode, externalLayerId, externalLayerSource, noInstall = false, packageManager, persistencePolicy, projectDir, templateId, variant, withMigrationUi = false, withTestPreset = false, withWpEnv = false, }) {
150
202
  return {
151
203
  spec: createBuiltInBlockSpec({
152
204
  answers,
@@ -160,6 +212,8 @@ export class BlockGeneratorService {
160
212
  target: {
161
213
  allowExistingDir,
162
214
  cwd,
215
+ externalLayerId,
216
+ externalLayerSource,
163
217
  noInstall,
164
218
  packageManager,
165
219
  projectDir,
@@ -168,21 +222,90 @@ export class BlockGeneratorService {
168
222
  };
169
223
  }
170
224
  async validate({ plan }) {
225
+ if (plan.target.externalLayerId && !plan.target.externalLayerSource) {
226
+ throw new Error("externalLayerId requires externalLayerSource when composing built-in template layers.");
227
+ }
171
228
  if (plan.target.variant) {
172
229
  throw new Error(`--variant is only supported for official external template configs. Received variant "${plan.target.variant}" for built-in template "${plan.spec.template.family}".`);
173
230
  }
174
231
  return plan;
175
232
  }
176
233
  async render({ validated }) {
177
- const templateSource = await resolveBuiltInTemplateSource(validated.spec.template.family, {
178
- persistenceEnabled: validated.spec.persistence.enabled,
234
+ const variables = buildTemplateVariablesFromBlockSpec(validated.spec);
235
+ const persistenceEnabled = validated.spec.persistence.enabled;
236
+ const artifacts = buildBuiltInBlockArtifacts({
237
+ templateId: validated.spec.template.family,
238
+ variables,
239
+ });
240
+ const codeArtifacts = buildBuiltInCodeArtifacts({
241
+ templateId: validated.spec.template.family,
242
+ variables,
243
+ });
244
+ const templateVariantOptions = {
245
+ persistenceEnabled,
179
246
  persistencePolicy: validated.spec.persistence.enabled &&
180
247
  validated.spec.persistence.persistencePolicy === "public"
181
248
  ? "public"
182
249
  : "authenticated",
183
- });
184
- const variables = buildTemplateVariablesFromBlockSpec(validated.spec);
185
- const persistenceEnabled = validated.spec.persistence.enabled;
250
+ };
251
+ let templateSource = await resolveBuiltInTemplateSource(validated.spec.template.family, templateVariantOptions);
252
+ const warnings = [...(templateSource.warnings ?? [])];
253
+ if (validated.target.externalLayerSource) {
254
+ let layerSeed;
255
+ try {
256
+ layerSeed = await resolveTemplateSeed(parseTemplateLocator(validated.target.externalLayerSource), validated.target.cwd);
257
+ const resolvedLayers = await resolveExternalTemplateLayers({
258
+ externalLayerId: validated.target.externalLayerId,
259
+ sourceRoot: layerSeed.rootDir,
260
+ });
261
+ const baseLayerDirs = getBuiltInTemplateSharedLayerDirs(validated.spec.template.family, templateVariantOptions);
262
+ await assertExternalTemplateLayersDoNotWriteProtectedOutputs({
263
+ externalEntries: resolvedLayers.entries,
264
+ protectedOutputPaths: buildProtectedTemplateOutputPaths({
265
+ artifacts,
266
+ codeArtifacts,
267
+ spec: validated.spec,
268
+ variables,
269
+ }),
270
+ view: variables,
271
+ });
272
+ await templateSource.cleanup?.();
273
+ templateSource = await resolveBuiltInTemplateSourceFromLayerDirs(validated.spec.template.family, buildCombinedTemplateLayerDirs({
274
+ baseLayerDirs,
275
+ externalEntries: resolvedLayers.entries,
276
+ templateId: validated.spec.template.family,
277
+ }));
278
+ const layerSourceCleanup = layerSeed.cleanup;
279
+ const templateCleanup = templateSource.cleanup;
280
+ templateSource.cleanup = async () => {
281
+ const cleanupErrors = [];
282
+ try {
283
+ await templateCleanup?.();
284
+ }
285
+ catch (error) {
286
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
287
+ }
288
+ try {
289
+ await layerSourceCleanup?.();
290
+ }
291
+ catch (error) {
292
+ cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
293
+ }
294
+ if (cleanupErrors.length > 0) {
295
+ throw new Error([
296
+ "Failed to cleanup composed template sources.",
297
+ ...cleanupErrors.map((error) => `- ${error.message}`),
298
+ ].join("\n"));
299
+ }
300
+ };
301
+ warnings.push(`Applied external layer "${resolvedLayers.selectedLayerId}" from "${validated.target.externalLayerSource}".`);
302
+ }
303
+ catch (error) {
304
+ await templateSource.cleanup?.();
305
+ await layerSeed?.cleanup?.();
306
+ throw error;
307
+ }
308
+ }
186
309
  const rendered = {
187
310
  ...validated,
188
311
  cleanup: templateSource.cleanup,
@@ -202,17 +325,11 @@ export class BlockGeneratorService {
202
325
  selectedVariant: null,
203
326
  templateDir: templateSource.templateDir,
204
327
  variables,
205
- warnings: templateSource.warnings ?? [],
328
+ warnings,
206
329
  };
207
330
  renderedArtifactCache.set(rendered, {
208
- artifacts: buildBuiltInBlockArtifacts({
209
- templateId: validated.spec.template.family,
210
- variables,
211
- }),
212
- codeArtifacts: buildBuiltInCodeArtifacts({
213
- templateId: validated.spec.template.family,
214
- variables,
215
- }),
331
+ artifacts,
332
+ codeArtifacts,
216
333
  variablesFingerprint: createVariablesFingerprint(variables),
217
334
  });
218
335
  return rendered;
@@ -0,0 +1,93 @@
1
+ import { BlockGeneratorService, type PlanBlockInput, type PlanBlockResult, type RenderBlockResult, type ValidateBlockResult } from "./block-generator-service.js";
2
+ import type { ManifestDocument } from "./migration-types.js";
3
+ import type { BuiltInTemplateId } from "./template-registry.js";
4
+ /**
5
+ * Semantic version marker for the public block generation tool contract.
6
+ *
7
+ * Increment this number whenever the serialized inspection payload changes in a
8
+ * breaking way.
9
+ */
10
+ export declare const BLOCK_GENERATION_TOOL_CONTRACT_VERSION: 1;
11
+ /**
12
+ * Staged execution points for the non-mutating inspection workflow.
13
+ *
14
+ * - `"plan"` returns the normalized `BlockSpec` and target metadata.
15
+ * - `"validate"` returns the validated generation stage without rendering.
16
+ * - `"render"` returns the full non-mutating preview, including copied and
17
+ * emitted file snapshots.
18
+ */
19
+ export type BlockGenerationToolStage = "plan" | "validate" | "render";
20
+ /**
21
+ * Input for the staged, non-mutating block generation inspection entrypoint.
22
+ *
23
+ * Extends `PlanBlockInput` with an optional `stopAfter` selector so callers can
24
+ * stop at `plan`, `validate`, or continue through the full `render` preview.
25
+ */
26
+ export interface InspectBlockGenerationInput extends PlanBlockInput {
27
+ stopAfter?: BlockGenerationToolStage;
28
+ }
29
+ export interface BlockGenerationTemplateCopyPreview {
30
+ owner: "template-copy";
31
+ relativePath: string;
32
+ }
33
+ export interface BlockGenerationEmittedFilePreview {
34
+ kind: "generated-source" | "starter-manifest" | "structural";
35
+ owner: "emitter";
36
+ relativePath: string;
37
+ source: string;
38
+ }
39
+ export interface BlockGenerationStarterManifestPreview {
40
+ document: ManifestDocument;
41
+ owner: "emitter";
42
+ relativePath: string;
43
+ source: string;
44
+ }
45
+ export interface BlockGenerationRenderPreview {
46
+ copiedTemplateFiles: BlockGenerationTemplateCopyPreview[];
47
+ emittedFiles: BlockGenerationEmittedFilePreview[];
48
+ postRender: RenderBlockResult["postRender"] & {
49
+ installsDependencies: boolean;
50
+ };
51
+ selectedVariant: null;
52
+ starterManifestFiles: BlockGenerationStarterManifestPreview[];
53
+ template: {
54
+ description: string;
55
+ family: BuiltInTemplateId;
56
+ features: string[];
57
+ format: "wp-typia";
58
+ };
59
+ warnings: string[];
60
+ readmeContent: string;
61
+ gitignoreContent: string;
62
+ }
63
+ interface BlockGenerationInspectionBase {
64
+ contractVersion: typeof BLOCK_GENERATION_TOOL_CONTRACT_VERSION;
65
+ mutatesWorkspace: false;
66
+ stage: BlockGenerationToolStage;
67
+ }
68
+ export interface InspectBlockGenerationPlanResult extends BlockGenerationInspectionBase {
69
+ plan: PlanBlockResult;
70
+ stage: "plan";
71
+ }
72
+ export interface InspectBlockGenerationValidateResult extends BlockGenerationInspectionBase {
73
+ plan: PlanBlockResult;
74
+ stage: "validate";
75
+ validated: ValidateBlockResult;
76
+ }
77
+ export interface InspectBlockGenerationRenderResult extends BlockGenerationInspectionBase {
78
+ plan: PlanBlockResult;
79
+ rendered: BlockGenerationRenderPreview;
80
+ stage: "render";
81
+ validated: ValidateBlockResult;
82
+ }
83
+ export type InspectBlockGenerationResult = InspectBlockGenerationPlanResult | InspectBlockGenerationValidateResult | InspectBlockGenerationRenderResult;
84
+ export declare function inspectBlockGeneration(input: InspectBlockGenerationInput & {
85
+ stopAfter: "plan";
86
+ }, service?: BlockGeneratorService): Promise<InspectBlockGenerationPlanResult>;
87
+ export declare function inspectBlockGeneration(input: InspectBlockGenerationInput & {
88
+ stopAfter: "validate";
89
+ }, service?: BlockGeneratorService): Promise<InspectBlockGenerationValidateResult>;
90
+ export declare function inspectBlockGeneration(input: InspectBlockGenerationInput & {
91
+ stopAfter?: "render" | undefined;
92
+ }, service?: BlockGeneratorService): Promise<InspectBlockGenerationRenderResult>;
93
+ export {};
@@ -0,0 +1,157 @@
1
+ import { buildBuiltInBlockArtifacts, stringifyBuiltInBlockJsonDocument, } from "./built-in-block-artifacts.js";
2
+ import { buildBuiltInCodeArtifacts, } from "./built-in-block-code-artifacts.js";
3
+ import { BlockGeneratorService, } from "./block-generator-service.js";
4
+ import { getStarterManifestFiles, stringifyStarterManifest, } from "./starter-manifests.js";
5
+ import { listInterpolatedDirectoryOutputs } from "./template-render.js";
6
+ /**
7
+ * Semantic version marker for the public block generation tool contract.
8
+ *
9
+ * Increment this number whenever the serialized inspection payload changes in a
10
+ * breaking way.
11
+ */
12
+ export const BLOCK_GENERATION_TOOL_CONTRACT_VERSION = 1;
13
+ function buildStarterManifestPreviews(templateId, variables, artifacts) {
14
+ const starterManifests = getStarterManifestFiles(templateId, variables);
15
+ const artifactManifests = new Map(artifacts.map((artifact) => [
16
+ `${artifact.relativeDir}/typia.manifest.json`,
17
+ artifact.manifestDocument,
18
+ ]));
19
+ return starterManifests
20
+ .map((entry) => {
21
+ const document = artifactManifests.get(entry.relativePath) ?? entry.document;
22
+ return {
23
+ document,
24
+ owner: "emitter",
25
+ relativePath: entry.relativePath,
26
+ source: stringifyStarterManifest(document),
27
+ };
28
+ })
29
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
30
+ }
31
+ function buildStructuralArtifactPreviews(artifacts) {
32
+ return artifacts
33
+ .flatMap((artifact) => [
34
+ {
35
+ kind: "structural",
36
+ owner: "emitter",
37
+ relativePath: `${artifact.relativeDir}/block.json`,
38
+ source: stringifyBuiltInBlockJsonDocument(artifact.blockJsonDocument),
39
+ },
40
+ {
41
+ kind: "structural",
42
+ owner: "emitter",
43
+ relativePath: `${artifact.relativeDir}/types.ts`,
44
+ source: artifact.typesSource,
45
+ },
46
+ ])
47
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
48
+ }
49
+ function buildCodeArtifactPreviews(codeArtifacts) {
50
+ return codeArtifacts
51
+ .map((artifact) => ({
52
+ kind: "generated-source",
53
+ owner: "emitter",
54
+ relativePath: artifact.relativePath,
55
+ source: artifact.source,
56
+ }))
57
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
58
+ }
59
+ async function buildRenderPreview(rendered) {
60
+ const artifacts = buildBuiltInBlockArtifacts({
61
+ templateId: rendered.spec.template.family,
62
+ variables: rendered.variables,
63
+ });
64
+ const codeArtifacts = buildBuiltInCodeArtifacts({
65
+ templateId: rendered.spec.template.family,
66
+ variables: rendered.variables,
67
+ });
68
+ const copiedTemplateFiles = await listInterpolatedDirectoryOutputs(rendered.templateDir, rendered.variables);
69
+ return {
70
+ copiedTemplateFiles: copiedTemplateFiles.map((relativePath) => ({
71
+ owner: "template-copy",
72
+ relativePath,
73
+ })),
74
+ emittedFiles: [
75
+ ...buildStructuralArtifactPreviews(artifacts),
76
+ ...buildCodeArtifactPreviews(codeArtifacts),
77
+ ].sort((left, right) => left.relativePath.localeCompare(right.relativePath)),
78
+ gitignoreContent: rendered.gitignoreContent,
79
+ postRender: {
80
+ ...rendered.postRender,
81
+ installsDependencies: !rendered.target.noInstall,
82
+ },
83
+ readmeContent: rendered.readmeContent,
84
+ selectedVariant: rendered.selectedVariant,
85
+ starterManifestFiles: buildStarterManifestPreviews(rendered.spec.template.family, rendered.variables, artifacts),
86
+ template: {
87
+ description: rendered.spec.template.description,
88
+ family: rendered.spec.template.family,
89
+ features: [...rendered.spec.template.features],
90
+ format: "wp-typia",
91
+ },
92
+ warnings: [...rendered.warnings],
93
+ };
94
+ }
95
+ /**
96
+ * Inspects built-in block generation through the staged generator boundary
97
+ * without mutating the destination workspace.
98
+ *
99
+ * Use `stopAfter` to halt after the `plan`, `validate`, or `render` stage.
100
+ * The render stage includes copied template file paths, emitter-owned source
101
+ * previews, starter manifest previews, and post-render intent metadata. Any
102
+ * temporary render state is cleaned up before the promise resolves.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * const inspection = await inspectBlockGeneration({
107
+ * answers,
108
+ * noInstall: true,
109
+ * packageManager: "bun",
110
+ * projectDir: "demo-block",
111
+ * templateId: "basic",
112
+ * stopAfter: "render",
113
+ * });
114
+ * ```
115
+ *
116
+ * @param input - Planning input plus an optional `stopAfter` stage selector.
117
+ * @param service - Optional generator service instance. Defaults to a new
118
+ * `BlockGeneratorService`.
119
+ * @returns The serialized inspection result for the reached stage.
120
+ * @throws Propagates planning, validation, or render failures from
121
+ * `BlockGeneratorService`.
122
+ */
123
+ export async function inspectBlockGeneration({ stopAfter = "render", ...planInput }, service = new BlockGeneratorService()) {
124
+ const plan = await service.plan(planInput);
125
+ if (stopAfter === "plan") {
126
+ return {
127
+ contractVersion: BLOCK_GENERATION_TOOL_CONTRACT_VERSION,
128
+ mutatesWorkspace: false,
129
+ plan,
130
+ stage: "plan",
131
+ };
132
+ }
133
+ const validated = await service.validate({ plan });
134
+ if (stopAfter === "validate") {
135
+ return {
136
+ contractVersion: BLOCK_GENERATION_TOOL_CONTRACT_VERSION,
137
+ mutatesWorkspace: false,
138
+ plan,
139
+ stage: "validate",
140
+ validated,
141
+ };
142
+ }
143
+ const rendered = await service.render({ validated });
144
+ try {
145
+ return {
146
+ contractVersion: BLOCK_GENERATION_TOOL_CONTRACT_VERSION,
147
+ mutatesWorkspace: false,
148
+ plan,
149
+ rendered: await buildRenderPreview(rendered),
150
+ stage: "render",
151
+ validated,
152
+ };
153
+ }
154
+ finally {
155
+ await rendered.cleanup?.();
156
+ }
157
+ }
@@ -12,6 +12,8 @@
12
12
  export { scaffoldProject, collectScaffoldAnswers, getDefaultAnswers, getTemplateVariables, resolvePackageManagerId, resolveTemplateId, } from "./scaffold.js";
13
13
  export { BlockGeneratorService } from "./block-generator-service.js";
14
14
  export type { ApplyBlockInput, BlockGenerationTarget, BlockSpec, PlanBlockInput, PlanBlockResult, RenderBlockInput, RenderBlockResult, ValidateBlockInput, ValidateBlockResult, } from "./block-generator-service.js";
15
+ export { BLOCK_GENERATION_TOOL_CONTRACT_VERSION, inspectBlockGeneration, } from "./block-generator-tool-contract.js";
16
+ export type { BlockGenerationEmittedFilePreview, BlockGenerationRenderPreview, BlockGenerationStarterManifestPreview, BlockGenerationTemplateCopyPreview, BlockGenerationToolStage, InspectBlockGenerationInput, InspectBlockGenerationPlanResult, InspectBlockGenerationRenderResult, InspectBlockGenerationResult, InspectBlockGenerationValidateResult, } from "./block-generator-tool-contract.js";
15
17
  export { formatMigrationHelpText, parseMigrationArgs, runMigrationCommand, } from "./migrations.js";
16
18
  export { parseWorkspacePackageManagerId, resolveWorkspaceProject, tryResolveWorkspaceProject, } from "./workspace-project.js";
17
19
  export { manifestAttributeToJsonSchema, projectJsonSchemaDocument, manifestToJsonSchema, manifestToOpenApi, normalizeEndpointAuthDefinition, } from "./schema-core.js";
@@ -11,6 +11,7 @@
11
11
  */
12
12
  export { scaffoldProject, collectScaffoldAnswers, getDefaultAnswers, getTemplateVariables, resolvePackageManagerId, resolveTemplateId, } from "./scaffold.js";
13
13
  export { BlockGeneratorService } from "./block-generator-service.js";
14
+ export { BLOCK_GENERATION_TOOL_CONTRACT_VERSION, inspectBlockGeneration, } from "./block-generator-tool-contract.js";
14
15
  export { formatMigrationHelpText, parseMigrationArgs, runMigrationCommand, } from "./migrations.js";
15
16
  export { parseWorkspacePackageManagerId, resolveWorkspaceProject, tryResolveWorkspaceProject, } from "./workspace-project.js";
16
17
  export { manifestAttributeToJsonSchema, projectJsonSchemaDocument, manifestToJsonSchema, manifestToOpenApi, normalizeEndpointAuthDefinition, } from "./schema-core.js";
@@ -19,6 +19,15 @@ export interface MaterializedBuiltInTemplateSource {
19
19
  selectedVariant?: string | null;
20
20
  warnings?: string[];
21
21
  }
22
+ export interface BuiltInSharedTemplateLayer {
23
+ dir: string;
24
+ id: string;
25
+ }
26
+ export declare function listBuiltInSharedTemplateLayers(): readonly BuiltInSharedTemplateLayer[];
27
+ export declare function isBuiltInSharedTemplateLayerId(layerId: string): boolean;
28
+ export declare function getBuiltInSharedTemplateLayerDir(layerId: string): string;
29
+ export declare function getBuiltInTemplateOverlayDir(templateId: BuiltInTemplateId): string;
30
+ export declare function getBuiltInTemplateSharedLayerDirs(templateId: BuiltInTemplateId, { persistenceEnabled, persistencePolicy, }?: BuiltInTemplateVariantOptions): string[];
22
31
  /**
23
32
  * Returns the ordered overlay directories for a built-in template.
24
33
  *
@@ -26,7 +35,7 @@ export interface MaterializedBuiltInTemplateSource {
26
35
  * the selected policy layer, and the thin template overlay. All other built-ins
27
36
  * resolve to the shared base plus their own template directory.
28
37
  */
29
- export declare function getBuiltInTemplateLayerDirs(templateId: BuiltInTemplateId, { persistenceEnabled, persistencePolicy, }?: BuiltInTemplateVariantOptions): string[];
38
+ export declare function getBuiltInTemplateLayerDirs(templateId: BuiltInTemplateId, options?: BuiltInTemplateVariantOptions): string[];
30
39
  /**
31
40
  * Returns whether a missing built-in overlay directory is expected because the
32
41
  * template family no longer ships any Mustache assets in that layer.
@@ -36,6 +45,7 @@ export declare function getBuiltInTemplateLayerDirs(templateId: BuiltInTemplateI
36
45
  * @returns True when the missing layer can be skipped safely.
37
46
  */
38
47
  export declare function isOmittableBuiltInTemplateLayerDir(templateId: BuiltInTemplateId, layerDir: string): boolean;
48
+ export declare function resolveBuiltInTemplateSourceFromLayerDirs(templateId: BuiltInTemplateId, layerDirs: readonly string[]): Promise<MaterializedBuiltInTemplateSource>;
39
49
  /**
40
50
  * Materializes a built-in template into a temporary directory by copying each
41
51
  * resolved layer in order.
@@ -2,20 +2,96 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { promises as fsp } from "node:fs";
5
- import { getTemplateById, SHARED_BASE_TEMPLATE_ROOT, SHARED_COMPOUND_TEMPLATE_ROOT, SHARED_PERSISTENCE_TEMPLATE_ROOT, SHARED_REST_HELPER_TEMPLATE_ROOT, } from "./template-registry.js";
5
+ import { getTemplateById, SHARED_BASE_TEMPLATE_ROOT, SHARED_COMPOUND_TEMPLATE_ROOT, SHARED_MIGRATION_UI_TEMPLATE_ROOT, SHARED_PERSISTENCE_TEMPLATE_ROOT, SHARED_PRESET_TEMPLATE_ROOT, SHARED_REST_HELPER_TEMPLATE_ROOT, SHARED_WORKSPACE_TEMPLATE_ROOT, } from "./template-registry.js";
6
+ const BUILT_IN_SHARED_TEMPLATE_LAYERS = Object.freeze([
7
+ {
8
+ dir: SHARED_BASE_TEMPLATE_ROOT,
9
+ id: "builtin:shared/base",
10
+ },
11
+ {
12
+ dir: path.join(SHARED_REST_HELPER_TEMPLATE_ROOT, "shared"),
13
+ id: "builtin:shared/rest-helpers/shared",
14
+ },
15
+ {
16
+ dir: path.join(SHARED_REST_HELPER_TEMPLATE_ROOT, "public"),
17
+ id: "builtin:shared/rest-helpers/public",
18
+ },
19
+ {
20
+ dir: path.join(SHARED_REST_HELPER_TEMPLATE_ROOT, "auth"),
21
+ id: "builtin:shared/rest-helpers/auth",
22
+ },
23
+ {
24
+ dir: path.join(SHARED_PERSISTENCE_TEMPLATE_ROOT, "core"),
25
+ id: "builtin:shared/persistence/core",
26
+ },
27
+ {
28
+ dir: path.join(SHARED_PERSISTENCE_TEMPLATE_ROOT, "public"),
29
+ id: "builtin:shared/persistence/public",
30
+ },
31
+ {
32
+ dir: path.join(SHARED_PERSISTENCE_TEMPLATE_ROOT, "auth"),
33
+ id: "builtin:shared/persistence/auth",
34
+ },
35
+ {
36
+ dir: path.join(SHARED_COMPOUND_TEMPLATE_ROOT, "core"),
37
+ id: "builtin:shared/compound/core",
38
+ },
39
+ {
40
+ dir: path.join(SHARED_COMPOUND_TEMPLATE_ROOT, "persistence"),
41
+ id: "builtin:shared/compound/persistence",
42
+ },
43
+ {
44
+ dir: path.join(SHARED_COMPOUND_TEMPLATE_ROOT, "persistence-public"),
45
+ id: "builtin:shared/compound/persistence-public",
46
+ },
47
+ {
48
+ dir: path.join(SHARED_COMPOUND_TEMPLATE_ROOT, "persistence-auth"),
49
+ id: "builtin:shared/compound/persistence-auth",
50
+ },
51
+ {
52
+ dir: path.join(SHARED_MIGRATION_UI_TEMPLATE_ROOT, "common"),
53
+ id: "builtin:shared/migration-ui/common",
54
+ },
55
+ {
56
+ dir: path.join(SHARED_PRESET_TEMPLATE_ROOT, "wp-env"),
57
+ id: "builtin:shared/presets/wp-env",
58
+ },
59
+ {
60
+ dir: path.join(SHARED_PRESET_TEMPLATE_ROOT, "test-preset"),
61
+ id: "builtin:shared/presets/test-preset",
62
+ },
63
+ {
64
+ dir: path.join(SHARED_WORKSPACE_TEMPLATE_ROOT, "persistence-public"),
65
+ id: "builtin:shared/workspace/persistence-public",
66
+ },
67
+ {
68
+ dir: path.join(SHARED_WORKSPACE_TEMPLATE_ROOT, "persistence-auth"),
69
+ id: "builtin:shared/workspace/persistence-auth",
70
+ },
71
+ ]);
72
+ const BUILT_IN_SHARED_TEMPLATE_LAYER_DIRS = new Map(BUILT_IN_SHARED_TEMPLATE_LAYERS.map((layer) => [layer.id, layer.dir]));
6
73
  const OMITTABLE_BUILT_IN_OVERLAY_TEMPLATE_IDS = new Set([
7
74
  "basic",
8
75
  "persistence",
9
76
  "compound",
10
77
  ]);
11
- /**
12
- * Returns the ordered overlay directories for a built-in template.
13
- *
14
- * Persistence templates include the shared base, the persistence core layer,
15
- * the selected policy layer, and the thin template overlay. All other built-ins
16
- * resolve to the shared base plus their own template directory.
17
- */
18
- export function getBuiltInTemplateLayerDirs(templateId, { persistenceEnabled = false, persistencePolicy = "authenticated", } = {}) {
78
+ export function listBuiltInSharedTemplateLayers() {
79
+ return BUILT_IN_SHARED_TEMPLATE_LAYERS;
80
+ }
81
+ export function isBuiltInSharedTemplateLayerId(layerId) {
82
+ return BUILT_IN_SHARED_TEMPLATE_LAYER_DIRS.has(layerId);
83
+ }
84
+ export function getBuiltInSharedTemplateLayerDir(layerId) {
85
+ const layerDir = BUILT_IN_SHARED_TEMPLATE_LAYER_DIRS.get(layerId);
86
+ if (!layerDir) {
87
+ throw new Error(`Unknown built-in shared template layer id: ${layerId}`);
88
+ }
89
+ return layerDir;
90
+ }
91
+ export function getBuiltInTemplateOverlayDir(templateId) {
92
+ return getTemplateById(templateId).templateDir;
93
+ }
94
+ export function getBuiltInTemplateSharedLayerDirs(templateId, { persistenceEnabled = false, persistencePolicy = "authenticated", } = {}) {
19
95
  if (templateId === "persistence") {
20
96
  return [
21
97
  SHARED_BASE_TEMPLATE_ROOT,
@@ -23,21 +99,32 @@ export function getBuiltInTemplateLayerDirs(templateId, { persistenceEnabled = f
23
99
  path.join(SHARED_PERSISTENCE_TEMPLATE_ROOT, "core"),
24
100
  path.join(SHARED_REST_HELPER_TEMPLATE_ROOT, persistencePolicy === "public" ? "public" : "auth"),
25
101
  path.join(SHARED_PERSISTENCE_TEMPLATE_ROOT, persistencePolicy === "public" ? "public" : "auth"),
26
- getTemplateById(templateId).templateDir,
27
102
  ];
28
103
  }
29
104
  if (templateId === "compound") {
30
105
  const layers = [
31
106
  SHARED_BASE_TEMPLATE_ROOT,
32
107
  path.join(SHARED_COMPOUND_TEMPLATE_ROOT, "core"),
33
- getTemplateById(templateId).templateDir,
34
108
  ];
35
109
  if (persistenceEnabled) {
36
110
  layers.push(path.join(SHARED_REST_HELPER_TEMPLATE_ROOT, "shared"), path.join(SHARED_COMPOUND_TEMPLATE_ROOT, "persistence"), path.join(SHARED_REST_HELPER_TEMPLATE_ROOT, persistencePolicy === "public" ? "public" : "auth"), path.join(SHARED_COMPOUND_TEMPLATE_ROOT, persistencePolicy === "public" ? "persistence-public" : "persistence-auth"));
37
111
  }
38
112
  return layers;
39
113
  }
40
- return [SHARED_BASE_TEMPLATE_ROOT, getTemplateById(templateId).templateDir];
114
+ return [SHARED_BASE_TEMPLATE_ROOT];
115
+ }
116
+ /**
117
+ * Returns the ordered overlay directories for a built-in template.
118
+ *
119
+ * Persistence templates include the shared base, the persistence core layer,
120
+ * the selected policy layer, and the thin template overlay. All other built-ins
121
+ * resolve to the shared base plus their own template directory.
122
+ */
123
+ export function getBuiltInTemplateLayerDirs(templateId, options = {}) {
124
+ return [
125
+ ...getBuiltInTemplateSharedLayerDirs(templateId, options),
126
+ getBuiltInTemplateOverlayDir(templateId),
127
+ ];
41
128
  }
42
129
  /**
43
130
  * Returns whether a missing built-in overlay directory is expected because the
@@ -51,8 +138,8 @@ export function isOmittableBuiltInTemplateLayerDir(templateId, layerDir) {
51
138
  return (OMITTABLE_BUILT_IN_OVERLAY_TEMPLATE_IDS.has(templateId) &&
52
139
  layerDir === getTemplateById(templateId).templateDir);
53
140
  }
54
- function resolveMaterializedBuiltInTemplateLayerDirs(templateId, options) {
55
- return getBuiltInTemplateLayerDirs(templateId, options).flatMap((layerDir) => {
141
+ function resolveMaterializedTemplateLayerDirs(templateId, layerDirs) {
142
+ return layerDirs.flatMap((layerDir) => {
56
143
  if (fs.existsSync(layerDir)) {
57
144
  return [layerDir];
58
145
  }
@@ -62,22 +149,14 @@ function resolveMaterializedBuiltInTemplateLayerDirs(templateId, options) {
62
149
  throw new Error(`Built-in template layer is missing: ${layerDir}`);
63
150
  });
64
151
  }
65
- /**
66
- * Materializes a built-in template into a temporary directory by copying each
67
- * resolved layer in order.
68
- *
69
- * Callers should invoke the returned `cleanup` function when they no longer
70
- * need the materialized directory. If copying fails, the temporary directory is
71
- * removed before the error is rethrown.
72
- */
73
- export async function resolveBuiltInTemplateSource(templateId, options = {}) {
152
+ async function materializeBuiltInTemplateSource(templateId, layerDirs) {
74
153
  const template = getTemplateById(templateId);
75
- const layerDirs = resolveMaterializedBuiltInTemplateLayerDirs(templateId, options);
154
+ const materializedLayerDirs = resolveMaterializedTemplateLayerDirs(templateId, layerDirs);
76
155
  const tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "wp-typia-template-"));
77
156
  const templateDir = path.join(tempRoot, templateId);
78
157
  try {
79
158
  await fsp.mkdir(templateDir, { recursive: true });
80
- for (const layerDir of layerDirs) {
159
+ for (const layerDir of materializedLayerDirs) {
81
160
  await fsp.cp(layerDir, templateDir, {
82
161
  recursive: true,
83
162
  force: true,
@@ -100,3 +179,17 @@ export async function resolveBuiltInTemplateSource(templateId, options = {}) {
100
179
  },
101
180
  };
102
181
  }
182
+ export async function resolveBuiltInTemplateSourceFromLayerDirs(templateId, layerDirs) {
183
+ return materializeBuiltInTemplateSource(templateId, layerDirs);
184
+ }
185
+ /**
186
+ * Materializes a built-in template into a temporary directory by copying each
187
+ * resolved layer in order.
188
+ *
189
+ * Callers should invoke the returned `cleanup` function when they no longer
190
+ * need the materialized directory. If copying fails, the temporary directory is
191
+ * removed before the error is rethrown.
192
+ */
193
+ export async function resolveBuiltInTemplateSource(templateId, options = {}) {
194
+ return materializeBuiltInTemplateSource(templateId, getBuiltInTemplateLayerDirs(templateId, options));
195
+ }
@@ -0,0 +1,31 @@
1
+ export declare const TEMPLATE_LAYER_MANIFEST_FILENAME = "wp-typia.layers.json";
2
+ declare const TEMPLATE_LAYER_MANIFEST_VERSION: 1;
3
+ export interface ExternalTemplateLayerDefinition {
4
+ description?: string;
5
+ extends?: string[];
6
+ path: string;
7
+ }
8
+ export interface ExternalTemplateLayerManifest {
9
+ layers: Record<string, ExternalTemplateLayerDefinition>;
10
+ version: typeof TEMPLATE_LAYER_MANIFEST_VERSION;
11
+ }
12
+ export interface ResolvedTemplateLayerEntry {
13
+ dir: string;
14
+ id: string;
15
+ kind: "built-in" | "external";
16
+ }
17
+ export interface ResolvedExternalTemplateLayers {
18
+ entries: ResolvedTemplateLayerEntry[];
19
+ selectedLayerId: string;
20
+ }
21
+ export declare function loadExternalTemplateLayerManifest(sourceRoot: string): Promise<ExternalTemplateLayerManifest | null>;
22
+ export declare function resolveExternalTemplateLayers({ externalLayerId, sourceRoot, }: {
23
+ externalLayerId?: string;
24
+ sourceRoot: string;
25
+ }): Promise<ResolvedExternalTemplateLayers>;
26
+ export declare function assertExternalTemplateLayersDoNotWriteProtectedOutputs({ externalEntries, protectedOutputPaths, view, }: {
27
+ externalEntries: readonly ResolvedTemplateLayerEntry[];
28
+ protectedOutputPaths: ReadonlySet<string>;
29
+ view: Record<string, string>;
30
+ }): Promise<void>;
31
+ export {};
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { promises as fsp } from "node:fs";
4
+ import { isPlainObject } from "./object-utils.js";
5
+ import { getBuiltInSharedTemplateLayerDir, isBuiltInSharedTemplateLayerId, } from "./template-builtins.js";
6
+ import { listInterpolatedDirectoryOutputs } from "./template-render.js";
7
+ export const TEMPLATE_LAYER_MANIFEST_FILENAME = "wp-typia.layers.json";
8
+ const TEMPLATE_LAYER_MANIFEST_VERSION = 1;
9
+ function resolveLayerPath(sourceRoot, relativePath) {
10
+ const targetPath = path.resolve(sourceRoot, relativePath);
11
+ const relativeTarget = path.relative(sourceRoot, targetPath);
12
+ if (relativeTarget.startsWith("..") || path.isAbsolute(relativeTarget)) {
13
+ throw new Error(`Template layer path "${relativePath}" must stay within ${sourceRoot}.`);
14
+ }
15
+ return targetPath;
16
+ }
17
+ async function assertNoSymlinks(sourceDir) {
18
+ const stats = await fsp.lstat(sourceDir);
19
+ if (stats.isSymbolicLink()) {
20
+ throw new Error(`Template layer packages may not include symbolic links: ${sourceDir}`);
21
+ }
22
+ if (!stats.isDirectory()) {
23
+ return;
24
+ }
25
+ for (const entry of await fsp.readdir(sourceDir)) {
26
+ await assertNoSymlinks(path.join(sourceDir, entry));
27
+ }
28
+ }
29
+ function parseLayerDefinition(layerId, value) {
30
+ if (isBuiltInSharedTemplateLayerId(layerId)) {
31
+ throw new Error(`Layer "${layerId}" uses a reserved built-in shared layer id and cannot be redefined in ${TEMPLATE_LAYER_MANIFEST_FILENAME}.`);
32
+ }
33
+ if (!isPlainObject(value)) {
34
+ throw new Error(`Layer "${layerId}" in ${TEMPLATE_LAYER_MANIFEST_FILENAME} must be an object.`);
35
+ }
36
+ const layerPath = value.path;
37
+ if (typeof layerPath !== "string" || layerPath.trim().length === 0) {
38
+ throw new Error(`Layer "${layerId}" must define a non-empty string "path".`);
39
+ }
40
+ const layerExtends = value.extends;
41
+ if (typeof layerExtends !== "undefined" &&
42
+ (!Array.isArray(layerExtends) ||
43
+ !layerExtends.every((entry) => typeof entry === "string" && entry.trim().length > 0))) {
44
+ throw new Error(`Layer "${layerId}" must define "extends" as an array of non-empty strings when present.`);
45
+ }
46
+ const description = value.description;
47
+ if (typeof description !== "undefined" &&
48
+ (typeof description !== "string" || description.trim().length === 0)) {
49
+ throw new Error(`Layer "${layerId}" must define "description" as a non-empty string when present.`);
50
+ }
51
+ return {
52
+ description: typeof description === "string" ? description : undefined,
53
+ extends: Array.isArray(layerExtends) ? [...layerExtends] : undefined,
54
+ path: layerPath,
55
+ };
56
+ }
57
+ export async function loadExternalTemplateLayerManifest(sourceRoot) {
58
+ const manifestPath = path.join(sourceRoot, TEMPLATE_LAYER_MANIFEST_FILENAME);
59
+ if (!fs.existsSync(manifestPath)) {
60
+ return null;
61
+ }
62
+ const raw = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
63
+ if (!isPlainObject(raw)) {
64
+ throw new Error(`${TEMPLATE_LAYER_MANIFEST_FILENAME} must export a JSON object.`);
65
+ }
66
+ if (raw.version !== TEMPLATE_LAYER_MANIFEST_VERSION) {
67
+ throw new Error(`${TEMPLATE_LAYER_MANIFEST_FILENAME} must declare "version": ${TEMPLATE_LAYER_MANIFEST_VERSION}.`);
68
+ }
69
+ if (!isPlainObject(raw.layers) || Object.keys(raw.layers).length === 0) {
70
+ throw new Error(`${TEMPLATE_LAYER_MANIFEST_FILENAME} must define a non-empty "layers" object.`);
71
+ }
72
+ return {
73
+ layers: Object.fromEntries(Object.entries(raw.layers).map(([layerId, value]) => [
74
+ layerId,
75
+ parseLayerDefinition(layerId, value),
76
+ ])),
77
+ version: TEMPLATE_LAYER_MANIFEST_VERSION,
78
+ };
79
+ }
80
+ function getDefaultExternalLayerId(manifest) {
81
+ const layerIds = Object.keys(manifest.layers);
82
+ if (layerIds.length === 1) {
83
+ return layerIds[0];
84
+ }
85
+ const referencedExternalLayerIds = new Set();
86
+ for (const definition of Object.values(manifest.layers)) {
87
+ for (const ancestorId of definition.extends ?? []) {
88
+ if (ancestorId in manifest.layers) {
89
+ referencedExternalLayerIds.add(ancestorId);
90
+ }
91
+ }
92
+ }
93
+ const publicLayerIds = layerIds.filter((layerId) => !referencedExternalLayerIds.has(layerId));
94
+ if (publicLayerIds.length === 1) {
95
+ return publicLayerIds[0];
96
+ }
97
+ throw new Error(`External layer package defines multiple selectable layers (${layerIds.join(", ")}). Pass an explicit externalLayerId until a final CLI selection UX exists.`);
98
+ }
99
+ export async function resolveExternalTemplateLayers({ externalLayerId, sourceRoot, }) {
100
+ const manifest = await loadExternalTemplateLayerManifest(sourceRoot);
101
+ if (!manifest) {
102
+ throw new Error(`No ${TEMPLATE_LAYER_MANIFEST_FILENAME} manifest found in ${sourceRoot}.`);
103
+ }
104
+ const manifestDocument = manifest;
105
+ const selectedLayerId = externalLayerId ?? getDefaultExternalLayerId(manifestDocument);
106
+ if (!(selectedLayerId in manifestDocument.layers)) {
107
+ throw new Error(`Unknown external layer "${selectedLayerId}". Expected one of: ${Object.keys(manifestDocument.layers).join(", ")}`);
108
+ }
109
+ const entries = [];
110
+ const emittedLayerIds = new Set();
111
+ const visitingLayerIds = new Set();
112
+ async function visitLayer(layerId) {
113
+ if (isBuiltInSharedTemplateLayerId(layerId)) {
114
+ if (!emittedLayerIds.has(layerId)) {
115
+ entries.push({
116
+ dir: getBuiltInSharedTemplateLayerDir(layerId),
117
+ id: layerId,
118
+ kind: "built-in",
119
+ });
120
+ emittedLayerIds.add(layerId);
121
+ }
122
+ return;
123
+ }
124
+ const definition = manifestDocument.layers[layerId];
125
+ if (!definition) {
126
+ throw new Error(`Layer "${layerId}" is not defined in ${TEMPLATE_LAYER_MANIFEST_FILENAME}.`);
127
+ }
128
+ if (visitingLayerIds.has(layerId)) {
129
+ throw new Error(`Detected a cycle while resolving external layer "${layerId}".`);
130
+ }
131
+ visitingLayerIds.add(layerId);
132
+ for (const ancestorId of definition.extends ?? []) {
133
+ await visitLayer(ancestorId);
134
+ }
135
+ visitingLayerIds.delete(layerId);
136
+ if (emittedLayerIds.has(layerId)) {
137
+ return;
138
+ }
139
+ const layerDir = resolveLayerPath(sourceRoot, definition.path);
140
+ const stats = await fsp.stat(layerDir).catch(() => null);
141
+ if (!stats || !stats.isDirectory()) {
142
+ throw new Error(`Layer "${layerId}" points to a missing directory: ${definition.path}`);
143
+ }
144
+ await assertNoSymlinks(layerDir);
145
+ entries.push({
146
+ dir: layerDir,
147
+ id: layerId,
148
+ kind: "external",
149
+ });
150
+ emittedLayerIds.add(layerId);
151
+ }
152
+ await visitLayer(selectedLayerId);
153
+ return {
154
+ entries,
155
+ selectedLayerId,
156
+ };
157
+ }
158
+ export async function assertExternalTemplateLayersDoNotWriteProtectedOutputs({ externalEntries, protectedOutputPaths, view, }) {
159
+ for (const entry of externalEntries) {
160
+ if (entry.kind !== "external") {
161
+ continue;
162
+ }
163
+ const renderedOutputPaths = await listInterpolatedDirectoryOutputs(entry.dir, view);
164
+ for (const relativePath of renderedOutputPaths) {
165
+ if (!protectedOutputPaths.has(relativePath)) {
166
+ continue;
167
+ }
168
+ throw new Error(`External layer "${entry.id}" writes protected output "${relativePath}".`);
169
+ }
170
+ }
171
+ }
@@ -26,4 +26,19 @@ export declare function renderMustacheTemplateString(template: string, view: Tem
26
26
  export declare function copyRawDirectory(sourceDir: string, targetDir: string, options?: CopyRawDirectoryOptions): Promise<void>;
27
27
  export declare function copyRenderedDirectory(sourceDir: string, targetDir: string, view: TemplateRenderView): Promise<void>;
28
28
  export declare function copyInterpolatedDirectory(sourceDir: string, targetDir: string, view: Record<string, string>): Promise<void>;
29
+ /**
30
+ * Lists the output file paths produced by an interpolated template directory
31
+ * without writing any files to disk.
32
+ *
33
+ * This walks the source directory, applies the same filename interpolation and
34
+ * `.mustache` stripping rules as `copyInterpolatedDirectory(...)`, and returns
35
+ * normalized output-relative paths under a virtual root.
36
+ *
37
+ * @param sourceDir - The template directory to traverse.
38
+ * @param view - The interpolation map used when resolving file and directory
39
+ * names.
40
+ * @returns A sorted array of normalized output paths relative to a virtual
41
+ * preview root.
42
+ */
43
+ export declare function listInterpolatedDirectoryOutputs(sourceDir: string, view: Record<string, string>): Promise<string[]>;
29
44
  export declare function pathExists(targetPath: string): boolean;
@@ -118,6 +118,42 @@ export async function copyInterpolatedDirectory(sourceDir, targetDir, view) {
118
118
  await fsp.writeFile(destinationPath, renderInterpolatedString(content, view), "utf8");
119
119
  }
120
120
  }
121
+ /**
122
+ * Lists the output file paths produced by an interpolated template directory
123
+ * without writing any files to disk.
124
+ *
125
+ * This walks the source directory, applies the same filename interpolation and
126
+ * `.mustache` stripping rules as `copyInterpolatedDirectory(...)`, and returns
127
+ * normalized output-relative paths under a virtual root.
128
+ *
129
+ * @param sourceDir - The template directory to traverse.
130
+ * @param view - The interpolation map used when resolving file and directory
131
+ * names.
132
+ * @returns A sorted array of normalized output paths relative to a virtual
133
+ * preview root.
134
+ */
135
+ export async function listInterpolatedDirectoryOutputs(sourceDir, view) {
136
+ const virtualRoot = path.resolve("/wp-typia-template-preview");
137
+ const outputs = [];
138
+ async function visit(currentSourceDir, currentTargetDir) {
139
+ const entries = await fsp.readdir(currentSourceDir, { withFileTypes: true });
140
+ for (const entry of entries) {
141
+ const sourcePath = path.join(currentSourceDir, entry.name);
142
+ const destinationNameTemplate = entry.name.endsWith(".mustache")
143
+ ? entry.name.slice(0, -".mustache".length)
144
+ : entry.name;
145
+ const destinationName = renderInterpolatedString(destinationNameTemplate, view);
146
+ const destinationPath = resolveRenderedPath(currentTargetDir, destinationName);
147
+ if (entry.isDirectory()) {
148
+ await visit(sourcePath, destinationPath);
149
+ continue;
150
+ }
151
+ outputs.push(path.relative(virtualRoot, destinationPath).replace(/\\/g, "/"));
152
+ }
153
+ }
154
+ await visit(sourceDir, virtualRoot);
155
+ return outputs.sort((left, right) => left.localeCompare(right));
156
+ }
121
157
  export function pathExists(targetPath) {
122
158
  return fs.existsSync(targetPath);
123
159
  }
@@ -52,6 +52,14 @@ interface NpmTemplateLocator {
52
52
  rawSpec: string;
53
53
  type: string;
54
54
  }
55
+ interface SeedSource {
56
+ assetsDir?: string;
57
+ blockDir: string;
58
+ cleanup?: () => Promise<void>;
59
+ rootDir: string;
60
+ selectedVariant?: string | null;
61
+ warnings?: string[];
62
+ }
55
63
  type RemoteTemplateLocator = {
56
64
  kind: "github";
57
65
  locator: GitHubTemplateLocator;
@@ -65,6 +73,15 @@ type RemoteTemplateLocator = {
65
73
  export declare function parseGitHubTemplateLocator(templateId: string): GitHubTemplateLocator | null;
66
74
  export declare function parseNpmTemplateLocator(templateId: string): NpmTemplateLocator | null;
67
75
  export declare function parseTemplateLocator(templateId: string): RemoteTemplateLocator;
76
+ /**
77
+ * Resolves a template locator into a local seed source directory.
78
+ *
79
+ * @param locator Remote template locator describing a local path, GitHub source, or npm package.
80
+ * @param cwd Current working directory used to resolve local template paths.
81
+ * @returns A local seed source containing the resolved root and block directory, plus optional cleanup.
82
+ * @throws When the locator is invalid, the source cannot be fetched, or filesystem validation fails.
83
+ */
84
+ export declare function resolveTemplateSeed(locator: RemoteTemplateLocator, cwd: string): Promise<SeedSource>;
68
85
  export declare function resolveTemplateSource(templateId: string, cwd: string, variables: {
69
86
  [key: string]: string;
70
87
  }, variant?: string): Promise<ResolvedTemplateSource>;
@@ -13,6 +13,7 @@ import { BUILTIN_TEMPLATE_IDS, OFFICIAL_WORKSPACE_TEMPLATE_PACKAGE, PROJECT_TOOL
13
13
  import { isPlainObject } from "./object-utils.js";
14
14
  import { getRemovedBuiltInTemplateMessage, isRemovedBuiltInTemplateId, } from "./template-defaults.js";
15
15
  import { getBuiltInTemplateLayerDirs, isOmittableBuiltInTemplateLayerDir, resolveBuiltInTemplateSource, } from "./template-builtins.js";
16
+ import { loadExternalTemplateLayerManifest } from "./template-layers.js";
16
17
  import { getPackageVersions } from "./package-versions.js";
17
18
  import { toSegmentPascalCase } from "./string-case.js";
18
19
  import { copyRawDirectory, copyRenderedDirectory } from "./template-render.js";
@@ -315,6 +316,9 @@ async function detectTemplateSourceFormat(sourceDir) {
315
316
  if (fs.existsSync(path.join(sourceDir, "package.json.mustache"))) {
316
317
  return "wp-typia";
317
318
  }
319
+ if (await loadExternalTemplateLayerManifest(sourceDir)) {
320
+ throw new Error(`Template source at ${sourceDir} is an external layer package. External layers currently compose only through built-in scaffolds via the runtime API, not as standalone template ids.`);
321
+ }
318
322
  if (getExternalTemplateEntry(sourceDir)) {
319
323
  return "create-block-external";
320
324
  }
@@ -728,7 +732,15 @@ async function resolveGitHubTemplateSource(locator) {
728
732
  throw error;
729
733
  }
730
734
  }
731
- async function resolveTemplateSeed(locator, cwd) {
735
+ /**
736
+ * Resolves a template locator into a local seed source directory.
737
+ *
738
+ * @param locator Remote template locator describing a local path, GitHub source, or npm package.
739
+ * @param cwd Current working directory used to resolve local template paths.
740
+ * @returns A local seed source containing the resolved root and block directory, plus optional cleanup.
741
+ * @throws When the locator is invalid, the source cannot be fetched, or filesystem validation fails.
742
+ */
743
+ export async function resolveTemplateSeed(locator, cwd) {
732
744
  if (locator.kind === "path") {
733
745
  const sourceDir = path.resolve(cwd, locator.templatePath);
734
746
  if (!fs.existsSync(sourceDir)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wp-typia/project-tools",
3
- "version": "0.16.7",
3
+ "version": "0.16.8",
4
4
  "description": "Project orchestration and programmatic tooling for wp-typia",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "type": "module",