Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2913,6 +2913,14 @@ export class CSSField extends TextAreaField {
};
}

// A plain markdown StringField. BFM embed directives (`:card[…]` / `:file[…]`)
// in its content render as (correctly-sized) broken-link placeholders and do
// NOT resolve: a StringField is a bare string with no `id` to resolve relative
// refs against, no `linksToMany` storage to hold resolved instances, and no
// query to populate them at index time. Resolution is an index-time
// relationship concern — use `RichMarkdownField` (or a `.md` `MarkdownDef`
// file), which own the `linkedCards`/`linkedFiles` relationships, when embeds
// must render live.
export class MarkdownField extends StringField {
static displayName = 'Markdown';
static icon = MarkdownIcon;
Expand Down
17 changes: 9 additions & 8 deletions packages/base/default-templates/markdown.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import LinkOffIcon from '@cardstack/boxel-icons/link-off';

import {
bfmRefFormatAndSize,
bfmResolvedEmbedStyle,
buildWaiter,
cardTypeName,
fileNameFromUrl,
Expand Down Expand Up @@ -271,14 +272,14 @@ export default class MarkDownTemplate extends GlimmerComponent<{
let format: CardSlotFormat = derived.format;
let sizeStyle: string | undefined = derived.sizeStyle;

// Fitted slots carry an inline width/height plus `overflow: hidden`
// so the resolved instance occupies the requested footprint.
let resolvedStyle: ReturnType<typeof htmlSafe> | undefined;
if (format === 'fitted') {
resolvedStyle = htmlSafe(
sizeStyle ? `${sizeStyle}; overflow: hidden` : 'overflow: hidden',
);
}
// Non-atom resolved slots carry a footprint so the instance occupies
// a definite box instead of collapsing (isolated/inline-embedded
// default templates lay out at 100%). Fitted uses its requested
// dimensions; embedded/isolated get shared defaults. Same helper as
// the other render surfaces so footprints stay in lockstep.
let resolvedStyleRaw = bfmResolvedEmbedStyle(format, kind, sizeStyle);
let resolvedStyle: ReturnType<typeof htmlSafe> | undefined =
resolvedStyleRaw ? htmlSafe(resolvedStyleRaw) : undefined;

let resolvedUrl = resolveUrl(rawUrl, baseUrl);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { eq, not } from '@cardstack/boxel-ui/helpers';

import {
bfmRefFormatAndSize,
bfmResolvedEmbedStyle,
type BfmSizeSpec,
} from '@cardstack/runtime-common/bfm-card-references';

Expand Down Expand Up @@ -266,38 +267,25 @@ export default class MarkdownEmbedPreview extends Component<Signature> {
return sizeStyle ? htmlSafe(sizeStyle) : undefined;
}

// 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`). Inline embedded
// and isolated have no intrinsic inline width: the default template's
// `width/height: 100%` resolves against the inline-block wrapper, which is
// itself shrink-wrapping, and the box collapses. Give the wrapper a definite
// footprint that matches the live renderer's loading placeholders so the
// preview shows a real card body.
// Resolved-embed footprint, shared with the live markdown render surfaces
// (saved `MarkdownTemplate`, host preview panel, CodeMirror editor) through
// `bfmResolvedEmbedStyle`. Isolated and inline embedded have no intrinsic
// inline width — their default `width/height: 100%` resolves against a
// shrink-wrapping wrapper and the box collapses — so the helper hands back a
// definite footprint that matches the live renderers' loading placeholders.
private get sizeStyle(): ReturnType<typeof htmlSafe> | undefined {
let { format } = this.args;
let fittedSizeStyle: string | undefined;
if (format === 'fitted') {
let { width, height } = this.args.sizeSpec ?? { format: 'fitted' };
let { sizeStyle } = bfmRefFormatAndSize(
fittedSizeStyle = bfmRefFormatAndSize(
'fitted',
width === undefined ? undefined : String(width),
height === undefined ? undefined : String(height),
);
return htmlSafe(
sizeStyle ? `${sizeStyle}; overflow: hidden` : 'overflow: hidden',
);
).sizeStyle;
}
if (
this.kind === 'inline' &&
(format === 'embedded' || format === 'isolated')
) {
let footprint =
format === 'isolated'
? 'width: 24rem; height: 18.75rem'
: 'width: 16rem; height: 9.375rem';
return htmlSafe(`${footprint}; overflow: hidden`);
}
return undefined;
let style = bfmResolvedEmbedStyle(format, this.kind, fittedSizeStyle);
return style ? htmlSafe(style) : undefined;
}

<template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { eq } from '@cardstack/boxel-ui/helpers';

import {
bfmRefFormatAndSize,
bfmResolvedEmbedStyle,
CardContextName,
cardTypeName,
extractCardReferenceUrls,
Expand Down Expand Up @@ -271,14 +272,14 @@ export default class RenderedMarkdown extends Component<Signature> {
let format: CardSlotFormat = derived.format;
let sizeStyle: string | undefined = derived.sizeStyle;

// Fitted slots carry an inline width/height plus `overflow: hidden`
// so the resolved instance occupies the requested footprint.
let resolvedStyle: ReturnType<typeof htmlSafe> | undefined;
if (format === 'fitted') {
resolvedStyle = htmlSafe(
sizeStyle ? `${sizeStyle}; overflow: hidden` : 'overflow: hidden',
);
}
// Non-atom resolved slots carry a footprint so the instance occupies
// a definite box instead of collapsing (isolated/inline-embedded
// default templates lay out at 100%). Fitted uses its requested
// dimensions; embedded/isolated get shared defaults. Same helper as
// the other render surfaces so footprints stay in lockstep.
let resolvedStyleRaw = bfmResolvedEmbedStyle(format, kind, sizeStyle);
let resolvedStyle: ReturnType<typeof htmlSafe> | undefined =
resolvedStyleRaw ? htmlSafe(resolvedStyleRaw) : undefined;

let resolvedUrl = resolveUrl(rawUrl, baseUrl);

Expand Down
13 changes: 5 additions & 8 deletions packages/host/app/lib/codemirror-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
type BfmRefFormat,
type BfmRefRange,
bfmRefFormatAndSize,
bfmResolvedEmbedStyle,
extractBfmRefRanges,
parseBfmSizeSpec,
} from '@cardstack/runtime-common/bfm-card-references';
Expand Down Expand Up @@ -941,14 +942,10 @@ function createCardTargetNotifier(
el.getAttribute('data-boxel-bfm-height') ?? undefined,
kind === 'inline' ? 'atom' : 'embedded',
);
// Fitted slots carry the width/height plus `overflow: hidden` so
// the resolved instance occupies the requested footprint.
let style =
format === 'fitted'
? sizeStyle
? `${sizeStyle}; overflow: hidden`
: 'overflow: hidden'
: undefined;
// Non-atom slots carry a footprint so the resolved instance
// occupies a definite box instead of collapsing. Same helper as
// the saved/preview renderers so footprints stay in lockstep.
let style = bfmResolvedEmbedStyle(format, kind, sizeStyle);
targets.push({
element: el as HTMLElement,
cardId,
Expand Down
52 changes: 52 additions & 0 deletions packages/host/tests/acceptance/markdown-file-def-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,29 @@ module('Acceptance | markdown BFM card references', function (hooks) {
'',
'::file[https://nonexistent.example/gone.pdf]',
].join('\n'),
// A plain markdown target embedded across the full format matrix.
// MarkdownDef defines atom/embedded/fitted/isolated, so each combo
// renders a distinct `data-test-markdown-*` hook.
'documents/target.md': ['# Target Doc', '', 'Body text.'].join('\n'),
'bfm-file-matrix.md': [
'# BFM File Matrix',
'',
`:file[${testRealmURL}documents/target.md | atom]`,
'',
`:file[${testRealmURL}documents/target.md | embedded]`,
'',
`:file[${testRealmURL}documents/target.md | fitted w:200 h:150]`,
'',
`:file[${testRealmURL}documents/target.md | isolated]`,
'',
`::file[${testRealmURL}documents/target.md | atom]`,
'',
`::file[${testRealmURL}documents/target.md | embedded]`,
'',
`::file[${testRealmURL}documents/target.md | fitted w:200 h:150]`,
'',
`::file[${testRealmURL}documents/target.md | isolated]`,
].join('\n'),
'mermaid-test.md': [
'# Mermaid Test',
'',
Expand Down Expand Up @@ -472,6 +495,35 @@ module('Acceptance | markdown BFM card references', function (hooks) {
);
});

test('every file format renders in both inline and block placement', async function (assert) {
// CS-12320: the file half of the resolved-render matrix. Each format ×
// placement must render the referenced markdown file in the requested
// format — isolated must not collapse.
await visitOperatorMode({
submode: 'code',
codePath: `${testRealmURL}bfm-file-matrix.md`,
});
await settled();

for (let [placement, wrapper] of [
['inline', '[data-test-markdown-bfm-inline-file]'],
['block', '[data-test-markdown-bfm-block-file]'],
] as const) {
for (let format of ['atom', 'embedded', 'fitted', 'isolated'] as const) {
assert
.dom(`${wrapper} [data-test-markdown-${format}]`)
.exists(`${format} file renders in ${placement} placement`);
}
}

assert
.dom('[data-test-markdown-bfm-unresolved-inline]')
.doesNotExist('no unresolved inline file placeholders remain');
assert
.dom('[data-test-markdown-bfm-unresolved-block]')
.doesNotExist('no unresolved block file placeholders remain');
});

test('shows fallback for unresolvable file references', async function (assert) {
await visitOperatorMode({
submode: 'code',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,14 +515,45 @@ module('Integration | codemirror-context', function (hooks) {
}
});

test('block isolated embed threads isolated format with no size style', async function (assert) {
test('block isolated embed threads isolated format with a growable footprint', async function (assert) {
let { targets, destroy } = await collectTargets(
'::card[https://example.com/cards/1 | isolated]',
);
try {
let target = targets.find((t) => t.kind === 'block');
assert.strictEqual(target?.format, 'isolated', 'format is isolated');
assert.notOk(target?.style, 'isolated embed has no inline size style');
// CS-12320: block isolated gets a growable min-height so it does not
// collapse (its default template lays out at height: 100%).
assert.strictEqual(
target?.style,
'min-height: 18.75rem',
'isolated embed carries a growable min-height footprint',
);
} finally {
destroy();
}
});

test('inline isolated / embedded embeds carry a definite footprint', async function (assert) {
// CS-12320: inline isolated/embedded collapse without a definite width +
// height (the default template lays out at 100% inside a shrink-wrapping
// inline-block wrapper).
let { targets, destroy } = await collectTargets(
':card[https://example.com/cards/1 | isolated] and :card[https://example.com/cards/2 | embedded]',
);
try {
let isolated = targets.find((t) => t.format === 'isolated');
assert.strictEqual(
isolated?.style,
'width: 24rem; height: 18.75rem; overflow: hidden',
'inline isolated carries the shared footprint',
);
let embedded = targets.find((t) => t.format === 'embedded');
assert.strictEqual(
embedded?.style,
'width: 16rem; height: 9.375rem; overflow: hidden',
'inline embedded carries the shared footprint',
);
} finally {
destroy();
}
Expand Down Expand Up @@ -573,7 +604,12 @@ module('Integration | codemirror-context', function (hooks) {
(t) => t.refType === 'file' && t.format === 'isolated',
);
assert.ok(isolated, 'isolated file target is present');
assert.notOk(isolated?.style, 'isolated file target has no size style');
// CS-12320: block isolated gets a growable min-height footprint.
assert.strictEqual(
isolated?.style,
'min-height: 18.75rem',
'isolated file target carries a growable min-height footprint',
);
} finally {
destroy();
}
Expand Down
Loading
Loading