diff --git a/.changeset/template-dialect-placeholder-contract.md b/.changeset/template-dialect-placeholder-contract.md new file mode 100644 index 0000000000..2324c28b94 --- /dev/null +++ b/.changeset/template-dialect-placeholder-contract.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": patch +--- + +`TemplateExpressionInputSchema` documents what the `template` dialect actually accepts, instead of illustrating it with a grammar it does not judge. + +The docblock introduced the dialect as "anything with `{{var}}` interpolation". Nothing in the schema enforces that: `TemplateExpressionInputSchema` judges the dialect tag and non-emptiness and nothing else, and the same docblock already said so one clause later. Read as a declaration, it made every single-brace `titleFormat` value in the wild look like it was crossing a gate. There is no gate. + +The corrected prose states where the placeholder grammar really lives — with the renderer that consumes the slot — and that the two spellings in circulation are not interchangeable everywhere: + +- `{{var}}` is the canonical form and the only one the registered `template` engine reads (`@objectstack/formula`'s `templateEngine`); the messaging renderer, the email plugin and the i18n adapters match it alone and leave a `{var}` in their output verbatim. +- `titleFormat` is the exception: its renderers accept `{{var}}` and `{var}` as equivalent, normalizing the double form down to the single one before substituting. Single-brace `titleFormat` values are legal by construction, not a grammar the schema failed to enforce. + +`titleFormat`'s `.describe()` says the same thing at the slot. Documentation only — the schema's judging logic, its accept-set and its exports are untouched, so nothing an author can write changes meaning. Both strings publish (`dist/*.d.ts` and the bundled `.describe()`), which is why this is a `patch` rather than a `skip-changeset`. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 7fd0b71cd0..ccc34fe4db 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -787,7 +787,7 @@ Metadata query with filtering, sorting, and pagination | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d6bdc51298..c8e7652aa9 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -162,7 +162,7 @@ const result = ApiMethod.parse(data); | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 9a13e648af..cad13b7554 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -22,7 +22,7 @@ envelope. |:---|:---|:---| | `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | | `cron` | none at parse time — `croner` fires it at schedule time, on the one wired slot | job schedules | -| `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates | +| `template` | placeholder interpolation at evaluate time (same variable scope as CEL); canonically `{{var}}`, but the accepted spelling belongs to the renderer, not to this schema — see `TemplateExpressionInputSchema` | notification subjects/bodies, `titleFormat`, prompt templates | No cron syntax is judged at parse time: `croner` evaluates a cron slot only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 59e1be5802..16c6653d45 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -338,7 +338,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | @@ -622,7 +622,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d55e218b4f..c3acdefb4b 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2119,7 +2119,7 @@ const ObjectSchemaBase = strictObject( * cross-repo back-compat). New metadata should set `nameField`. */ displayNameField: z.string().optional().describe('[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.'), - titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField.'), + titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.'), /** * [ADR-0085] Semantic role: the object's most important fields, in priority * order (the first entry wins wherever only one field fits, e.g. child-record diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index bbccf2c39b..78a47d454c 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -20,7 +20,7 @@ import { z } from 'zod'; * |:---|:---|:---| * | `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | * | `cron` | none at parse time — `croner` fires it at schedule time, on the one wired slot | job schedules | - * | `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates | + * | `template` | placeholder interpolation at evaluate time (same variable scope as CEL); canonically `{{var}}`, but the accepted spelling belongs to the renderer, not to this schema — see `TemplateExpressionInputSchema` | notification subjects/bodies, `titleFormat`, prompt templates | * * No cron syntax is judged at parse time: `croner` evaluates a cron slot only * when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → @@ -283,7 +283,27 @@ export type CronExpressionInput = z.input; * naming the fix (`TYPED_EXPRESSION_DIALECT_ONLY.template`), as is a blank * string (`TYPED_EXPRESSION_SOURCE_REQUIRED.template`). Use this for * notification subjects/bodies, titleFormat, prompt templates — anything with - * `{{var}}` interpolation. No template syntax is judged at parse time. + * placeholder interpolation. + * + * No template syntax is judged at parse time: this schema judges the dialect + * tag and non-emptiness and nothing else, so it declares no placeholder + * grammar and refuses no spelling. Which spelling actually interpolates + * belongs to the CONSUMING RENDERER, and the two spellings in use are not + * interchangeable everywhere: + * + * - `{{var}}` is the canonical form and the only one the registered `template` + * engine reads (`@objectstack/formula`'s `templateEngine`); the messaging, + * email and i18n renderers match it alone and leave a `{var}` in their + * output verbatim. + * - `titleFormat` is the exception: its renderers take `{{var}}` and `{var}` + * as equivalent, normalizing the double form down to the single one before + * substituting (objectui's `formatTitleTemplate`, and the title renderer in + * `@objectstack/metadata-protocol`). Both spellings resolve identically + * there — single-brace `titleFormat` values are legal by construction, not + * a grammar this schema failed to enforce. + * + * So write `{{var}}` unless the slot's renderer is known to normalize, and do + * not read either spelling as declared, preferred or rejected here. */ export const TemplateExpressionInputSchema = z.union([ typedExpressionStringArm('template'),