From 7b7e09fa4a98d62e41024b7d6d2a20216282079c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 15:02:00 -0400 Subject: [PATCH 1/8] Add RichMarkdown playground card and integration tests for card embedding Add a playground card in the experiments realm exercising the RichMarkdown field (standard markdown, GFM alerts, card references, math, mermaid, tables, code blocks, footnotes). Add two integration tests covering the full card-in-markdown rendering pipeline: one with absolute URLs and one with relative paths, both verifying that referenced cards actually render inside the markdown (not just that placeholders exist). Co-Authored-By: Claude Opus 4.6 --- .../rich-markdown-playground-1.json | 30 ++++ .../rich-markdown-playground.gts | 69 ++++++++ .../components/rich-markdown-field-test.gts | 162 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 packages/experiments-realm/rich-markdown-playground-1.json create mode 100644 packages/experiments-realm/rich-markdown-playground.gts diff --git a/packages/experiments-realm/rich-markdown-playground-1.json b/packages/experiments-realm/rich-markdown-playground-1.json new file mode 100644 index 0000000000..a981fb2ffc --- /dev/null +++ b/packages/experiments-realm/rich-markdown-playground-1.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Rich MarkdownPlayground", + "body": { + "content": "# Boxel Flavored Markdown Showcase\n\nThis playground exercises the **RichMarkdown** field and its support for Boxel Flavored Markdown (BFM) extensions.\n\n## Standard Markdown\n\nRegular markdown works as expected: **bold**, *italic*, ~~strikethrough~~, and `inline code`.\n\n### Ordered List\n\n1. First item\n2. Second item\n3. Third item with **bold** and `code`\n\n### Unordered List\n\n- Bullet one\n- Bullet two\n - Nested bullet\n - Another nested bullet\n- Bullet three\n\n### Blockquote\n\n> The best way to predict the future is to invent it.\n> — Alan Kay\n\n---\n\n## GFM Alerts\n\n> [!NOTE]\n> This is a **note** callout — useful for highlighting important information.\n\n> [!TIP]\n> You can nest `code`, **bold**, and *italic* inside alerts.\n\n> [!WARNING]\n> Be careful when editing production data.\n\n---\n\n## Card References\n\nInline card reference: :card[./Author/alice-enwunder]\n\nBlock card reference:\n\n::card[./Author/jane-doe]\n\nBlock card at strip size:\n\n::card[./Author/jane-doe | strip]\n\n---\n\n## Math / LaTeX\n\nThe quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ and appears inline.\n\nEuler's identity in a display block:\n\n$$\ne^{i\\pi} + 1 = 0\n$$\n\nA summation:\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n$$\n\n---\n\n## Mermaid Diagrams\n\n```mermaid\nflowchart TD\n A[Markdown Source] --> B[marked.parse]\n B --> C{Has Placeholders?}\n C -->|Math| D[KaTeX Render]\n C -->|Mermaid| E[Mermaid Render]\n C -->|Card Ref| F[Card Slot Modifier]\n D --> G[Final Output]\n E --> G\n F --> G\n```\n\nA sequence diagram:\n\n```mermaid\nsequenceDiagram\n participant User\n participant Host\n participant Realm\n User->>Host: Open markdown file\n Host->>Realm: Fetch file content\n Realm-->>Host: Markdown + linked cards\n Host-->>User: Rendered document\n```\n\n---\n\n## Tables\n\n| Feature | Rendering | Bundle Impact |\n| ------- | --------- | ------------- |\n| GFM Alerts | Static HTML | None |\n| Footnotes | Static HTML | None |\n| Math / LaTeX | Dynamic | Lazy KaTeX (~268 KB) |\n| Mermaid | Dynamic | Lazy Mermaid (~2 MB) |\n\n---\n\n## Code Blocks\n\n```typescript\nimport RichMarkdownField from 'https://cardstack.com/base/rich-markdown';\nimport { CardDef, contains, field } from 'https://cardstack.com/base/card-api';\n\nexport class Article extends CardDef {\n @field body = contains(RichMarkdownField);\n}\n```\n\n---\n\n## Footnotes\n\nBoxel Flavored Markdown[^1] extends standard GFM with additional features for rich document rendering.\n\nMath rendering uses KaTeX[^2] for typesetting, while diagrams use Mermaid.js[^3].\n\n[^1]: BFM is the Boxel extension to GitHub Flavored Markdown.\n[^2]: KaTeX is a fast math typesetting library for the web.\n[^3]: Mermaid lets you create diagrams using a markdown-like syntax." + }, + "cardInfo": { + "notes": null, + "name": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "./rich-markdown-playground", + "name": "RichMarkdownPlayground" + } + } + } +} diff --git a/packages/experiments-realm/rich-markdown-playground.gts b/packages/experiments-realm/rich-markdown-playground.gts new file mode 100644 index 0000000000..dfe4e62fa7 --- /dev/null +++ b/packages/experiments-realm/rich-markdown-playground.gts @@ -0,0 +1,69 @@ +import { + contains, + field, + CardDef, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import RichMarkdownField from 'https://cardstack.com/base/rich-markdown'; +import TextIcon from '@cardstack/boxel-icons/text'; + +export class RichMarkdownPlayground extends CardDef { + static displayName = 'Rich Markdown Playground'; + static icon = TextIcon; + + @field title = contains(StringField); + @field body = contains(RichMarkdownField); + + static isolated = class Isolated extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; +} diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index 7db96ef2b4..ed697a1f9c 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -1,4 +1,5 @@ import type { RenderingTestContext } from '@ember/test-helpers'; +import { waitFor } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; @@ -11,6 +12,8 @@ import { } from '@cardstack/runtime-common'; import type { Loader } from '@cardstack/runtime-common/loader'; +import type { BaseDef } from 'https://cardstack.com/base/card-api'; + import { provideConsumeContext, setupCardLogs, @@ -22,6 +25,7 @@ import { setupBaseRealm, CardDef, Component, + StringField, RichMarkdownField, contains, field, @@ -311,4 +315,162 @@ module('Integration | RichMarkdownField', function (hooks) { 'footnote content is present', ); }); + + test('linkedCards render inside the markdown when card is loaded from realm', async function (assert) { + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static embedded = class Embedded extends Component { + + }; + static atom = class Atom extends Component { + + }; + } + + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + static isolated = class Isolated extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { + adoptsFrom: { module: '../pet', name: 'Pet' }, + }, + }, + }, + 'article-1.json': { + data: { + attributes: { + body: { + content: `Inline ref: :card[${testRealmURL}Pet/mango]\n\nBlock ref:\n\n::card[${testRealmURL}Pet/mango]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let store = getService('store'); + let article = await store.get(`${testRealmURL}article-1`); + await store.loaded(); + + await renderCard(loader, article, 'isolated'); + + await waitFor('[data-test-pet-atom]', { timeout: 10_000 }); + + assert + .dom('[data-test-pet-atom]') + .exists('inline card reference renders the referenced card in atom format'); + assert + .dom('[data-test-pet-atom]') + .hasText('Mango', 'inline atom shows the correct card'); + + assert + .dom('[data-test-pet-embedded]') + .exists('block card reference renders the referenced card in embedded format'); + assert + .dom('[data-test-pet-embedded]') + .hasText('Mango', 'block embedded shows the correct card'); + }); + + test('linkedCards resolve when markdown uses relative card references', async function (assert) { + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static embedded = class Embedded extends Component { + + }; + static atom = class Atom extends Component { + + }; + } + + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + static isolated = class Isolated extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { + adoptsFrom: { module: '../pet', name: 'Pet' }, + }, + }, + }, + 'article-1.json': { + data: { + attributes: { + body: { + content: `Inline ref: :card[./Pet/mango]\n\nBlock ref:\n\n::card[./Pet/mango]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let store = getService('store'); + let article = await store.get(`${testRealmURL}article-1`); + await store.loaded(); + + await renderCard(loader, article, 'isolated'); + + await waitFor('[data-test-pet-atom]', { timeout: 10_000 }); + + assert + .dom('[data-test-pet-atom]') + .exists('inline card reference with relative path renders the referenced card'); + assert + .dom('[data-test-pet-atom]') + .hasText('Mango', 'inline atom shows the correct card'); + + assert + .dom('[data-test-pet-embedded]') + .exists('block card reference with relative path renders the referenced card'); + assert + .dom('[data-test-pet-embedded]') + .hasText('Mango', 'block embedded shows the correct card'); + }); }); From 033604b9171a713f73ad1f590d72bef06c146d14 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 17:04:16 -0400 Subject: [PATCH 2/8] Add visual states for unresolved card references in BFM markdown Unresolved :card[URL] and ::card[URL] references now show a muted Pill with a broken-link icon and the card type name instead of raw URL text. A CSS shimmer animation displays while linkedCards is still loading. Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/markdown.gts | 130 ++++++++++++++++-- packages/experiments-realm/bfm-showcase.md | 4 +- .../acceptance/markdown-file-def-test.gts | 31 ++++- .../components/rich-markdown-field-test.gts | 68 ++++++++- .../tests/bfm-card-references-test.ts | 36 +++++ packages/realm-server/tests/index.ts | 1 + .../runtime-common/bfm-card-references.ts | 26 ++++ .../tests/bfm-card-references-test.ts | 46 +++++++ 8 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 packages/realm-server/tests/bfm-card-references-test.ts create mode 100644 packages/runtime-common/tests/bfm-card-references-test.ts diff --git a/packages/base/default-templates/markdown.gts b/packages/base/default-templates/markdown.gts index 44e750ea62..0aa410a895 100644 --- a/packages/base/default-templates/markdown.gts +++ b/packages/base/default-templates/markdown.gts @@ -5,9 +5,12 @@ import { cached, tracked } from '@glimmer/tracking'; import { htmlSafe } from '@ember/template'; import { modifier } from 'ember-modifier'; +import { Pill } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; +import LinkOffIcon from '@cardstack/boxel-icons/link-off'; import { + cardTypeName, extractMermaidBlocks, hasCodeBlocks, markdownToHtml, @@ -46,6 +49,13 @@ interface CardSlot { style?: ReturnType; } +interface UnresolvedSlot { + element: HTMLElement; + url: string; + typeName: string; + kind: 'inline' | 'block'; +} + function resolveUrl(raw: string, baseUrl: string | null | undefined): string { try { return trimJsonExtension(resolveCardReference(raw, baseUrl || undefined)); @@ -63,6 +73,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ }> { @tracked monacoContextInternal: any = undefined; @tracked cardSlots: CardSlot[] = []; + @tracked unresolvedSlots: UnresolvedSlot[] = []; // Tracks whether the modifier has run at least once. On the first run, // linkedCards is likely still loading (empty []) so we skip fallback text // injection to avoid flashing raw URLs for cards that will soon resolve. @@ -233,11 +244,10 @@ export default class MarkDownTemplate extends GlimmerComponent<{ } } - // Inject fallback text for unresolvable card refs. Text content was - // stripped from the HTML in renderedHtml to prevent flash, so the URL - // must be read from the data attribute. Only runs after the first - // modifier setup (showFallback) so we don't flash URLs while - // linkedCards is still loading. + // Build unresolved slots for card refs that could not be matched to a + // linkedCard. Only after the first modifier run (showFallback) so we + // don't show "not found" while linkedCards is still loading. + let unresolved: UnresolvedSlot[] = []; if (showFallback) { let resolvedEls = new Set(slots.map((s) => s.element)); for (let el of Array.from( @@ -247,17 +257,21 @@ export default class MarkDownTemplate extends GlimmerComponent<{ )) { let url = el.dataset.boxelBfmInlineRef || el.dataset.boxelBfmBlockRef || ''; - if ( - !resolvedEls.has(el) && - el.childElementCount === 0 && - el.textContent !== url - ) { - el.textContent = url; + if (!resolvedEls.has(el) && url) { + let kind: 'inline' | 'block' = el.dataset.boxelBfmInlineRef + ? 'inline' + : 'block'; + unresolved.push({ + element: el, + url, + typeName: cardTypeName(url), + kind, + }); } } } - return slots; + return { resolved: slots, unresolved }; }; // Deferred via scheduleOnce to avoid Glimmer backtracking assertion. @@ -266,7 +280,8 @@ export default class MarkDownTemplate extends GlimmerComponent<{ // re-render → observer fires again. let updateSlots = () => { pendingUpdate = false; - let nextSlots = collectSlots(); + let { resolved: nextSlots, unresolved: nextUnresolved } = + collectSlots(); let didChange = nextSlots.length !== this.cardSlots.length || nextSlots.some((slot, index) => { @@ -284,6 +299,21 @@ export default class MarkDownTemplate extends GlimmerComponent<{ if (didChange) { this.cardSlots = nextSlots; } + + let unresolvedDidChange = + nextUnresolved.length !== this.unresolvedSlots.length || + nextUnresolved.some((slot, index) => { + let current = this.unresolvedSlots[index]; + return ( + !current || + current.element !== slot.element || + current.url !== slot.url + ); + }); + + if (unresolvedDidChange) { + this.unresolvedSlots = nextUnresolved; + } }; let scheduleUpdate = () => { @@ -434,6 +464,32 @@ export default class MarkDownTemplate extends GlimmerComponent<{ {{/in-element}} {{/each}} + {{#each this.unresolvedSlots as |slot|}} + {{#in-element slot.element insertBefore=null}} + {{#if (eq slot.kind 'inline')}} + + <:iconLeft> + <:default>{{slot.typeName}} + + {{else}} +
+ + <:iconLeft> + <:default>{{slot.typeName}} + +
+ {{/if}} + {{/in-element}} + {{/each}} diff --git a/packages/experiments-realm/bfm-showcase.md b/packages/experiments-realm/bfm-showcase.md index 57c33ec1a0..fd50247f3c 100644 --- a/packages/experiments-realm/bfm-showcase.md +++ b/packages/experiments-realm/bfm-showcase.md @@ -4,11 +4,11 @@ This document exercises the Boxel Flavored Markdown features added in the Layer ## Card References -Inline reference to an author: :card[./Author/alice-enwunder] — renders in atom format. +Inline reference to an author: :card[./Author/aliceenwunder] — renders in atom format. Block reference renders in embedded format: -::card[./Author/jane-doe] +::card[./Author/janedoe] ### Fitted Sizes diff --git a/packages/host/tests/acceptance/markdown-file-def-test.gts b/packages/host/tests/acceptance/markdown-file-def-test.gts index d897568963..64027757f8 100644 --- a/packages/host/tests/acceptance/markdown-file-def-test.gts +++ b/packages/host/tests/acceptance/markdown-file-def-test.gts @@ -302,6 +302,8 @@ module('Acceptance | markdown BFM card references', function (hooks) { '# Fallback Test', '', ':card[https://nonexistent.example/Card/missing]', + '', + '::card[https://nonexistent.example/BlogPost/gone]', ].join('\n'), 'mermaid-test.md': [ '# Mermaid Test', @@ -385,20 +387,39 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-fallback.md`, }); - // Fallback text is only injected after the modifier's second run + // Fallback Pill is only rendered after the modifier's second run // (first run skips it while linkedCards is loading). await waitUntil( () => - document.querySelector('[data-boxel-bfm-inline-ref]')?.textContent === - 'https://nonexistent.example/Card/missing', + document.querySelector( + '[data-test-markdown-bfm-unresolved-inline]', + ) !== null, { timeout: 10000 }, ); assert - .dom('[data-boxel-bfm-inline-ref]') + .dom('[data-test-markdown-bfm-unresolved-inline]') .hasText( + 'Card', + 'unresolvable inline reference shows Pill with type name', + ); + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .hasAttribute( + 'title', 'https://nonexistent.example/Card/missing', - 'unresolvable reference shows URL as fallback text', + 'inline Pill title shows the raw URL', + ); + + assert + .dom('[data-test-markdown-bfm-unresolved-block]') + .exists('block-level unresolved reference renders a Pill'); + assert + .dom('[data-test-markdown-bfm-unresolved-block]') + .hasAttribute( + 'title', + 'https://nonexistent.example/BlogPost/gone', + 'block Pill title shows the raw URL', ); }); diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index ed697a1f9c..5b2d1ed7d6 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -1,5 +1,5 @@ import type { RenderingTestContext } from '@ember/test-helpers'; -import { waitFor } from '@ember/test-helpers'; +import { waitFor, waitUntil } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; @@ -395,6 +395,72 @@ module('Integration | RichMarkdownField', function (hooks) { .hasText('Mango', 'block embedded shows the correct card'); }); + test('unresolved card references render as muted Pill indicators', async function (assert) { + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + static isolated = class Isolated extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'article.gts': { ArticleCard }, + 'article-unresolved.json': { + data: { + attributes: { + body: { + content: `Inline: :card[https://nonexistent.example/Pet/missing]\n\nBlock:\n\n::card[https://nonexistent.example/BlogPost/gone]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let store = getService('store'); + let article = await store.get( + `${testRealmURL}article-unresolved`, + ); + await store.loaded(); + + await renderCard(loader, article, 'isolated'); + + await waitUntil( + () => + document.querySelector( + '[data-test-markdown-bfm-unresolved-inline]', + ) !== null, + { timeout: 10_000 }, + ); + + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .hasText('Pet', 'inline unresolved ref shows type name in Pill'); + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .hasAttribute( + 'title', + 'https://nonexistent.example/Pet/missing', + 'inline Pill title shows the raw URL', + ); + + assert + .dom('[data-test-markdown-bfm-unresolved-block]') + .exists('block unresolved ref renders a Pill'); + assert + .dom('[data-test-markdown-bfm-unresolved-block]') + .hasAttribute( + 'title', + 'https://nonexistent.example/BlogPost/gone', + 'block Pill title shows the raw URL', + ); + }); + test('linkedCards resolve when markdown uses relative card references', async function (assert) { class Pet extends CardDef { static displayName = 'Pet'; diff --git a/packages/realm-server/tests/bfm-card-references-test.ts b/packages/realm-server/tests/bfm-card-references-test.ts new file mode 100644 index 0000000000..0557049ee5 --- /dev/null +++ b/packages/realm-server/tests/bfm-card-references-test.ts @@ -0,0 +1,36 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import { runSharedTest } from '@cardstack/runtime-common/helpers'; +import bfmCardReferencesTests from '@cardstack/runtime-common/tests/bfm-card-references-test'; + +module(basename(__filename), function () { + module('cardTypeName', function () { + test('cardTypeName extracts type from absolute URL', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + + test('cardTypeName extracts type from relative path', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + + test('cardTypeName strips .json extension before extracting', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + + test('cardTypeName strips trailing slash', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + + test('cardTypeName returns single segment as type name', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + + test('cardTypeName returns Card for empty string', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + + test('cardTypeName handles deeply nested URLs', async function (assert) { + await runSharedTest(bfmCardReferencesTests, assert, {}); + }); + }); +}); diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index 0d594445ee..784e5a227a 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -191,6 +191,7 @@ import './boxel-domain-availability-test'; import './get-boxel-claimed-domain-test'; import './claim-boxel-domain-test'; import './card-reference-resolver-test'; +import './bfm-card-references-test'; import './command-parsing-utils-test'; import './delete-boxel-claimed-domain-test'; import './realm-auth-test'; diff --git a/packages/runtime-common/bfm-card-references.ts b/packages/runtime-common/bfm-card-references.ts index dd65cc0e2d..4e2d32912c 100644 --- a/packages/runtime-common/bfm-card-references.ts +++ b/packages/runtime-common/bfm-card-references.ts @@ -296,6 +296,32 @@ export function bfmCardReferenceExtensions(): TokenizerAndRendererExtension[] { return bfmExtensionsForKeyword('card'); } +/** + * Extracts a human-readable card type name from a card URL or path. + * + * Card URLs follow the pattern `//`, so the type name is + * the second-to-last path segment. The last segment is typically a UUID or + * slug and is not human-readable. + * + * Examples: + * - `https://example.com/Pet/a3b2c1d4-...` → `"Pet"` + * - `./Author/jane-doe` → `"Author"` + * - `./BlogPost/some-id.json` → `"BlogPost"` + * - `./Foo` → `"Foo"` + * - `""` → `"Card"` + */ +export function cardTypeName(url: string): string { + let cleaned = url.replace(/\/+$/, '').replace(/\.json$/, ''); + let segments = cleaned.split('/').filter(Boolean); + if (segments.length >= 2) { + return segments[segments.length - 2]; + } + if (segments.length === 1) { + return segments[0]; + } + return 'Card'; +} + function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/packages/runtime-common/tests/bfm-card-references-test.ts b/packages/runtime-common/tests/bfm-card-references-test.ts new file mode 100644 index 0000000000..96eac9847c --- /dev/null +++ b/packages/runtime-common/tests/bfm-card-references-test.ts @@ -0,0 +1,46 @@ +import type { SharedTests } from '../helpers'; +import { cardTypeName } from '../bfm-card-references'; + +const tests = Object.freeze({ + 'cardTypeName extracts type from absolute URL': async (assert) => { + assert.strictEqual( + cardTypeName('https://example.com/Pet/a3b2c1d4-e5f6'), + 'Pet', + ); + }, + + 'cardTypeName extracts type from relative path': async (assert) => { + assert.strictEqual(cardTypeName('./Author/jane-doe'), 'Author'); + }, + + 'cardTypeName strips .json extension before extracting': async (assert) => { + assert.strictEqual( + cardTypeName('./BlogPost/some-id.json'), + 'BlogPost', + ); + }, + + 'cardTypeName strips trailing slash': async (assert) => { + assert.strictEqual( + cardTypeName('https://example.com/Pet/mango/'), + 'Pet', + ); + }, + + 'cardTypeName returns single segment as type name': async (assert) => { + assert.strictEqual(cardTypeName('./Foo'), 'Foo'); + }, + + 'cardTypeName returns Card for empty string': async (assert) => { + assert.strictEqual(cardTypeName(''), 'Card'); + }, + + 'cardTypeName handles deeply nested URLs': async (assert) => { + assert.strictEqual( + cardTypeName('https://example.com/realm/nested/Pet/some-uuid'), + 'Pet', + ); + }, +} as SharedTests<{}>); + +export default tests; From 4f7f30f47de883c331ae3f3b31f21213cecea690 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 18:21:24 -0400 Subject: [PATCH 3/8] Fix unresolved card ref bugs and add test coverage for loading/error states Merge resolved and unresolved slots into a single RenderSlot array to eliminate dual #in-element race. Add deferred setTimeout fallback for _modifierHasRun so in-app navigation correctly transitions from loading shimmer to error Pill. Remove vestigial el.firstChild.remove() calls that broke Glimmer text node markers. Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/markdown.gts | 269 +++++++++--------- packages/experiments-realm/bfm-showcase.md | 136 +-------- .../acceptance/markdown-file-def-test.gts | 2 - .../components/rich-markdown-field-test.gts | 102 +++++++ 4 files changed, 247 insertions(+), 262 deletions(-) diff --git a/packages/base/default-templates/markdown.gts b/packages/base/default-templates/markdown.gts index 0aa410a895..1ace623417 100644 --- a/packages/base/default-templates/markdown.gts +++ b/packages/base/default-templates/markdown.gts @@ -41,7 +41,7 @@ function wrapTablesHtml(html: string | null | undefined): string { type CardSlotFormat = 'atom' | 'embedded' | 'fitted' | 'isolated'; -interface CardSlot { +interface ResolvedSlot { element: HTMLElement; card: CardDef; format: CardSlotFormat; @@ -56,6 +56,10 @@ interface UnresolvedSlot { kind: 'inline' | 'block'; } +type RenderSlot = + | (ResolvedSlot & { card: CardDef }) + | (UnresolvedSlot & { card?: undefined }); + function resolveUrl(raw: string, baseUrl: string | null | undefined): string { try { return trimJsonExtension(resolveCardReference(raw, baseUrl || undefined)); @@ -72,11 +76,11 @@ export default class MarkDownTemplate extends GlimmerComponent<{ }; }> { @tracked monacoContextInternal: any = undefined; - @tracked cardSlots: CardSlot[] = []; - @tracked unresolvedSlots: UnresolvedSlot[] = []; - // Tracks whether the modifier has run at least once. On the first run, - // linkedCards is likely still loading (empty []) so we skip fallback text - // injection to avoid flashing raw URLs for cards that will soon resolve. + @tracked renderSlots: RenderSlot[] = []; + // On the first modifier run linkedCards is likely still loading (empty []) + // so we skip unresolved Pills to avoid flashing them for refs that will + // soon resolve. On subsequent runs showFallback is true. A deferred timer + // handles the in-app-navigation case where the modifier may only run once. private _modifierHasRun = false; get isPrerenderContext() { return Boolean((globalThis as any).__boxelRenderContext); @@ -168,7 +172,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ let showFallback = this._modifierHasRun; this._modifierHasRun = true; - let collectSlots = () => { + let collectSlots = (): RenderSlot[] => { let cardsByUrl = new Map(); if (linkedCards?.length) { for (let card of linkedCards) { @@ -178,7 +182,8 @@ export default class MarkDownTemplate extends GlimmerComponent<{ } } - let slots: CardSlot[] = []; + let slots: RenderSlot[] = []; + let resolvedEls = new Set(); for (let el of Array.from( element.querySelectorAll( @@ -190,9 +195,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ let resolved = resolveUrl(rawUrl, baseUrl); let card = cardsByUrl.get(resolved); if (card) { - if (el.firstChild?.nodeType === Node.TEXT_NODE) { - el.firstChild.remove(); - } + resolvedEls.add(el); slots.push({ element: el, card, format: 'atom', kind: 'inline' }); } } @@ -207,10 +210,6 @@ export default class MarkDownTemplate extends GlimmerComponent<{ let resolved = resolveUrl(rawUrl, baseUrl); let card = cardsByUrl.get(resolved); if (card) { - if (el.firstChild?.nodeType === Node.TEXT_NODE) { - el.firstChild.remove(); - } - let bfmFormat = el.dataset.boxelBfmFormat; let format: CardSlotFormat = bfmFormat === 'fitted' || bfmFormat === 'isolated' @@ -234,6 +233,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ style = htmlSafe(parts.join('; ')); } + resolvedEls.add(el); slots.push({ element: el, card, @@ -245,33 +245,30 @@ export default class MarkDownTemplate extends GlimmerComponent<{ } // Build unresolved slots for card refs that could not be matched to a - // linkedCard. Only after the first modifier run (showFallback) so we - // don't show "not found" while linkedCards is still loading. - let unresolved: UnresolvedSlot[] = []; - if (showFallback) { - let resolvedEls = new Set(slots.map((s) => s.element)); - for (let el of Array.from( - element.querySelectorAll( - '[data-boxel-bfm-type="card"]', - ), - )) { - let url = - el.dataset.boxelBfmInlineRef || el.dataset.boxelBfmBlockRef || ''; - if (!resolvedEls.has(el) && url) { - let kind: 'inline' | 'block' = el.dataset.boxelBfmInlineRef - ? 'inline' - : 'block'; - unresolved.push({ - element: el, - url, - typeName: cardTypeName(url), - kind, - }); - } + // linkedCard. Skipped on the first modifier run (showFallback=false) + // to avoid flashing Pills while linkedCards is still loading. + if (!showFallback) return slots; + for (let el of Array.from( + element.querySelectorAll( + '[data-boxel-bfm-type="card"]', + ), + )) { + let url = + el.dataset.boxelBfmInlineRef || el.dataset.boxelBfmBlockRef || ''; + if (!resolvedEls.has(el) && url) { + let kind: 'inline' | 'block' = el.dataset.boxelBfmInlineRef + ? 'inline' + : 'block'; + slots.push({ + element: el, + url, + typeName: cardTypeName(url), + kind, + }); } } - return { resolved: slots, unresolved }; + return slots; }; // Deferred via scheduleOnce to avoid Glimmer backtracking assertion. @@ -280,39 +277,35 @@ export default class MarkDownTemplate extends GlimmerComponent<{ // re-render → observer fires again. let updateSlots = () => { pendingUpdate = false; - let { resolved: nextSlots, unresolved: nextUnresolved } = - collectSlots(); + let nextSlots = collectSlots(); let didChange = - nextSlots.length !== this.cardSlots.length || + nextSlots.length !== this.renderSlots.length || nextSlots.some((slot, index) => { - let current = this.cardSlots[index]; - return ( - !current || - current.element !== slot.element || - current.card !== slot.card || - current.format !== slot.format || - current.kind !== slot.kind || - String(current.style ?? '') !== String(slot.style ?? '') - ); + let current = this.renderSlots[index]; + if (!current || current.element !== slot.element) return true; + if (current.kind !== slot.kind) return true; + // Resolved ↔ unresolved transition + if (!!current.card !== !!slot.card) return true; + if (current.card && slot.card) { + return ( + current.card !== slot.card || + (current as ResolvedSlot).format !== + (slot as ResolvedSlot).format || + String((current as ResolvedSlot).style ?? '') !== + String((slot as ResolvedSlot).style ?? '') + ); + } + if (!current.card && !slot.card) { + return ( + (current as UnresolvedSlot).url !== + (slot as UnresolvedSlot).url + ); + } + return false; }); if (didChange) { - this.cardSlots = nextSlots; - } - - let unresolvedDidChange = - nextUnresolved.length !== this.unresolvedSlots.length || - nextUnresolved.some((slot, index) => { - let current = this.unresolvedSlots[index]; - return ( - !current || - current.element !== slot.element || - current.url !== slot.url - ); - }); - - if (unresolvedDidChange) { - this.unresolvedSlots = nextUnresolved; + this.renderSlots = nextSlots; } }; @@ -326,6 +319,18 @@ export default class MarkDownTemplate extends GlimmerComponent<{ scheduleUpdate(); + // When the modifier only runs once (e.g. in-app navigation where + // linkedCards is already resolved), the showFallback flag stays false. + // Schedule a deferred update that enables it so unresolvable refs + // eventually show their Pill indicator. + let deferredFallbackTimer: ReturnType | undefined; + if (!showFallback) { + deferredFallbackTimer = setTimeout(() => { + showFallback = true; + scheduleUpdate(); + }, 0); + } + // MutationObserver re-collects slots when the DOM is reconstructed // (e.g. after browser back-navigation rebuilds the element's children). if (typeof MutationObserver === 'undefined') { @@ -338,7 +343,12 @@ export default class MarkDownTemplate extends GlimmerComponent<{ subtree: true, }); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + if (deferredFallbackTimer !== undefined) { + clearTimeout(deferredFallbackTimer); + } + }; }, ); @@ -421,72 +431,71 @@ export default class MarkDownTemplate extends GlimmerComponent<{ > {{this.renderedHtml}} - {{#each this.cardSlots as |slot|}} - {{#in-element slot.element insertBefore=null}} - - {{#let (this.getCardComponent slot.card) as |CardComponent|}} - {{#if (eq slot.kind 'inline')}} - - - - {{else}} -
- -
- {{/if}} - {{/let}} -
- {{/in-element}} - {{/each}} - {{#each this.unresolvedSlots as |slot|}} + {{#each this.renderSlots key="element" as |slot|}} {{#in-element slot.element insertBefore=null}} - {{#if (eq slot.kind 'inline')}} - - <:iconLeft> - <:default>{{slot.typeName}} - + {{#if slot.card}} + + {{#let (this.getCardComponent slot.card) as |CardComponent|}} + {{#if (eq slot.kind 'inline')}} + + + + {{else}} +
+ +
+ {{/if}} + {{/let}} +
{{else}} -
- - <:iconLeft> + {{#if (eq slot.kind 'inline')}} + + <:iconLeft> <:default>{{slot.typeName}} -
+ {{else}} +
+ + <:iconLeft> + <:default>{{slot.typeName}} + +
+ {{/if}} {{/if}} {{/in-element}} {{/each}} diff --git a/packages/experiments-realm/bfm-showcase.md b/packages/experiments-realm/bfm-showcase.md index fd50247f3c..b26cab2115 100644 --- a/packages/experiments-realm/bfm-showcase.md +++ b/packages/experiments-realm/bfm-showcase.md @@ -4,138 +4,14 @@ This document exercises the Boxel Flavored Markdown features added in the Layer ## Card References -Inline reference to an author: :card[./Author/aliceenwunder] — renders in atom format. +WORKING inline reference to an author: :card[./Author/alice-enwunder] — renders in atom format. -Block reference renders in embedded format: +BROKEN inline reference to an author: :card[./Author/aliceenwunder] — renders in atom format. -::card[./Author/janedoe] - -### Fitted Sizes - -A card rendered as a strip (250 x 40): - -::card[./Author/jane-doe | strip] - -A double-wide-strip (400 x 65): - -::card[./Author/jane-doe | double-wide-strip] - -A tile (250 x 170, the default fitted size): - -::card[./Author/jane-doe | tile] - -A compact-card (400 x 170): - -::card[./Author/jane-doe | compact-card] - -### Custom Dimensions - -Exact dimensions using WxH syntax (300 x 150): - -::card[./Author/jane-doe | 300x150] - -Height-only constraint (width fills container): - -::card[./Author/jane-doe | h:200] - -Percentage width (50%, auto height): - -::card[./Author/jane-doe | w:50%] - -### Isolated Format - -::card[./Author/jane-doe | isolated] - -## GFM Alerts - -> [!NOTE] -> This is a note callout. Use it to highlight important information. - -> [!WARNING] -> This is a warning. Be careful when editing production data. +WORKING block reference to an author: -> [!TIP] -> You can nest **bold**, *italic*, and `code` inside alerts. +::card[./Author/jane-doe] -## Math / LaTeX +BROKEN block reference to an author: -The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ and appears inline. - -Euler's identity in a display block: - -$$ -e^{i\pi} + 1 = 0 -$$ - -A summation: - -$$ -\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} -$$ - -## Mermaid Diagrams - -```mermaid -flowchart TD - A[Markdown Source] --> B[marked.parse] - B --> C{Has Placeholders?} - C -->|Math| D[KaTeX Render] - C -->|Mermaid| E[Mermaid Render] - C -->|Card Ref| F[Card Slot Modifier] - D --> G[Final Output] - E --> G - F --> G -``` - -A sequence diagram: - -```mermaid -sequenceDiagram - participant User - participant Host - participant Realm - User->>Host: Open markdown file - Host->>Realm: Fetch file content - Realm-->>Host: Markdown + linked cards - Host->>Host: marked.parse() with BFM extensions - Host->>Host: Render placeholders (math, mermaid, cards) - Host-->>User: Rendered document -``` - -## Footnotes - -Boxel Flavored Markdown[^1] extends standard GFM with additional features for rich document rendering. - -The math rendering uses KaTeX[^2] for typesetting, while diagrams use Mermaid.js[^3]. - -[^1]: BFM is documented at [bfm.boxel.site](https://bfm.boxel.site/). -[^2]: KaTeX is a fast math typesetting library for the web. -[^3]: Mermaid lets you create diagrams using a markdown-like syntax. - -## Extended Tables - -| Feature | Status | Bundle Impact | -| ------- | ------ | ------------- | -| GFM Alerts | Static HTML | None | -| Heading IDs | Static HTML | None | -| Footnotes | Static HTML | None | -| Extended Tables | Static HTML | None | -| Math / LaTeX || Lazy KaTeX (~268KB) | -| Mermaid Diagrams || Lazy Mermaid (~2MB) | - -## Heading IDs - -Each heading on this page has an auto-generated slug ID (inspect the DOM to see `id="bfm-showcase"`, `id="math--latex"`, etc.). These enable anchor links and table-of-contents navigation. - -## Code Blocks - -Standard fenced code blocks still work as expected: - -```typescript -import { marked } from 'marked'; -import { markedKatexPlaceholder } from './bfm-math'; - -marked.use(markedKatexPlaceholder()); - -const html = marked.parse('The formula $E = mc^2$ is famous.'); -``` +::card[./Author/janedoe] diff --git a/packages/host/tests/acceptance/markdown-file-def-test.gts b/packages/host/tests/acceptance/markdown-file-def-test.gts index 64027757f8..907ef6b991 100644 --- a/packages/host/tests/acceptance/markdown-file-def-test.gts +++ b/packages/host/tests/acceptance/markdown-file-def-test.gts @@ -387,8 +387,6 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-fallback.md`, }); - // Fallback Pill is only rendered after the modifier's second run - // (first run skips it while linkedCards is loading). await waitUntil( () => document.querySelector( diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index 5b2d1ed7d6..09d74ecd96 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -393,6 +393,97 @@ module('Integration | RichMarkdownField', function (hooks) { assert .dom('[data-test-pet-embedded]') .hasText('Mango', 'block embedded shows the correct card'); + + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .doesNotExist( + 'no unresolved Pill remains after card resolves (inline)', + ); + assert + .dom('[data-test-markdown-bfm-unresolved-block]') + .doesNotExist( + 'no unresolved Pill remains after card resolves (block)', + ); + }); + + test('card references show loading shimmer before linkedCards resolves, not broken Pills', async function (assert) { + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static atom = class Atom extends Component { + + }; + } + + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + static isolated = class Isolated extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { + adoptsFrom: { module: '../pet', name: 'Pet' }, + }, + }, + }, + 'article-1.json': { + data: { + attributes: { + body: { + content: `Inline ref: :card[${testRealmURL}Pet/mango]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let store = getService('store'); + let article = await store.get(`${testRealmURL}article-1`); + await store.loaded(); + + await renderCard(loader, article, 'isolated'); + + // Immediately after render, the card ref element should be empty (shimmer) + // — NOT showing an unresolved Pill. + let inlineRef = document.querySelector('[data-boxel-bfm-inline-ref]'); + assert.ok(inlineRef, 'card ref element exists in the DOM'); + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .doesNotExist( + 'no broken Pill flashes while linkedCards is still loading', + ); + + // Wait for the card to resolve + await waitFor('[data-test-pet-atom]', { timeout: 10_000 }); + + assert + .dom('[data-test-pet-atom]') + .hasText('Mango', 'card resolves correctly'); + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .doesNotExist( + 'no unresolved Pill after card resolves', + ); }); test('unresolved card references render as muted Pill indicators', async function (assert) { @@ -538,5 +629,16 @@ module('Integration | RichMarkdownField', function (hooks) { assert .dom('[data-test-pet-embedded]') .hasText('Mango', 'block embedded shows the correct card'); + + assert + .dom('[data-test-markdown-bfm-unresolved-inline]') + .doesNotExist( + 'no unresolved Pill remains after card resolves (inline)', + ); + assert + .dom('[data-test-markdown-bfm-unresolved-block]') + .doesNotExist( + 'no unresolved Pill remains after card resolves (block)', + ); }); }); From f4bdc58c834d36e307dc4da3ea4cb436070a456d Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 19:02:56 -0400 Subject: [PATCH 4/8] Fix lint/type errors, icon attrs, cardTypeName bug, and flashing broken Pills - Replace setTimeout(0) fallback with linkedCards.length > 0 check to avoid flashing broken Pills before cards resolve on fresh page load - Fix LinkOffIcon @width/@height to plain HTML attributes (TS2554) - Fix store.get to untyped + as BaseDef cast (TS2344/TS2749) - Fix cardTypeName to filter '.' segments from relative paths like './Foo' - Add link-off icon to realm-indexing-test expected card references - Use MutationObserver in loading shimmer test for better regression detection Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/markdown.gts | 36 +++------ .../acceptance/markdown-file-def-test.gts | 5 +- .../components/rich-markdown-field-test.gts | 79 +++++++++++-------- .../tests/integration/realm-indexing-test.gts | 2 + .../runtime-common/bfm-card-references.ts | 2 +- 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/packages/base/default-templates/markdown.gts b/packages/base/default-templates/markdown.gts index 1ace623417..b2c9c73116 100644 --- a/packages/base/default-templates/markdown.gts +++ b/packages/base/default-templates/markdown.gts @@ -79,8 +79,9 @@ export default class MarkDownTemplate extends GlimmerComponent<{ @tracked renderSlots: RenderSlot[] = []; // On the first modifier run linkedCards is likely still loading (empty []) // so we skip unresolved Pills to avoid flashing them for refs that will - // soon resolve. On subsequent runs showFallback is true. A deferred timer - // handles the in-app-navigation case where the modifier may only run once. + // soon resolve. On subsequent runs showFallback is true. For in-app + // navigation where linkedCards is already cached, we detect this by + // checking linkedCards.length > 0 on the first run. private _modifierHasRun = false; get isPrerenderContext() { return Boolean((globalThis as any).__boxelRenderContext); @@ -169,7 +170,13 @@ export default class MarkDownTemplate extends GlimmerComponent<{ let linkedCards = this.args.linkedCards; let baseUrl = this.args.cardReferenceBaseUrl; let pendingUpdate = false; - let showFallback = this._modifierHasRun; + // On the very first modifier run linkedCards is likely still loading + // (empty []) so we skip unresolved Pills to avoid flashing them for + // refs that will soon resolve. On subsequent runs (linkedCards changed) + // showFallback is true. We also enable it immediately if linkedCards + // already has data (in-app navigation with cached results). + let showFallback = + this._modifierHasRun || (linkedCards != null && linkedCards.length > 0); this._modifierHasRun = true; let collectSlots = (): RenderSlot[] => { @@ -319,18 +326,6 @@ export default class MarkDownTemplate extends GlimmerComponent<{ scheduleUpdate(); - // When the modifier only runs once (e.g. in-app navigation where - // linkedCards is already resolved), the showFallback flag stays false. - // Schedule a deferred update that enables it so unresolvable refs - // eventually show their Pill indicator. - let deferredFallbackTimer: ReturnType | undefined; - if (!showFallback) { - deferredFallbackTimer = setTimeout(() => { - showFallback = true; - scheduleUpdate(); - }, 0); - } - // MutationObserver re-collects slots when the DOM is reconstructed // (e.g. after browser back-navigation rebuilds the element's children). if (typeof MutationObserver === 'undefined') { @@ -343,12 +338,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ subtree: true, }); - return () => { - observer.disconnect(); - if (deferredFallbackTimer !== undefined) { - clearTimeout(deferredFallbackTimer); - } - }; + return () => observer.disconnect(); }, ); @@ -481,7 +471,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ title={{slot.url}} data-test-markdown-bfm-unresolved-inline > - <:iconLeft> + <:iconLeft> <:default>{{slot.typeName}} {{else}} @@ -491,7 +481,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ data-test-markdown-bfm-unresolved-block > - <:iconLeft> + <:iconLeft> <:default>{{slot.typeName}} diff --git a/packages/host/tests/acceptance/markdown-file-def-test.gts b/packages/host/tests/acceptance/markdown-file-def-test.gts index 907ef6b991..9ff97b201a 100644 --- a/packages/host/tests/acceptance/markdown-file-def-test.gts +++ b/packages/host/tests/acceptance/markdown-file-def-test.gts @@ -389,9 +389,8 @@ module('Acceptance | markdown BFM card references', function (hooks) { await waitUntil( () => - document.querySelector( - '[data-test-markdown-bfm-unresolved-inline]', - ) !== null, + document.querySelector('[data-test-markdown-bfm-unresolved-inline]') !== + null, { timeout: 10000 }, ); diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index 09d74ecd96..d86f26fafa 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -373,7 +373,7 @@ module('Integration | RichMarkdownField', function (hooks) { }); let store = getService('store'); - let article = await store.get(`${testRealmURL}article-1`); + let article = (await store.get(`${testRealmURL}article-1`)) as BaseDef; await store.loaded(); await renderCard(loader, article, 'isolated'); @@ -382,28 +382,28 @@ module('Integration | RichMarkdownField', function (hooks) { assert .dom('[data-test-pet-atom]') - .exists('inline card reference renders the referenced card in atom format'); + .exists( + 'inline card reference renders the referenced card in atom format', + ); assert .dom('[data-test-pet-atom]') .hasText('Mango', 'inline atom shows the correct card'); assert .dom('[data-test-pet-embedded]') - .exists('block card reference renders the referenced card in embedded format'); + .exists( + 'block card reference renders the referenced card in embedded format', + ); assert .dom('[data-test-pet-embedded]') .hasText('Mango', 'block embedded shows the correct card'); assert .dom('[data-test-markdown-bfm-unresolved-inline]') - .doesNotExist( - 'no unresolved Pill remains after card resolves (inline)', - ); + .doesNotExist('no unresolved Pill remains after card resolves (inline)'); assert .dom('[data-test-markdown-bfm-unresolved-block]') - .doesNotExist( - 'no unresolved Pill remains after card resolves (block)', - ); + .doesNotExist('no unresolved Pill remains after card resolves (block)'); }); test('card references show loading shimmer before linkedCards resolves, not broken Pills', async function (assert) { @@ -458,32 +458,44 @@ module('Integration | RichMarkdownField', function (hooks) { }); let store = getService('store'); - let article = await store.get(`${testRealmURL}article-1`); + let article = (await store.get(`${testRealmURL}article-1`)) as BaseDef; await store.loaded(); await renderCard(loader, article, 'isolated'); - // Immediately after render, the card ref element should be empty (shimmer) - // — NOT showing an unresolved Pill. + // Use a MutationObserver to detect if an unresolved Pill *ever* appears + // during the loading→resolved transition. This catches regressions where + // a deferred timer prematurely enables unresolved Pills, even if the flash + // is too brief for a point-in-time assertion to catch. + let unresolvedPillEverAppeared = false; + let testRoot = document.querySelector('#ember-testing')!; + let testObserver = new MutationObserver(() => { + if ( + testRoot.querySelector('[data-test-markdown-bfm-unresolved-inline]') + ) { + unresolvedPillEverAppeared = true; + } + }); + testObserver.observe(testRoot, { childList: true, subtree: true }); + let inlineRef = document.querySelector('[data-boxel-bfm-inline-ref]'); assert.ok(inlineRef, 'card ref element exists in the DOM'); - assert - .dom('[data-test-markdown-bfm-unresolved-inline]') - .doesNotExist( - 'no broken Pill flashes while linkedCards is still loading', - ); // Wait for the card to resolve await waitFor('[data-test-pet-atom]', { timeout: 10_000 }); + testObserver.disconnect(); + + assert.false( + unresolvedPillEverAppeared, + 'no broken Pill flashed during the loading→resolved transition', + ); assert .dom('[data-test-pet-atom]') .hasText('Mango', 'card resolves correctly'); assert .dom('[data-test-markdown-bfm-unresolved-inline]') - .doesNotExist( - 'no unresolved Pill after card resolves', - ); + .doesNotExist('no unresolved Pill after card resolves'); }); test('unresolved card references render as muted Pill indicators', async function (assert) { @@ -514,18 +526,17 @@ module('Integration | RichMarkdownField', function (hooks) { }); let store = getService('store'); - let article = await store.get( + let article = (await store.get( `${testRealmURL}article-unresolved`, - ); + )) as BaseDef; await store.loaded(); await renderCard(loader, article, 'isolated'); await waitUntil( () => - document.querySelector( - '[data-test-markdown-bfm-unresolved-inline]', - ) !== null, + document.querySelector('[data-test-markdown-bfm-unresolved-inline]') !== + null, { timeout: 10_000 }, ); @@ -609,7 +620,7 @@ module('Integration | RichMarkdownField', function (hooks) { }); let store = getService('store'); - let article = await store.get(`${testRealmURL}article-1`); + let article = (await store.get(`${testRealmURL}article-1`)) as BaseDef; await store.loaded(); await renderCard(loader, article, 'isolated'); @@ -618,27 +629,27 @@ module('Integration | RichMarkdownField', function (hooks) { assert .dom('[data-test-pet-atom]') - .exists('inline card reference with relative path renders the referenced card'); + .exists( + 'inline card reference with relative path renders the referenced card', + ); assert .dom('[data-test-pet-atom]') .hasText('Mango', 'inline atom shows the correct card'); assert .dom('[data-test-pet-embedded]') - .exists('block card reference with relative path renders the referenced card'); + .exists( + 'block card reference with relative path renders the referenced card', + ); assert .dom('[data-test-pet-embedded]') .hasText('Mango', 'block embedded shows the correct card'); assert .dom('[data-test-markdown-bfm-unresolved-inline]') - .doesNotExist( - 'no unresolved Pill remains after card resolves (inline)', - ); + .doesNotExist('no unresolved Pill remains after card resolves (inline)'); assert .dom('[data-test-markdown-bfm-unresolved-block]') - .doesNotExist( - 'no unresolved Pill remains after card resolves (block)', - ); + .doesNotExist('no unresolved Pill remains after card resolves (block)'); }); }); diff --git a/packages/host/tests/integration/realm-indexing-test.gts b/packages/host/tests/integration/realm-indexing-test.gts index bad0bc0e64..f5eb48ff3b 100644 --- a/packages/host/tests/integration/realm-indexing-test.gts +++ b/packages/host/tests/integration/realm-indexing-test.gts @@ -4488,6 +4488,7 @@ module(`Integration | realm indexing`, function (hooks) { 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/import', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/letter-case', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link', + 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link-off', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/notepad-text', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/palette', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/rectangle-ellipsis', @@ -4618,6 +4619,7 @@ module(`Integration | realm indexing`, function (hooks) { 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/layout-list', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/letter-case', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link', + 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link-off', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/notepad-text', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/palette', 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/rectangle-ellipsis', diff --git a/packages/runtime-common/bfm-card-references.ts b/packages/runtime-common/bfm-card-references.ts index 4e2d32912c..6eda8f251b 100644 --- a/packages/runtime-common/bfm-card-references.ts +++ b/packages/runtime-common/bfm-card-references.ts @@ -312,7 +312,7 @@ export function bfmCardReferenceExtensions(): TokenizerAndRendererExtension[] { */ export function cardTypeName(url: string): string { let cleaned = url.replace(/\/+$/, '').replace(/\.json$/, ''); - let segments = cleaned.split('/').filter(Boolean); + let segments = cleaned.split('/').filter((s) => s && s !== '.'); if (segments.length >= 2) { return segments[segments.length - 2]; } From af352d2aca7395ce217275d65a8607d64c0a0ce5 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 19:05:32 -0400 Subject: [PATCH 5/8] Revert bfm-showcase.md to main branch version Co-Authored-By: Claude Opus 4.6 --- packages/experiments-realm/bfm-showcase.md | 136 ++++++++++++++++++++- 1 file changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/experiments-realm/bfm-showcase.md b/packages/experiments-realm/bfm-showcase.md index b26cab2115..57c33ec1a0 100644 --- a/packages/experiments-realm/bfm-showcase.md +++ b/packages/experiments-realm/bfm-showcase.md @@ -4,14 +4,138 @@ This document exercises the Boxel Flavored Markdown features added in the Layer ## Card References -WORKING inline reference to an author: :card[./Author/alice-enwunder] — renders in atom format. +Inline reference to an author: :card[./Author/alice-enwunder] — renders in atom format. -BROKEN inline reference to an author: :card[./Author/aliceenwunder] — renders in atom format. - -WORKING block reference to an author: +Block reference renders in embedded format: ::card[./Author/jane-doe] -BROKEN block reference to an author: +### Fitted Sizes + +A card rendered as a strip (250 x 40): + +::card[./Author/jane-doe | strip] + +A double-wide-strip (400 x 65): + +::card[./Author/jane-doe | double-wide-strip] + +A tile (250 x 170, the default fitted size): + +::card[./Author/jane-doe | tile] + +A compact-card (400 x 170): + +::card[./Author/jane-doe | compact-card] + +### Custom Dimensions + +Exact dimensions using WxH syntax (300 x 150): + +::card[./Author/jane-doe | 300x150] + +Height-only constraint (width fills container): + +::card[./Author/jane-doe | h:200] + +Percentage width (50%, auto height): + +::card[./Author/jane-doe | w:50%] + +### Isolated Format + +::card[./Author/jane-doe | isolated] + +## GFM Alerts + +> [!NOTE] +> This is a note callout. Use it to highlight important information. + +> [!WARNING] +> This is a warning. Be careful when editing production data. + +> [!TIP] +> You can nest **bold**, *italic*, and `code` inside alerts. + +## Math / LaTeX + +The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ and appears inline. + +Euler's identity in a display block: + +$$ +e^{i\pi} + 1 = 0 +$$ + +A summation: + +$$ +\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +$$ + +## Mermaid Diagrams + +```mermaid +flowchart TD + A[Markdown Source] --> B[marked.parse] + B --> C{Has Placeholders?} + C -->|Math| D[KaTeX Render] + C -->|Mermaid| E[Mermaid Render] + C -->|Card Ref| F[Card Slot Modifier] + D --> G[Final Output] + E --> G + F --> G +``` + +A sequence diagram: + +```mermaid +sequenceDiagram + participant User + participant Host + participant Realm + User->>Host: Open markdown file + Host->>Realm: Fetch file content + Realm-->>Host: Markdown + linked cards + Host->>Host: marked.parse() with BFM extensions + Host->>Host: Render placeholders (math, mermaid, cards) + Host-->>User: Rendered document +``` + +## Footnotes + +Boxel Flavored Markdown[^1] extends standard GFM with additional features for rich document rendering. + +The math rendering uses KaTeX[^2] for typesetting, while diagrams use Mermaid.js[^3]. + +[^1]: BFM is documented at [bfm.boxel.site](https://bfm.boxel.site/). +[^2]: KaTeX is a fast math typesetting library for the web. +[^3]: Mermaid lets you create diagrams using a markdown-like syntax. + +## Extended Tables + +| Feature | Status | Bundle Impact | +| ------- | ------ | ------------- | +| GFM Alerts | Static HTML | None | +| Heading IDs | Static HTML | None | +| Footnotes | Static HTML | None | +| Extended Tables | Static HTML | None | +| Math / LaTeX || Lazy KaTeX (~268KB) | +| Mermaid Diagrams || Lazy Mermaid (~2MB) | + +## Heading IDs + +Each heading on this page has an auto-generated slug ID (inspect the DOM to see `id="bfm-showcase"`, `id="math--latex"`, etc.). These enable anchor links and table-of-contents navigation. + +## Code Blocks + +Standard fenced code blocks still work as expected: + +```typescript +import { marked } from 'marked'; +import { markedKatexPlaceholder } from './bfm-math'; + +marked.use(markedKatexPlaceholder()); -::card[./Author/janedoe] +const html = marked.parse('The formula $E = mc^2$ is famous.'); +``` From 0a63e4eeb5759e5ee2c157eb3d541f55f49d166e Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 19:14:46 -0400 Subject: [PATCH 6/8] Fix prettier formatting in bfm-card-references-test Co-Authored-By: Claude Opus 4.6 --- .../runtime-common/tests/bfm-card-references-test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/runtime-common/tests/bfm-card-references-test.ts b/packages/runtime-common/tests/bfm-card-references-test.ts index 96eac9847c..4acbaeeb48 100644 --- a/packages/runtime-common/tests/bfm-card-references-test.ts +++ b/packages/runtime-common/tests/bfm-card-references-test.ts @@ -14,17 +14,11 @@ const tests = Object.freeze({ }, 'cardTypeName strips .json extension before extracting': async (assert) => { - assert.strictEqual( - cardTypeName('./BlogPost/some-id.json'), - 'BlogPost', - ); + assert.strictEqual(cardTypeName('./BlogPost/some-id.json'), 'BlogPost'); }, 'cardTypeName strips trailing slash': async (assert) => { - assert.strictEqual( - cardTypeName('https://example.com/Pet/mango/'), - 'Pet', - ); + assert.strictEqual(cardTypeName('https://example.com/Pet/mango/'), 'Pet'); }, 'cardTypeName returns single segment as type name': async (assert) => { From a4fd4454890cf102eb85e027c2c79473b3f2ab3a Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 22:31:05 -0400 Subject: [PATCH 7/8] Address PR review comments: harden cardTypeName and fix title typo Parse absolute URLs via new URL() in cardTypeName to correctly handle query strings, fragments, and URLs without an id segment. Filter out ".." segments for relative paths. Fix missing space in playground title. Co-Authored-By: Claude Opus 4.6 --- .../rich-markdown-playground-1.json | 2 +- .../runtime-common/bfm-card-references.ts | 15 ++++++++-- .../tests/bfm-card-references-test.ts | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/experiments-realm/rich-markdown-playground-1.json b/packages/experiments-realm/rich-markdown-playground-1.json index a981fb2ffc..e27e41955c 100644 --- a/packages/experiments-realm/rich-markdown-playground-1.json +++ b/packages/experiments-realm/rich-markdown-playground-1.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "title": "Rich MarkdownPlayground", + "title": "Rich Markdown Playground", "body": { "content": "# Boxel Flavored Markdown Showcase\n\nThis playground exercises the **RichMarkdown** field and its support for Boxel Flavored Markdown (BFM) extensions.\n\n## Standard Markdown\n\nRegular markdown works as expected: **bold**, *italic*, ~~strikethrough~~, and `inline code`.\n\n### Ordered List\n\n1. First item\n2. Second item\n3. Third item with **bold** and `code`\n\n### Unordered List\n\n- Bullet one\n- Bullet two\n - Nested bullet\n - Another nested bullet\n- Bullet three\n\n### Blockquote\n\n> The best way to predict the future is to invent it.\n> — Alan Kay\n\n---\n\n## GFM Alerts\n\n> [!NOTE]\n> This is a **note** callout — useful for highlighting important information.\n\n> [!TIP]\n> You can nest `code`, **bold**, and *italic* inside alerts.\n\n> [!WARNING]\n> Be careful when editing production data.\n\n---\n\n## Card References\n\nInline card reference: :card[./Author/alice-enwunder]\n\nBlock card reference:\n\n::card[./Author/jane-doe]\n\nBlock card at strip size:\n\n::card[./Author/jane-doe | strip]\n\n---\n\n## Math / LaTeX\n\nThe quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$ and appears inline.\n\nEuler's identity in a display block:\n\n$$\ne^{i\\pi} + 1 = 0\n$$\n\nA summation:\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n$$\n\n---\n\n## Mermaid Diagrams\n\n```mermaid\nflowchart TD\n A[Markdown Source] --> B[marked.parse]\n B --> C{Has Placeholders?}\n C -->|Math| D[KaTeX Render]\n C -->|Mermaid| E[Mermaid Render]\n C -->|Card Ref| F[Card Slot Modifier]\n D --> G[Final Output]\n E --> G\n F --> G\n```\n\nA sequence diagram:\n\n```mermaid\nsequenceDiagram\n participant User\n participant Host\n participant Realm\n User->>Host: Open markdown file\n Host->>Realm: Fetch file content\n Realm-->>Host: Markdown + linked cards\n Host-->>User: Rendered document\n```\n\n---\n\n## Tables\n\n| Feature | Rendering | Bundle Impact |\n| ------- | --------- | ------------- |\n| GFM Alerts | Static HTML | None |\n| Footnotes | Static HTML | None |\n| Math / LaTeX | Dynamic | Lazy KaTeX (~268 KB) |\n| Mermaid | Dynamic | Lazy Mermaid (~2 MB) |\n\n---\n\n## Code Blocks\n\n```typescript\nimport RichMarkdownField from 'https://cardstack.com/base/rich-markdown';\nimport { CardDef, contains, field } from 'https://cardstack.com/base/card-api';\n\nexport class Article extends CardDef {\n @field body = contains(RichMarkdownField);\n}\n```\n\n---\n\n## Footnotes\n\nBoxel Flavored Markdown[^1] extends standard GFM with additional features for rich document rendering.\n\nMath rendering uses KaTeX[^2] for typesetting, while diagrams use Mermaid.js[^3].\n\n[^1]: BFM is the Boxel extension to GitHub Flavored Markdown.\n[^2]: KaTeX is a fast math typesetting library for the web.\n[^3]: Mermaid lets you create diagrams using a markdown-like syntax." }, diff --git a/packages/runtime-common/bfm-card-references.ts b/packages/runtime-common/bfm-card-references.ts index 6eda8f251b..f2be49071f 100644 --- a/packages/runtime-common/bfm-card-references.ts +++ b/packages/runtime-common/bfm-card-references.ts @@ -311,8 +311,19 @@ export function bfmCardReferenceExtensions(): TokenizerAndRendererExtension[] { * - `""` → `"Card"` */ export function cardTypeName(url: string): string { - let cleaned = url.replace(/\/+$/, '').replace(/\.json$/, ''); - let segments = cleaned.split('/').filter((s) => s && s !== '.'); + let path = url; + + try { + path = new URL(url).pathname; + } catch { + // Not an absolute URL; treat as a path/reference string. + } + + let cleaned = path + .split(/[?#]/, 1)[0] + .replace(/\/+$/, '') + .replace(/\.json$/, ''); + let segments = cleaned.split('/').filter((s) => s && s !== '.' && s !== '..'); if (segments.length >= 2) { return segments[segments.length - 2]; } diff --git a/packages/runtime-common/tests/bfm-card-references-test.ts b/packages/runtime-common/tests/bfm-card-references-test.ts index 4acbaeeb48..1764039870 100644 --- a/packages/runtime-common/tests/bfm-card-references-test.ts +++ b/packages/runtime-common/tests/bfm-card-references-test.ts @@ -35,6 +35,35 @@ const tests = Object.freeze({ 'Pet', ); }, + + 'cardTypeName filters out .. segments in relative paths': async (assert) => { + assert.strictEqual(cardTypeName('../Pet/some-id'), 'Pet'); + }, + + 'cardTypeName returns last segment for relative .. with single name': + async (assert) => { + assert.strictEqual(cardTypeName('../Pet'), 'Pet'); + }, + + 'cardTypeName strips query string from URL': async (assert) => { + assert.strictEqual( + cardTypeName('https://example.com/Pet/abc?v=1'), + 'Pet', + ); + }, + + 'cardTypeName strips fragment from URL': async (assert) => { + assert.strictEqual( + cardTypeName('https://example.com/Pet/abc#section'), + 'Pet', + ); + }, + + 'cardTypeName handles absolute URL with single path segment': async ( + assert, + ) => { + assert.strictEqual(cardTypeName('https://example.com/Pet'), 'Pet'); + }, } as SharedTests<{}>); export default tests; From a7afbfd2eb0e3f658cd1d67f1bb13f58b46b73fa Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 8 Apr 2026 22:58:38 -0400 Subject: [PATCH 8/8] Fix prettier formatting in bfm-card-references-test Co-Authored-By: Claude Opus 4.6 --- .../tests/bfm-card-references-test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/runtime-common/tests/bfm-card-references-test.ts b/packages/runtime-common/tests/bfm-card-references-test.ts index 1764039870..d7f7ca15a2 100644 --- a/packages/runtime-common/tests/bfm-card-references-test.ts +++ b/packages/runtime-common/tests/bfm-card-references-test.ts @@ -40,16 +40,14 @@ const tests = Object.freeze({ assert.strictEqual(cardTypeName('../Pet/some-id'), 'Pet'); }, - 'cardTypeName returns last segment for relative .. with single name': - async (assert) => { - assert.strictEqual(cardTypeName('../Pet'), 'Pet'); - }, + 'cardTypeName returns last segment for relative .. with single name': async ( + assert, + ) => { + assert.strictEqual(cardTypeName('../Pet'), 'Pet'); + }, 'cardTypeName strips query string from URL': async (assert) => { - assert.strictEqual( - cardTypeName('https://example.com/Pet/abc?v=1'), - 'Pet', - ); + assert.strictEqual(cardTypeName('https://example.com/Pet/abc?v=1'), 'Pet'); }, 'cardTypeName strips fragment from URL': async (assert) => {