diff --git a/packages/base/default-templates/markdown.gts b/packages/base/default-templates/markdown.gts index 44e750ea62c..b2c9c73116a 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, @@ -38,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; @@ -46,6 +49,17 @@ interface CardSlot { style?: ReturnType; } +interface UnresolvedSlot { + element: HTMLElement; + url: string; + typeName: string; + 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)); @@ -62,10 +76,12 @@ export default class MarkDownTemplate extends GlimmerComponent<{ }; }> { @tracked monacoContextInternal: any = undefined; - @tracked cardSlots: CardSlot[] = []; - // 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. 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); @@ -154,10 +170,16 @@ 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 = () => { + let collectSlots = (): RenderSlot[] => { let cardsByUrl = new Map(); if (linkedCards?.length) { for (let card of linkedCards) { @@ -167,7 +189,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( @@ -179,9 +202,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' }); } } @@ -196,10 +217,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' @@ -223,6 +240,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{ style = htmlSafe(parts.join('; ')); } + resolvedEls.add(el); slots.push({ element: el, card, @@ -233,27 +251,27 @@ 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. - 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) && - el.childElementCount === 0 && - el.textContent !== url - ) { - el.textContent = url; - } + // Build unresolved slots for card refs that could not be matched to a + // 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, + }); } } @@ -268,21 +286,33 @@ export default class MarkDownTemplate extends GlimmerComponent<{ pendingUpdate = false; 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; + this.renderSlots = nextSlots; } }; @@ -391,47 +421,72 @@ export default class MarkDownTemplate extends GlimmerComponent<{ > {{this.renderedHtml}} - {{#each this.cardSlots as |slot|}} + {{#each this.renderSlots key="element" as |slot|}} {{#in-element slot.element insertBefore=null}} - - {{#let (this.getCardComponent slot.card) as |CardComponent|}} - {{#if (eq slot.kind 'inline')}} - - - - {{else}} -
- -
- {{/if}} - {{/let}} -
+ {{#if slot.card}} + + {{#let (this.getCardComponent slot.card) as |CardComponent|}} + {{#if (eq slot.kind 'inline')}} + + + + {{else}} +
+ +
+ {{/if}} + {{/let}} +
+ {{else}} + {{#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/rich-markdown-playground-1.json b/packages/experiments-realm/rich-markdown-playground-1.json new file mode 100644 index 00000000000..e27e41955cc --- /dev/null +++ b/packages/experiments-realm/rich-markdown-playground-1.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "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." + }, + "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 00000000000..dfe4e62fa74 --- /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/acceptance/markdown-file-def-test.gts b/packages/host/tests/acceptance/markdown-file-def-test.gts index d8975689631..9ff97b201a5 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,36 @@ module('Acceptance | markdown BFM card references', function (hooks) { codePath: `${testRealmURL}bfm-fallback.md`, }); - // Fallback text is only injected 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 7db96ef2b4b..d86f26fafab 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, waitUntil } 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,341 @@ 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`)) as BaseDef; + 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'); + + 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`)) as BaseDef; + await store.loaded(); + + await renderCard(loader, article, 'isolated'); + + // 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'); + + // 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'); + }); + + 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`, + )) as BaseDef; + 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'; + @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`)) as BaseDef; + 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'); + + 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)'); + }); }); diff --git a/packages/host/tests/integration/realm-indexing-test.gts b/packages/host/tests/integration/realm-indexing-test.gts index bad0bc0e64e..f5eb48ff3b1 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/realm-server/tests/bfm-card-references-test.ts b/packages/realm-server/tests/bfm-card-references-test.ts new file mode 100644 index 00000000000..0557049ee58 --- /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 0d594445eec..784e5a227a0 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 dd65cc0e2d9..f2be49071f8 100644 --- a/packages/runtime-common/bfm-card-references.ts +++ b/packages/runtime-common/bfm-card-references.ts @@ -296,6 +296,43 @@ 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 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]; + } + 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 00000000000..d7f7ca15a27 --- /dev/null +++ b/packages/runtime-common/tests/bfm-card-references-test.ts @@ -0,0 +1,67 @@ +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', + ); + }, + + '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;