diff --git a/packages/base/address.gts b/packages/base/address.gts index 479631383b4..032de202e05 100644 --- a/packages/base/address.gts +++ b/packages/base/address.gts @@ -3,6 +3,7 @@ import StringField from './string'; import CountryField from './country'; import MapPinIcon from '@cardstack/boxel-icons/map-pin'; import { EntityDisplayWithIcon } from '@cardstack/boxel-ui/components'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; function getAddressRows( addressLine1: string | undefined, @@ -92,4 +93,29 @@ export default class AddressField extends FieldDef { }; static atom = Atom; + + // CS-10786: emit the address as a CommonMark hard-break-delimited block so + // downstream consumers preserve line structure. Each logical row is + // markdown-escaped individually so e.g. a `#` in an apartment label + // doesn't become a heading. + static markdown = class Markdown extends Component { + get text() { + let rows = getAddressRows( + this.args.model?.addressLine1, + this.args.model?.addressLine2, + this.args.model?.city, + this.args.model?.state, + this.args.model?.postalCode, + this.args.model?.country?.name, + this.args.model?.poBoxNumber, + ); + if (rows.length === 0) { + return ''; + } + // Two-space-then-newline = CommonMark hard break, matching the + // TextAreaField markdown convention. + return rows.map((r) => markdownEscape(r)).join(' \n'); + } + + }; } diff --git a/packages/base/base64-image.gts b/packages/base/base64-image.gts index 06a8d96ab67..928b1f0b8ae 100644 --- a/packages/base/base64-image.gts +++ b/packages/base/base64-image.gts @@ -8,6 +8,7 @@ import { primitive, useIndexBasedKey, } from './card-api'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; import { fn } from '@ember/helper'; @@ -321,6 +322,23 @@ export default class Base64ImageField extends FieldDef { }; 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-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 3d7dea9f99f..b232895faa9 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -3,7 +3,11 @@ import GlimmerComponent from '@glimmer/component'; import { isEqual } from 'lodash'; import { WatchedArray } from './watched-array'; import { BoxelInput, CopyButton } from '@cardstack/boxel-ui/components'; -import { type MenuItemOptions, not } from '@cardstack/boxel-ui/helpers'; +import { + markdownEscape, + type MenuItemOptions, + not, +} from '@cardstack/boxel-ui/helpers'; import { getBoxComponent, type BoxComponent, @@ -105,6 +109,8 @@ import DefaultHeadTemplate from './default-templates/head'; 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'; @@ -2365,6 +2371,11 @@ export class FieldDef extends BaseDef { static edit: BaseDefComponent = FieldDefEditTemplate; static atom: BaseDefComponent = DefaultAtomViewTemplate; static fitted: BaseDefComponent = MissingTemplate; + // Default `markdown` fallback (CS-10784): renders the field's HTML embedded + // template into a hidden source container, then converts it to markdown via + // turndown (registered on `globalThis` by `packages/host`). Subclasses can + // override `static markdown` to author bespoke markdown directly. + static markdown: BaseDefComponent = DefaultMarkdownFallbackTemplate; } export class ReadOnlyField extends FieldDef { @@ -2376,6 +2387,12 @@ export class ReadOnlyField extends FieldDef { static edit = class Edit extends Component { }; + // CS-10785: emit plain text, escaped so markdown metacharacters in the + // raw string (e.g. `*`, `#`, `1.`) don't trigger formatting when the + // value is interpolated into a surrounding markdown document. + static markdown = class Markdown extends Component { + + }; } export class StringField extends FieldDef { @@ -2398,6 +2415,15 @@ export class StringField extends FieldDef { static atom = class Atom extends Component { }; + // CS-10785: plain text, escaped. Same rationale as ReadOnlyField. + // Explicit `BaseDefComponent` annotation so subclass overrides (e.g. + // TextAreaField, MarkdownField, MaybeBase64Field) aren't forced to + // structurally match this inline class shape. + static markdown: BaseDefComponent = class Markdown extends Component< + typeof this + > { + + }; } // TODO: This is a simple workaround until the thumbnailURL is converted into an actual image field @@ -2415,6 +2441,24 @@ export class MaybeBase64Field extends StringField { }; 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 { @@ -2431,6 +2475,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 @@ -2484,6 +2544,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 { @@ -2513,6 +2593,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 { @@ -2562,6 +2649,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 { @@ -2624,6 +2718,13 @@ export class FileDef extends BaseDef { static isolated = this.embedded; static atom = this.embedded; static edit: BaseDefComponent = FileDefEditTemplate; + // Default `markdown` fallback (CS-10784): inherits from FieldDef but + // restated explicitly so this class's own slot is set rather than relying on + // prototype lookup — the format-resolution code reads slots via bracket + // notation on the resolved class (`(cls as any)[format]`), which traverses + // the prototype chain, but having an own property keeps subclass overrides + // less surprising. + static markdown: BaseDefComponent = DefaultMarkdownFallbackTemplate; static async extractAttributes( url: string, @@ -2708,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 { @@ -2793,6 +2915,11 @@ export class CardDef extends BaseDef { static edit: BaseDefComponent = DefaultCardDefTemplate; static atom: BaseDefComponent = DefaultAtomViewTemplate; static head: BaseDefComponent = DefaultHeadTemplate; + // Default `markdown` fallback (CS-10784): renders the card's HTML isolated + // template into a hidden source container, then converts it to markdown via + // turndown (registered on `globalThis` by `packages/host`). Subclasses can + // override `static markdown` to author bespoke markdown directly. + static markdown: BaseDefComponent = DefaultMarkdownFallbackTemplate; static prefersWideFormat = false; // whether the card is full-width in the stack static headerColor: string | null = null; // set string color value if the stack-item header has a background color diff --git a/packages/base/code-ref.gts b/packages/base/code-ref.gts index e9689949a31..e69e18b9251 100644 --- a/packages/base/code-ref.gts +++ b/packages/base/code-ref.gts @@ -98,6 +98,36 @@ export default class CodeRefField extends FieldDef { static [fieldSerializer] = 'code-ref'; static embedded = class Embedded extends BaseView {}; static edit = EditView; + + // CS-10786: emit the code reference as an inline code span — `module/name` + // is the canonical serialization. Wrapping in backticks avoids having to + // escape any module-path characters. + static markdown = class Markdown extends Component { + get text() { + let model = this.args.model; + if (!model?.module || !model?.name) { + return ''; + } + // Combine module + name into the same string the edit input shows, + // then wrap in a fence of sufficient width to contain any backticks + // in the module path. + let raw = `${model.module}/${model.name}`; + let longestRun = 0; + let match = raw.match(/`+/g); + if (match) { + for (let run of match) { + if (run.length > longestRun) longestRun = run.length; + } + } + let fence = '`'.repeat(Math.max(1, longestRun + 1)); + // Pad with spaces when the content starts/ends with a backtick so the + // inline-code-span parser doesn't consume the delimiter. + let needsPad = + raw.startsWith('`') || raw.endsWith('`') || /^\s|\s$/.test(raw); + return needsPad ? `${fence} ${raw} ${fence}` : `${fence}${raw}${fence}`; + } + + }; } export class AbsoluteCodeRefField extends CodeRefField { diff --git a/packages/base/color.gts b/packages/base/color.gts index 296b9067480..4ccbe56259d 100644 --- a/packages/base/color.gts +++ b/packages/base/color.gts @@ -4,7 +4,7 @@ import { Swatch, ColorPicker, } from '@cardstack/boxel-ui/components'; -import { not } from '@cardstack/boxel-ui/helpers'; +import { markdownEscape, not } from '@cardstack/boxel-ui/helpers'; import PaintBucket from '@cardstack/boxel-icons/paint-bucket'; // TypeScript configuration interface @@ -55,4 +55,14 @@ export default class ColorField extends StringField { static atom = View; static fitted = View; static edit = EditView; + + // CS-10786: escape the hex string. A leading `#` at line start would be + // interpreted as an ATX heading by CommonMark; `markdownEscape` emits + // `\#` to prevent that. + static markdown = class Markdown extends Component { + get text() { + return markdownEscape(this.args.model); + } + + }; } diff --git a/packages/base/coordinate.gts b/packages/base/coordinate.gts index d42983381bb..ab7040fb99e 100644 --- a/packages/base/coordinate.gts +++ b/packages/base/coordinate.gts @@ -1,9 +1,27 @@ import NumberField from './number'; -import { contains, FieldDef, field } from './card-api'; +import { contains, FieldDef, field, Component } from './card-api'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; export default class CoordinateField extends FieldDef { @field x = contains(NumberField); @field y = contains(NumberField); static displayName = 'Coordinate'; + + // CS-10786: emit `(x, y)` with numeric components escaped to avoid a + // negative-number leading dash being read as a bullet marker at line + // start. Empty when both components are null. + static markdown = class Markdown extends Component { + get text() { + let x = this.args.model?.x; + let y = this.args.model?.y; + if (x == null && y == null) { + return ''; + } + let xs = x == null ? '' : markdownEscape(String(x)); + let ys = y == null ? '' : markdownEscape(String(y)); + return `(${xs}, ${ys})`; + } + + }; } diff --git a/packages/base/country.gts b/packages/base/country.gts index 3103c061715..ef402eadea3 100644 --- a/packages/base/country.gts +++ b/packages/base/country.gts @@ -3,6 +3,7 @@ import StringField from './string'; import World from '@cardstack/boxel-icons/world'; import MapPinned from '@cardstack/boxel-icons/map-pinned'; import { BoxelSelect } from '@cardstack/boxel-ui/components'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { restartableTask } from 'ember-concurrency'; @@ -119,6 +120,16 @@ export default class CountryField extends FieldDef { {{@model.name}} }; + + // 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/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/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-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/ethereum-address.gts b/packages/base/ethereum-address.gts index c155fdabc75..17bfa5b40b8 100644 --- a/packages/base/ethereum-address.gts +++ b/packages/base/ethereum-address.gts @@ -1,7 +1,7 @@ import { primitive, Component, useIndexBasedKey, 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 CurrencyEthereum from '@cardstack/boxel-icons/currency-ethereum'; import { fieldSerializer, @@ -52,4 +52,14 @@ export default class EthereumAddressField extends FieldDef { static embedded = View; static atom = View; static edit = Edit; + + // CS-10786: addresses are hex strings (`0x...`) — no metacharacters in the + // typical output, but pass through `markdownEscape` defensively so an + // invalid or future-format value can't escape into surrounding markdown. + static markdown = class Markdown extends Component { + get text() { + return markdownEscape(this.args.model); + } + + }; } diff --git a/packages/base/field-component.gts b/packages/base/field-component.gts index 019d6457b9c..7fe3ad08040 100644 --- a/packages/base/field-component.gts +++ b/packages/base/field-component.gts @@ -318,6 +318,8 @@ export function getBoxComponent( fieldName=field.name }} style={{getThemeStyles card}} + data-boxel-card-id={{card.id}} + data-boxel-card-format={{effectiveFormats.cardDef}} data-test-card={{card.id}} data-test-card-format={{effectiveFormats.cardDef}} data-test-field-component-card @@ -541,6 +543,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/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/llm-model.gts b/packages/base/llm-model.gts index f5892fb32da..f64a27aad9c 100644 --- a/packages/base/llm-model.gts +++ b/packages/base/llm-model.gts @@ -1,6 +1,7 @@ import { Component } from './card-api'; import StringField from './string'; import { BoxelSelect } from '@cardstack/boxel-ui/components'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; import { DEFAULT_LLM_ID_TO_NAME } from '@cardstack/runtime-common'; const LLM_MODEL_OPTIONS = Object.entries(DEFAULT_LLM_ID_TO_NAME).map( @@ -44,4 +45,20 @@ class LLMModelEdit extends Component { export default class LLMModelField extends StringField { static displayName = 'LLM Model'; static edit = LLMModelEdit; + + // CS-10786: prefer the human-readable label when we recognize the model + // id, falling back to the id itself. Escaped so any metacharacters in the + // label don't leak into the document. + static markdown = class Markdown extends Component { + get text() { + let value = this.args.model; + if (value == null || value === '') { + return ''; + } + let label = + (DEFAULT_LLM_ID_TO_NAME as Record)[value] ?? value; + return markdownEscape(label); + } + + }; } 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 new file mode 100644 index 00000000000..ca055cf5b40 --- /dev/null +++ b/packages/base/markdown-helpers.ts @@ -0,0 +1,254 @@ +// CS-10786: Shared helpers for `static markdown` templates on specialized +// fields. Centralized here so all fields agree on date formatting and link +// construction, and so future adjustments happen in one place. + +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; +import { isValidDate } from '@cardstack/runtime-common'; + +// Date formatting shared by DateField, DateTimeField, and DateRangeField so +// their markdown output is consistent. Matches the existing `en-US` `{year: +// numeric, month: short, day: numeric}` pattern already used by each field's +// HTML view; DateTime appends hour/minute. +const MARKDOWN_DATE_FORMAT = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', +}); + +const MARKDOWN_DATETIME_FORMAT = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour12: true, + hour: 'numeric', + minute: '2-digit', +}); + +// Markdown strings returned from these helpers are always pre-escaped: any +// metacharacters in the formatted output (e.g. commas in dates are fine, but +// `*`/`_`/`#` could appear in locale-specific variants) are run through +// `markdownEscape`. Callers can interpolate the result directly into a +// surrounding markdown document. + +export function formatDateForMarkdown(value: Date | null | undefined): string { + if (value == null || !isValidDate(value)) { + return ''; + } + return markdownEscape(MARKDOWN_DATE_FORMAT.format(value)); +} + +export function formatDateTimeForMarkdown( + value: Date | null | undefined, +): string { + if (value == null || !isValidDate(value)) { + return ''; + } + return markdownEscape(MARKDOWN_DATETIME_FORMAT.format(value)); +} + +export function formatDateRangeForMarkdown( + start: Date | null | undefined, + end: Date | null | undefined, +): string { + let startText = formatDateForMarkdown(start); + let endText = formatDateForMarkdown(end); + if (!startText && !endText) { + return ''; + } + // `–` (en-dash) would be nicer typography, but keep it ASCII-friendly so + // downstream markdown tooling doesn't have to normalize. The `-` below is + // literal; it's not at line start, so `markdownEscape`'s bullet-marker rule + // does not apply. + return `${startText} - ${endText}`; +} + +// Build a markdown inline link: `[escaped text](encoded href)`. The text is +// run through `markdownEscape` so any metacharacters in the visible text +// don't break formatting; the href is passed through `encodeURI` to quote +// whitespace and other unsafe characters without over-encoding already- +// percent-encoded URLs. +export function markdownLink( + text: string | null | undefined, + href: string | null | undefined, +): string { + let safeText = markdownEscape(text ?? ''); + let rawHref = href ?? ''; + let encodedHref: string; + try { + encodedHref = encodeURI(rawHref); + } catch { + encodedHref = rawHref; + } + // Parentheses inside a URL break the markdown link syntax; escape them. + encodedHref = encodedHref.replace(/\(/g, '%28').replace(/\)/g, '%29'); + return `[${safeText}](${encodedHref})`; +} + +// CS-10797: Convenience helpers for rendering linksTo / linksToMany +// relationships as markdown links. Template authors call these explicitly; +// they are not wired into default markdown rendering. + +// Minimal shape expected from a linked card — keeps this module free of +// heavyweight card-api imports. +interface CardLike { + id?: string; + cardTitle?: string; +} + +// Returns `[text](card.id)` for a single linked card. Falls back to +// `card.cardTitle` when `text` is omitted. Returns `''` for null/undefined +// cards so callers can handle placeholders themselves. +export function markdownLinkForCard( + card: CardLike | null | undefined, + text?: string, +): string { + if (!card) { + return ''; + } + let linkText = text ?? card.cardTitle ?? ''; + return markdownLink(linkText, card.id); +} + +interface MarkdownLinksForCardsOptions { + style?: 'list' | 'inline'; + text?: (card: CardLike) => string; +} + +// Renders an array of linked cards as markdown links. +// `style: 'list'` (default) emits `- [Title](id)` per line. +// `style: 'inline'` emits comma-separated `[A](idA), [B](idB)`. +// Null entries in the array are skipped. Empty / all-null arrays return `''`. +export function markdownLinksForCards( + cards: (CardLike | null | undefined)[] | null | undefined, + options?: MarkdownLinksForCardsOptions, +): string { + if (!cards) { + return ''; + } + let style = options?.style ?? 'list'; + let textFn = options?.text; + let links: string[] = []; + for (let card of cards) { + if (!card) continue; + let linkText = textFn ? textFn(card) : undefined; + let link = markdownLinkForCard(card, linkText); + if (link) { + links.push(link); + } + } + if (links.length === 0) { + return ''; + } + if (style === 'inline') { + return links.join(', '); + } + return links.map((l) => `- ${l}`).join('\n'); +} + +// CS-10797: Convenience helpers for embedding cards using BFM (Boxel File +// Model) syntax. Inline embeds render the card inline (`:card[URL]`), block +// embeds render on their own line (`::card[URL]` or `::card[URL | spec]`). + +interface MarkdownEmbedOptions { + // 'inline' produces `:card[URL]`, 'block' produces `::card[URL]`. + // Default: 'block'. + kind?: 'inline' | 'block'; + // Size specifier appended after `|` in block embeds (e.g. 'fitted 250x40', + // 'isolated', 'strip'). Ignored for inline embeds. + size?: string; +} + +// Returns a BFM card-reference directive for a single card. +// Returns `''` for null/undefined cards. +export function markdownEmbedForCard( + card: CardLike | null | undefined, + options?: MarkdownEmbedOptions, +): string { + if (!card?.id) { + return ''; + } + let kind = options?.kind ?? 'block'; + if (kind === 'inline') { + return `:card[${card.id}]`; + } + let size = options?.size; + if (size) { + return `::card[${card.id} | ${size}]`; + } + return `::card[${card.id}]`; +} + +interface MarkdownEmbedsOptions extends MarkdownEmbedOptions { + // Separator between embeds. Defaults to '\n\n' for block, ', ' for inline. + separator?: string; +} + +// Renders an array of cards as BFM card-reference directives. +// Null entries are skipped. Empty / all-null arrays return `''`. +export function markdownEmbedsForCards( + cards: (CardLike | null | undefined)[] | null | undefined, + options?: MarkdownEmbedsOptions, +): string { + if (!cards) { + return ''; + } + let embeds: string[] = []; + for (let card of cards) { + let embed = markdownEmbedForCard(card, options); + if (embed) { + embeds.push(embed); + } + } + if (embeds.length === 0) { + return ''; + } + let kind = options?.kind ?? 'block'; + let separator = options?.separator ?? (kind === 'inline' ? ' ' : '\n\n'); + return embeds.join(separator); +} + +// 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/menu-items.ts b/packages/base/menu-items.ts index cf5b41a800d..6b75ff9b305 100644 --- a/packages/base/menu-items.ts +++ b/packages/base/menu-items.ts @@ -4,6 +4,7 @@ import { type MenuItemOptions, } from '@cardstack/boxel-ui/helpers'; +import CopyCardAsMarkdownCommand from '@cardstack/boxel-host/commands/copy-card-as-markdown'; import CopyCardCommand from '@cardstack/boxel-host/commands/copy-card'; import GenerateExampleCardsCommand from '@cardstack/boxel-host/commands/generate-example-cards'; import OpenCreateListingModalCommand from '@cardstack/boxel-host/commands/open-create-listing-modal'; @@ -27,6 +28,7 @@ import { resolveAdoptsFrom } from '@cardstack/runtime-common'; import CodeIcon from '@cardstack/boxel-icons/code'; import ArrowLeft from '@cardstack/boxel-icons/arrow-left'; +import ClipboardCopy from '@cardstack/boxel-icons/clipboard-copy'; import Eye from '@cardstack/boxel-icons/eye'; import LinkIcon from '@cardstack/boxel-icons/link'; import Trash2Icon from '@cardstack/boxel-icons/trash-2'; @@ -72,6 +74,15 @@ export function getDefaultCardMenuItems( }); } if (params.menuContext === 'interact') { + menuItems.push({ + label: 'Copy as Markdown', + action: () => + new CopyCardAsMarkdownCommand(params.commandContext).execute({ + cardId, + }), + icon: ClipboardCopy, + disabled: !cardId, + }); if ( !isRealmIndexCard(card) && // workspace index card cannot be deleted cardId && diff --git a/packages/base/percentage.gts b/packages/base/percentage.gts index e4264e98821..95e01640097 100644 --- a/packages/base/percentage.gts +++ b/packages/base/percentage.gts @@ -1,5 +1,6 @@ import { Component } from './card-api'; import NumberField from './number'; +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; import PercentageIcon from '@cardstack/boxel-icons/square-percentage'; @@ -40,4 +41,16 @@ export default class PercentageField extends NumberField { {{/if}} }; + + // 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/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/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/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 { }; + + // 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/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts index 3c7d2426707..afbe4e2fb97 100644 --- a/packages/boxel-cli/tests/helpers/integration.ts +++ b/packages/boxel-cli/tests/helpers/integration.ts @@ -38,6 +38,7 @@ const noopPrerenderer: Prerenderer = { embeddedHTML: null, fittedHTML: null, iconHTML: null, + markdown: null, error: { type: 'instance-error' as const, error: { diff --git a/packages/boxel-ui/addon/src/helpers.ts b/packages/boxel-ui/addon/src/helpers.ts index 3040d8b3c6a..aede220ee49 100644 --- a/packages/boxel-ui/addon/src/helpers.ts +++ b/packages/boxel-ui/addon/src/helpers.ts @@ -24,6 +24,7 @@ import formatOrdinal from './helpers/format-ordinal.ts'; import formatPeriod from './helpers/format-period.ts'; import formatRelativeTime from './helpers/format-relative-time.ts'; import { generateCssVariables } from './helpers/generate-css-variables.ts'; +import { markdownEscape } from './helpers/markdown-escape.ts'; import { add, divide, multiply, subtract } from './helpers/math-helpers.ts'; import menuDivider, { MenuDivider } from './helpers/menu-divider.ts'; import menuItem, { @@ -101,6 +102,7 @@ export { gte, lt, lte, + markdownEscape, MenuDivider, menuDivider, MenuItem, diff --git a/packages/boxel-ui/addon/src/helpers/markdown-escape.ts b/packages/boxel-ui/addon/src/helpers/markdown-escape.ts new file mode 100644 index 00000000000..dfe173fb2b9 --- /dev/null +++ b/packages/boxel-ui/addon/src/helpers/markdown-escape.ts @@ -0,0 +1,36 @@ +// Escape CommonMark + GFM metacharacters so user-supplied content can be +// safely interpolated into a markdown template (`static markdown`) without +// accidentally triggering formatting. The returned plain string is HTML- +// escaped by Glimmer in the DOM, then the prerender textContent-extraction +// step (see CS-10782) decodes those entities so the markdown parser sees +// the backslash escapes and renders the input as literal text. + +// Characters that are always escaped. Includes: +// \ ` * _ — emphasis / inline code +// [ ] ( ) — links / images +// < > — HTML / autolink / blockquote (line-start case handled +// incidentally by unconditional escape) +// | — GFM table separator +// ~ — GFM strikethrough +// ! — image marker (before `[`) +// # — ATX headings (line-start, but safe to escape anywhere) +// + - — unordered list markers (line-start, but safe to +// escape anywhere; `\+` and `\-` render as `+`/`-`) +const ESCAPE_CHARS = /[\\`*_[\]()<>|~!#+-]/g; + +// Numeric list prefixes like `1.` or `42.` at the start of a (possibly +// indented) line. `)` is already covered by ESCAPE_CHARS, so `1)` becomes +// `1\)` via the always-escape pass. +const NUMERIC_LIST_PREFIX = /^(\s*\d+)\./gm; + +export function markdownEscape(input: unknown): string { + if (input == null) { + return ''; + } + let str = typeof input === 'string' ? input : String(input); + let escaped = str.replace(ESCAPE_CHARS, (c) => `\\${c}`); + escaped = escaped.replace(NUMERIC_LIST_PREFIX, '$1\\.'); + return escaped; +} + +export default markdownEscape; diff --git a/packages/boxel-ui/test-app/tests/unit/markdown-escape-test.ts b/packages/boxel-ui/test-app/tests/unit/markdown-escape-test.ts new file mode 100644 index 00000000000..3fe696f7878 --- /dev/null +++ b/packages/boxel-ui/test-app/tests/unit/markdown-escape-test.ts @@ -0,0 +1,150 @@ +import { module, test } from 'qunit'; + +import { markdownEscape } from '@cardstack/boxel-ui/helpers'; + +// markdownEscape emits CommonMark backslash escapes. Per the CommonMark spec +// (https://spec.commonmark.org/0.30/#backslash-escapes), any ASCII punctuation +// character that is backslash-escaped renders as the literal character. These +// unit tests verify that the helper emits the correct `\X` sequence for each +// metacharacter the spec treats as escapable — which is equivalent to a +// round-trip guarantee that the escaped output, parsed as markdown, yields +// the literal input text. + +module('Unit | markdown-escape', function () { + test('escapes asterisks (emphasis / bold / list)', function (assert) { + assert.strictEqual(markdownEscape('*bold*'), '\\*bold\\*'); + assert.strictEqual(markdownEscape('**strong**'), '\\*\\*strong\\*\\*'); + }); + + test('escapes underscores (emphasis / bold)', function (assert) { + assert.strictEqual(markdownEscape('_em_'), '\\_em\\_'); + assert.strictEqual(markdownEscape('__strong__'), '\\_\\_strong\\_\\_'); + }); + + test('escapes backticks (inline code / fences)', function (assert) { + assert.strictEqual(markdownEscape('`code`'), '\\`code\\`'); + assert.strictEqual(markdownEscape('```'), '\\`\\`\\`'); + }); + + test('escapes `#` (ATX headings)', function (assert) { + assert.strictEqual(markdownEscape('# Heading'), '\\# Heading'); + assert.strictEqual(markdownEscape('## Sub'), '\\#\\# Sub'); + // `#` inside text is also escaped — `\#` renders as `#` literal, which + // is harmless and avoids false-positive headings when a line happens to + // begin with `#` after trimming. + assert.strictEqual(markdownEscape('room #4'), 'room \\#4'); + }); + + test('escapes `-` (unordered list / setext h2 / thematic break)', function (assert) { + assert.strictEqual(markdownEscape('- item'), '\\- item'); + assert.strictEqual(markdownEscape('---'), '\\-\\-\\-'); + // Mid-text hyphens are also escaped; `\-` renders as `-`. + assert.strictEqual(markdownEscape('sign-in'), 'sign\\-in'); + }); + + test('escapes `+` (unordered list marker)', function (assert) { + assert.strictEqual(markdownEscape('+ item'), '\\+ item'); + assert.strictEqual(markdownEscape('a + b'), 'a \\+ b'); + }); + + test('escapes `>` (blockquote / HTML bracket)', function (assert) { + assert.strictEqual(markdownEscape('> quote'), '\\> quote'); + assert.strictEqual(markdownEscape('a > b'), 'a \\> b'); + }); + + test('escapes `[` and `]` (link / image text)', function (assert) { + assert.strictEqual(markdownEscape('[link](url)'), '\\[link\\]\\(url\\)'); + }); + + test('escapes `(` and `)` (link / image URL)', function (assert) { + assert.strictEqual(markdownEscape('(paren)'), '\\(paren\\)'); + }); + + test('escapes `!` (image marker)', function (assert) { + assert.strictEqual(markdownEscape('![alt](src)'), '\\!\\[alt\\]\\(src\\)'); + assert.strictEqual(markdownEscape('Wow!'), 'Wow\\!'); + }); + + test('escapes `|` (GFM table separator)', function (assert) { + assert.strictEqual(markdownEscape('a | b'), 'a \\| b'); + assert.strictEqual(markdownEscape('| h |'), '\\| h \\|'); + }); + + test('escapes `\\` (backslash)', function (assert) { + assert.strictEqual(markdownEscape('\\'), '\\\\'); + assert.strictEqual(markdownEscape('a\\b'), 'a\\\\b'); + // A literal `\*` in the source should become `\\\*` so the parser sees + // an escaped backslash followed by an escaped asterisk — preserving the + // original two characters as literals. + assert.strictEqual(markdownEscape('\\*'), '\\\\\\*'); + }); + + test('escapes `<` and `>` (HTML / autolink brackets)', function (assert) { + assert.strictEqual( + markdownEscape(''), + '\\alert\\(1\\)\\', + ); + assert.strictEqual( + markdownEscape(''), + '\\', + ); + }); + + test('escapes numeric list prefixes at line start (e.g. `1.`)', function (assert) { + assert.strictEqual(markdownEscape('1. first'), '1\\. first'); + assert.strictEqual(markdownEscape('42. answer'), '42\\. answer'); + // Multi-line: each line-start numeric prefix gets escaped. + assert.strictEqual(markdownEscape('1. one\n2. two'), '1\\. one\n2\\. two'); + // Indented list is also escaped (leading whitespace preserved). + assert.strictEqual(markdownEscape(' 3. indented'), ' 3\\. indented'); + // Periods mid-sentence are NOT escaped — they are only meaningful as list + // markers when at line start after digits. + assert.strictEqual(markdownEscape('v1.2.3'), 'v1.2.3'); + assert.strictEqual(markdownEscape('End of sentence.'), 'End of sentence.'); + }); + + test('escapes numeric list prefixes with `)` via always-escape', function (assert) { + // `)` is always escaped, so `1)` at any position becomes `1\)`. + assert.strictEqual(markdownEscape('1) first'), '1\\) first'); + }); + + test('escapes `~` (GFM strikethrough)', function (assert) { + assert.strictEqual(markdownEscape('~~del~~'), '\\~\\~del\\~\\~'); + }); + + test('handles null input by returning empty string', function (assert) { + assert.strictEqual(markdownEscape(null), ''); + }); + + test('handles undefined input by returning empty string', function (assert) { + assert.strictEqual(markdownEscape(undefined), ''); + }); + + test('coerces non-string inputs via String()', function (assert) { + assert.strictEqual(markdownEscape(42), '42'); + assert.strictEqual(markdownEscape(true), 'true'); + assert.strictEqual(markdownEscape(false), 'false'); + // Numbers containing a `.` are coerced to string but the `.` is only + // escaped at line start after digits — which applies here. + assert.strictEqual(markdownEscape(1.5), '1\\.5'); + }); + + test('returns empty string for empty string input', function (assert) { + assert.strictEqual(markdownEscape(''), ''); + }); + + test('leaves safe characters untouched', function (assert) { + assert.strictEqual( + markdownEscape('Hello world, how are you today?'), + 'Hello world, how are you today?', + ); + }); + + test('handles combined metacharacters without double-escaping', function (assert) { + // Input with many metacharacters — verify each is escaped exactly once. + let input = '# Title *bold* _em_ `code` [link](url) | ~strike~'; + let expected = + '\\# Title \\*bold\\* \\_em\\_ \\`code\\` \\[link\\]\\(url\\) \\| \\~strike\\~'; + assert.strictEqual(markdownEscape(input), expected); + }); +}); diff --git a/packages/experiments-realm/BlogPost/mad-as-a-hatter.json b/packages/experiments-realm/BlogPost/mad-as-a-hatter.json index e354f8ef2ce..1dc5e60b2f4 100644 --- a/packages/experiments-realm/BlogPost/mad-as-a-hatter.json +++ b/packages/experiments-realm/BlogPost/mad-as-a-hatter.json @@ -1,26 +1,44 @@ { "data": { + "meta": { + "adoptsFrom": { + "name": "BlogPost", + "module": "../blog-post" + } + }, "type": "card", "attributes": { - "headline": "Mad As a Hatter", - "slug": "mad-as-a-hatter", "body": "## Where it all begins\n\nThis is a story of a man named [Brady](https://eightiesforbrady.com), who was bringing up three very lovely girls under the rule of the Queen of Hearts.", + "slug": "mad-as-a-hatter", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": "https://images.unsplash.com/photo-1551422788-18e2a1f5bb88?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + }, + "headline": "Mad As a Hatter", "publishDate": "2034-11-20T18:00:00.000Z", "featuredImage": { - "imageUrl": "https://images.unsplash.com/photo-1551422788-18e2a1f5bb88?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - "credit": null, - "caption": "We're all mad here.", - "altText": "", "size": "contain", + "width": null, + "credit": null, "height": 400, - "width": null - }, - "cardInfo": { - "summary": null, - "cardThumbnailURL": "https://images.unsplash.com/photo-1551422788-18e2a1f5bb88?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + "altText": "", + "caption": "We're all mad here.", + "imageUrl": "https://images.unsplash.com/photo-1551422788-18e2a1f5bb88?q=80&w=3087&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" } }, "relationships": { + "blog": { + "links": { + "self": null + } + }, + "editors": { + "links": { + "self": null + } + }, "authors.0": { "links": { "self": "../Author/alice-enwunder" @@ -36,22 +54,21 @@ "self": "../Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7" } }, - "blog": { + "categories": { "links": { "self": null } }, - "editors": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { "links": { "self": null } - } - }, - "meta": { - "adoptsFrom": { - "module": "../blog-post", - "name": "BlogPost" } } } -} +} \ No newline at end of file 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/blog-post.gts b/packages/experiments-realm/blog-post.gts index 5cc9a2ff8bb..c23d034ccfa 100644 --- a/packages/experiments-realm/blog-post.gts +++ b/packages/experiments-realm/blog-post.gts @@ -23,6 +23,13 @@ import { formatDatetime, BlogApp as BlogAppCard } from './blog-app'; import { BlogCategory, categoryStyle } from './blog-category'; import { User } from './user'; import { markdownToHtml } from '@cardstack/runtime-common/marked-sync'; +import { + markdownLinkForCard, + markdownLinksForCards, + markdownEmbedsForCards, + markdownImage, + formatDateTimeForMarkdown, +} from 'https://cardstack.com/base/markdown-helpers'; class EmbeddedTemplate extends Component {