diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index fad0e9cebe..fe9cee8c64 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -530,6 +530,16 @@ class ContainsMany implements Field< } let results = getter(instance, this); propagateRealmContext(results, instance); + // Propagate the store from the parent instance to child FieldDef values + // so that nested linksTo fields can properly track their loads + let parentStore = stores.get(instance); + if (parentStore && Array.isArray(results)) { + for (let item of results) { + if (isCardOrField(item) && !stores.has(item)) { + stores.set(item, parentStore); + } + } + } return results; } @@ -836,6 +846,14 @@ class Contains implements Field { } let value = getter(instance, this); propagateRealmContext(value, instance); + // Propagate the store from the parent instance to child FieldDef values + // so that nested linksTo fields can properly track their loads + if (isCardOrField(value)) { + let parentStore = stores.get(instance); + if (parentStore && !stores.has(value)) { + stores.set(value, parentStore); + } + } return value; } @@ -1340,9 +1358,7 @@ class LinksTo implements Field { {{#if - (shouldRenderEditor - @format defaultFormats.cardDef isComputed - ) + (shouldRenderEditor @format defaultFormats.cardDef isComputed) }} {{! template-lint-disable no-forbidden-elements }} {{this.title}} @@ -43,6 +47,11 @@ export default class DefaultHeadTemplate extends GlimmerComponent<{ {{/if}} + {{#if this.themeIcon}} + + + {{/if}} + } diff --git a/packages/host/app/index.html b/packages/host/app/index.html index 570dafe505..eee30f05ad 100644 --- a/packages/host/app/index.html +++ b/packages/host/app/index.html @@ -1,48 +1,50 @@ - - - - - - - {{content-for "head"}} - - - - - - - - - - {{content-for "head-footer"}} - - - Boxel - - - - - - - - - -
- {{content-for "body"}} - - - - - - {{content-for "body-footer"}} - - - \ No newline at end of file +
+ {{content-for "body"}} + + + + + + {{content-for "body-footer"}} + + diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 874f0defe6..15ac5d7c3e 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -398,6 +398,14 @@ export default class RenderRoute extends Route { } if (instance) { await this.#authGuard.race(() => this.#touchIsUsedFields(instance)); + // Pre-load cardInfo.theme so the head format template can render + // favicon/apple-touch-icon links. This is a nested linksTo inside a + // contains FieldDef, so #touchIsUsedFields doesn't reach it. + try { + (instance as any).cardInfo?.theme; + } catch { + // ignore — card may not have cardInfo or theme + } } await this.#authGuard.race(() => this.store.loaded()); if (instance) { diff --git a/packages/host/tests/acceptance/prerender-meta-test.gts b/packages/host/tests/acceptance/prerender-meta-test.gts index efabb84498..4ed947c8e8 100644 --- a/packages/host/tests/acceptance/prerender-meta-test.gts +++ b/packages/host/tests/acceptance/prerender-meta-test.gts @@ -266,6 +266,11 @@ module('Acceptance | prerender | meta', function (hooks) { numOfPets: '3', }, relationships: { + 'cardInfo.theme': { + links: { + self: null, + }, + }, 'pets.0': { links: { self: '../Pet/mango', @@ -346,7 +351,9 @@ module('Acceptance | prerender | meta', function (hooks) { { id: `${testRealmURL}Pet/mango`, _cardType: 'Pet', - cardInfo: {}, + cardInfo: { + theme: null, + }, name: 'Mango', cardTitle: 'Mango', }, @@ -364,7 +371,9 @@ module('Acceptance | prerender | meta', function (hooks) { { id: `${testRealmURL}Pet/paper`, _cardType: 'Cat', - cardInfo: {}, + cardInfo: { + theme: null, + }, name: 'Paper', cardTitle: 'Paper', aliases: ['Satan', "Satan's Mistress"], diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index f8e4a0f9f5..65c973b2c2 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -285,6 +285,31 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, }, ); + + // Cards for testing default head template with cardInfo.theme + writeJSONSync(join(context.testRealmDir, 'a-test-theme.json'), { + data: { + type: 'card', + attributes: { + cardInfo: { + cardThumbnailURL: 'https://example.com/brand-icon.png', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'Theme', + }, + }, + }, + }); + + // NOTE: card-with-theme.json is NOT written here because from-scratch + // indexing uses a batched write strategy (boxel_index_working → boxel_index). + // Cards within the same batch can't resolve linksTo references to each other + // because the data isn't in the production table yet. Instead, card-with-theme + // is created via API in the test itself, triggering incremental indexing + // after the theme card is already committed to boxel_index. }, }); @@ -543,6 +568,158 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); + test('HTML response always includes default static favicon and apple-touch-icon', async function (assert) { + // Even a card with no theme should get the static icons from index.html + // (which live outside the head injection markers) + let response = await context.request2 + .get('/test/isolated-test') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + assert.ok( + response.text.includes('rel="icon"'), + 'static favicon link is present in the HTML response', + ); + assert.ok( + response.text.includes('rel="apple-touch-icon"'), + 'static apple-touch-icon link is present in the HTML response', + ); + }); + + test('head HTML does not include icon links when card has no theme', async function (assert) { + let response = await context.request2 + .get('/test/isolated-test') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + + let headMatch = response.text.match( + /data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/, + ); + let headContent = headMatch?.[1] ?? ''; + + // The default head template should NOT emit icon links when there's no theme + // (the static icons from index.html serve as defaults) + assert.notOk( + headContent.includes('rel="icon"'), + 'injected head HTML does not contain favicon link (static ones from index.html are the default)', + ); + assert.notOk( + headContent.includes('rel="apple-touch-icon"'), + 'injected head HTML does not contain apple-touch-icon link', + ); + }); + + test('non-public realm preserves static favicon and apple-touch-icon', async function (assert) { + await context.dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${testRealm2URL.href}' AND username = '*'`, + ); + + let response = await context.request2 + .get('/test/private-index-test') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + // Even without head injection, static icons from index.html remain + assert.ok( + response.text.includes('rel="icon"'), + 'static favicon link is present even without head injection', + ); + assert.ok( + response.text.includes('rel="apple-touch-icon"'), + 'static apple-touch-icon link is present even without head injection', + ); + }); + + test('default head template includes favicon and apple-touch-icon from cardInfo.theme', async function (assert) { + // Create card-with-theme via API so it's indexed incrementally AFTER + // the theme card is already in boxel_index (from-scratch indexing + // batches writes and can't resolve cross-card linksTo references). + let cardWithThemeJSON = JSON.stringify({ + data: { + type: 'card', + attributes: { + firstName: 'Themed Card', + cardInfo: { + name: null, + summary: null, + cardThumbnailURL: null, + notes: null, + }, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: './a-test-theme', + }, + }, + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }); + + let writeResponse = await context.request2 + .post('/test/card-with-theme.json') + .set('Accept', 'application/vnd.card+source') + .send(cardWithThemeJSON); + + assert.strictEqual( + writeResponse.status, + 204, + 'card-with-theme file write was accepted', + ); + + // Wait for the card to be indexed (head_html populated, even if empty string). + await waitUntil( + async () => { + let rows = (await context.dbAdapter.execute( + `SELECT url, head_html FROM boxel_index + WHERE url LIKE '%card-with-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`, + )) as { url: string; head_html: string | null }[]; + + return rows.length > 0 && rows[0].head_html != null; + }, + { + timeout: 30000, + interval: 500, + timeoutMessage: + 'Timed out waiting for card-with-theme to be indexed', + }, + ); + + let response = await context.request2 + .get('/test/card-with-theme') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + + let headMatch = response.text.match( + /data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/, + ); + let headContent = headMatch?.[1] ?? ''; + + assert.ok( + headContent.includes( + '