diff --git a/packages/base/markdown-helpers.ts b/packages/base/markdown-helpers.ts index f1c63147345..bb340fb5a93 100644 --- a/packages/base/markdown-helpers.ts +++ b/packages/base/markdown-helpers.ts @@ -3,7 +3,7 @@ // construction, and so future adjustments happen in one place. import { markdownEscape } from '@cardstack/boxel-ui/helpers'; -import { isValidDate } from '@cardstack/runtime-common'; +import { isValidDate, serializeBfmRef } from '@cardstack/runtime-common'; // Date formatting shared by DateField, DateTimeField, and DateRangeField so // their markdown output is consistent. Matches the existing `en-US` `{year: @@ -158,24 +158,25 @@ interface MarkdownEmbedOptions { size?: string; } +// Returns a BFM reference directive (`:card`/`::card`, `:file`/`::file`, …) +// for a single reference by keyword + id. Returns `''` for a missing id. +// Thin wrapper over the shared `serializeBfmRef` builder so base-realm and +// host code emit identical BFM syntax. +export function markdownEmbedForRef( + refType: string, + id: string | null | undefined, + options?: MarkdownEmbedOptions, +): string { + return serializeBfmRef(refType, id, options); +} + // Returns a BFM card-reference directive for a single card. // Returns `''` for null/undefined cards. export function markdownEmbedForCard( card: CardLike | null | undefined, options?: MarkdownEmbedOptions, ): string { - if (!card?.id) { - return ''; - } - let kind = options?.kind ?? 'block'; - if (kind === 'inline') { - return `:card[${card.id}]`; - } - let size = options?.size; - if (size) { - return `::card[${card.id} | ${size}]`; - } - return `::card[${card.id}]`; + return serializeBfmRef('card', card?.id, options); } interface MarkdownEmbedsOptions extends MarkdownEmbedOptions { diff --git a/packages/host/app/components/markdown-embed-chooser/pane-usage.gts b/packages/host/app/components/markdown-embed-chooser/pane-usage.gts new file mode 100644 index 00000000000..22bcad4df78 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/pane-usage.gts @@ -0,0 +1,112 @@ +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import { isCardErrorJSONAPI } from '@cardstack/runtime-common'; + +import MiniCardChooser from '@cardstack/host/components/card-chooser/mini'; + +import type StoreService from '@cardstack/host/services/store'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import MarkdownEmbedPreviewPane from './pane'; + +export default class MarkdownEmbedPreviewPaneUsage extends Component { + @service declare private store: StoreService; + + @tracked private target: CardDef | undefined; + @tracked private inserted: string | undefined; + + @action private async onSelect(url: string) { + let result = await this.store.get(url); + if (!isCardErrorJSONAPI(result)) { + this.target = result as CardDef; + } + } + + @action private onInsert(bfm: string) { + this.inserted = bfm; + } + + +} diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts new file mode 100644 index 00000000000..41e1325ad91 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -0,0 +1,417 @@ +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { + BoxelInput, + BoxelSelect, + Button, +} from '@cardstack/boxel-ui/components'; +import { + eq, + type FittedFormatId, + type FittedFormatSpec, + FITTED_FORMAT_SIZES, + fittedFormatById, +} from '@cardstack/boxel-ui/helpers'; + +import { + serializeBfmRef, + serializeBfmSizeSpec, + type BfmSizeSpec, +} from '@cardstack/runtime-common/bfm-card-references'; + +import type { CardDef, FileDef } from 'https://cardstack.com/base/card-api'; + +import MarkdownEmbedPreview from './preview'; + +type EmbedFormat = 'atom' | 'embedded' | 'fitted' | 'isolated'; +type FormatCategory = 'atom' | 'embedded' | 'fitted' | 'custom'; +type OptionValue = 'atom' | 'embedded' | FittedFormatId | 'custom'; + +interface FormatOption { + value: OptionValue; + label: string; + category: FormatCategory; +} + +// Flat dropdown list (no group headers): Atom, Embedded, every Fitted variant, +// then Custom — matching the designer's dropdown. `Custom` is labelled +// `Fitted - Custom size` for grouping but is its own CTA category. +function buildFormatOptions(): FormatOption[] { + let options: FormatOption[] = [ + { value: 'atom', label: 'Atom - Variable size', category: 'atom' }, + { + value: 'embedded', + label: 'Embedded - Variable size', + category: 'embedded', + }, + ]; + for (let spec of FITTED_FORMAT_SIZES) { + options.push({ + value: spec.id, + label: `Fitted - ${spec.title} - ${spec.width}x${spec.height}`, + category: 'fitted', + }); + } + options.push({ + value: 'custom', + label: 'Fitted - Custom size', + category: 'custom', + }); + return options; +} + +interface Signature { + Element: HTMLElement; + Args: { + // Resolved instance being previewed. Its `id` is the BFM ref URL. + target: CardDef | FileDef; + // Which BFM keyword to emit: `:card[...]` vs `:file[...]`. + refType: 'card' | 'file'; + // Receives the serialized BFM directive when the CTA is clicked. The host + // owns actual cursor insertion (a later ticket). + onInsert: (bfm: string) => void; + }; +} + +// Right-hand companion to the mini choosers: a live preview plus the controls +// that decide how a card/file embeds — format dropdown, always-on W×H inputs +// for Fitted (with smart variant matching), an Inline/Block toggle, and a +// dynamic "Insert as …" CTA. +export default class MarkdownEmbedPreviewPane extends Component { + private formatOptions: FormatOption[] = buildFormatOptions(); + + // Atom is the default on first selection; atom is inline-only (see below). + @tracked private selectedValue: OptionValue = 'atom'; + @tracked private kind: 'inline' | 'block' = 'inline'; + // Raw input strings so a partially-typed value (e.g. while clearing) doesn't + // throw away the user's keystrokes. `%` widths are preserved verbatim. + @tracked private widthInput = ''; + @tracked private heightInput = ''; + + private get selectedOption(): FormatOption { + return ( + this.formatOptions.find((o) => o.value === this.selectedValue) ?? + this.formatOptions[0] + ); + } + + private get category(): FormatCategory { + return this.selectedOption.category; + } + + private get showSizeInputs(): boolean { + return this.category === 'fitted' || this.category === 'custom'; + } + + // The preview renders the selected format in the chosen placement; format and + // inline/block are independent (every format works in both modes). + private get previewFormat(): EmbedFormat { + switch (this.category) { + case 'atom': + return 'atom'; + case 'embedded': + return 'embedded'; + default: + return 'fitted'; + } + } + + // px number, `%` string, or undefined for an unparseable/empty input. + private get width(): number | string | undefined { + let v = this.widthInput.trim(); + if (/^\d+%$/.test(v)) return v; + if (/^\d+$/.test(v)) return parseInt(v, 10); + return undefined; + } + + private get height(): number | undefined { + let v = this.heightInput.trim(); + return /^\d+$/.test(v) ? parseInt(v, 10) : undefined; + } + + private get sizeSpec(): BfmSizeSpec | undefined { + if (!this.showSizeInputs) { + return undefined; + } + return { format: 'fitted', width: this.width, height: this.height }; + } + + private get categoryLabel(): string { + switch (this.category) { + case 'atom': + return 'Atom'; + case 'embedded': + return 'Embedded'; + case 'custom': + return 'Custom'; + case 'fitted': + default: + return 'Fitted'; + } + } + + private get ctaLabel(): string { + return `Insert as ${this.categoryLabel}`; + } + + // Size specifier for the chosen format, independent of placement: `atom` / + // `embedded` keywords, the variant id for a named Fitted variant, and + // `w: h:` for Custom dimensions (per CS-11674). + private get sizeSpecifier(): string | undefined { + switch (this.category) { + case 'atom': + return 'atom'; + case 'embedded': + return 'embedded'; + case 'fitted': + return this.selectedValue; + case 'custom': + return serializeBfmSizeSpec({ + format: 'fitted', + width: this.width, + height: this.height, + }); + default: + return undefined; + } + } + + private get bfmString(): string { + let url = this.args.target.id; + // Declare intent uniformly (kind + size); the BFM serializer in + // runtime-common owns whether a given kind carries the size. Today it drops + // the size for inline (so inline emits `:card[url]`); the BFM "all formats + // in both modes" ticket makes inline honor it, after which this chooser + // emits `:card[url | spec]` with no change here. + return serializeBfmRef(this.args.refType, url, { + kind: this.kind, + size: this.sizeSpecifier, + }); + } + + @action + private selectFormat(option: FormatOption) { + this.selectedValue = option.value; + // Pick a sensible default placement for the format — atom reads as inline, + // sized formats as block — but the toggle stays free, so the user can flip + // either way afterward. + this.kind = option.category === 'atom' ? 'inline' : 'block'; + if (option.category === 'fitted') { + let spec = fittedFormatById.get(option.value as FittedFormatId); + if (spec) { + this.widthInput = String(spec.width); + this.heightInput = String(spec.height); + } + } + } + + // Bidirectional sync: editing either dimension re-points the dropdown to the + // matching named variant, or to Custom when nothing matches exactly. + private syncVariantFromSize() { + let w = this.width; + let h = this.height; + if (typeof w === 'number' && typeof h === 'number') { + let match = FITTED_FORMAT_SIZES.find( + (s: FittedFormatSpec) => s.width === w && s.height === h, + ); + this.selectedValue = match ? match.id : 'custom'; + } else { + this.selectedValue = 'custom'; + } + } + + @action + private setWidth(value: string) { + this.widthInput = value; + this.syncVariantFromSize(); + } + + @action + private setHeight(value: string) { + this.heightInput = value; + this.syncVariantFromSize(); + } + + @action + private setKind(kind: 'inline' | 'block') { + this.kind = kind; + } + + @action + private insert() { + this.args.onInsert(this.bfmString); + } + + +} diff --git a/packages/host/app/components/markdown-embed-chooser/preview/index.gts b/packages/host/app/components/markdown-embed-chooser/preview/index.gts new file mode 100644 index 00000000000..8bb3053bd70 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/preview/index.gts @@ -0,0 +1,233 @@ +import type { TOC } from '@ember/component/template-only'; +import { htmlSafe } from '@ember/template'; + +import Component from '@glimmer/component'; + +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { + bfmBlockFormatAndSize, + type BfmSizeSpec, +} from '@cardstack/runtime-common/bfm-card-references'; + +import CardRenderer from '@cardstack/host/components/card-renderer'; + +import type { + CardDef, + FileDef, + Format, +} from 'https://cardstack.com/base/card-api'; + +type EmbedFormat = 'atom' | 'embedded' | 'fitted' | 'isolated'; + +interface EmbedSignature { + Element: HTMLElement; + Args: { + target: CardDef | FileDef; + format: Format; + kind: 'inline' | 'block'; + sizeStyle?: ReturnType; + }; +} + +// The embed itself: inline placement flows within text (``), block gives +// it its own line (`
`). Both render through the same CardRenderer. +const Embed: TOC = ; + +interface Signature { + Element: HTMLElement; + Args: { + // Already-resolved instance to preview. Both card refs (`:card[...]`) and + // file refs (`:file[...]`) render through the same CardRenderer, so the + // caller resolves the URL and hands us the instance — this component loads + // nothing. + target: CardDef | FileDef; + // Render format. `fitted` consults `@sizeSpec` for its width/height; + // atom/embedded/isolated ignore it. + format: EmbedFormat; + // Width/height for fitted renders. Width may be a px number or a `%` + // string; height is a px number. Ignored unless `@format` is 'fitted'. + sizeSpec?: BfmSizeSpec; + // Placement only: inline flows the preview within text (``); block + // gives it its own line (`
`). Does NOT change the render format. + // Default: 'block'. + kind?: 'inline' | 'block'; + // When true, the embed is shown inside placeholder document text so the + // viewer sees how it sits in a real markdown doc: an inline embed flows + // within the paragraph, a block embed breaks onto its own line. Off by + // default so format galleries / overlays can render the bare embed. + showSurroundingText?: boolean; + }; +} + +// Renders a resolved card/file in the requested format + size, matching how +// `rendered-markdown.gts` paints the eventual document slot. With +// `@showSurroundingText` it wraps the embed in skeleton document text to +// preview placement in context; the chooser's preview pane turns this on. +export default class MarkdownEmbedPreview extends Component { + private get kind(): 'inline' | 'block' { + return this.args.kind ?? 'block'; + } + + private get renderFormat(): Format { + return this.args.format; + } + + // Fitted slots carry an inline width/height plus `overflow: hidden` so the + // instance occupies the requested footprint — derived through the same helper + // the live markdown renderer uses (`rendered-markdown.gts`). + private get sizeStyle(): ReturnType | undefined { + if (this.args.format !== 'fitted') { + return undefined; + } + let { width, height } = this.args.sizeSpec ?? { format: 'fitted' }; + let { sizeStyle } = bfmBlockFormatAndSize( + 'fitted', + width === undefined ? undefined : String(width), + height === undefined ? undefined : String(height), + ); + return htmlSafe( + sizeStyle ? `${sizeStyle}; overflow: hidden` : 'overflow: hidden', + ); + } + + +} diff --git a/packages/host/app/components/markdown-embed-chooser/preview/usage.gts b/packages/host/app/components/markdown-embed-chooser/preview/usage.gts new file mode 100644 index 00000000000..94272c9d57a --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/preview/usage.gts @@ -0,0 +1,151 @@ +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import { isCardErrorJSONAPI } from '@cardstack/runtime-common'; + +import MiniCardChooser from '@cardstack/host/components/card-chooser/mini'; + +import type StoreService from '@cardstack/host/services/store'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import MarkdownEmbedPreview from './index'; + +export default class MarkdownEmbedPreviewUsage extends Component { + @service declare private store: StoreService; + + @tracked private target: CardDef | undefined; + + @action private async onSelect(url: string) { + let result = await this.store.get(url); + if (!isCardErrorJSONAPI(result)) { + this.target = result as CardDef; + } + } + + + + 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: [] } }> = ; + +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 {}; + } + +} + +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: [] } }> = ; + +// 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 {}; + } + +} + +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'; /**