Skip to content

Commit 00f5249

Browse files
backspaceclaude
andcommitted
Thread VirtualNetwork into MarkdownField default-template rendering
MarkdownField.embedded / MarkdownField.atom render BFM markdown with `<MarkdownTemplate @content={{@model}} />`. The field templates only receive the field's primitive @model (a string), with no path to the owning card — so before this change they couldn't supply a VN to MarkdownTemplate, and prefix-form BFM pill refs (e.g. `@cardstack/catalog/foo`) couldn't be resolved at render time. Pick design option 2 from CS-11375: wrap MarkdownField's defaults in `<CardContextConsumer as |context|>` and pass `@cardReferenceVirtualNetwork={{context.store.virtualNetwork}}`. Adding `virtualNetwork: VirtualNetwork` to the Store interface (and exposing it as a getter on host StoreService that delegates to `this.network.virtualNetwork`) makes the context.store path supply VN without changing the field-render protocol — touches MarkdownField specifically rather than every field template. With every MarkdownTemplate call site now threading VN, `cardReferenceVirtualNetwork` becomes required on the component arg and the `resolveUrl` helper's no-VN branch deletes. markdown-file-def and rich-markdown's `virtualNetwork` getters tighten to `VirtualNetwork` with a `?? new VirtualNetwork()` fallback for detached models (preserves the no-throw behavior we already accept for static parsers). Adds a new `markdown-field-test.gts` covering MarkdownField.embedded and .atom rendering BFM pills, including a prefix-form ref that exercises the consumer plumbing end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0eba1c7 commit 00f5249

7 files changed

Lines changed: 225 additions & 22 deletions

File tree

packages/base/card-api.gts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {
1212
getBoxComponent,
1313
type BoxComponent,
14+
CardContextConsumer,
1415
CardCrudFunctionsConsumer,
1516
DefaultFormatsConsumer,
1617
} from './field-component';
@@ -2821,12 +2822,26 @@ export class MarkdownField extends StringField {
28212822
static embedded = class MarkdownViewTemplate extends Component<
28222823
typeof MarkdownField
28232824
> {
2824-
<template><MarkdownTemplate @content={{@model}} /></template>
2825+
<template>
2826+
<CardContextConsumer as |context|>
2827+
<MarkdownTemplate
2828+
@content={{@model}}
2829+
@cardReferenceVirtualNetwork={{context.store.virtualNetwork}}
2830+
/>
2831+
</CardContextConsumer>
2832+
</template>
28252833
};
28262834
static atom = class MarkdownViewTemplate extends Component<
28272835
typeof MarkdownField
28282836
> {
2829-
<template><MarkdownTemplate @content={{@model}} /></template>
2837+
<template>
2838+
<CardContextConsumer as |context|>
2839+
<MarkdownTemplate
2840+
@content={{@model}}
2841+
@cardReferenceVirtualNetwork={{context.store.virtualNetwork}}
2842+
/>
2843+
</CardContextConsumer>
2844+
</template>
28302845
};
28312846

28322847
static edit = class Edit extends Component<typeof this> {

packages/base/default-templates/markdown.gts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,12 @@ interface RenderSlot {
6767
function resolveUrl(
6868
raw: string,
6969
baseUrl: string | null | undefined,
70-
virtualNetwork: VirtualNetwork | undefined,
70+
virtualNetwork: VirtualNetwork,
7171
): string {
72-
// With a VN, resolve through it so prefix-form bases and registered
73-
// prefix-form refs round-trip correctly. Without a VN, plain
74-
// `new URL(raw, baseUrl)` still handles the common case — URL-form
75-
// refs (with or without a base) and relative refs against a URL-form
76-
// base. Prefix-form bases need a VN; `new URL()` throws on those and
77-
// we fall back to the raw ref.
7872
try {
79-
if (virtualNetwork) {
80-
return trimJsonExtension(
81-
virtualNetwork.resolveURL(raw, baseUrl || undefined).href,
82-
);
83-
}
84-
return trimJsonExtension(new URL(raw, baseUrl || undefined).href);
73+
return trimJsonExtension(
74+
virtualNetwork.resolveURL(raw, baseUrl || undefined).href,
75+
);
8576
} catch {
8677
return trimJsonExtension(raw);
8778
}
@@ -92,7 +83,7 @@ export default class MarkDownTemplate extends GlimmerComponent<{
9283
content: string | null | undefined;
9384
linkedCards?: CardDef[] | null;
9485
cardReferenceBaseUrl?: string | null;
95-
cardReferenceVirtualNetwork?: VirtualNetwork;
86+
cardReferenceVirtualNetwork: VirtualNetwork;
9687
};
9788
}> {
9889
@tracked monacoContextInternal: any = undefined;

packages/base/markdown-file-def.gts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,25 @@ class Isolated extends Component<typeof MarkdownDef> {
130130
return Boolean(this.args.model?.content?.trim());
131131
}
132132

133+
get virtualNetwork(): VirtualNetwork {
134+
// Detached models (no store-attached VN) fall back to an empty VN
135+
// so MarkdownTemplate's required cardReferenceVirtualNetwork is
136+
// satisfied; prefix-form refs in such contexts just won't resolve
137+
// since there are no realm mappings.
138+
return (
139+
(this.args.model ? virtualNetworkFor(this.args.model) : undefined) ??
140+
new VirtualNetwork()
141+
);
142+
}
143+
133144
<template>
134145
<article class='markdown-isolated' data-test-markdown-isolated>
135146
{{#if this.hasContent}}
136147
<MarkdownTemplate
137148
@content={{this.content}}
138149
@linkedCards={{@model.linkedCards}}
139150
@cardReferenceBaseUrl={{@model.id}}
151+
@cardReferenceVirtualNetwork={{this.virtualNetwork}}
140152
/>
141153
{{else}}
142154
<header class='markdown-isolated__title'>{{this.title}}</header>
@@ -189,6 +201,17 @@ class Embedded extends Component<typeof MarkdownDef> {
189201
return headingText === this.title;
190202
}
191203

204+
get virtualNetwork(): VirtualNetwork {
205+
// Detached models (no store-attached VN) fall back to an empty VN
206+
// so MarkdownTemplate's required cardReferenceVirtualNetwork is
207+
// satisfied; prefix-form refs in such contexts just won't resolve
208+
// since there are no realm mappings.
209+
return (
210+
(this.args.model ? virtualNetworkFor(this.args.model) : undefined) ??
211+
new VirtualNetwork()
212+
);
213+
}
214+
192215
<template>
193216
<article class='markdown-embedded' data-test-markdown-embedded>
194217
{{#unless this.contentStartsWithTitle}}
@@ -199,6 +222,7 @@ class Embedded extends Component<typeof MarkdownDef> {
199222
@content={{this.content}}
200223
@linkedCards={{@model.linkedCards}}
201224
@cardReferenceBaseUrl={{@model.id}}
225+
@cardReferenceVirtualNetwork={{this.virtualNetwork}}
202226
/>
203227
</div>
204228
</article>

packages/base/rich-markdown.gts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,15 @@ export class RichMarkdownField extends FieldDef {
8383
get content() {
8484
return this.args.model?.content ?? null;
8585
}
86-
get virtualNetwork() {
87-
return this.args.model ? virtualNetworkFor(this.args.model) : undefined;
86+
get virtualNetwork(): VirtualNetwork {
87+
// Detached models (no store-attached VN) fall back to an empty VN
88+
// so MarkdownTemplate's required cardReferenceVirtualNetwork is
89+
// satisfied; prefix-form refs in such contexts just won't resolve
90+
// since there are no realm mappings.
91+
return (
92+
(this.args.model ? virtualNetworkFor(this.args.model) : undefined) ??
93+
new VirtualNetwork()
94+
);
8895
}
8996
get baseUrl(): string | null {
9097
let model = this.args.model;
@@ -110,8 +117,15 @@ export class RichMarkdownField extends FieldDef {
110117
get content() {
111118
return this.args.model?.content ?? null;
112119
}
113-
get virtualNetwork() {
114-
return this.args.model ? virtualNetworkFor(this.args.model) : undefined;
120+
get virtualNetwork(): VirtualNetwork {
121+
// Detached models (no store-attached VN) fall back to an empty VN
122+
// so MarkdownTemplate's required cardReferenceVirtualNetwork is
123+
// satisfied; prefix-form refs in such contexts just won't resolve
124+
// since there are no realm mappings.
125+
return (
126+
(this.args.model ? virtualNetworkFor(this.args.model) : undefined) ??
127+
new VirtualNetwork()
128+
);
115129
}
116130
get baseUrl(): string | null {
117131
let model = this.args.model;
@@ -155,8 +169,15 @@ export class RichMarkdownField extends FieldDef {
155169
updateContent = (markdown: string) => {
156170
this.args.model.content = markdown;
157171
};
158-
get virtualNetwork() {
159-
return this.args.model ? virtualNetworkFor(this.args.model) : undefined;
172+
get virtualNetwork(): VirtualNetwork {
173+
// Detached models (no store-attached VN) fall back to an empty VN
174+
// so MarkdownTemplate's required cardReferenceVirtualNetwork is
175+
// satisfied; prefix-form refs in such contexts just won't resolve
176+
// since there are no realm mappings.
177+
return (
178+
(this.args.model ? virtualNetworkFor(this.args.model) : undefined) ??
179+
new VirtualNetwork()
180+
);
160181
}
161182
get baseUrl(): string | null {
162183
let model = this.args.model;

packages/host/app/services/store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ export default class StoreService extends Service implements StoreInterface {
302302
});
303303
}
304304

305+
get virtualNetwork() {
306+
return this.network.virtualNetwork;
307+
}
308+
305309
protected renderContextBlocksPersistence() {
306310
return (
307311
this.isRenderStore && Boolean((globalThis as any).__boxelRenderContext)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { RenderingTestContext } from '@ember/test-helpers';
2+
3+
import { getService } from '@universal-ember/test-support';
4+
import { module, test } from 'qunit';
5+
6+
import {
7+
PermissionsContextName,
8+
type Permissions,
9+
baseRealm,
10+
} from '@cardstack/runtime-common';
11+
import type { Loader } from '@cardstack/runtime-common/loader';
12+
13+
import {
14+
provideConsumeContext,
15+
setupCardLogs,
16+
setupIntegrationTestRealm,
17+
setupLocalIndexing,
18+
} from '../../helpers';
19+
import {
20+
setupBaseRealm,
21+
CardDef,
22+
Component,
23+
MarkdownField,
24+
contains,
25+
field,
26+
} from '../../helpers/base-realm';
27+
import { setupMockMatrix } from '../../helpers/mock-matrix';
28+
import { renderCard } from '../../helpers/render-component';
29+
import { setupRenderingTest } from '../../helpers/setup';
30+
31+
let loader: Loader;
32+
33+
module('Integration | MarkdownField', function (hooks) {
34+
setupRenderingTest(hooks);
35+
36+
let mockMatrixUtils = setupMockMatrix(hooks);
37+
38+
setupBaseRealm(hooks);
39+
40+
hooks.beforeEach(function (this: RenderingTestContext) {
41+
let permissions: Permissions = {
42+
canWrite: true,
43+
canRead: true,
44+
};
45+
provideConsumeContext(PermissionsContextName, permissions);
46+
loader = getService('loader-service').loader;
47+
});
48+
setupLocalIndexing(hooks);
49+
50+
setupCardLogs(
51+
hooks,
52+
async () => await loader.import(`${baseRealm.url}card-api`),
53+
);
54+
55+
test('embedded renders inline :card references as BFM elements for URL-form refs', async function (assert) {
56+
class TestCard extends CardDef {
57+
@field body = contains(MarkdownField);
58+
static embedded = class Embedded extends Component<typeof this> {
59+
<template><@fields.body /></template>
60+
};
61+
}
62+
63+
await setupIntegrationTestRealm({
64+
mockMatrixUtils,
65+
contents: {
66+
'test-card.gts': { TestCard },
67+
},
68+
});
69+
70+
let card = new TestCard({
71+
body: 'See :card[https://example.com/cards/1] for details.',
72+
});
73+
let root = await renderCard(loader, card, 'embedded');
74+
assert
75+
.dom(root.querySelector('[data-boxel-bfm-inline-ref]'))
76+
.exists('inline card reference is rendered as BFM element');
77+
assert
78+
.dom(root.querySelector('[data-boxel-bfm-inline-ref]'))
79+
.hasAttribute('data-boxel-bfm-inline-ref', 'https://example.com/cards/1');
80+
});
81+
82+
test('embedded renders inline :card references for prefix-form refs without crashing', async function (assert) {
83+
// Regression test for the CardContextConsumer plumbing in
84+
// MarkdownField.embedded. Before VN was threaded through the field
85+
// default, prefix-form refs in MarkdownField content couldn't be
86+
// resolved at all (no VN reached the resolveUrl helper); with VN
87+
// required at the MarkdownTemplate boundary, this render would
88+
// throw if the consumer plumbing weren't in place.
89+
class TestCard extends CardDef {
90+
@field body = contains(MarkdownField);
91+
static embedded = class Embedded extends Component<typeof this> {
92+
<template><@fields.body /></template>
93+
};
94+
}
95+
96+
await setupIntegrationTestRealm({
97+
mockMatrixUtils,
98+
contents: {
99+
'test-card.gts': { TestCard },
100+
},
101+
});
102+
103+
let card = new TestCard({
104+
body: 'See :card[@cardstack/catalog/Card/foo] for details.',
105+
});
106+
let root = await renderCard(loader, card, 'embedded');
107+
assert
108+
.dom(root.querySelector('[data-boxel-bfm-inline-ref]'))
109+
.exists('prefix-form card reference renders as BFM element');
110+
assert
111+
.dom(root.querySelector('[data-boxel-bfm-inline-ref]'))
112+
.hasAttribute(
113+
'data-boxel-bfm-inline-ref',
114+
'@cardstack/catalog/Card/foo',
115+
);
116+
});
117+
118+
test('atom renders inline :card references as BFM elements', async function (assert) {
119+
class TestCard extends CardDef {
120+
@field body = contains(MarkdownField);
121+
static atom = class Atom extends Component<typeof this> {
122+
<template><@fields.body /></template>
123+
};
124+
}
125+
126+
await setupIntegrationTestRealm({
127+
mockMatrixUtils,
128+
contents: {
129+
'test-card.gts': { TestCard },
130+
},
131+
});
132+
133+
let card = new TestCard({
134+
body: 'See :card[https://example.com/cards/2] for details.',
135+
});
136+
let root = await renderCard(loader, card, 'atom');
137+
assert
138+
.dom(root.querySelector('[data-boxel-bfm-inline-ref]'))
139+
.exists('inline card reference is rendered in atom format');
140+
assert
141+
.dom(root.querySelector('[data-boxel-bfm-inline-ref]'))
142+
.hasAttribute('data-boxel-bfm-inline-ref', 'https://example.com/cards/2');
143+
});
144+
});

packages/runtime-common/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,10 @@ export interface AddOptions extends CreateOptions {
992992
export type StoreReadType = 'card' | 'file-meta';
993993

994994
export interface Store {
995+
// The VirtualNetwork that owns the store's realm mappings — used by
996+
// store consumers (e.g. field templates obtaining VN through
997+
// CardContext) for prefix/RRI resolution at render time.
998+
virtualNetwork: VirtualNetwork;
995999
save(id: string): void;
9961000
create(
9971001
doc: LooseSingleCardDocument,

0 commit comments

Comments
 (0)