diff --git a/packages/base/realm-config.gts b/packages/base/realm-config.gts index cad1a6be87..e2c0f349b6 100644 --- a/packages/base/realm-config.gts +++ b/packages/base/realm-config.gts @@ -4,7 +4,6 @@ import { field, contains, containsMany, - linksTo, } from './card-api'; import BooleanField from './boolean'; import StringField from './string'; @@ -19,9 +18,14 @@ export class RoutingRuleField extends FieldDef { description: 'Static path within the realm, e.g. "/" or "/pricing"', }); - @field instance = linksTo(CardDef, { + // Card URL of the instance to render when the realm is navigated at + // `path`. Stored as a string (rather than a `linksTo`) so the rule + // serializes flat alongside `path` inside `attributes` — no JSON:API + // relationships split. Relative URLs (e.g. `./whitepaper`) are + // resolved against the realm root by the routing-map reader. + @field instance = contains(StringField, { description: - 'Card instance to render when the realm is navigated at the given path', + 'Card URL to render at this path. Relative URLs are resolved against the realm root.', }); } diff --git a/packages/host/app/routes/index.gts b/packages/host/app/routes/index.gts index 8a9f08e6cb..5e5b73e246 100644 --- a/packages/host/app/routes/index.gts +++ b/packages/host/app/routes/index.gts @@ -81,7 +81,15 @@ export default class Card extends Route { }) { if (this.hostModeService.isActive) { let normalizedPath = params.path ?? ''; - let cardUrl = `${this.hostModeService.hostModeOrigin}/${normalizedPath}`; + // CS-10055: a routing rule in the realm config can map a bare path + // to a target card. When the path matches a rule, use the rule's + // target id directly; otherwise resolve the path as a card URL + // under the host-mode origin. + let routedId = this.hostModeService.resolveRoutedPath( + normalizedPath || '/', + ); + let cardUrl = + routedId ?? `${this.hostModeService.hostModeOrigin}/${normalizedPath}`; return this.store.get(cardUrl); } diff --git a/packages/host/app/services/host-mode-service.ts b/packages/host/app/services/host-mode-service.ts index e125d9fa26..df7e36ab7f 100644 --- a/packages/host/app/services/host-mode-service.ts +++ b/packages/host/app/services/host-mode-service.ts @@ -113,6 +113,23 @@ export default class HostModeService extends Service { return this.operatorModeStateService.realmURL; } + // CS-10055: routing rules from the realm config card. The realm-server + // injects this map as `window.__hostRoutingMap` in the SPA shell so the + // first-render decision in the index route is synchronous. + get hostRoutingMap(): { path: string; id: string }[] { + let map = (window as { __hostRoutingMap?: unknown }).__hostRoutingMap; + return Array.isArray(map) ? (map as { path: string; id: string }[]) : []; + } + + // Returns the target card id if `path` matches a routing rule, else null. + // `path` is the path within the realm; a leading slash is added if absent + // so the index path is matchable as either '' or '/'. + resolveRoutedPath(path: string): string | null { + let normalized = path.startsWith('/') ? path : `/${path}`; + let rule = this.hostRoutingMap.find((r) => r.path === normalized); + return rule ? rule.id : null; + } + get currentCardId() { if (this.isActive) { let stack = this.hostModeStateService.stackItems; diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index 7deec360ee..6ae0c8174a 100644 --- a/packages/matrix/tests/host-mode.spec.ts +++ b/packages/matrix/tests/host-mode.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from './fixtures'; import { createRealm, createSubscribedUserAndLogin, + login, logout, postCardSource, waitUntil, @@ -10,6 +11,7 @@ import { appURL } from '../helpers/isolated-realm-server'; import { randomUUID } from 'crypto'; test.describe('Host mode', () => { + let realmURL: string; let publishedRealmURL: string; let publishedCardURL: string; let publishedWhitePaperCardURL: string; @@ -31,7 +33,7 @@ test.describe('Host mode', () => { const realmName = `host-mode-${randomUUID()}`; await createRealm(page, realmName); - const realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href; + realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href; await page.goto(realmURL); await page.locator('[data-test-stack-item-content]').first().waitFor(); @@ -340,4 +342,95 @@ test.describe('Host mode', () => { const pageTitle = await page.title(); expect(pageTitle).toBe('My Custom Title From Head Template'); }); + + // CS-10054 + CS-10055: routing rules in the realm config card resolve a + // bare path (no .json extension) to a target card and render it in host + // mode. This test fails until the host-mode request handler reads the + // routing map from the indexed RealmConfig card and applies it. + test('routing rule resolves a bare path to its target card', async ({ + page, + }) => { + // beforeEach logged out — re-login so we can write to the source realm. + await login(page, username, password); + await page.goto(realmURL); + await page.locator('[data-test-stack-item-content]').first().waitFor(); + + // Overwrite realm.json with a routing rule mapping /whitepaper to the + // existing white-paper card. The auto-generated realm.json from + // createRealm has no rules; we replace it before re-publishing. + await postCardSource( + page, + realmURL, + 'realm.json', + JSON.stringify({ + data: { + type: 'card', + attributes: { + cardInfo: { name: `Routed Realm ${randomUUID()}` }, + hostRoutingRules: [ + { path: '/whitepaper', instance: './white-paper' }, + ], + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/realm-config', + name: 'RealmConfig', + }, + }, + }, + }), + ); + + // Re-publish so the routing rule lands in the published realm. + await page.evaluate( + async ({ realmURL, publishedRealmURL }) => { + let sessions = JSON.parse( + window.localStorage.getItem('boxel-session') ?? '{}', + ); + let token = sessions[realmURL]; + if (!token) { + throw new Error(`No session token found for ${realmURL}`); + } + let response = await fetch('https://localhost:4205/_publish-realm', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: token, + }, + body: JSON.stringify({ + sourceRealmURL: realmURL, + publishedRealmURL, + }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + }, + { realmURL, publishedRealmURL }, + ); + + await logout(page); + + // The _publish-realm POST returns 202 before the published realm has + // finished re-indexing the new realm.json. Poll the bare URL until the + // server-rendered HTML contains the target card's marker — that + // confirms the routing rule is indexed AND the server cardURL rewrite + // is applying it. Mirrors the waitUntil pattern in the + // `published card response` test above. + let routedURL = `${publishedRealmURL}whitepaper`; + await waitUntil(async () => { + let response = await page.request.get(routedURL, { + headers: { Accept: 'text/html' }, + }); + if (!response.ok()) { + return false; + } + let text = await response.text(); + return text.includes('data-test-white-paper'); + }); + + await page.goto(routedURL); + await expect(page.locator('[data-test-white-paper]')).toBeVisible(); + }); }); diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts index 29efa8ee95..7a76c7c061 100644 --- a/packages/realm-server/handlers/serve-index.ts +++ b/packages/realm-server/handlers/serve-index.ts @@ -7,6 +7,7 @@ import { logger, param, query, + RealmPaths, sanitizeHeadHTMLToString, } from '@cardstack/runtime-common'; import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; @@ -19,6 +20,7 @@ import { } from '../lib/index-html-injection'; import { retrieveScopedCSS } from '../lib/retrieve-scoped-css'; import { + findOrMountRealm, getPublishedRealmInfo, hasPublicPermissions, isIndexedCardInstance, @@ -96,7 +98,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { let work = (async () => { let indexHTML = (await getIndexHTML()).replace( - /()/, + // Closing matches both HTML5-style `">` and Vite's XHTML-style `" />` + // so the rewrite runs against both production build and Vite dev HTML. + /()/, (_match, g1, g2, g3) => { let config = JSON.parse(decodeURIComponent(g2)); @@ -330,6 +334,27 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { return; } + // CS-10055: host routing rules in the realm config can map a bare path + // (e.g. /whitepaper) to a target card. When the requested path matches + // a rule, rewrite cardURL so the head/isolated/scoped CSS fetched + // below render the routed target. The same map is also injected as + // a ` or `