From 1420822cbc1f1b88121354e23ff749da0f3891b1 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 2 Apr 2026 09:16:36 -0400 Subject: [PATCH 01/50] Add BFM card references for MarkdownDef (CS-10570) Support `:card[URL]` (inline) and `::card[URL]` (block) Boxel Flavored Markdown syntax in MarkdownDef files. Inline references render as atom-format cards, block references as embedded-format. The parsing utility is keyword- generic to support future `:file` syntax (CS-10583). Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/markdown.gts | 106 ++++++- packages/base/markdown-file-def.gts | 37 ++- .../acceptance/markdown-file-def-test.gts | 129 ++++++++- .../tests/unit/bfm-card-references-test.ts | 260 ++++++++++++++++++ .../runtime-common/bfm-card-references.ts | 176 ++++++++++++ packages/runtime-common/index.ts | 1 + packages/runtime-common/marked-sync.ts | 5 + 7 files changed, 708 insertions(+), 6 deletions(-) create mode 100644 packages/host/tests/unit/bfm-card-references-test.ts create mode 100644 packages/runtime-common/bfm-card-references.ts diff --git a/packages/base/default-templates/markdown.gts b/packages/base/default-templates/markdown.gts index 8f97aa38790..8d53511f171 100644 --- a/packages/base/default-templates/markdown.gts +++ b/packages/base/default-templates/markdown.gts @@ -2,12 +2,15 @@ import { task } from 'ember-concurrency'; import GlimmerComponent from '@glimmer/component'; import { cached, tracked } from '@glimmer/tracking'; import { htmlSafe } from '@ember/template'; +import { modifier } from 'ember-modifier'; import { hasCodeBlocks, markdownToHtml, preloadMarkdownLanguages, + resolveCardReference, } from '@cardstack/runtime-common'; +import { type CardDef, getComponent } from '../card-api'; function wrapTablesHtml(html: string | null | undefined): string { if (!html) return ''; // Fast path when there are no tables to wrap. @@ -25,10 +28,29 @@ function wrapTablesHtml(html: string | null | undefined): string { return doc.body.innerHTML; } +interface CardSlot { + element: HTMLElement; + card: CardDef; + format: 'atom' | 'embedded'; +} + +function resolveUrl(raw: string, baseUrl: string | null | undefined): string { + try { + return resolveCardReference(raw, baseUrl || undefined); + } catch { + return raw; + } +} + export default class MarkDownTemplate extends GlimmerComponent<{ - Args: { content: string | null }; + Args: { + content: string | null; + linkedCards?: BaseDef[] | null; + cardReferenceBaseUrl?: string | null; + }; }> { @tracked monacoContextInternal: any = undefined; + @tracked cardSlots: CardSlot[] = []; get isPrerenderContext() { return Boolean((globalThis as any).__boxelRenderContext); } @@ -73,8 +95,75 @@ export default class MarkDownTemplate extends GlimmerComponent<{ return htmlSafe(wrapTablesHtml(html)); } + captureCardSlots = modifier( + (element: HTMLElement, _positional: unknown[]) => { + let linkedCards = this.args.linkedCards; + let baseUrl = this.args.cardReferenceBaseUrl; + + if (!linkedCards?.length) { + if (this.cardSlots.length > 0) { + this.cardSlots = []; + } + return; + } + + let cardsByUrl = new Map(); + for (let card of linkedCards) { + if (card?.id) { + cardsByUrl.set(card.id, card); + } + } + + let slots: CardSlot[] = []; + + for (let el of element.querySelectorAll( + '[data-boxel-bfm-inline-ref][data-boxel-bfm-type="card"]', + )) { + let rawUrl = el.dataset.boxelBfmInlineRef; + if (!rawUrl) continue; + let resolved = resolveUrl(rawUrl, baseUrl); + let card = cardsByUrl.get(resolved); + if (card) { + slots.push({ element: el, card, format: 'atom' }); + } else { + el.textContent = rawUrl; + } + } + + for (let el of element.querySelectorAll( + '[data-boxel-bfm-block-ref][data-boxel-bfm-type="card"]', + )) { + let rawUrl = el.dataset.boxelBfmBlockRef; + if (!rawUrl) continue; + let resolved = resolveUrl(rawUrl, baseUrl); + let card = cardsByUrl.get(resolved); + if (card) { + slots.push({ element: el, card, format: 'embedded' }); + } else { + el.textContent = rawUrl; + } + } + + this.cardSlots = slots; + }, + ); + + getCardComponent = (card: BaseDef) => getComponent(card); + diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index e142814176a..7c2ace68fea 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -1,11 +1,17 @@ -import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { + byteStreamToUint8Array, + extractCardReferenceUrls, +} from '@cardstack/runtime-common'; import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle'; import { BaseDefComponent, + CardDef, Component, StringField, contains, + containsMany, field, + linksToMany, } from './card-api'; import MarkdownTemplate from './default-templates/markdown'; import { @@ -125,7 +131,11 @@ class Isolated extends Component {