diff --git a/packages/cli/src/lib/templates.ts b/packages/cli/src/lib/templates.ts index eb3a465..9c7ad5f 100644 --- a/packages/cli/src/lib/templates.ts +++ b/packages/cli/src/lib/templates.ts @@ -55,9 +55,52 @@ export function readTemplateSource(entry: TemplateEntry): string { * Substitute `{{TOKEN}}` placeholders with values from `context`. Unknown * tokens are left untouched (keeps the double braces visible so bad keys * are easy to spot in rendered output). + * + * Also processes conditional blocks: + * + * {{#if HAS_SITE}} + * ...lines rendered only when HAS_SITE is truthy... + * {{/if}} + * + * Markers can appear inside a host-language comment so the template stays + * parseable by syntax-aware tools (prettier on YAML, for example): + * + * # {{#if HAS_SITE}} + * ... + * # {{/if}} + * + * A token is "truthy" when its string value is non-empty and not `"false"`. + * Blocks may nest; they are expanded before token substitution so a block's + * body can itself reference tokens. */ export function renderTokens(source: string, context: Record): string { + return renderTokenSubstitutions(renderConditionalBlocks(source, context), context); +} + +function renderTokenSubstitutions(source: string, context: Record): string { return source.replace(/\{\{([A-Z_]+)\}\}/g, (match, token: string) => { return Object.prototype.hasOwnProperty.call(context, token) ? context[token]! : match; }); } + +function renderConditionalBlocks(source: string, context: Record): string { + // Match an entire line containing `{{#if TOKEN}}` (possibly wrapped in a + // host-language comment like `# {{#if TOKEN}}` or `// {{#if TOKEN}}`), + // up through the matching `{{/if}}` line. The whole marker line is + // consumed so commented-out markers don't leak into rendered output. + const pattern = + /^[ \t]*[^\S\n]*[^\n]*\{\{#if ([A-Z_]+)\}\}[^\n]*\n([\s\S]*?)^[ \t]*[^\n]*\{\{\/if\}\}[^\n]*\n?/gm; + let prev: string; + let out = source; + // Re-run to handle nested blocks. Simple fixed-point loop keeps the + // replacement rules uniform. + do { + prev = out; + out = out.replace(pattern, (_match, token: string, body: string) => { + const value = context[token]; + const truthy = value !== undefined && value !== '' && value !== 'false'; + return truthy ? body : ''; + }); + } while (out !== prev); + return out; +} diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index db1640a..8c6e7d9 100644 --- a/packages/cli/src/manifest.ts +++ b/packages/cli/src/manifest.ts @@ -36,8 +36,28 @@ export interface PrecisaManifest { /** Publishes any workspace package to npm? */ publishesToNpm: boolean; + /** + * Workspace package directories (relative to repo root) that the + * `publish` job in `ci.yml` should pass to `_publish.yml`. Only read + * when `publishesToNpm: true`. Example: `["packages/core", "packages/cli"]`. + */ + publishPackages?: string[]; + /** Schema version of this manifest file. Bump when the schema changes. */ schemaVersion: 1; + + /** + * pnpm filter selector for the site package, passed to the deploy + * workflow. Only read when `hasSite: true`. Example: `@my-repo/site`. + */ + siteFilter?: string; + + /** + * Cloudflare Pages project name for the deploy-site workflow. Only + * read when `hasSite: true`. + */ + siteProjectName?: string; + /** Public-OSS or private-internal. Controls which templates are rendered. */ visibility: 'oss' | 'private'; } @@ -104,10 +124,16 @@ export function tokenContext(manifest: PrecisaManifest): Record NODE_VERSION: manifest.nodeVersion ?? '22', OWNER_ORG: manifest.owner, PNPM_VERSION: manifest.pnpmVersion ?? '9.15.9', + // YAML block-scalar body for the `packages: |` input of `_publish.yml`. + // The template provides the first entry's indent; subsequent entries + // get 8 spaces explicitly to match. Empty when no packages declared. + PUBLISH_PACKAGES_YAML: (manifest.publishPackages ?? []).join('\n '), PUBLISHES_TO_NPM: String(manifest.publishesToNpm), REPO_NAME: manifest.name, REPO_SLUG: `${manifest.owner}/${manifest.name}`, SECURITY_EMAIL: manifest.contactEmails.security, + SITE_FILTER: manifest.siteFilter ?? '', + SITE_PROJECT_NAME: manifest.siteProjectName ?? '', VISIBILITY: manifest.visibility, }; } @@ -143,6 +169,34 @@ export function validateManifest(raw: unknown): ManifestValidationError[] { if (!Array.isArray(m.commitScopes)) { errors.push({ message: 'must be an array of strings', path: 'commitScopes' }); } + if (m.publishPackages !== undefined) { + if (!Array.isArray(m.publishPackages)) { + errors.push({ message: 'must be an array of strings', path: 'publishPackages' }); + } else if (m.publishPackages.some((p) => typeof p !== 'string' || !p)) { + errors.push({ message: 'entries must be non-empty strings', path: 'publishPackages' }); + } + } + if (m.publishesToNpm) { + if (!Array.isArray(m.publishPackages) || m.publishPackages.length === 0) { + errors.push({ + message: 'must have at least one entry when publishesToNpm is true', + path: 'publishPackages', + }); + } + } + if (m.hasSite === true) { + for (const key of ['siteProjectName', 'siteFilter'] as const) { + if (typeof m[key] !== 'string' || !m[key]) { + errors.push({ message: 'required and non-empty when hasSite is true', path: key }); + } + } + } + if (m.siteProjectName !== undefined && typeof m.siteProjectName !== 'string') { + errors.push({ message: 'must be a string', path: 'siteProjectName' }); + } + if (m.siteFilter !== undefined && typeof m.siteFilter !== 'string') { + errors.push({ message: 'must be a string', path: 'siteFilter' }); + } if (!m.contactEmails || typeof m.contactEmails !== 'object') { errors.push({ message: 'must be an object', path: 'contactEmails' }); } else { diff --git a/templates/github/workflows/ci.yml b/templates/github/workflows/ci.yml index 0bf95ff..7c15af1 100644 --- a/templates/github/workflows/ci.yml +++ b/templates/github/workflows/ci.yml @@ -10,11 +10,9 @@ name: CI/CD # review.yml — automated Anthropic → OpenAI code review # publish-tag.yml — manual recovery (workflow_dispatch) # -# This orchestrator wires the pieces together. Each repo adapts: -# - Which reusable workflows to call (comment out what doesn't apply) -# - The publish `packages` list -# - The site `project_name` and `site_filter` -# - Extra repo-specific workflows (e.g. fhir's publish-ig.yml) +# Per-repo customization flows through `.precisa.json`: +# publishesToNpm + publishPackages[] gate the `publish` job +# hasSite + siteProjectName + siteFilter gate the `deploy-site` job on: push: @@ -53,6 +51,7 @@ jobs: with: pr_number: ${{ github.event.pull_request.number }} secrets: inherit + # {{#if PUBLISHES_TO_NPM}} # ── Release (main only, guarded on package changes) ──────────── release: @@ -70,16 +69,18 @@ jobs: release_sha: ${{ needs.release.outputs.release_sha }} # Newline-separated list of package directories. One per line. packages: | - packages/core - packages/calculators + {{PUBLISH_PACKAGES_YAML}} secrets: inherit + # {{/if}} + # {{#if HAS_SITE}} - # ── Deploy site (repos with a site — remove if not applicable) ── + # ── Deploy site (Cloudflare Pages + Slack notify) ────────────── deploy-site: needs: checks if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: ./.github/workflows/_deploy-site.yml with: - project_name: '{{PROJECT_NAME}}' - site_filter: '@{{SITE_FILTER}}' + project_name: '{{SITE_PROJECT_NAME}}' + site_filter: '{{SITE_FILTER}}' secrets: inherit + # {{/if}}