). Default 'block'."
+ />
+
+
+
+
+
+
+ private tallTile = { format: 'fitted', width: 150, height: 275 } as const;
+ private custom = { format: 'fitted', width: 300, height: 200 } as const;
+}
diff --git a/packages/host/app/templates/host-freestyle.gts b/packages/host/app/templates/host-freestyle.gts
index 9e63a71c57a..0c463020629 100644
--- a/packages/host/app/templates/host-freestyle.gts
+++ b/packages/host/app/templates/host-freestyle.gts
@@ -11,9 +11,11 @@ import { provide } from 'ember-provide-consume-context';
import RouteTemplate from 'ember-route-template';
import {
+ CardContextName,
GetCardContextName,
GetCardsContextName,
GetCardCollectionContextName,
+ type getCard as GetCardType,
} from '@cardstack/runtime-common';
import AiAssistantApplyButtonUsage from '@cardstack/host/components/ai-assistant/apply-button/usage';
@@ -24,14 +26,21 @@ import AiAssistantMessageUsage from '@cardstack/host/components/ai-assistant/mes
import AiAssistantSkillMenuUsage from '@cardstack/host/components/ai-assistant/skill-menu/usage';
import MiniCardChooserUsage from '@cardstack/host/components/card-chooser/mini/usage';
import CardChooserModal from '@cardstack/host/components/card-chooser/modal';
+import SearchResults from '@cardstack/host/components/card-search/search-results';
+import MarkdownEmbedPreviewPaneUsage from '@cardstack/host/components/markdown-embed-chooser/pane-usage';
+import MarkdownEmbedPreviewUsage from '@cardstack/host/components/markdown-embed-chooser/preview/usage';
import PillMenuUsage from '@cardstack/host/components/pill-menu/usage';
+import PrerenderedCardSearch from '@cardstack/host/components/prerendered-card-search';
import SearchSheetUsage from '@cardstack/host/components/search-sheet/usage';
import { getCardCollection } from '@cardstack/host/resources/card-collection';
import { getCard } from '@cardstack/host/resources/card-resource';
+import type { CardContext } from 'https://cardstack.com/base/card-api';
+
import formatComponentName from '../helpers/format-component-name';
+import type CommandService from '../services/command-service';
import type StoreService from '../services/store';
import type { ComponentLike } from '@glint/template';
@@ -46,12 +55,13 @@ interface HostFreestyleSignature {
class HostFreestyleComponent extends Component
{
@service declare private store: StoreService;
+ @service declare private commandService: CommandService;
formatComponentName = formatComponentName;
@provide(GetCardContextName)
// @ts-ignore "getCard" is declared but not used
- private get getCard() {
- return getCard;
+ private get getCard(): GetCardType {
+ return getCard as unknown as GetCardType;
}
@provide(GetCardsContextName)
@@ -66,6 +76,22 @@ class HostFreestyleComponent extends Component {
return getCardCollection;
}
+ // CardRenderer (used by the markdown-embed preview usages) consumes the full
+ // CardContext; provide it here so previewed cards/files actually render.
+ @provide(CardContextName)
+ // @ts-ignore "cardContext" is declared but not used
+ private get cardContext(): CardContext {
+ return {
+ getCard: this.getCard,
+ getCards: this.store.getSearchResource.bind(this.store),
+ getCardCollection,
+ store: this.store,
+ commandContext: this.commandService.commandContext,
+ prerenderedCardSearchComponent: PrerenderedCardSearch,
+ searchResultsComponent: SearchResults,
+ };
+ }
+
get usageComponents() {
return [
['AiAssistant::ApplyButton', AiAssistantApplyButtonUsage],
@@ -76,6 +102,8 @@ class HostFreestyleComponent extends Component {
['AiAssistant::PillMenu', PillMenuUsage],
['AiAssistant::SkillMenu', AiAssistantSkillMenuUsage],
['MiniCardChooser', MiniCardChooserUsage],
+ ['MarkdownEmbedChooser::Preview', MarkdownEmbedPreviewUsage],
+ ['MarkdownEmbedChooser::Pane', MarkdownEmbedPreviewPaneUsage],
['SearchSheet', SearchSheetUsage],
].map(([name, c]) => {
return {
diff --git a/packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts b/packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts
new file mode 100644
index 00000000000..7a440d7cbb5
--- /dev/null
+++ b/packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts
@@ -0,0 +1,397 @@
+import type { TOC } from '@ember/component/template-only';
+import {
+ type RenderingTestContext,
+ click,
+ fillIn,
+ render,
+ waitFor,
+} from '@ember/test-helpers';
+
+import GlimmerComponent from '@glimmer/component';
+
+import { getService } from '@universal-ember/test-support';
+import { provide } from 'ember-provide-consume-context';
+
+import { module, test } from 'qunit';
+
+import {
+ baseRealm,
+ CardContextName,
+ GetCardContextName,
+ GetCardCollectionContextName,
+ GetCardsContextName,
+} from '@cardstack/runtime-common';
+
+import MarkdownEmbedPreviewPane from '@cardstack/host/components/markdown-embed-chooser/pane';
+import { getCardCollection } from '@cardstack/host/resources/card-collection';
+import { getCard } from '@cardstack/host/resources/card-resource';
+import type StoreService from '@cardstack/host/services/store';
+
+// The base-realm helper below exports `CardDef` as a value (for defining test
+// card classes); import the instance *type* separately for annotations.
+import type { CardDef as CardDefInstance } from 'https://cardstack.com/base/card-api';
+
+import {
+ setupIntegrationTestRealm,
+ setupLocalIndexing,
+ testRealmURL,
+} from '../../helpers';
+import {
+ CardDef,
+ StringField,
+ contains,
+ field,
+ setupBaseRealm,
+} from '../../helpers/base-realm';
+import { setupMockMatrix } from '../../helpers/mock-matrix';
+import { setupRenderingTest } from '../../helpers/setup';
+
+const PaneBox: TOC<{ Blocks: { default: [] } }> =
+
+ {{yield}}
+
+
+;
+
+class HostContextProvider extends GlimmerComponent<{
+ Blocks: { default: [] };
+}> {
+ @provide(GetCardContextName)
+ get getCardFn() {
+ return getCard;
+ }
+ @provide(GetCardsContextName)
+ get getCardsFn() {
+ let store = getService('store') as StoreService;
+ return store.getSearchResource.bind(store);
+ }
+ @provide(GetCardCollectionContextName)
+ get getCardCollectionFn() {
+ return getCardCollection;
+ }
+ @provide(CardContextName)
+ get cardContext() {
+ return {};
+ }
+
+ {{! template-lint-disable no-yield-only }}
+ {{yield}}
+
+}
+
+class InsertHarness {
+ inserts: string[] = [];
+ onInsert = (bfm: string) => {
+ this.inserts.push(bfm);
+ };
+ get last() {
+ return this.inserts[this.inserts.length - 1];
+ }
+}
+
+async function chooseFormat(value: string) {
+ // BoxelSelect spreads attributes onto the PowerSelect trigger itself, so the
+ // data-test attribute lands directly on the `.ember-power-select-trigger`
+ // element (not a wrapper) — click it directly to open the dropdown.
+ await click('[data-test-markdown-embed-preview-format-select]');
+ await waitFor('.ember-power-select-option', { timeout: 3000 });
+ await click(`[data-test-format-option="${value}"]`);
+}
+
+module('Integration | markdown-embed-preview-pane', function (hooks) {
+ setupRenderingTest(hooks);
+ setupBaseRealm(hooks);
+ setupLocalIndexing(hooks);
+
+ let mockMatrixUtils = setupMockMatrix(hooks, {
+ loggedInAs: '@testuser:localhost',
+ activeRealms: [baseRealm.url, testRealmURL],
+ autostart: true,
+ });
+
+ const mango = `${testRealmURL}books/mango`;
+
+ hooks.beforeEach(async function (this: RenderingTestContext) {
+ class Book extends CardDef {
+ static displayName = 'Book';
+ @field title = contains(StringField);
+ }
+ await setupIntegrationTestRealm({
+ mockMatrixUtils,
+ realmURL: testRealmURL,
+ contents: {
+ 'book.gts': { Book },
+ 'books/mango.json': new Book({ title: 'Mango' }),
+ },
+ });
+ await getService('realm').login(testRealmURL);
+ });
+
+ async function loadCard(): Promise {
+ let store = getService('store') as StoreService;
+ return (await store.get(mango)) as CardDefInstance;
+ }
+
+ test('atom is the default; both placements are available', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ assert
+ .dom('[data-test-markdown-embed-preview-cta]')
+ .hasText('Insert as Atom', 'CTA reflects the default Atom category');
+ assert
+ .dom('[data-test-markdown-embed-preview-block]')
+ .isNotDisabled('Block toggle is available for every format');
+ assert
+ .dom('[data-test-markdown-embed-preview-size]')
+ .doesNotExist('no W/H inputs for Atom');
+
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(
+ harness.last,
+ `:card[${card.id}]`,
+ 'inline atom inserts the size-less inline directive',
+ );
+
+ // Atom is available in block placement too (`::card[url | atom]`); the BFM
+ // grammar ticket makes the renderer honor it.
+ await click('[data-test-markdown-embed-preview-block]');
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(
+ harness.last,
+ `::card[${card.id} | atom]`,
+ 'block atom emits the atom specifier',
+ );
+ });
+
+ test('embedded emits a block directive with the embedded keyword', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ await chooseFormat('embedded');
+ assert
+ .dom('[data-test-markdown-embed-preview-cta]')
+ .hasText('Insert as Embedded');
+ assert
+ .dom('[data-test-markdown-embed-preview-block]')
+ .isNotDisabled('Block re-enables for non-atom formats');
+
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(harness.last, `::card[${card.id} | embedded]`);
+ });
+
+ test('the preview tracks the selected format in either placement', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ await chooseFormat('embedded');
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .hasAttribute(
+ 'data-test-markdown-embed-preview-format',
+ 'embedded',
+ 'block embedded previews in embedded format',
+ );
+
+ // Format and placement are independent: toggling to inline keeps the
+ // embedded render rather than collapsing to atom.
+ await click('[data-test-markdown-embed-preview-inline]');
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .hasAttribute(
+ 'data-test-markdown-embed-preview-format',
+ 'embedded',
+ 'inline still previews the selected embedded format',
+ );
+ });
+
+ test('fitted variant prefills W/H, emits the variant id, and inline drops the size', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ await chooseFormat('tall-tile');
+ assert
+ .dom('[data-test-markdown-embed-preview-cta]')
+ .hasText('Insert as Fitted');
+ assert
+ .dom('[data-test-markdown-embed-preview-width]')
+ .hasValue('150', 'width prefilled from the variant');
+ assert
+ .dom('[data-test-markdown-embed-preview-height]')
+ .hasValue('275', 'height prefilled from the variant');
+
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(
+ harness.last,
+ `::card[${card.id} | tall-tile]`,
+ 'block emits the named variant id',
+ );
+
+ // The chooser passes the size for inline too, but the BFM serializer
+ // (runtime-common) currently drops it for inline, so the emitted directive
+ // is the size-less `:card[url]`. The BFM "all formats in both modes" ticket
+ // flips this to `:card[url | tall-tile]` and updates this assertion.
+ await click('[data-test-markdown-embed-preview-inline]');
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(
+ harness.last,
+ `:card[${card.id}]`,
+ 'inline emission drops the size today (BFM-owned, pending the grammar ticket)',
+ );
+ });
+
+ test('editing W/H to unknown dims switches to Custom and emits w:/h:', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ await chooseFormat('tall-tile');
+ await fillIn('[data-test-markdown-embed-preview-width]', '300');
+ await fillIn('[data-test-markdown-embed-preview-height]', '200');
+
+ assert
+ .dom('[data-test-markdown-embed-preview-cta]')
+ .hasText('Insert as Custom', 'unknown dims fall back to Custom');
+
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(harness.last, `::card[${card.id} | w:300 h:200]`);
+ });
+
+ test('editing W/H to a known variant follows the dropdown to that variant', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ await chooseFormat('tall-tile');
+ // regular-tile = 250x170
+ await fillIn('[data-test-markdown-embed-preview-width]', '250');
+ await fillIn('[data-test-markdown-embed-preview-height]', '170');
+
+ assert
+ .dom('[data-test-markdown-embed-preview-cta]')
+ .hasText(
+ 'Insert as Fitted',
+ 'an exact match stays in the Fitted category',
+ );
+
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(
+ harness.last,
+ `::card[${card.id} | regular-tile]`,
+ 'the dropdown follows the dimensions to the matching variant',
+ );
+ });
+
+ test('refType drives the keyword (file)', async function (assert) {
+ let card = await loadCard();
+ let harness = new InsertHarness();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(
+ harness.last,
+ `:file[${card.id}]`,
+ 'atom inline file ref',
+ );
+
+ await chooseFormat('embedded');
+ await click('[data-test-markdown-embed-preview-cta]');
+ assert.strictEqual(harness.last, `::file[${card.id} | embedded]`);
+ });
+});
diff --git a/packages/host/tests/integration/components/markdown-embed-preview-test.gts b/packages/host/tests/integration/components/markdown-embed-preview-test.gts
new file mode 100644
index 00000000000..77abafbd18f
--- /dev/null
+++ b/packages/host/tests/integration/components/markdown-embed-preview-test.gts
@@ -0,0 +1,285 @@
+import type { TOC } from '@ember/component/template-only';
+import { type RenderingTestContext, render } from '@ember/test-helpers';
+
+import GlimmerComponent from '@glimmer/component';
+
+import { getService } from '@universal-ember/test-support';
+import { provide } from 'ember-provide-consume-context';
+
+import { module, test } from 'qunit';
+
+import {
+ baseRealm,
+ CardContextName,
+ GetCardContextName,
+ GetCardCollectionContextName,
+ GetCardsContextName,
+ type BfmSizeSpec,
+} from '@cardstack/runtime-common';
+
+import MarkdownEmbedPreview from '@cardstack/host/components/markdown-embed-chooser/preview';
+import { getCardCollection } from '@cardstack/host/resources/card-collection';
+import { getCard } from '@cardstack/host/resources/card-resource';
+import type StoreService from '@cardstack/host/services/store';
+
+// The base-realm helper below exports `CardDef` as a value (for defining test
+// card classes); import the instance *type* separately for annotations.
+import type { CardDef as CardDefInstance } from 'https://cardstack.com/base/card-api';
+
+import {
+ setupIntegrationTestRealm,
+ setupLocalIndexing,
+ testRealmURL,
+} from '../../helpers';
+import {
+ CardDef,
+ StringField,
+ contains,
+ field,
+ setupBaseRealm,
+} from '../../helpers/base-realm';
+import { setupMockMatrix } from '../../helpers/mock-matrix';
+import { setupRenderingTest } from '../../helpers/setup';
+
+const PreviewBox: TOC<{ Blocks: { default: [] } }> =
+
+ {{yield}}
+
+
+;
+
+// Provides the contexts CardRenderer reads via @consume so the preview can
+// render outside OperatorMode.
+class HostContextProvider extends GlimmerComponent<{
+ Blocks: { default: [] };
+}> {
+ @provide(GetCardContextName)
+ get getCardFn() {
+ return getCard;
+ }
+ @provide(GetCardsContextName)
+ get getCardsFn() {
+ let store = getService('store') as StoreService;
+ return store.getSearchResource.bind(store);
+ }
+ @provide(GetCardCollectionContextName)
+ get getCardCollectionFn() {
+ return getCardCollection;
+ }
+ @provide(CardContextName)
+ get cardContext() {
+ return {};
+ }
+
+ {{! template-lint-disable no-yield-only }}
+ {{yield}}
+
+}
+
+function styleOf(): string {
+ return (
+ document
+ .querySelector('[data-test-markdown-embed-preview]')
+ ?.getAttribute('style') ?? ''
+ );
+}
+
+module('Integration | markdown-embed-preview', function (hooks) {
+ setupRenderingTest(hooks);
+ setupBaseRealm(hooks);
+ setupLocalIndexing(hooks);
+
+ let mockMatrixUtils = setupMockMatrix(hooks, {
+ loggedInAs: '@testuser:localhost',
+ activeRealms: [baseRealm.url, testRealmURL],
+ autostart: true,
+ });
+
+ const mango = `${testRealmURL}books/mango`;
+
+ hooks.beforeEach(async function (this: RenderingTestContext) {
+ class Book extends CardDef {
+ static displayName = 'Book';
+ @field title = contains(StringField);
+ }
+ await setupIntegrationTestRealm({
+ mockMatrixUtils,
+ realmURL: testRealmURL,
+ contents: {
+ 'book.gts': { Book },
+ 'books/mango.json': new Book({ title: 'Mango' }),
+ },
+ });
+ await getService('realm').login(testRealmURL);
+ });
+
+ async function loadCard(): Promise {
+ let store = getService('store') as StoreService;
+ return (await store.get(mango)) as CardDefInstance;
+ }
+
+ test('renders atom format with no fitted sizing', async function (assert) {
+ let card = await loadCard();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .hasAttribute('data-test-markdown-embed-preview-format', 'atom');
+ assert
+ .dom('[data-test-markdown-embed-preview].markdown-embed-preview--fitted')
+ .doesNotExist('atom carries no fitted sizing');
+ });
+
+ test('renders embedded format', async function (assert) {
+ let card = await loadCard();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .hasAttribute('data-test-markdown-embed-preview-format', 'embedded');
+ });
+
+ test('renders a named fitted variant at its exact footprint', async function (assert) {
+ let card = await loadCard();
+ // tall-tile = 150x275
+ let tallTile: BfmSizeSpec = { format: 'fitted', width: 150, height: 275 };
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .hasAttribute('data-test-markdown-embed-preview-format', 'fitted');
+ let style = styleOf();
+ assert.ok(style.includes('width: 150px'), `width applied (${style})`);
+ assert.ok(style.includes('height: 275px'), `height applied (${style})`);
+ assert.ok(style.includes('overflow: hidden'), 'fitted clips overflow');
+ });
+
+ test('renders an arbitrary custom W×H', async function (assert) {
+ let card = await loadCard();
+ let custom: BfmSizeSpec = { format: 'fitted', width: 300, height: 200 };
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ let style = styleOf();
+ assert.ok(style.includes('width: 300px'), `custom width (${style})`);
+ assert.ok(style.includes('height: 200px'), `custom height (${style})`);
+ });
+
+ test('kind controls placement: inline renders a span, block renders a div', async function (assert) {
+ let card = await loadCard();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ assert
+ .dom('span[data-test-markdown-embed-preview]')
+ .exists('inline kind renders a span');
+ assert
+ .dom('div[data-test-markdown-embed-preview]')
+ .doesNotExist('no block wrapper for inline kind');
+ });
+
+ test('showSurroundingText wraps the embed in skeleton document text', async function (assert) {
+ let card = await loadCard();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ // The real embed still renders…
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .hasAttribute('data-test-markdown-embed-preview-format', 'atom');
+ // …flowing inside decorative skeleton document text (2 lines above + 2
+ // below the paragraph that carries the inline embed).
+ assert.dom('.markdown-embed-preview-doc__line').exists({ count: 4 });
+ assert
+ .dom(
+ '.markdown-embed-preview-doc__para span[data-test-markdown-embed-preview]',
+ )
+ .exists('inline embed flows within the skeleton paragraph');
+ });
+
+ test('bare preview (no surrounding text) renders no skeleton document', async function (assert) {
+ let card = await loadCard();
+ await render(
+
+
+
+
+
+
+ ,
+ );
+ assert
+ .dom('[data-test-markdown-embed-preview]')
+ .exists('the embed renders');
+ assert
+ .dom('.markdown-embed-preview-doc')
+ .doesNotExist(
+ 'no skeleton document wrapper without @showSurroundingText',
+ );
+ });
+});
diff --git a/packages/host/tests/unit/bfm-card-references-test.ts b/packages/host/tests/unit/bfm-card-references-test.ts
index 4a1b07cfd29..40ee88f3f66 100644
--- a/packages/host/tests/unit/bfm-card-references-test.ts
+++ b/packages/host/tests/unit/bfm-card-references-test.ts
@@ -1,5 +1,7 @@
import { module, test } from 'qunit';
+import { fittedFormatIds } from '@cardstack/boxel-ui/helpers';
+
import {
extractCardReferenceUrls,
extractFileReferenceUrls,
@@ -8,6 +10,9 @@ import {
bfmCardReferenceExtensions,
bfmExtensionsForKeyword,
parseBfmSizeSpec,
+ serializeBfmSizeSpec,
+ serializeBfmRef,
+ type BfmSizeSpec,
} from '@cardstack/runtime-common/bfm-card-references';
import { markdownToHtml } from '@cardstack/runtime-common/marked-sync';
import { VirtualNetwork } from '@cardstack/runtime-common/virtual-network';
@@ -998,4 +1003,121 @@ module('Unit | bfm-card-references', function () {
});
});
});
+
+ module('serializeBfmSizeSpec', function () {
+ test('isolated / embedded round-trip to their keyword', function (assert) {
+ assert.strictEqual(
+ serializeBfmSizeSpec({ format: 'isolated' }),
+ 'isolated',
+ );
+ assert.strictEqual(
+ serializeBfmSizeSpec({ format: 'embedded' }),
+ 'embedded',
+ );
+ });
+
+ test('bare fitted with no dimensions serializes to `fitted`', function (assert) {
+ assert.strictEqual(serializeBfmSizeSpec({ format: 'fitted' }), 'fitted');
+ });
+
+ test('fitted dimensions serialize to the explicit-key form', function (assert) {
+ assert.strictEqual(
+ serializeBfmSizeSpec({ format: 'fitted', width: 300, height: 200 }),
+ 'w:300 h:200',
+ );
+ });
+
+ test('percentage width is preserved', function (assert) {
+ assert.strictEqual(
+ serializeBfmSizeSpec({ format: 'fitted', width: '50%', height: 200 }),
+ 'w:50% h:200',
+ );
+ });
+
+ test('a single dimension serializes on its own', function (assert) {
+ assert.strictEqual(
+ serializeBfmSizeSpec({ format: 'fitted', height: 300 }),
+ 'h:300',
+ );
+ });
+
+ test('round-trips through parseBfmSizeSpec for isolated, embedded, dims, and %', function (assert) {
+ let specs: BfmSizeSpec[] = [
+ { format: 'isolated' },
+ { format: 'embedded' },
+ { format: 'fitted', width: 300, height: 200 },
+ { format: 'fitted', width: '50%', height: 120 },
+ { format: 'fitted', height: 300 },
+ ];
+ for (let spec of specs) {
+ assert.deepEqual(
+ parseBfmSizeSpec(serializeBfmSizeSpec(spec)),
+ spec,
+ `round-trips ${JSON.stringify(spec)}`,
+ );
+ }
+ });
+
+ test('every named fitted id round-trips dimensionally', function (assert) {
+ for (let id of fittedFormatIds) {
+ let parsed = parseBfmSizeSpec(id)!;
+ // The serializer emits `w:N h:N`, which re-parses to the same spec —
+ // the named identity is intentionally not reconstructed.
+ assert.deepEqual(
+ parseBfmSizeSpec(serializeBfmSizeSpec(parsed)),
+ parsed,
+ `${id} round-trips dimensionally`,
+ );
+ }
+ });
+ });
+
+ module('serializeBfmRef', function () {
+ let url = 'https://example.com/Author/jane';
+
+ test('inline drops the size and emits the single-colon form', function (assert) {
+ assert.strictEqual(
+ serializeBfmRef('card', url, { kind: 'inline', size: 'tall-tile' }),
+ `:card[${url}]`,
+ );
+ });
+
+ test('block with no size emits the bare double-colon form', function (assert) {
+ assert.strictEqual(
+ serializeBfmRef('card', url, { kind: 'block' }),
+ `::card[${url}]`,
+ );
+ });
+
+ test('block with a size appends the specifier', function (assert) {
+ assert.strictEqual(
+ serializeBfmRef('card', url, { kind: 'block', size: 'tall-tile' }),
+ `::card[${url} | tall-tile]`,
+ );
+ assert.strictEqual(
+ serializeBfmRef('card', url, { kind: 'block', size: 'w:300 h:200' }),
+ `::card[${url} | w:300 h:200]`,
+ );
+ });
+
+ test('defaults to block', function (assert) {
+ assert.strictEqual(serializeBfmRef('card', url), `::card[${url}]`);
+ });
+
+ test('honors the refType keyword (file)', function (assert) {
+ assert.strictEqual(
+ serializeBfmRef('file', url, { kind: 'inline' }),
+ `:file[${url}]`,
+ );
+ assert.strictEqual(
+ serializeBfmRef('file', url, { kind: 'block', size: 'embedded' }),
+ `::file[${url} | embedded]`,
+ );
+ });
+
+ test('returns empty string for a missing url', function (assert) {
+ assert.strictEqual(serializeBfmRef('card', undefined), '');
+ assert.strictEqual(serializeBfmRef('card', ''), '');
+ });
+ });
});
diff --git a/packages/runtime-common/bfm-card-references.ts b/packages/runtime-common/bfm-card-references.ts
index d9c1d9c8a4b..ba9d954276a 100644
--- a/packages/runtime-common/bfm-card-references.ts
+++ b/packages/runtime-common/bfm-card-references.ts
@@ -110,6 +110,65 @@ export function parseBfmSizeSpec(specifier: string): BfmSizeSpec | null {
return null;
}
+/**
+ * Serializes a `BfmSizeSpec` back into the specifier string that goes after
+ * `|` in `::card[url | spec]` — the inverse of `parseBfmSizeSpec`.
+ *
+ * `isolated` / `embedded` round-trip to their keyword. Fitted specs serialize
+ * to the explicit-key form (`w: h:`, `w:50%`, `h:200`, or bare `fitted`
+ * when no dimensions are present). Named variants are intentionally NOT
+ * reconstructed here: a `BfmSizeSpec` only carries dimensions, not the named
+ * identity, so callers that want the friendlier `tall-tile` form must emit it
+ * themselves (the chooser pane does this off the user's explicit selection).
+ * The `w:`/`h:` output still parses back to a dimensionally-identical spec.
+ */
+export function serializeBfmSizeSpec(spec: BfmSizeSpec): string {
+ if (spec.format === 'isolated' || spec.format === 'embedded') {
+ return spec.format;
+ }
+ let parts: string[] = [];
+ if (spec.width !== undefined) {
+ parts.push(`w:${spec.width}`);
+ }
+ if (spec.height !== undefined) {
+ parts.push(`h:${spec.height}`);
+ }
+ return parts.length ? parts.join(' ') : 'fitted';
+}
+
+export interface BfmRefOptions {
+ // 'inline' produces `:[url]`, 'block' produces
+ // `::[url]` (or `::[url | size]`). Default: 'block'.
+ kind?: 'inline' | 'block';
+ // Size specifier appended after `|` in block embeds (e.g. 'fitted',
+ // 'tall-tile', 'w:300 h:200'). Ignored for inline embeds, which always
+ // render atom and carry no size.
+ size?: string;
+}
+
+/**
+ * Builds a BFM reference directive (`:card[url]`, `::file[url | size]`, …) for
+ * a single reference by keyword + url. Returns `''` for a missing url. This is
+ * the single source of truth for BFM directive syntax, shared by the base-realm
+ * markdown helpers and host-side serializers (the `extract*`/`parse*` functions
+ * above are the matching readers).
+ */
+export function serializeBfmRef(
+ refType: string,
+ url: string | null | undefined,
+ options?: BfmRefOptions,
+): string {
+ if (!url) {
+ return '';
+ }
+ let kind = options?.kind ?? 'block';
+ if (kind === 'inline') {
+ return `:${refType}[${url}]`;
+ }
+ let size = options?.size;
+ return size ? `::${refType}[${url} | ${size}]` : `::${refType}[${url}]`;
+}
+
export type BfmBlockFormat = 'embedded' | 'fitted' | 'isolated';
/**