From 4a63f9d21fb695d9ced0ccc00a7cd67fc04e45c8 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 15 Apr 2026 12:05:18 -0400 Subject: [PATCH 01/33] Register `markdown` in the Format type and formats array Add `markdown` to the `Format` type union and the `formats` array so downstream format resolution (via `cardOrField[effectiveFormat]` in `getBoxComponent()`), the format chooser UI, and prerender routes can pick it up automatically. Foundational unblocker for the Markdown Format project (CS-10780). Co-Authored-By: Claude Opus 4.6 --- packages/runtime-common/formats.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime-common/formats.ts b/packages/runtime-common/formats.ts index e4edce57a37..fc5790ef91b 100644 --- a/packages/runtime-common/formats.ts +++ b/packages/runtime-common/formats.ts @@ -5,7 +5,8 @@ export type Format = | 'edit' | 'atom' | 'head' - | 'metadata'; + | 'metadata' + | 'markdown'; export function isValidFormat( format: string, @@ -21,6 +22,7 @@ export const formats: Format[] = [ 'atom', 'edit', 'head', + 'markdown', ]; export const FITTED_FORMATS = [ From ead3b01bdd10eb9f7e677389ec03459276a8681f Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 15 Apr 2026 12:39:53 -0400 Subject: [PATCH 02/33] Add whitespace-preserving render container for markdown format (CS-10781) Wrap markdown-format renders in a `
` with `white-space: pre` in the prerender render route template. The dedicated data attribute gives downstream extraction a tight target so surrounding route-template whitespace does not leak into the captured markdown string, and `white-space: pre` keeps authored newlines and indentation intact. Also teach `defaultFieldFormats` to recurse in `markdown` when the containing format is `markdown` so `<@fields.x />` delegation inside a markdown template composes markdown output from its children, not embedded/fitted HTML. Other formats are unaffected: non-markdown renders still use the existing `<@model.Component>` invocation with no wrapper. Co-Authored-By: Claude Opus 4.6 --- packages/base/field-component.gts | 5 +++++ packages/host/app/templates/render/html.gts | 23 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/base/field-component.gts b/packages/base/field-component.gts index 019d6457b9c..de12fadb1f4 100644 --- a/packages/base/field-component.gts +++ b/packages/base/field-component.gts @@ -541,6 +541,11 @@ function defaultFieldFormats(containingFormat: Format): FieldFormats { return { fieldDef: 'atom', cardDef: 'atom' }; case 'head': return { fieldDef: 'head', cardDef: 'head' }; + case 'markdown': + // Recurse in the same format: `<@fields.x />` inside a markdown template + // should delegate to the child's markdown template, not embedded/fitted + // HTML, so the composed output is uniformly markdown text. + return { fieldDef: 'markdown', cardDef: 'markdown' }; default: return { fieldDef: 'embedded', cardDef: 'fitted' }; } diff --git a/packages/host/app/templates/render/html.gts b/packages/host/app/templates/render/html.gts index d5dd741c36a..619db45ab96 100644 --- a/packages/host/app/templates/render/html.gts +++ b/packages/host/app/templates/render/html.gts @@ -4,6 +4,8 @@ import Component from '@glimmer/component'; import { provide } from 'ember-provide-consume-context'; import RouteTemplate from 'ember-route-template'; +import { eq } from '@cardstack/boxel-ui/helpers'; + import { type getCard as GetCardType, GetCardContextName, @@ -59,7 +61,26 @@ class RenderHtmlTemplate extends Component { }; } - + }; static atom = MaybeBase64Field.embedded; + // CS-10785: suppress embedded base64 payloads from the markdown emission — + // they're never useful to downstream markdown consumers and would blow up + // the output size. Non-base64 strings are escaped like a StringField. + static markdown = class Markdown extends Component { + get isBase64() { + return this.args.model?.startsWith('data:'); + } + get escaped() { + return markdownEscape(this.args.model); + } + + }; } export class TextAreaField extends StringField { @@ -2437,6 +2474,22 @@ export class TextAreaField extends StringField { /> }; + // CS-10785: escape the content and convert single `\n` to a CommonMark + // hard-break (` \n`) so a multi-line text area renders as stacked lines + // rather than collapsing into one paragraph. Empty-line paragraph breaks + // (`\n\n`) are preserved — the regex touches every newline, producing + // ` \n \n`, which is still a valid paragraph separator. + // Explicit `BaseDefComponent` annotation so subclass overrides (e.g. + // CSSField) aren't forced to structurally match this inline class shape. + static markdown: BaseDefComponent = class Markdown extends Component< + typeof this + > { + get escapedWithBreaks() { + let escaped = markdownEscape(this.args.model); + return escaped.replace(/\n/g, ' \n'); + } + + }; } // enumField has moved to packages/base/enum.gts @@ -2490,6 +2543,26 @@ export class CSSField extends TextAreaField { }; + // CS-10785: emit the CSS in a fenced code block with a `css` info string. + // The fence is computed as the longest run of backticks in the content + // plus one (minimum 3), so embedded triple-backtick sequences in CSS + // content can't prematurely close the block. Content itself is not + // escaped — inside a fenced block, CommonMark treats it as literal. + static markdown = class Markdown extends Component { + get fenced() { + let value = this.args.model ?? ''; + let longestRun = 0; + let match = value.match(/`+/g); + if (match) { + for (let run of match) { + if (run.length > longestRun) longestRun = run.length; + } + } + let fence = '`'.repeat(Math.max(3, longestRun + 1)); + return `${fence}css\n${value}\n${fence}`; + } + + }; } export class MarkdownField extends StringField { @@ -2519,6 +2592,13 @@ export class MarkdownField extends StringField { /> }; + // CS-10785: raw markdown passthrough. Content is already authored as + // markdown, so interpolating a value with `#`, `*`, etc. must NOT + // double-escape. This overrides the StringField inherited `static + // markdown` to suppress escaping. + static markdown = class Markdown extends Component { + + }; } export function deserializeForUI(value: string | number | null): number | null { @@ -2568,6 +2648,13 @@ export class NumberField extends FieldDef { NumberSerializer.validate, ); }; + // CS-10785: render the number as text. `markdownEscape` handles the null/ + // undefined case (empty string) and also protects against line-start + // `1.`/`2.` etc. being interpreted as ordered list markers when this + // value gets interpolated into a larger markdown document. + static markdown = class Markdown extends Component { + + }; } export interface SerializedFileDef { diff --git a/packages/host/tests/helpers/base-realm.ts b/packages/host/tests/helpers/base-realm.ts index 338c612fdb2..4383f9c9b31 100644 --- a/packages/host/tests/helpers/base-realm.ts +++ b/packages/host/tests/helpers/base-realm.ts @@ -98,6 +98,7 @@ let isCard: (typeof CardAPIModule)['isCard']; let linksTo: (typeof CardAPIModule)['linksTo']; let linksToMany: (typeof CardAPIModule)['linksToMany']; let MaybeBase64Field: (typeof CardAPIModule)['MaybeBase64Field']; +let CSSField: (typeof CardAPIModule)['CSSField']; let createFromSerialized: (typeof CardAPIModule)['createFromSerialized']; let updateFromSerialized: (typeof CardAPIModule)['updateFromSerialized']; let serializeCard: (typeof CardAPIModule)['serializeCard']; @@ -240,6 +241,7 @@ async function initialize() { flushLogs, queryableValue, MaybeBase64Field, + CSSField, getFieldDescription, ReadOnlyField, instanceOf, @@ -292,6 +294,7 @@ export { linksTo, linksToMany, MaybeBase64Field, + CSSField, createFromSerialized, updateFromSerialized, serializeCard, diff --git a/packages/host/tests/integration/components/field-markdown-primitives-test.gts b/packages/host/tests/integration/components/field-markdown-primitives-test.gts new file mode 100644 index 00000000000..24c1cdb6c33 --- /dev/null +++ b/packages/host/tests/integration/components/field-markdown-primitives-test.gts @@ -0,0 +1,316 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import type { Loader } from '@cardstack/runtime-common'; + +import { + CardDef, + Component, + CSSField, + contains, + field, + MarkdownField, + MaybeBase64Field, + NumberField, + ReadOnlyField, + setupBaseRealm, + StringField, + TextAreaField, +} from '../../helpers/base-realm'; +import { renderCard } from '../../helpers/render-component'; +import { setupRenderingTest } from '../../helpers/setup'; + +// Verifies the default `static markdown` templates for primitive fields +// (CS-10785). Each primitive renders through a CardDef wrapper whose +// `isolated` template invokes `<@fields.foo @format='markdown' />`, placing +// the markdown output inside a `[data-test-md]` container we query for the +// text. + +function readMarkdown(root: Element | Document): string { + let el = root.querySelector('[data-test-md]'); + return (el?.textContent ?? '').trim(); +} + +module('Integration | field markdown primitives', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + + let loader: Loader; + + hooks.beforeEach(function (this: RenderingTestContext) { + loader = getService('loader-service').loader; + }); + + test('StringField markdown escapes metacharacters', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(StringField); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ value: 'Hello *world* [link]' }); + await renderCard(loader, card, 'isolated'); + + // markdownEscape backslash-escapes `*`, `[`, and `]`. + assert.strictEqual( + readMarkdown(this.element), + 'Hello \\*world\\* \\[link\\]', + ); + }); + + test('StringField markdown handles null/undefined gracefully', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(StringField); + static isolated = class extends Component { + + }; + } + + // No value set — field resolves to undefined/null; markdownEscape emits ''. + let card = new Sample(); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), ''); + }); + + test('ReadOnlyField markdown escapes metacharacters', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(ReadOnlyField); + static isolated = class extends Component { + + }; + } + + let card = new Sample(); + // ReadOnlyField is computed/assigned by the serializer normally; set via + // @field only to drive the template. `1.` at line-start would normally + // read as an ordered list marker — the escape prevents that. + (card as any).value = '1. Item'; + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '1\\. Item'); + }); + + test('NumberField markdown emits the number as text', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(NumberField); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ value: 42 }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '42'); + }); + + test('NumberField markdown handles negative and decimal values', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(NumberField); + static isolated = class extends Component { + + }; + } + + // `-3.14` → leading `-` would look like a bullet marker at line start; + // `3.` would look like an ordered list prefix. markdownEscape handles + // both: `-` becomes `\-`, and `3.` at line start becomes `3\.`. + let card = new Sample({ value: -3.14 }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '\\-3\\.14'); + }); + + test('TextAreaField markdown preserves line breaks as CommonMark hard breaks', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(TextAreaField); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ value: 'Line 1\nLine 2\nLine 3' }); + await renderCard(loader, card, 'isolated'); + + let el = this.element.querySelector('[data-test-md]'); + // Use raw textContent (not trim/collapse) so we can inspect whitespace. + let raw = el?.textContent ?? ''; + assert.strictEqual(raw, 'Line 1 \nLine 2 \nLine 3'); + }); + + test('TextAreaField markdown escapes metacharacters per line', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(TextAreaField); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ value: '# not a heading\n* not a bullet' }); + await renderCard(loader, card, 'isolated'); + let el = this.element.querySelector('[data-test-md]'); + let raw = el?.textContent ?? ''; + assert.strictEqual(raw, '\\# not a heading \n\\* not a bullet'); + }); + + test('MarkdownField passes through content unescaped', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(MarkdownField); + static isolated = class extends Component { + + }; + } + + // The author's markdown must survive interpolation unchanged — no double + // escaping. Downstream consumers render it as markdown. + let card = new Sample({ value: '# Heading\n\n- item 1\n- item 2' }); + await renderCard(loader, card, 'isolated'); + let el = this.element.querySelector('[data-test-md]'); + let raw = el?.textContent ?? ''; + assert.strictEqual(raw, '# Heading\n\n- item 1\n- item 2'); + }); + + test('CSSField markdown emits a fenced code block with css info-string', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field styles = contains(CSSField); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ styles: '.foo { color: red; }' }); + await renderCard(loader, card, 'isolated'); + let el = this.element.querySelector('[data-test-md]'); + let raw = el?.textContent ?? ''; + assert.strictEqual(raw, '```css\n.foo { color: red; }\n```'); + }); + + test('CSSField markdown extends the fence when content contains triple backticks', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field styles = contains(CSSField); + static isolated = class extends Component { + + }; + } + + // Pathological input: a comment with the literal fence sequence. The + // emitter bumps the fence width so the block is still syntactically + // well-formed. + let weird = '/* ' + '`'.repeat(3) + ' */'; + let card = new Sample({ styles: weird }); + await renderCard(loader, card, 'isolated'); + let el = this.element.querySelector('[data-test-md]'); + let raw = el?.textContent ?? ''; + let fence = '`'.repeat(4); + assert.strictEqual(raw, `${fence}css\n${weird}\n${fence}`); + }); + + test('MaybeBase64Field markdown emits placeholder for data: URIs', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field thumbnail = contains(MaybeBase64Field); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ + thumbnail: 'data:image/png;base64,iVBORw0KGgoAAAANSU', + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '[binary content]'); + }); + + test('MaybeBase64Field markdown falls back to escaped text for non-base64 values', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field thumbnail = contains(MaybeBase64Field); + static isolated = class extends Component { + + }; + } + + let card = new Sample({ thumbnail: 'https://example.com/a*b.png' }); + await renderCard(loader, card, 'isolated'); + // `*` is escaped; `https://...` is left as-is (`:` and `/` are not + // markdown metacharacters). + assert.strictEqual( + readMarkdown(this.element), + 'https://example.com/a\\*b.png', + ); + }); + + test('all primitives compose into a single markdown document', async function (this: RenderingTestContext, assert) { + class Everything extends CardDef { + @field name = contains(StringField); + @field count = contains(NumberField); + @field notes = contains(TextAreaField); + @field body = contains(MarkdownField); + @field styles = contains(CSSField); + static isolated = class extends Component { + + }; + } + + let card = new Everything({ + name: 'Widget *v1*', + count: 7, + notes: 'line 1\nline 2', + body: '**bold** value', + styles: 'a { color: red }', + }); + await renderCard(loader, card, 'isolated'); + + let el = this.element.querySelector('[data-test-md]'); + let raw = el?.textContent ?? ''; + // Ensure each field's characteristic output appears. Whitespace between + // labels is template-driven and not meaningful — the key thing is that + // each primitive rendered its markdown rather than its HTML template. + assert.ok(raw.includes('Widget \\*v1\\*'), `StringField escape: ${raw}`); + assert.ok(raw.includes('7'), `NumberField value: ${raw}`); + assert.ok(raw.includes('line 1 \nline 2'), `TextArea hard-break: ${raw}`); + assert.ok( + raw.includes('**bold** value'), + `MarkdownField passthrough: ${raw}`, + ); + assert.ok( + raw.includes('```css\na { color: red }\n```'), + `CSSField fenced block: ${raw}`, + ); + }); +}); diff --git a/packages/host/tests/integration/components/markdown-fallback-test.gts b/packages/host/tests/integration/components/markdown-fallback-test.gts index c50cfccc027..8fb9a5cf806 100644 --- a/packages/host/tests/integration/components/markdown-fallback-test.gts +++ b/packages/host/tests/integration/components/markdown-fallback-test.gts @@ -147,7 +147,7 @@ module('Integration | markdown-fallback', function (hooks) { assert.true(md.includes('let x = 1;'), `expected code body in: ${md}`); }); - test('CardDef subclass override of `static markdown` wins over the fallback', async function (assert) { + test('CardDef subclass override of `static markdown` wins over the fallback', async function (this: RenderingTestContext, assert) { class Custom extends CardDef { @field title = contains(StringField); static isolated = class extends Component { @@ -173,11 +173,11 @@ module('Integration | markdown-fallback', function (hooks) { .dom('[data-markdown-output]') .doesNotExist('fallback output container should not render'); - // The render-route wraps the markdown component in - // [data-markdown-render-container]; the prerender capture extracts text - // from there. The authored markdown should be what we see. - let container = document.querySelector('[data-markdown-render-container]'); - let text = cleanWhiteSpace(container?.textContent ?? ''); + // The render route wraps format='markdown' in [data-markdown-render-container], + // but that wrapper is route-only (packages/host/app/templates/render/html.gts). + // In rendering tests we query the root test element directly — the authored + // markdown is the only text content in the render. + let text = cleanWhiteSpace(this.element.textContent ?? ''); assert.strictEqual(text, '# Override (authored)'); }); From 4fc681af555cb9f949834ef4e5fcabdcb9e8c2cc Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 15 Apr 2026 14:39:17 -0400 Subject: [PATCH 07/33] Fix CS-10785 primitive markdown test expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections after running the field-markdown-primitives tests against the freshly built host bundle: 1. NumberField negative/decimal expectation — `markdownEscape` only escapes line-anchored numeric prefixes (`/^(\s*\d+)\./gm`). For `-3.14`, after the always-escape pass produces `\-3.14`, the digits are no longer at the start of a line, so the `.` correctly stays unescaped. Updated the expected value from `\-3\.14` → `\-3.14`. 2. TextAreaField / MarkdownField / CSSField tests — were comparing raw `textContent` against an exact string, but the FieldComponent wrapper chain (CardContextConsumer → ... → DefaultFormatsProvider → Markdown) pads each level with template-driven whitespace that ends up around the rendered markdown content. Switched to the `readMarkdown` helper (which trims leading/trailing whitespace) and verified the inner content is exactly what the static markdown templates emit. All 13 field-markdown-primitives tests pass, and the 7 CS-10784 markdown-fallback tests still pass. Co-Authored-By: Claude Opus 4.6 --- .../field-markdown-primitives-test.gts | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/host/tests/integration/components/field-markdown-primitives-test.gts b/packages/host/tests/integration/components/field-markdown-primitives-test.gts index 24c1cdb6c33..1bdd7ea9dcb 100644 --- a/packages/host/tests/integration/components/field-markdown-primitives-test.gts +++ b/packages/host/tests/integration/components/field-markdown-primitives-test.gts @@ -123,12 +123,13 @@ module('Integration | field markdown primitives', function (hooks) { }; } - // `-3.14` → leading `-` would look like a bullet marker at line start; - // `3.` would look like an ordered list prefix. markdownEscape handles - // both: `-` becomes `\-`, and `3.` at line start becomes `3\.`. + // `-3.14` → leading `-` would look like a bullet marker at line start, + // so `markdownEscape` emits `\-`. The `.` mid-string doesn't need + // escaping because the digits aren't at line start once the `\-` is + // emitted (numeric-list-prefix detection is line-anchored). let card = new Sample({ value: -3.14 }); await renderCard(loader, card, 'isolated'); - assert.strictEqual(readMarkdown(this.element), '\\-3\\.14'); + assert.strictEqual(readMarkdown(this.element), '\\-3.14'); }); test('TextAreaField markdown preserves line breaks as CommonMark hard breaks', async function (this: RenderingTestContext, assert) { @@ -144,10 +145,15 @@ module('Integration | field markdown primitives', function (hooks) { let card = new Sample({ value: 'Line 1\nLine 2\nLine 3' }); await renderCard(loader, card, 'isolated'); - let el = this.element.querySelector('[data-test-md]'); - // Use raw textContent (not trim/collapse) so we can inspect whitespace. - let raw = el?.textContent ?? ''; - assert.strictEqual(raw, 'Line 1 \nLine 2 \nLine 3'); + // The FieldComponent wrapper chain (CardContextConsumer → + // CardCrudFunctionsConsumer → ... → DefaultFormatsProvider → Markdown) + // pads the rendered content with leading/trailing whitespace from each + // wrapping ` }; static embedded = Base64ImageField.isolated; + + // CS-10786: never emit the raw base64 payload — it bloats the document + // and is useless to downstream consumers. Emit a placeholder that names + // the alt text if available, mirroring MaybeBase64Field's behavior. + static markdown = class Markdown extends Component { + get text() { + if (!this.args.model?.base64) { + return ''; + } + let alt = this.args.model?.altText; + if (alt) { + return `[binary image: ${markdownEscape(alt)}]`; + } + return '[binary image]'; + } + + }; } // from "ember-css-url" diff --git a/packages/base/big-integer.gts b/packages/base/big-integer.gts index be1cdb3fec4..40589cfe6f8 100644 --- a/packages/base/big-integer.gts +++ b/packages/base/big-integer.gts @@ -1,7 +1,7 @@ import { primitive, Component, FieldDef } from './card-api'; import { BoxelInput } from '@cardstack/boxel-ui/components'; import { TextInputValidator } from './text-input-validator'; -import { not } from '@cardstack/boxel-ui/helpers'; +import { markdownEscape, not } from '@cardstack/boxel-ui/helpers'; import Number99SmallIcon from '@cardstack/boxel-icons/number-99-small'; import { fieldSerializer, @@ -64,4 +64,18 @@ export default class BigIntegerField extends FieldDef { static embedded = View; static atom = View; static edit = Edit; + + // CS-10786: serialize the bigint to a decimal string and escape it — the + // leading `-` of a negative value would otherwise look like a bullet + // marker at line start. + static markdown = class Markdown extends Component { + get text() { + let value = this.args.model; + if (value == null) { + return ''; + } + return markdownEscape(BigIntegerSerializer.serialize(value)); + } + + }; } diff --git a/packages/base/boolean.gts b/packages/base/boolean.gts index 8d355624b6d..d7e16a75c43 100644 --- a/packages/base/boolean.gts +++ b/packages/base/boolean.gts @@ -40,6 +40,16 @@ export default class BooleanField extends FieldDef { static embedded = View; static atom = View; + // CS-10786: emit `true`/`false` literally. The primitive is already safe + // (neither `true` nor `false` is a markdown metacharacter), and `null`/ + // `undefined` resolves to empty string. + static markdown = class Markdown extends Component { + get text() { + return this.args.model == null ? '' : String(this.args.model); + } + + }; + static edit = class Edit extends Component { }; + + // CS-10786: the country's display name, markdown-escaped. The ISO code is + // omitted — downstream markdown consumers care about the human-readable + // label; the code is available programmatically on the field. + static markdown = class Markdown extends Component { + get text() { + return markdownEscape(this.args.model?.name); + } + + }; } export class CardWithCountryField extends CardDef { diff --git a/packages/base/date-range-field.gts b/packages/base/date-range-field.gts index 77418900c74..9ead82a6e36 100644 --- a/packages/base/date-range-field.gts +++ b/packages/base/date-range-field.gts @@ -13,6 +13,7 @@ import { on } from '@ember/modifier'; import { fn } from '@ember/helper'; import CalendarIcon from '@cardstack/boxel-icons/calendar'; import { cn } from '@cardstack/boxel-ui/helpers'; +import { formatDateRangeForMarkdown } from './markdown-helpers'; const Format = new Intl.DateTimeFormat('en-US', { year: 'numeric', @@ -183,6 +184,18 @@ export default class DateRangeField extends FieldDef { <@fields.start /> - <@fields.end /> }; + + // CS-10786: consistent markdown-escaped range output. Uses the shared + // helper so formatting matches DateField/DateTimeField. + static markdown = class Markdown extends Component { + get formatted() { + return formatDateRangeForMarkdown( + this.args.model.start, + this.args.model.end, + ); + } + + }; } interface DateRangeConfig { diff --git a/packages/base/date.gts b/packages/base/date.gts index e5fc2cce09d..923863e15c2 100644 --- a/packages/base/date.gts +++ b/packages/base/date.gts @@ -10,6 +10,7 @@ import { getSerializer, isValidDate, } from '@cardstack/runtime-common'; +import { formatDateForMarkdown } from './markdown-helpers'; // The Intl API is supported in all modern browsers. In older ones, we polyfill // it in the application route at app startup. @@ -44,6 +45,17 @@ export default class DateField extends FieldDef { static embedded = View; static atom = View; + // CS-10786: emit a consistently-formatted, markdown-escaped date so the + // value doesn't introduce accidental formatting when interpolated into a + // surrounding markdown document. Empty string for null/invalid dates so + // downstream tooling gets a valid (if terse) markdown document. + static markdown = class Markdown extends Component { + get formatted() { + return formatDateForMarkdown(this.args.model); + } + + }; + static edit = class Edit extends Component { }; + + // CS-10786: emit `NN%` escaped — a leading negative would look like a + // bullet marker at line start, and `markdownEscape` handles that. + static markdown = class Markdown extends Component { + get text() { + if (this.args.model == null) { + return ''; + } + return markdownEscape(displayPercentage(this.args.model)); + } + + }; } diff --git a/packages/base/phone-number.gts b/packages/base/phone-number.gts index 785c5aac233..dc75af6c454 100644 --- a/packages/base/phone-number.gts +++ b/packages/base/phone-number.gts @@ -19,10 +19,12 @@ import { RadioInput, } from '@cardstack/boxel-ui/components'; import { + markdownEscape, not, type NormalizePhoneFormatResult, } from '@cardstack/boxel-ui/helpers'; import { fieldSerializer, PhoneSerializer } from '@cardstack/runtime-common'; +import { markdownLink } from './markdown-helpers'; import PhoneIcon from '@cardstack/boxel-icons/phone'; @@ -152,6 +154,27 @@ export default class PhoneNumberField extends FieldDef { return parsed?.number?.international ?? null; } }; + + // CS-10786: emit a markdown link `[formatted number](tel:+E164)` when the + // value parses, otherwise escape the raw text. `markdownLink` handles text + // escaping and URL-encoding for us. + static markdown = class Markdown extends Component { + get text() { + let value = this.args.model; + if (value == null || value === '') { + return ''; + } + let parsed = parseForDisplay(value); + if (parsed?.number?.international && parsed?.number?.rfc3966) { + return markdownLink( + parsed.number.international, + parsed.number.rfc3966, + ); + } + return markdownEscape(value); + } + + }; } class PhoneNumberTypeEdit extends Component { diff --git a/packages/base/rich-markdown.gts b/packages/base/rich-markdown.gts index 6f64f6caf5c..ff7b02a1ea1 100644 --- a/packages/base/rich-markdown.gts +++ b/packages/base/rich-markdown.gts @@ -100,6 +100,16 @@ export class RichMarkdownField extends FieldDef { }; + // CS-10786: author-authored markdown passes through verbatim — double- + // escaping would corrupt the user's formatting. Mirrors MarkdownField's + // approach. + static markdown = class Markdown extends Component { + get text() { + return this.args.model?.content ?? ''; + } + + }; + static edit = class Edit extends Component { _modeState = new TrackedObject({ value: 'compose' as 'compose' | 'source' | 'preview' }); diff --git a/packages/base/url.gts b/packages/base/url.gts index e1805082795..a3b90d97c7a 100644 --- a/packages/base/url.gts +++ b/packages/base/url.gts @@ -1,8 +1,9 @@ import { StringField, Component, field, CardDef, contains } from './card-api'; import { BoxelInput } from '@cardstack/boxel-ui/components'; -import { not } from '@cardstack/boxel-ui/helpers'; +import { markdownEscape, not } from '@cardstack/boxel-ui/helpers'; import ExternalLink from '@cardstack/boxel-icons/external-link'; +import { markdownLink } from './markdown-helpers'; export default class UrlField extends StringField { static icon = ExternalLink; @@ -46,6 +47,22 @@ export default class UrlField extends StringField { }; + + // CS-10786: emit a markdown link when the URL parses; otherwise fall back + // to an escaped plain string so invalid values still render safely. + static markdown = class Markdown extends Component { + get text() { + let value = this.args.model; + if (value == null || value === '') { + return ''; + } + if (isValidUrl(value)) { + return markdownLink(value, value); + } + return markdownEscape(value); + } + + }; } function isValidUrl(urlString: string): boolean { diff --git a/packages/base/website.gts b/packages/base/website.gts index e0f35afd0f6..59d63092aeb 100644 --- a/packages/base/website.gts +++ b/packages/base/website.gts @@ -2,6 +2,8 @@ import WorldWwwIcon from '@cardstack/boxel-icons/world-www'; import UrlField from './url'; import { Component } from './card-api'; import { EntityDisplayWithIcon } from '@cardstack/boxel-ui/components'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; +import { markdownLink } from './markdown-helpers'; const domainWithPath = (urlString: string | null) => { if (!urlString) { @@ -64,6 +66,23 @@ export default class WebsiteField extends UrlField { }; + + // CS-10786: show `[domain/path](full-url)` for valid URLs — the atom/ + // embedded templates hide the scheme for readability, and we preserve that + // choice for markdown output too. Invalid URLs fall back to escaped text. + static markdown = class Markdown extends Component { + get text() { + let value = this.args.model; + if (value == null || value === '') { + return ''; + } + if (isValidUrl(value)) { + return markdownLink(domainWithPath(value), value); + } + return markdownEscape(value); + } + + }; } function isValidUrl(urlString: string): boolean { diff --git a/packages/host/tests/integration/components/field-markdown-specialized-test.gts b/packages/host/tests/integration/components/field-markdown-specialized-test.gts new file mode 100644 index 00000000000..d866eb6f2e5 --- /dev/null +++ b/packages/host/tests/integration/components/field-markdown-specialized-test.gts @@ -0,0 +1,457 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRealm, type Loader } from '@cardstack/runtime-common'; + +import type AddressFieldModule from 'https://cardstack.com/base/address'; +import type ColorFieldModule from 'https://cardstack.com/base/color'; +import type CoordinateFieldModule from 'https://cardstack.com/base/coordinate'; +import type CountryFieldModule from 'https://cardstack.com/base/country'; +import type DateRangeFieldModule from 'https://cardstack.com/base/date-range-field'; +import type LLMModelFieldModule from 'https://cardstack.com/base/llm-model'; +import type PercentageFieldModule from 'https://cardstack.com/base/percentage'; +import type UrlFieldModule from 'https://cardstack.com/base/url'; +import type WebsiteFieldModule from 'https://cardstack.com/base/website'; + +import { + BigIntegerField, + BooleanField, + CardDef, + CodeRefField, + Component, + DateField, + DateTimeField, + EmailField, + EthereumAddressField, + PhoneNumberField, + RichMarkdownField, + contains, + field, + setupBaseRealm, +} from '../../helpers/base-realm'; +import { renderCard } from '../../helpers/render-component'; +import { setupRenderingTest } from '../../helpers/setup'; + +// Verifies the explicit `static markdown` templates added per CS-10786 to +// specialized fields. Each primitive/composite field renders through a +// CardDef wrapper whose `isolated` template invokes `<@fields.foo +// @format='markdown' />` placing the markdown output inside a +// `[data-test-md]` container we query for the text. + +function readMarkdown(root: Element | Document): string { + let el = root.querySelector('[data-test-md]'); + return (el?.textContent ?? '').trim(); +} + +module('Integration | field markdown specialized', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + + let loader: Loader; + let AddressField: typeof AddressFieldModule.default; + let ColorField: typeof ColorFieldModule.default; + let CoordinateField: typeof CoordinateFieldModule.default; + let CountryField: typeof CountryFieldModule.default; + let DateRangeField: typeof DateRangeFieldModule.default; + let LLMModelField: typeof LLMModelFieldModule.default; + let PercentageField: typeof PercentageFieldModule.default; + let UrlField: typeof UrlFieldModule.default; + let WebsiteField: typeof WebsiteFieldModule.default; + + hooks.beforeEach(async function (this: RenderingTestContext) { + loader = getService('loader-service').loader; + AddressField = ( + await loader.import(`${baseRealm.url}address`) + ).default; + ColorField = ( + await loader.import(`${baseRealm.url}color`) + ).default; + CoordinateField = ( + await loader.import( + `${baseRealm.url}coordinate`, + ) + ).default; + CountryField = ( + await loader.import(`${baseRealm.url}country`) + ).default; + DateRangeField = ( + await loader.import( + `${baseRealm.url}date-range-field`, + ) + ).default; + LLMModelField = ( + await loader.import( + `${baseRealm.url}llm-model`, + ) + ).default; + PercentageField = ( + await loader.import( + `${baseRealm.url}percentage`, + ) + ).default; + UrlField = ( + await loader.import(`${baseRealm.url}url`) + ).default; + WebsiteField = ( + await loader.import(`${baseRealm.url}website`) + ).default; + }); + + // ---- Date family ----------------------------------------------------- + + test('DateField markdown emits consistently-formatted escaped text', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(DateField); + static isolated = class extends Component { + + }; + } + // Construct a UTC midnight date that will format in en-US as a specific + // day-month-year regardless of the runner's timezone by using a local + // constructor that matches the shared formatter's assumptions. + let card = new Sample({ value: new Date(2026, 3, 15) }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), 'Apr 15, 2026'); + }); + + test('DateField markdown emits empty string for null/invalid', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(DateField); + static isolated = class extends Component { + + }; + } + let card = new Sample(); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), ''); + }); + + test('DateTimeField markdown includes hour/minute', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(DateTimeField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: new Date(2026, 3, 15, 14, 30) }); + await renderCard(loader, card, 'isolated'); + // The shared en-US formatter with hour12 yields "Apr 15, 2026, 2:30 PM". + // We just verify the date and time components are present and escaped + // (commas aren't markdown metacharacters, so no escaping needed). + let text = readMarkdown(this.element); + assert.true( + text.includes('Apr 15, 2026'), + `expected date portion in: ${text}`, + ); + assert.true(text.includes('2:30'), `expected time portion in: ${text}`); + }); + + test('DateRangeField markdown joins start/end with a dash', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(DateRangeField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: new DateRangeField({ + start: new Date(2026, 3, 1), + end: new Date(2026, 3, 30), + }), + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual( + readMarkdown(this.element), + 'Apr 1, 2026 - Apr 30, 2026', + ); + }); + + // ---- Simple scalars -------------------------------------------------- + + test('BooleanField markdown emits the boolean literal', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(BooleanField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: true }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), 'true'); + }); + + test('BigIntegerField markdown escapes leading minus sign', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(BigIntegerField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: -12345678901234567890n }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '\\-12345678901234567890'); + }); + + test('PhoneNumberField markdown emits a tel: link when parseable', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(PhoneNumberField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: '+14155551234' }); + await renderCard(loader, card, 'isolated'); + let text = readMarkdown(this.element); + assert.true(text.startsWith('['), `expected link open: ${text}`); + assert.true(text.includes('](tel:'), `expected tel: href in link: ${text}`); + assert.true( + text.includes('+1 415'), + `expected international formatted text: ${text}`, + ); + }); + + test('ColorField markdown escapes leading # heading marker', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(ColorField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: '#ff00ff' }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '\\#ff00ff'); + }); + + test('PercentageField markdown formats and escapes', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(PercentageField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: 42.5 }); + await renderCard(loader, card, 'isolated'); + // `%` is not a markdown metacharacter, but the `42.` at line start + // would look like an ordered-list marker, so `markdownEscape` emits + // `42\.5%`. + assert.strictEqual(readMarkdown(this.element), '42\\.5%'); + }); + + test('CountryField markdown emits the country name', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(CountryField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: new CountryField({ name: 'United States', code: 'US' }), + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), 'United States'); + }); + + test('LLMModelField markdown prefers the display label', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(LLMModelField); + static isolated = class extends Component { + + }; + } + // An unknown id falls back to the id itself, escaped — the `-` + // would otherwise read as a bullet marker at line start. + let card = new Sample({ value: 'custom/model-id' }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), 'custom/model\\-id'); + }); + + test('EthereumAddressField markdown escapes its value', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(EthereumAddressField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: '0x0000000000000000000000000000000000000001', + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual( + readMarkdown(this.element), + '0x0000000000000000000000000000000000000001', + ); + }); + + // ---- URL / link fields ---------------------------------------------- + + test('EmailField markdown emits a mailto link', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(EmailField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: 'alice@example.com' }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual( + readMarkdown(this.element), + '[alice@example.com](mailto:alice@example.com)', + ); + }); + + test('UrlField markdown emits a bracketed link with encoded href', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(UrlField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: 'https://example.com/path with space' }); + await renderCard(loader, card, 'isolated'); + let text = readMarkdown(this.element); + // href gets `%20` for the space; text escapes nothing since `/`, `:` + // aren't metacharacters. + assert.strictEqual( + text, + '[https://example.com/path with space](https://example.com/path%20with%20space)', + ); + }); + + test('UrlField markdown falls back to escaped text for invalid URL', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(UrlField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: 'not a *url*' }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), 'not a \\*url\\*'); + }); + + test('WebsiteField markdown shows domain/path text with full-URL href', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(WebsiteField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ value: 'https://example.com/docs/api' }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual( + readMarkdown(this.element), + '[example.com/docs/api](https://example.com/docs/api)', + ); + }); + + // ---- Composite fields ----------------------------------------------- + + test('AddressField markdown renders rows with hard breaks', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(AddressField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: new AddressField({ + addressLine1: '123 Main St', + city: 'Springfield', + state: 'IL', + postalCode: '62701', + }), + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual( + readMarkdown(this.element), + '123 Main St \nSpringfield, IL, 62701', + ); + }); + + test('CoordinateField markdown formats (x, y) with escaped components', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(CoordinateField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: new CoordinateField({ x: -1.5, y: 2 }), + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '(\\-1.5, 2)'); + }); + + test('CodeRefField markdown emits an inline code span', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(CodeRefField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: { module: 'https://cardstack.com/base/string', name: 'default' }, + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual( + readMarkdown(this.element), + '`https://cardstack.com/base/string/default`', + ); + }); + + test('RichMarkdownField markdown passes content through unescaped', async function (this: RenderingTestContext, assert) { + class Sample extends CardDef { + @field value = contains(RichMarkdownField); + static isolated = class extends Component { + + }; + } + let card = new Sample({ + value: new RichMarkdownField({ content: '# Heading\n\n- item' }), + }); + await renderCard(loader, card, 'isolated'); + assert.strictEqual(readMarkdown(this.element), '# Heading\n\n- item'); + }); +}); From 98bab6bfce9d24e067b3de76a9ccf268854a307c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 15 Apr 2026 16:35:15 -0400 Subject: [PATCH 09/33] Add explicit static markdown templates for domain fields (CS-10787) Adds field-specific `static markdown` slots to domain/reference, file, theme, and brand fields. Extends `packages/base/markdown-helpers.ts` with `fencedCodeBlock` (auto-widens the fence beyond any interior backtick run) and `markdownImage` helpers. Covers: - Reference/relation: RealmField (self-linked), ResponseField (status placeholder) - FileDef subclasses: MarkdownDef (passthrough), TsFileDef, GtsFileDef, JsonFileDef, CsvFileDef, TextFileDef (fenced code with per-subclass `static markdownLanguage`), ImageDef (markdown image reference) - Theme/style: CSSValueField (backtick fence with padding when value touches a backtick), TypographyField (bulleted properties), ThemeVarField/ThemeTypographyField (bulleted CSS variable entries), StructuredTheme (title/description/version + Typography + Root Variables sections) - Brand: MarkField (markdown image), BrandLogo (bulleted mark URLs with labels), BrandFunctionalPalette (bulleted palette colors) - enumField factory: renders the matching option's label (falls back to escaped raw value) Tests live in packages/host/tests/integration/components/ field-markdown-domain-test.gts (25 tests). Co-Authored-By: Claude Opus 4.6 --- packages/base/brand-functional-palette.gts | 30 + packages/base/brand-logo.gts | 64 ++ packages/base/card-api.gts | 22 + packages/base/css-value.gts | 24 + packages/base/csv-file-def.gts | 16 + packages/base/enum.gts | 22 +- packages/base/gts-file-def.gts | 2 + packages/base/json-file-def.gts | 16 + packages/base/markdown-file-def.gts | 12 + packages/base/markdown-helpers.ts | 46 ++ packages/base/realm.gts | 14 + packages/base/response-field.gts | 27 +- packages/base/structured-theme-variables.gts | 51 ++ packages/base/structured-theme.gts | 51 ++ packages/base/text-file-def.gts | 17 + packages/base/ts-file-def.gts | 23 + packages/base/typography.gts | 29 + .../components/field-markdown-domain-test.gts | 642 ++++++++++++++++++ 18 files changed, 1106 insertions(+), 2 deletions(-) create mode 100644 packages/host/tests/integration/components/field-markdown-domain-test.gts diff --git a/packages/base/brand-functional-palette.gts b/packages/base/brand-functional-palette.gts index 745ce204a52..3a248f12d99 100644 --- a/packages/base/brand-functional-palette.gts +++ b/packages/base/brand-functional-palette.gts @@ -2,6 +2,7 @@ import { GridContainer, Swatch } from '@cardstack/boxel-ui/components'; import { buildCssVariableName, entriesToCssRuleMap, + markdownEscape, } from '@cardstack/boxel-ui/helpers'; import { field, contains, Component, getFields, FieldDef } from './card-api'; @@ -94,4 +95,33 @@ export default class BrandFunctionalPalette extends FieldDef { } return entriesToCssRuleMap(this.cssVariableFields); } + + // CS-10787: emit a bulleted list of the palette entries with their CSS + // color values in inline code. Skips empty slots so the output stays + // compact. + static markdown = class Markdown extends Component< + typeof BrandFunctionalPalette + > { + get text() { + let model = this.args.model; + if (!model) { + return ''; + } + let rows: string[] = []; + let pairs: { key: keyof typeof model; label: string }[] = [ + { key: 'primary', label: 'Primary' }, + { key: 'secondary', label: 'Secondary' }, + { key: 'accent', label: 'Accent' }, + { key: 'light', label: 'Light' }, + { key: 'dark', label: 'Dark' }, + ]; + for (let { key, label } of pairs) { + let value = model[key] as string | undefined; + if (!value) continue; + rows.push(`- ${markdownEscape(label)}: \`${value}\``); + } + return rows.join('\n'); + } + + }; } diff --git a/packages/base/brand-logo.gts b/packages/base/brand-logo.gts index 01e93a1484b..fe975c1f4b2 100644 --- a/packages/base/brand-logo.gts +++ b/packages/base/brand-logo.gts @@ -1,6 +1,7 @@ import { FieldContainer, GridContainer } from '@cardstack/boxel-ui/components'; import { entriesToCssRuleMap, + markdownEscape, type CssRuleMap, } from '@cardstack/boxel-ui/helpers'; @@ -14,6 +15,7 @@ import { getFieldDescription, } from './card-api'; import { buildCssVariableName } from '@cardstack/boxel-ui/helpers'; +import { markdownLink } from './markdown-helpers'; import { type CssVariableField, type CssVariableFieldEntry, @@ -308,6 +310,26 @@ export class MarkField extends URLField { }; + + // CS-10787: render the mark as a markdown image — the URL is the asset, + // and the alt text is empty (callers set alt via context). + static markdown = class Markdown extends Component { + get text() { + let url = this.args.model; + if (!url) { + return ''; + } + let encoded: string; + try { + encoded = encodeURI(url); + } catch { + encoded = url; + } + encoded = encoded.replace(/\(/g, '%28').replace(/\)/g, '%29'); + return `![](${encoded})`; + } + + }; } export default class BrandLogo extends FieldDef { @@ -388,4 +410,46 @@ export default class BrandLogo extends FieldDef { } static embedded = Embedded; + + // CS-10787: emit a bulleted list of the logo URLs that are actually + // populated. Skips empty slots so the output stays compact. + static markdown = class Markdown extends Component { + get text() { + let model = this.args.model; + if (!model) { + return ''; + } + let rows: { label: string; url: string }[] = []; + let pairs: { key: keyof typeof model; label: string }[] = [ + { key: 'primaryMark1', label: 'Primary mark (light)' }, + { key: 'primaryMark2', label: 'Primary mark (dark)' }, + { key: 'primaryMarkGreyscale1', label: 'Primary mark greyscale (light)' }, + { key: 'primaryMarkGreyscale2', label: 'Primary mark greyscale (dark)' }, + { key: 'secondaryMark1', label: 'Secondary mark (light)' }, + { key: 'secondaryMark2', label: 'Secondary mark (dark)' }, + { + key: 'secondaryMarkGreyscale1', + label: 'Secondary mark greyscale (light)', + }, + { + key: 'secondaryMarkGreyscale2', + label: 'Secondary mark greyscale (dark)', + }, + { key: 'socialMediaProfileIcon', label: 'Social media icon' }, + ]; + for (let { key, label } of pairs) { + let url = model[key] as string | undefined; + if (url) { + rows.push({ label, url }); + } + } + if (!rows.length) { + return ''; + } + return rows + .map(({ label, url }) => `- ${markdownEscape(label)}: ${markdownLink(url, url)}`) + .join('\n'); + } + + }; } diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 482a57836a5..b232895faa9 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -110,6 +110,7 @@ import MissingTemplate from './default-templates/missing-template'; import FieldDefEditTemplate from './default-templates/field-edit'; import MarkdownTemplate from './default-templates/markdown'; import DefaultMarkdownFallbackTemplate from './default-templates/markdown-fallback'; +import { markdownImage } from './markdown-helpers'; import FileDefEditTemplate from './default-templates/file-def-edit'; import ImageDefAtomTemplate from './default-templates/image-def-atom'; import ImageDefEmbeddedTemplate from './default-templates/image-def-embedded'; @@ -2808,6 +2809,27 @@ export class ImageDef extends FileDef { static atom: BaseDefComponent = ImageDefAtomTemplate; static embedded: BaseDefComponent = ImageDefEmbeddedTemplate; static fitted: BaseDefComponent = ImageDefFittedTemplate; + + // CS-10787: emit a markdown image reference. If no URL is available we + // fall back to a placeholder that names the image — useful to downstream + // consumers (e.g. an LLM ingesting the markdown) without a broken link. + static markdown: BaseDefComponent = class Markdown extends Component< + typeof ImageDef + > { + get text() { + let model = this.args.model; + if (!model) { + return ''; + } + let url = model.url ?? model.sourceUrl ?? ''; + let name = model.name ?? ''; + if (!url && !name) { + return ''; + } + return markdownImage(name, url); + } + + }; } export class CardInfoField extends FieldDef { diff --git a/packages/base/css-value.gts b/packages/base/css-value.gts index 5a933dad91a..48799a826dc 100644 --- a/packages/base/css-value.gts +++ b/packages/base/css-value.gts @@ -17,4 +17,28 @@ export default class CSSValueField extends StringField { }; + + // CS-10787: render CSS values inside inline code delimiters so they're + // clearly identifiable as literal values. Wraps in a backtick fence wide + // enough to contain any backticks in the value. + static markdown = class Markdown extends Component { + get text() { + let value = this.args.model; + if (!value) { + return ''; + } + let longestRun = 0; + let match = value.match(/`+/g); + if (match) { + for (let run of match) { + if (run.length > longestRun) longestRun = run.length; + } + } + let fence = '`'.repeat(Math.max(1, longestRun + 1)); + let needsPad = + value.startsWith('`') || value.endsWith('`') || /^\s|\s$/.test(value); + return needsPad ? `${fence} ${value} ${fence}` : `${fence}${value}${fence}`; + } + + }; } diff --git a/packages/base/csv-file-def.gts b/packages/base/csv-file-def.gts index 97c9f631ccc..2c9a2732e57 100644 --- a/packages/base/csv-file-def.gts +++ b/packages/base/csv-file-def.gts @@ -16,6 +16,7 @@ import { type ByteStream, type SerializedFile, } from './file-api'; +import { fencedCodeBlock } from './markdown-helpers'; const EXCERPT_MAX_LENGTH = 500; @@ -551,6 +552,21 @@ export class CsvFileDef extends FileDef { static atom: BaseDefComponent = Atom; static head: BaseDefComponent = Head; + // CS-10787: emit the CSV source as a fenced `csv` code block. Empty + // content produces an empty string. + static markdown: BaseDefComponent = class Markdown extends Component< + typeof CsvFileDef + > { + get text() { + let content = this.args.model?.content; + if (!content) { + return ''; + } + return fencedCodeBlock(content, 'csv'); + } + + }; + static async extractAttributes( url: string, getStream: () => Promise, diff --git a/packages/base/enum.gts b/packages/base/enum.gts index b8a6f45781e..0dcbbfe8ee9 100644 --- a/packages/base/enum.gts +++ b/packages/base/enum.gts @@ -4,7 +4,7 @@ import { BoxelSelect } from '@cardstack/boxel-ui/components'; import { getField } from '@cardstack/runtime-common'; import { resolveFieldConfiguration } from './field-support'; import type { FieldDefConstructor } from './card-api'; -import { not } from '@cardstack/boxel-ui/helpers'; +import { markdownEscape, not } from '@cardstack/boxel-ui/helpers'; // enumField factory moved out of card-api: creates a FieldDef subclass with // editor/atom wired to read options via configuration.enum.* @@ -181,6 +181,26 @@ function enumField( }; + // CS-10787: render the matching option's label (not its raw value), so + // enum fields read naturally in markdown output. Falls back to the + // string form of the model when no option matches. + static markdown = class Markdown extends GlimmerComponent { + get text() { + let v = this.args.model as any; + if (v == null) { + return ''; + } + let cfg = this.args.configuration as + | { enum?: { options?: any[] } } + | undefined; + let opts = normalizeEnumOptions(cfg?.enum?.options ?? []); + let match = opts.find((o) => isEqual(o.value, v)); + let display = match?.label ?? String(v); + return markdownEscape(display); + } + + }; + static edit = class Edit extends GlimmerComponent { get options() { let cfg = this.args.configuration as diff --git a/packages/base/gts-file-def.gts b/packages/base/gts-file-def.gts index 4dd4bd473c2..61e5113b8dc 100644 --- a/packages/base/gts-file-def.gts +++ b/packages/base/gts-file-def.gts @@ -4,4 +4,6 @@ export class GtsFileDef extends TsFileDef { static displayName = 'GTS Module'; static acceptTypes = '.gts'; static validExtensions = new Set(['.gts']); + // CS-10787: identify GTS content to markdown consumers. + static markdownLanguage = 'gts'; } diff --git a/packages/base/json-file-def.gts b/packages/base/json-file-def.gts index cc9c44a3e8d..05e59eb6e02 100644 --- a/packages/base/json-file-def.gts +++ b/packages/base/json-file-def.gts @@ -14,6 +14,7 @@ import { type ByteStream, type SerializedFile, } from './file-api'; +import { fencedCodeBlock } from './markdown-helpers'; const EXCERPT_MAX_LENGTH = 500; @@ -498,6 +499,21 @@ export class JsonFileDef extends FileDef { static atom: BaseDefComponent = Atom; static head: BaseDefComponent = Head; + // CS-10787: emit the JSON source as a fenced `json` code block. Empty + // content produces an empty string. + static markdown: BaseDefComponent = class Markdown extends Component< + typeof JsonFileDef + > { + get text() { + let content = this.args.model?.content; + if (!content) { + return ''; + } + return fencedCodeBlock(content, 'json'); + } + + }; + static async extractAttributes( url: string, getStream: () => Promise, diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index f4801efba0d..e786fa2cbc1 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -467,6 +467,18 @@ export class MarkdownDef extends FileDef { static atom: BaseDefComponent = Atom; static head: BaseDefComponent = Head; + // CS-10787: markdown files already are markdown, so pass the content + // through verbatim rather than wrapping in a fenced block that would + // double-render when consumed. + static markdown: BaseDefComponent = class Markdown extends Component< + typeof MarkdownDef + > { + get text() { + return this.args.model?.content ?? ''; + } + + }; + static async extractAttributes( url: string, getStream: () => Promise, diff --git a/packages/base/markdown-helpers.ts b/packages/base/markdown-helpers.ts index a86c7847198..79861da73d3 100644 --- a/packages/base/markdown-helpers.ts +++ b/packages/base/markdown-helpers.ts @@ -83,3 +83,49 @@ export function markdownLink( encodedHref = encodedHref.replace(/\(/g, '%28').replace(/\)/g, '%29'); return `[${safeText}](${encodedHref})`; } + +// CS-10787: Build a fenced code block. The fence is made of at least three +// backticks, expanded to be longer than any run of backticks in the content +// so the fence isn't prematurely closed. An optional language identifier +// labels the block for syntax highlighting consumers. +export function fencedCodeBlock( + content: string | null | undefined, + language?: string, +): string { + let body = content ?? ''; + let longestRun = 0; + let match = body.match(/`+/g); + if (match) { + for (let run of match) { + if (run.length > longestRun) longestRun = run.length; + } + } + let fence = '`'.repeat(Math.max(3, longestRun + 1)); + let lang = language ? language : ''; + // Ensure the body ends with a newline so the closing fence sits on its own + // line. CommonMark allows omission, but normalizing avoids surprises. + let normalized = body.endsWith('\n') ? body : `${body}\n`; + return `${fence}${lang}\n${normalized}${fence}`; +} + +// CS-10787: Build a markdown image reference `![alt](url)` with proper +// escaping/encoding. If url is missing, fall back to a plain placeholder so +// downstream consumers still get something meaningful. +export function markdownImage( + alt: string | null | undefined, + url: string | null | undefined, +): string { + if (!url) { + let safeAlt = markdownEscape(alt ?? ''); + return safeAlt ? `[binary image: ${safeAlt}]` : '[binary image]'; + } + let safeAlt = markdownEscape(alt ?? ''); + let encodedHref: string; + try { + encodedHref = encodeURI(url); + } catch { + encodedHref = url; + } + encodedHref = encodedHref.replace(/\(/g, '%28').replace(/\)/g, '%29'); + return `![${safeAlt}](${encodedHref})`; +} diff --git a/packages/base/realm.gts b/packages/base/realm.gts index b7f68c64fc2..220dd3dae1c 100644 --- a/packages/base/realm.gts +++ b/packages/base/realm.gts @@ -1,5 +1,6 @@ import { Component } from './card-api'; import StringField from './string'; +import { markdownLink } from './markdown-helpers'; import { BoxelDropdown, BoxelInput, @@ -167,4 +168,17 @@ export default class RealmField extends StringField { static displayName = 'Realm'; static icon = World; static edit = EditComponent; + + // CS-10787: emit the realm URL as a markdown link (self-linked, since we + // don't have the realm's name accessible from just the primitive URL). + static markdown = class Markdown extends Component { + get text() { + let url = this.args.model; + if (!url) { + return ''; + } + return markdownLink(url, url); + } + + }; } diff --git a/packages/base/response-field.gts b/packages/base/response-field.gts index 1c86aacaf23..6385e545aad 100644 --- a/packages/base/response-field.gts +++ b/packages/base/response-field.gts @@ -1,6 +1,31 @@ -import { primitive, FieldDef } from './card-api'; +import { Component, primitive, FieldDef } from './card-api'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; export default class ResponseField extends FieldDef { static displayName = 'Response'; static [primitive]: Response; + + // CS-10787: emit a short placeholder describing the HTTP response. The raw + // Response object isn't markdown-representable, so we summarize by status + // line when present and emit nothing otherwise. + static markdown = class Markdown extends Component { + get text() { + let model = this.args.model; + if (!model) { + return ''; + } + let status = typeof model.status === 'number' ? model.status : undefined; + let statusText = typeof model.statusText === 'string' + ? model.statusText + : ''; + if (status == null) { + return '[HTTP response]'; + } + let summary = statusText + ? `${status} ${statusText}` + : String(status); + return `[HTTP response: ${markdownEscape(summary)}]`; + } + + }; } diff --git a/packages/base/structured-theme-variables.gts b/packages/base/structured-theme-variables.gts index 4fa87a6ec4f..58809ac0c70 100644 --- a/packages/base/structured-theme-variables.gts +++ b/packages/base/structured-theme-variables.gts @@ -3,6 +3,7 @@ import { buildCssVariableName, dasherize, entriesToCssRuleMap, + markdownEscape, type CssVariableEntry, type CssRuleMap, } from '@cardstack/boxel-ui/helpers'; @@ -142,6 +143,33 @@ export class ThemeTypographyField extends FieldDef { return entriesToCssRuleMap(this.cssVariableFields); } + // CS-10787: emit a small header + bulleted entries section for each + // populated typography slot. Delegates the per-slot rendering to + // TypographyField.markdown by emitting its text directly. + static markdown = class Markdown extends Component< + typeof ThemeTypographyField + > { + get text() { + let model = this.args.model; + if (!model) { + return ''; + } + let entries = model.cssVariableFields ?? []; + if (!entries.length) { + return ''; + } + let rows: string[] = []; + for (let { name, value } of entries) { + if (!value) continue; + rows.push( + `- ${markdownEscape(name ?? '')}: \`${value}\``, + ); + } + return rows.join('\n'); + } + + }; + static embedded = class Embedded extends Component { From 90732ac043dc3c03c4a30c183f28397f911f3c09 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 16 Apr 2026 16:27:30 -0400 Subject: [PATCH 26/33] Remove redundant pointer-events rule from spec-preview-overlay Now that the base .base-overlay class includes pointer-events: none, the spec-preview-overlay no longer needs to declare it separately. Co-Authored-By: Claude Opus 4.6 --- .../app/components/operator-mode/code-submode/spec-preview.gts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/host/app/components/operator-mode/code-submode/spec-preview.gts b/packages/host/app/components/operator-mode/code-submode/spec-preview.gts index fcf975f1e36..ae72c0e0c66 100644 --- a/packages/host/app/components/operator-mode/code-submode/spec-preview.gts +++ b/packages/host/app/components/operator-mode/code-submode/spec-preview.gts @@ -282,7 +282,6 @@ class SpecPreviewContent extends GlimmerComponent { text-align: center; } .spec-preview-overlay { - pointer-events: none; border-radius: var(--boxel-border-radius); box-shadow: 0 0 0 1px var(--boxel-dark); } From 9bc1ddb1556bbfe736282a2c3ce596576f6c329d Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 16 Apr 2026 16:46:43 -0400 Subject: [PATCH 27/33] Add markdown format support to field playground and RatingsSummary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add custom `static markdown` template to RatingsSummary with ASCII star rendering - Add 'markdown' to field format chooser options in playground panel - Widen playground instance-chooser container (380px → 460px) to fit all format buttons - Wrap markdown preview in CardContainer for white background in playground - Update field playground test to cover markdown format selection Co-Authored-By: Claude Opus 4.6 --- .../Spec/fields/rating-field.json | 57 +++++++++++++++---- .../experiments-realm/ratings-summary.gts | 19 +++++++ .../playground/playground-panel.gts | 4 +- .../playground/playground-preview.gts | 4 +- .../code-submode/field-playground-test.gts | 8 ++- 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/experiments-realm/Spec/fields/rating-field.json b/packages/experiments-realm/Spec/fields/rating-field.json index e21b3b3d986..2374d7bb22d 100644 --- a/packages/experiments-realm/Spec/fields/rating-field.json +++ b/packages/experiments-realm/Spec/fields/rating-field.json @@ -1,19 +1,56 @@ { "data": { + "meta": { + "fields": { + "containedExamples": [ + { + "adoptsFrom": { + "module": "../../ratings-summary", + "name": "RatingsSummary" + } + } + ] + }, + "adoptsFrom": { + "name": "Spec", + "module": "https://cardstack.com/base/spec" + } + }, "type": "card", "attributes": { - "cardTitle": "Rating Summary Field", - "specType": "field", "ref": { - "module": "../../ratings-summary", - "name": "RatingsSummary" - } + "name": "RatingsSummary", + "module": "../../ratings-summary" + }, + "readMe": null, + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "specType": "field", + "cardTitle": "Rating Summary Field", + "cardDescription": null, + "containedExamples": [ + { + "average": 4.4, + "count": 57, + "isEditable": false + } + ] }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } } } } -} +} \ No newline at end of file diff --git a/packages/experiments-realm/ratings-summary.gts b/packages/experiments-realm/ratings-summary.gts index 27cae9be6f9..ef272be10d5 100644 --- a/packages/experiments-realm/ratings-summary.gts +++ b/packages/experiments-realm/ratings-summary.gts @@ -201,6 +201,25 @@ export class RatingsSummary extends FieldDef { }; + static markdown = class Markdown extends Component { + get stars(): string { + let rating = this.args.model.average ?? 0; + let full = Math.floor(rating); + let half = rating % 1 >= 0.5 ? 1 : 0; + let empty = 5 - full - half; + return '★'.repeat(full) + (half ? '⯨' : '') + '☆'.repeat(empty); + } + get label(): string { + let rating = this.args.model.average; + if (!rating) return '☆☆☆☆☆ No rating'; + let count = this.args.model.count; + let out = `${this.stars} ${rating}/5`; + if (count) out += ` (${count} reviews)`; + return out; + } + + }; + static atom = class Atom extends Component {