@sitecoreai-labs/sitecoreai-cli 0.2.2 → 0.2.3

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.
@@ -31,4 +31,4 @@ export { listBrandKits, getBrandKit, type BrandKitSummary, type ListBrandKitsRes
31
31
  export { listBrandKitSections, listBrandKitFields, updateBrandKitField, type BrandKitSectionSummary, type BrandKitFieldSummary, type BrandKitFieldType, type BrandKitFieldValue, type BrandKitArrayEntry, type BrandKitRichArrayEntry, type ListBrandKitSectionsOptions, type ListBrandKitFieldsOptions, type UpdateBrandKitFieldOptions, } from "./kits/sections";
32
32
  export { createBrandKit, publishBrandKit, deleteBrandKit, type CreateBrandKitOptions, type PublishBrandKitOptions, type DeleteBrandKitOptions, } from "./kits/create";
33
33
  export { listDocuments, getDocument, deleteDocument, type DocumentSummary, type ListDocumentsResponse, type ListDocumentsOptions, type GetDocumentOptions, type DeleteDocumentOptions, } from "./documents/list";
34
- export { seedBrandKit, type SeedBrandKitOptions, type SeedBrandKitResult, type SeedProgressEvent, type SeedStage, } from "./seed";
34
+ export { seedBrandKit, enrichBrandKitWithDocuments, type SeedBrandKitOptions, type SeedBrandKitResult, type SeedProgressEvent, type SeedStage, type EnrichExistingKitOptions, type EnrichExistingKitResult, } from "./seed";
@@ -16,7 +16,7 @@
16
16
  * change in any release without a major bump or a changeset.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.seedBrandKit = exports.deleteDocument = exports.getDocument = exports.listDocuments = exports.deleteBrandKit = exports.publishBrandKit = exports.createBrandKit = exports.updateBrandKitField = exports.listBrandKitFields = exports.listBrandKitSections = exports.getBrandKit = exports.listBrandKits = exports.PIPELINE_BASE_PATH = exports.runEnrichSectionsPipeline = exports.runBrandIngestionPipeline = exports.LOCAL_UPLOAD_UNSUPPORTED_HINT = exports.LOCAL_UPLOAD_UNSUPPORTED_MESSAGE = exports.DOCUMENTS_BASE_PATH = exports.uploadDocument = exports.formatTextSummary = exports.formatTextRow = exports.buildTextReport = exports.buildSarifReport = exports.buildJsonReport = exports.summarizeOutcomes = exports.scoreToSarifLevel = exports.computeExitCode = exports.detectInputFormat = exports.resolveReviewInputs = exports.runBrandReview = exports.runBrandLogin = exports.generateBrandReview = exports.BRAND_REVIEW_BASE_PATH = exports.BRAND_MANAGEMENT_BASE_PATH = exports.BRAND_API_HOST = exports.requestBrandApi = exports.extractScopes = exports.hasBrandScopes = exports.acquireBrandToken = exports.BRAND_REQUIRED_SCOPES = void 0;
19
+ exports.enrichBrandKitWithDocuments = exports.seedBrandKit = exports.deleteDocument = exports.getDocument = exports.listDocuments = exports.deleteBrandKit = exports.publishBrandKit = exports.createBrandKit = exports.updateBrandKitField = exports.listBrandKitFields = exports.listBrandKitSections = exports.getBrandKit = exports.listBrandKits = exports.PIPELINE_BASE_PATH = exports.runEnrichSectionsPipeline = exports.runBrandIngestionPipeline = exports.LOCAL_UPLOAD_UNSUPPORTED_HINT = exports.LOCAL_UPLOAD_UNSUPPORTED_MESSAGE = exports.DOCUMENTS_BASE_PATH = exports.uploadDocument = exports.formatTextSummary = exports.formatTextRow = exports.buildTextReport = exports.buildSarifReport = exports.buildJsonReport = exports.summarizeOutcomes = exports.scoreToSarifLevel = exports.computeExitCode = exports.detectInputFormat = exports.resolveReviewInputs = exports.runBrandReview = exports.runBrandLogin = exports.generateBrandReview = exports.BRAND_REVIEW_BASE_PATH = exports.BRAND_MANAGEMENT_BASE_PATH = exports.BRAND_API_HOST = exports.requestBrandApi = exports.extractScopes = exports.hasBrandScopes = exports.acquireBrandToken = exports.BRAND_REQUIRED_SCOPES = void 0;
20
20
  var auth_1 = require("./api/auth");
21
21
  Object.defineProperty(exports, "BRAND_REQUIRED_SCOPES", { enumerable: true, get: function () { return auth_1.BRAND_REQUIRED_SCOPES; } });
22
22
  Object.defineProperty(exports, "acquireBrandToken", { enumerable: true, get: function () { return auth_1.acquireBrandToken; } });
@@ -75,3 +75,4 @@ Object.defineProperty(exports, "getDocument", { enumerable: true, get: function
75
75
  Object.defineProperty(exports, "deleteDocument", { enumerable: true, get: function () { return list_2.deleteDocument; } });
76
76
  var seed_1 = require("./seed");
77
77
  Object.defineProperty(exports, "seedBrandKit", { enumerable: true, get: function () { return seed_1.seedBrandKit; } });
78
+ Object.defineProperty(exports, "enrichBrandKitWithDocuments", { enumerable: true, get: function () { return seed_1.enrichBrandKitWithDocuments; } });
Binary file
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Synthesize a minimal stub PDF to drive the Sitecore EnrichSections
3
+ * pipeline when a brand-kit recipe declares section content but ships
4
+ * no source document.
5
+ *
6
+ * Why this exists: the Sitecore Brand Management API has no "create
7
+ * section" endpoint — sections only appear as a side effect of
8
+ * `EnrichSectionsPipeline` running over an uploaded document. A
9
+ * recipe that wants to push field values into the canonical 7
10
+ * sections (Global Goals, Brand Context, Tone of Voice, Do's and
11
+ * Don'ts, Visual Guidelines, Grammar Checklists, Glossary and
12
+ * Localization) must therefore feed the pipeline *something*. When
13
+ * the operator has no real brand-guidelines PDF to upload, we
14
+ * synthesize one: a single-page PDF naming each declared section so
15
+ * enrichment produces the matching section structure. The actual
16
+ * field values converge via `updateBrandKitField` PATCH calls
17
+ * immediately after — the stub's content is throwaway scaffolding.
18
+ *
19
+ * The PDF is hand-rolled (no `pdf-lib` dependency). The format we
20
+ * emit is a minimal, valid PDF 1.4 document with:
21
+ * - one Catalog → Pages → Page → Contents chain
22
+ * - one Helvetica font (built-in, no embedded data)
23
+ * - a single text stream with section names line-broken
24
+ *
25
+ * Sitecore's Documents API accepts `data:application/pdf;base64,…`
26
+ * URLs in the v2 form-urlencoded create-request flow (verified in
27
+ * [[project-scai-documents-api-v1-vs-v2]]), with a ~1MB limit from
28
+ * the form-urlencoded body cap. Our synthesized PDF is well under
29
+ * 1KB even for kits with the full canonical section list.
30
+ */
31
+ export interface SynthesizedStubDocument {
32
+ /** Data URL (`data:application/pdf;base64,…`) ready for `BrandDocument.url`. */
33
+ url: string;
34
+ /** Display title used when uploading. */
35
+ title: string;
36
+ /** Tags applied to the synthesized document. Distinguishes it from
37
+ * operator-supplied docs for downstream filtering. */
38
+ tags: string[];
39
+ /** Byte length of the underlying PDF — useful for tests / logging. */
40
+ byteLength: number;
41
+ }
42
+ export interface SynthesizeStubDocumentOptions {
43
+ /** Brand-kit display name; used in the doc title + PDF body. */
44
+ brandKitName: string;
45
+ /** Section names declared in the recipe — one PDF line per name. */
46
+ sectionNames: readonly string[];
47
+ }
48
+ /**
49
+ * Build a stub brand-document for a recipe that has section data but
50
+ * no source PDF. The synthesized doc has just enough structure for
51
+ * Sitecore's BrandIngestion + EnrichSections pipelines to produce
52
+ * the canonical section set; the field values declared in the recipe
53
+ * overwrite enrichment's output via `updateBrandKitField`.
54
+ *
55
+ * Throws when `sectionNames` is empty — a stub doc with no section
56
+ * names defeats the purpose, and callers should have gated on
57
+ * "recipe has sections" before reaching here.
58
+ */
59
+ export declare const synthesizeBrandStubDocument: (options: SynthesizeStubDocumentOptions) => SynthesizedStubDocument;
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ /**
3
+ * Synthesize a minimal stub PDF to drive the Sitecore EnrichSections
4
+ * pipeline when a brand-kit recipe declares section content but ships
5
+ * no source document.
6
+ *
7
+ * Why this exists: the Sitecore Brand Management API has no "create
8
+ * section" endpoint — sections only appear as a side effect of
9
+ * `EnrichSectionsPipeline` running over an uploaded document. A
10
+ * recipe that wants to push field values into the canonical 7
11
+ * sections (Global Goals, Brand Context, Tone of Voice, Do's and
12
+ * Don'ts, Visual Guidelines, Grammar Checklists, Glossary and
13
+ * Localization) must therefore feed the pipeline *something*. When
14
+ * the operator has no real brand-guidelines PDF to upload, we
15
+ * synthesize one: a single-page PDF naming each declared section so
16
+ * enrichment produces the matching section structure. The actual
17
+ * field values converge via `updateBrandKitField` PATCH calls
18
+ * immediately after — the stub's content is throwaway scaffolding.
19
+ *
20
+ * The PDF is hand-rolled (no `pdf-lib` dependency). The format we
21
+ * emit is a minimal, valid PDF 1.4 document with:
22
+ * - one Catalog → Pages → Page → Contents chain
23
+ * - one Helvetica font (built-in, no embedded data)
24
+ * - a single text stream with section names line-broken
25
+ *
26
+ * Sitecore's Documents API accepts `data:application/pdf;base64,…`
27
+ * URLs in the v2 form-urlencoded create-request flow (verified in
28
+ * [[project-scai-documents-api-v1-vs-v2]]), with a ~1MB limit from
29
+ * the form-urlencoded body cap. Our synthesized PDF is well under
30
+ * 1KB even for kits with the full canonical section list.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.synthesizeBrandStubDocument = void 0;
34
+ /**
35
+ * Reduce arbitrary input text to printable ASCII for safe embedding
36
+ * in a PDF content stream that uses Helvetica + WinAnsiEncoding.
37
+ *
38
+ * Non-ASCII bytes would either (a) get truncated by
39
+ * `Buffer.from(..., "binary")` to garbage codepoints, or (b) render
40
+ * as the wrong glyph through the font's encoding map. Since this PDF
41
+ * is throwaway scaffolding the Sitecore pipeline parses for section
42
+ * names — not something a human reads — a lossy ASCII coercion is
43
+ * the right tradeoff. Operators who care about display content
44
+ * should ship a real document instead.
45
+ */
46
+ const toPrintableAscii = (input) => input
47
+ .normalize("NFKD")
48
+ .replace(/[‐-―]/g, "-") // dashes
49
+ .replace(/[‘’‚‛]/g, "'") // single quotes
50
+ .replace(/[“”„‟]/g, '"') // double quotes
51
+ .replace(/…/g, "...") // ellipsis
52
+ .replace(/[^\x20-\x7E]/g, "?");
53
+ /**
54
+ * Escape a string for inclusion inside a PDF string literal — the
55
+ * `(...)` form. Per PDF 1.4 §3.2.3, the backslash escape applies to
56
+ * `(`, `)`, and `\`. Newlines are emitted as actual newlines in the
57
+ * stream via separate Td positioning, so they never appear inside
58
+ * a literal here. Caller is responsible for ASCII-sanitizing the
59
+ * input first (see `toPrintableAscii`).
60
+ */
61
+ const escapePdfLiteral = (input) => input.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
62
+ /**
63
+ * Build a single-page PDF whose content stream lists the given lines,
64
+ * one line per row in 12pt Helvetica. Returns the raw bytes.
65
+ *
66
+ * Byte-offset bookkeeping: PDF's `xref` table demands the exact byte
67
+ * offset of each object's first byte from the start of the file. We
68
+ * concatenate object bodies into a running buffer and record each
69
+ * object's position before appending it; the final `startxref` value
70
+ * is the position of the `xref` keyword itself.
71
+ */
72
+ const buildMinimalPdf = (lines) => {
73
+ // PDF 1.4 binary-tag comment — the four high-bit bytes mark the
74
+ // file as binary so naive ASCII-only tools don't truncate it.
75
+ const header = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n";
76
+ // Content stream — BT/ET wraps the text-rendering block; Td moves
77
+ // the text cursor. 72,720 is roughly 1in from top-left on letter.
78
+ const streamLines = ["BT", "/F1 12 Tf", "72 720 Td"];
79
+ for (let i = 0; i < lines.length; i += 1) {
80
+ if (i > 0)
81
+ streamLines.push("0 -16 Td");
82
+ streamLines.push(`(${escapePdfLiteral(lines[i] ?? "")}) Tj`);
83
+ }
84
+ streamLines.push("ET");
85
+ const stream = streamLines.join("\n");
86
+ // 5 indirect objects: Catalog, Pages, Page, Contents (text stream),
87
+ // Font. Order matters — refs (e.g. /Pages 2 0 R) point at the
88
+ // numbered indirect object below.
89
+ const objects = [
90
+ "<< /Type /Catalog /Pages 2 0 R >>",
91
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
92
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " +
93
+ "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
94
+ `<< /Length ${Buffer.byteLength(stream, "binary")} >>\nstream\n${stream}\nendstream`,
95
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
96
+ ];
97
+ let body = header;
98
+ const offsets = [];
99
+ for (let i = 0; i < objects.length; i += 1) {
100
+ offsets.push(Buffer.byteLength(body, "binary"));
101
+ body += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`;
102
+ }
103
+ const xrefStart = Buffer.byteLength(body, "binary");
104
+ let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
105
+ for (const off of offsets) {
106
+ xref += `${String(off).padStart(10, "0")} 00000 n \n`;
107
+ }
108
+ xref +=
109
+ `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` + `startxref\n${xrefStart}\n%%EOF\n`;
110
+ return Buffer.from(body + xref, "binary");
111
+ };
112
+ /**
113
+ * Build a stub brand-document for a recipe that has section data but
114
+ * no source PDF. The synthesized doc has just enough structure for
115
+ * Sitecore's BrandIngestion + EnrichSections pipelines to produce
116
+ * the canonical section set; the field values declared in the recipe
117
+ * overwrite enrichment's output via `updateBrandKitField`.
118
+ *
119
+ * Throws when `sectionNames` is empty — a stub doc with no section
120
+ * names defeats the purpose, and callers should have gated on
121
+ * "recipe has sections" before reaching here.
122
+ */
123
+ const synthesizeBrandStubDocument = (options) => {
124
+ if (options.sectionNames.length === 0) {
125
+ throw new Error("synthesizeBrandStubDocument requires at least one section name; " +
126
+ "callers should check `Object.keys(recipe.sections).length > 0` first.");
127
+ }
128
+ const headline = toPrintableAscii(`${options.brandKitName} - brand kit scaffold (synthesized by scai).`);
129
+ const sectionLines = options.sectionNames.map((name) => toPrintableAscii(`Section: ${name}`));
130
+ const lines = [headline, "", ...sectionLines];
131
+ const pdf = buildMinimalPdf(lines);
132
+ const base64 = pdf.toString("base64");
133
+ return {
134
+ url: `data:application/pdf;base64,${base64}`,
135
+ title: `${options.brandKitName} brand kit scaffold`,
136
+ tags: ["scai-synthesized", "stub"],
137
+ byteLength: pdf.byteLength,
138
+ };
139
+ };
140
+ exports.synthesizeBrandStubDocument = synthesizeBrandStubDocument;
@@ -61,6 +61,40 @@ export interface SeedBrandKitResult {
61
61
  /** Total wall-clock seconds spent. */
62
62
  elapsedSec: number;
63
63
  }
64
+ export interface EnrichExistingKitOptions {
65
+ client: BrandApiClientOptions;
66
+ /** Brand kit id (the API uuid, not the display name). */
67
+ brandKitId: string;
68
+ /** Display name — used only for default upload metadata and log lines. */
69
+ name: string;
70
+ /** One or more documents to upload before the ingest+enrich cycle. */
71
+ documents: BrandDocument[];
72
+ pollIntervalSec?: number;
73
+ timeoutSec?: number;
74
+ onProgress?: (event: SeedProgressEvent) => void;
75
+ signal?: AbortSignal;
76
+ }
77
+ export interface EnrichExistingKitResult {
78
+ document: UploadedDocument;
79
+ sections: BrandKitSectionSummary[];
80
+ elapsedSec: number;
81
+ }
82
+ /**
83
+ * Run the upload → publish → ingest → enrich → poll pipeline against
84
+ * an EXISTING brand kit. Mirrors steps 2-6 of `seedBrandKit` without
85
+ * creating a new kit — the self-heal path for bare kits that were
86
+ * previously created without documents (and therefore have zero
87
+ * sections).
88
+ *
89
+ * Why this exists: `scai brand sync push` would historically call
90
+ * `createBrandKit` (no documents) when the operator's recipe shipped
91
+ * no source PDF, leaving the kit with zero sections forever. The
92
+ * synthesize-stub-PDF feature fixed initial creation; this function
93
+ * unblocks the kits already stuck in that state — calling
94
+ * `apply()` against the existing kit now produces a stub doc and
95
+ * runs enrichment, so field writes have somewhere to land.
96
+ */
97
+ export declare const enrichBrandKitWithDocuments: (options: EnrichExistingKitOptions) => Promise<EnrichExistingKitResult>;
64
98
  /**
65
99
  * The headline composite — drive a brand kit from "doesn't exist" to
66
100
  * "has populated sections and is ready for Brand Review" in one call.
@@ -1,12 +1,126 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.seedBrandKit = void 0;
3
+ exports.seedBrandKit = exports.enrichBrandKitWithDocuments = void 0;
4
4
  const create_1 = require("./kits/create");
5
5
  const sections_1 = require("./kits/sections");
6
6
  const upload_1 = require("./documents/upload");
7
7
  const run_1 = require("./pipeline/run");
8
8
  const errors_1 = require("../shared/errors");
9
9
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+ /**
11
+ * Run the upload → publish → ingest → enrich → poll pipeline against
12
+ * an EXISTING brand kit. Mirrors steps 2-6 of `seedBrandKit` without
13
+ * creating a new kit — the self-heal path for bare kits that were
14
+ * previously created without documents (and therefore have zero
15
+ * sections).
16
+ *
17
+ * Why this exists: `scai brand sync push` would historically call
18
+ * `createBrandKit` (no documents) when the operator's recipe shipped
19
+ * no source PDF, leaving the kit with zero sections forever. The
20
+ * synthesize-stub-PDF feature fixed initial creation; this function
21
+ * unblocks the kits already stuck in that state — calling
22
+ * `apply()` against the existing kit now produces a stub doc and
23
+ * runs enrichment, so field writes have somewhere to land.
24
+ */
25
+ const enrichBrandKitWithDocuments = async (options) => {
26
+ const start = Date.now();
27
+ const pollIntervalSec = options.pollIntervalSec ?? 15;
28
+ const timeoutSec = options.timeoutSec ?? 900;
29
+ const emit = (event) => {
30
+ options.onProgress?.({
31
+ ...event,
32
+ elapsedSec: Math.round((Date.now() - start) / 1000),
33
+ });
34
+ };
35
+ // Validate the document list — same registry-file guard seedBrandKit
36
+ // applies; keeps the rejection contract consistent across the two
37
+ // entry points.
38
+ for (const doc of options.documents) {
39
+ if (doc.kind === "registry-file") {
40
+ throw (0, errors_1.createScaiError)(`Brand document "${doc.path}" is a registry-file path; scai cannot upload local bytes.`, "INPUT_INVALID", {
41
+ hint: 'Host the file at an HTTPS URL Sitecore\'s edge can reach (S3, GitHub raw, a CDN) and pass it as a `{ kind: "url", url }` document.',
42
+ });
43
+ }
44
+ }
45
+ if (options.documents.length === 0) {
46
+ throw (0, errors_1.createScaiError)("enrichBrandKitWithDocuments requires at least one document.", "INPUT_INVALID");
47
+ }
48
+ // Upload docs
49
+ const documents = [];
50
+ for (const [index, doc] of options.documents.entries()) {
51
+ if (doc.kind !== "url") {
52
+ throw (0, errors_1.createScaiError)("Unreachable: non-URL document survived the registry-file guard.", "INPUT_INVALID");
53
+ }
54
+ emit({
55
+ stage: "uploadDocument",
56
+ message: `Uploading source document ${index + 1}/${options.documents.length}…`,
57
+ });
58
+ const uploaded = await (0, upload_1.uploadDocument)({
59
+ client: options.client,
60
+ brandKitId: options.brandKitId,
61
+ source: { url: doc.url },
62
+ title: doc.title ?? `${options.name} Brand Guidelines`,
63
+ summary: doc.summary ?? `Source doc for ${options.name} brand kit (seeded via scai)`,
64
+ type: "brand guidelines",
65
+ fileType: "application/pdf",
66
+ });
67
+ documents.push(uploaded);
68
+ emit({ stage: "uploadDocument", message: `Uploaded document ${uploaded.id}` });
69
+ }
70
+ // Publish kit (idempotent — already-published kits accept the PATCH).
71
+ emit({ stage: "publishKit", message: "Publishing kit…" });
72
+ await (0, create_1.publishBrandKit)({ client: options.client, brandKitId: options.brandKitId });
73
+ emit({ stage: "publishKit", message: "Kit published" });
74
+ // Ingest + enrich
75
+ emit({ stage: "runIngestion", message: "Triggering BrandIngestionPipeline…" });
76
+ const ingestionRun = await (0, run_1.runBrandIngestionPipeline)({
77
+ client: options.client,
78
+ brandKitId: options.brandKitId,
79
+ populateSections: true,
80
+ documentIds: documents.map((doc) => doc.id),
81
+ });
82
+ emit({ stage: "runIngestion", message: `Ingestion run started: ${ingestionRun.id}` });
83
+ emit({ stage: "runEnrichment", message: "Triggering EnrichSectionsPipeline…" });
84
+ const enrichmentRun = await (0, run_1.runEnrichSectionsPipeline)({
85
+ client: options.client,
86
+ brandKitId: options.brandKitId,
87
+ });
88
+ emit({ stage: "runEnrichment", message: `Enrichment run started: ${enrichmentRun.id}` });
89
+ // Poll until sections appear
90
+ const deadline = Date.now() + timeoutSec * 1000;
91
+ let sections = [];
92
+ while (Date.now() < deadline) {
93
+ if (options.signal?.aborted) {
94
+ throw new Error("enrichBrandKitWithDocuments aborted by signal");
95
+ }
96
+ sections = await (0, sections_1.listBrandKitSections)({
97
+ client: options.client,
98
+ brandKitId: options.brandKitId,
99
+ });
100
+ emit({
101
+ stage: "pollSections",
102
+ message: `Sections: ${sections.length}`,
103
+ sectionCount: sections.length,
104
+ });
105
+ if (sections.length > 0)
106
+ break;
107
+ await sleep(pollIntervalSec * 1000);
108
+ }
109
+ if (sections.length === 0) {
110
+ throw new Error(`Timed out after ${timeoutSec}s waiting for sections to populate on kit ${options.brandKitId}.`);
111
+ }
112
+ emit({
113
+ stage: "done",
114
+ message: `Enriched kit ${options.brandKitId} with ${sections.length} sections`,
115
+ sectionCount: sections.length,
116
+ });
117
+ return {
118
+ document: documents[0],
119
+ sections,
120
+ elapsedSec: Math.round((Date.now() - start) / 1000),
121
+ };
122
+ };
123
+ exports.enrichBrandKitWithDocuments = enrichBrandKitWithDocuments;
10
124
  /**
11
125
  * The headline composite — drive a brand kit from "doesn't exist" to
12
126
  * "has populated sections and is ready for Brand Review" in one call.
@@ -448,19 +448,33 @@ function emitRendering(operations, recipe, context, icon, hasParams, policy, emi
448
448
  const sectionName = resolveSectionName(recipe, context);
449
449
  const renderingParentPath = (0, shared_1.resolveRenderingParent)(context, sectionName);
450
450
  const renderingPath = (0, shared_1.joinPath)(renderingParentPath, recipe.name);
451
- // Datasource template ref: prefer the explicit `datasource.template`
452
- // ref when present (separate ContentTemplateRecipe under Content
453
- // Models/); otherwise the component template itself is the datasource
454
- // template (inline `fields:` pattern).
451
+ // Datasource template ref. Three cases:
452
+ // 1. Explicit `datasource.template` handle reference the separate
453
+ // ContentTemplateRecipe (compatible-data-source pattern).
454
+ // 2. Inline `fields:` (recipe has ≥ 1 field) → the component template
455
+ // IS the datasource template (legacy inline-fields pattern).
456
+ // 3. Neither — a pure-layout rendering (Container, ColumnSplitter,
457
+ // RowSplitter, …). Emit no Datasource Template field at all so
458
+ // the rendering item's shared field stays empty. Sitecore Pages
459
+ // gates its "create or pick a datasource" prompt on this field
460
+ // being non-empty, so an empty value is what makes a layout-only
461
+ // rendering droppable without an authoring prompt.
462
+ const hasInlineFields = (recipe.fields?.length ?? 0) > 0;
455
463
  const datasourceRefKey = recipe.datasource?.template
456
464
  ? (0, guids_1.templateId)(site, recipe.datasource.template.handle)
457
- : (0, guids_1.templateId)(site, recipe.handle);
465
+ : hasInlineFields
466
+ ? (0, guids_1.templateId)(site, recipe.handle)
467
+ : undefined;
458
468
  const fields = [
459
469
  (0, shared_1.sharedField)(sitecore_templates_1.RENDERING_FIELDS.COMPONENT_NAME, { kind: "string", value: recipe.name }),
460
- (0, shared_1.sharedField)(sitecore_templates_1.RENDERING_FIELDS.DATASOURCE_TEMPLATE, {
461
- kind: "ref-recipe",
462
- refKey: datasourceRefKey,
463
- }),
470
+ ...(datasourceRefKey
471
+ ? [
472
+ (0, shared_1.sharedField)(sitecore_templates_1.RENDERING_FIELDS.DATASOURCE_TEMPLATE, {
473
+ kind: "ref-recipe",
474
+ refKey: datasourceRefKey,
475
+ }),
476
+ ]
477
+ : []),
464
478
  (0, shared_1.sharedField)(sitecore_templates_1.SYSTEM_FIELDS.ICON, { kind: "string", value: icon }),
465
479
  (0, shared_1.versionedField)(sitecore_templates_1.SYSTEM_FIELDS.DISPLAY_NAME, { kind: "string", value: recipe.displayName }),
466
480
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sitecoreai-labs/sitecoreai-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "SitecoreAI developer toolkit — a native TypeScript CLI, SDK, and MCP server for deploy, serialization, recipes, publishing, and content operations.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",