Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
176 changes: 168 additions & 8 deletions packages/base/realm-config.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,47 @@ import {
realmURL,
} from './card-api';
import BooleanField from './boolean';
import NumberField from './number';
import StringField from './string';
import CardInfoTemplates from './default-templates/card-info';
import {
cardDefComputedFields,
DEFAULT_REDIRECT_STATUS,
findDuplicateRoutingPaths,
getField,
getFieldIcon,
REDIRECT_STATUS_CODES,
validateRedirectTarget,
validateRoutingPath,
} from '@cardstack/runtime-common';
import {
BoxelInput,
BoxelInputGroup,
BoxelSelect,
FieldContainer,
Header,
RadioInput,
} from '@cardstack/boxel-ui/components';
import { eq } from '@cardstack/boxel-ui/helpers';
import FileSettingsIcon from '@cardstack/boxel-icons/file-settings';
import LinkIcon from '@cardstack/boxel-icons/link';
import { fn } from '@ember/helper';
import { action } from '@ember/object';
import type Owner from '@ember/owner';
import { tracked } from '@glimmer/tracking';
import { startCase } from 'lodash-es';
import type { FieldsTypeFor } from './card-api';

class RoutingRuleAtom extends Component<typeof RoutingRuleField> {
<template>
<span class='routing-rule-atom'>
<span class='path'>{{if @model.path @model.path '(no path)'}}</span>
{{#if @model.instance}}
{{#if @model.redirectTo}}
<span class='arrow' aria-hidden='true'>→</span>
<span class='redirect-target' data-test-redirect-target>
{{@model.redirectTo}}
</span>
{{else if @model.instance}}
<span class='arrow' aria-hidden='true'>→</span>
<@fields.instance @format='atom' />
{{/if}}
Expand All @@ -47,7 +61,8 @@ class RoutingRuleAtom extends Component<typeof RoutingRuleField> {
align-items: center;
gap: var(--boxel-sp-xxs);
}
.path {
.path,
.redirect-target {
font-family: var(--boxel-font-family-mono, monospace);
}
.arrow {
Expand All @@ -57,7 +72,25 @@ class RoutingRuleAtom extends Component<typeof RoutingRuleField> {
</template>
}

let routingRuleKindGroupNumber = 0;

class RoutingRuleEdit extends Component<typeof RoutingRuleField> {
// Which target editor is showing. Backed by the data: a rule whose
// `redirectTo` is non-null (empty string included — `setKind` seeds
// '' so the choice survives a reload) is a redirect rule. The tracked
// override exists only so the toggle responds instantly while the
// model write settles.
@tracked private kindOverride: 'card' | 'redirect' | null = null;
Comment thread
backspace marked this conversation as resolved.
Outdated

private kindItems: { id: 'card' | 'redirect'; text: string }[] = [
{ id: 'card', text: 'Render a card' },
{ id: 'redirect', text: 'Redirect' },
];

private kindRadioGroup = `__routing_rule_kind${routingRuleKindGroupNumber++}__`;

private statusCodeOptions = [...REDIRECT_STATUS_CODES];

constructor(owner: Owner, args: any) {
super(owner, args);
// The path input renders an empty input alongside a fixed `/`
Expand Down Expand Up @@ -104,6 +137,61 @@ class RoutingRuleEdit extends Component<typeof RoutingRuleField> {
this.args.model.path = `/${trimmed}`;
}

get kind(): 'card' | 'redirect' {
return (
this.kindOverride ??
(this.args.model.redirectTo != null ? 'redirect' : 'card')
);
}

get isRedirect(): boolean {
return this.kind === 'redirect';
}

// Switching kind clears the other kind's target so a rule is never
// ambiguous (the read path prefers `redirectTo` when both are set,
// but only a hand-edited realm.json can get into that state).
@action
setKind(kind: 'card' | 'redirect') {
this.kindOverride = kind;
if (kind === 'redirect') {
this.args.model.instance = undefined;
if (this.args.model.redirectTo == null) {
this.args.model.redirectTo = '';
}
} else {
this.args.model.redirectTo = undefined;
this.args.model.statusCode = undefined;
}
}

get redirectToValue(): string {
return this.args.model.redirectTo ?? '';
}

@action
setRedirectTo(value: string) {
this.args.model.redirectTo = value ?? '';
}

get redirectWarning(): string | undefined {
return validateRedirectTarget(this.args.model.redirectTo);
}

get selectedStatusCode(): number {
return this.args.model.statusCode ?? DEFAULT_REDIRECT_STATUS;
}

@action
setStatusCode(code: number) {
this.args.model.statusCode = code;
}

@action
statusCodeLabel(code: number): string {
return code === 301 ? '301 · permanent' : '302 · temporary';
}

// The chooser is locked to the consuming realm; pass it through
// explicitly rather than letting LinksToEditor read it from
// `RealmURLContext`. The context is only provided by the operator-mode
Expand All @@ -119,6 +207,21 @@ class RoutingRuleEdit extends Component<typeof RoutingRuleField> {

<template>
<div class='routing-rule-edit' data-test-routing-rule-edit>
<div class='kind-toggle' data-test-routing-rule-kind>
<RadioInput
@items={{this.kindItems}}
@groupDescription='Routing rule target'
name='{{this.kindRadioGroup}}'
Comment thread
backspace marked this conversation as resolved.
Outdated
@checkedId={{this.kind}}
@spacing='compact'
@hideBorder={{true}}
as |item|
>
<item.component @onChange={{fn this.setKind item.data.id}}>
{{item.data.text}}
</item.component>
</RadioInput>
</div>
<div class='row'>
<div class='path-cell'>
<BoxelInputGroup
Expand All @@ -132,18 +235,45 @@ class RoutingRuleEdit extends Component<typeof RoutingRuleField> {
</BoxelInputGroup>
</div>
<span class='arrow' aria-hidden='true'>→</span>
<div class='instance-cell'>
<@fields.instance
@lockConsumingRealm={{true}}
@consumingRealm={{this.consumingRealm}}
/>
</div>
{{#if this.isRedirect}}
<div class='redirect-cell'>
<BoxelInput
@value={{this.redirectToValue}}
@onInput={{this.setRedirectTo}}
@placeholder='/path or https://example.com/page'
data-test-redirect-input
/>
<div class='status-code-cell'>
<BoxelSelect
@options={{this.statusCodeOptions}}
@selected={{this.selectedStatusCode}}
@onChange={{this.setStatusCode}}
data-test-status-code-select
as |code|
>
{{this.statusCodeLabel code}}
</BoxelSelect>
</div>
</div>
{{else}}
<div class='instance-cell'>
<@fields.instance
@lockConsumingRealm={{true}}
@consumingRealm={{this.consumingRealm}}
/>
</div>
{{/if}}
</div>
{{#if this.pathWarning}}
<div class='path-warning' role='status' data-test-path-warning>
{{this.pathWarning}}
</div>
{{/if}}
{{#if this.redirectWarning}}
<div class='path-warning' role='status' data-test-redirect-warning>
{{this.redirectWarning}}
</div>
{{/if}}
</div>
<style scoped>
.routing-rule-edit {
Expand Down Expand Up @@ -182,6 +312,26 @@ class RoutingRuleEdit extends Component<typeof RoutingRuleField> {
.instance-cell {
min-width: 0;
}
/* The target cell shares a 1fr track with the path input and the
card can render quite narrow (operator-mode stack item), so the
status picker stacks BELOW the target input rather than beside
it — side-by-side, their combined minimum width overflows the
rule container. min-width: 0 (cell and input) lets the track
shrink the URL input instead of pushing the row wider. */
.redirect-cell {
display: grid;
gap: var(--boxel-sp-xxs);
min-width: 0;
}
.redirect-cell :deep(input) {
font-family: var(--boxel-font-family-mono, monospace);
min-width: 0;
}
.status-code-cell {
justify-self: start;
min-width: 9rem;
max-width: 100%;
}
.path-warning {
font-size: var(--boxel-font-size-xs);
color: #92400e;
Expand All @@ -204,6 +354,16 @@ export class RoutingRuleField extends FieldDef {
'Card instance to render when the realm is navigated at this path',
});

@field redirectTo = contains(StringField, {
description:
'Redirect target — a path in this realm (e.g. "/terms") or an external http(s) URL. When set, the path redirects instead of rendering a card',
});

@field statusCode = contains(NumberField, {
description:
'HTTP status for a redirect rule: 301 (permanent) or 302 (temporary, the default)',
});

static atom = RoutingRuleAtom;
static edit = RoutingRuleEdit;
}
Expand Down
37 changes: 26 additions & 11 deletions packages/host/app/routes/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,38 @@ export default class Card extends Route {
// OperatorModeStateService.schedulePersist() is called (due to the fact we
// care about the back button, see note at bottom). Because of that make sure
// that there is as little async as possible in this model hook.
async model(params: {
authRedirect?: string;
cardPath?: string;
path: string;
operatorModeState: string;
}) {
async model(
params: {
authRedirect?: string;
cardPath?: string;
path: string;
operatorModeState: string;
},
transition: Transition,
) {
if (this.hostModeService.isActive) {
let normalizedPath = params.path ?? '';
// 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(
// to a target card. When the path matches a serve rule, use the
// rule's target id directly; otherwise resolve the path as a card
// URL under the host-mode origin. A redirect rule is never
// rendered — navigate to its target instead, mirroring the 3xx
// the server answers for a full-page request to this path. The
// transition target's query params ride along so the two paths
// agree (`params` holds only the pathname).
let routed = this.hostModeService.resolveRoutedPath(
normalizedPath || '/',
);
if (routed && 'redirectTo' in routed) {
this.hostModeService.redirectTo(
routed.redirectTo,
transition.to?.queryParams,
);
return;
}
let cardUrl =
routedId ?? `${this.hostModeService.hostModeOrigin}/${normalizedPath}`;
routed?.id ??
`${this.hostModeService.hostModeOrigin}/${normalizedPath}`;

return this.store.get(cardUrl);
}
Expand Down
47 changes: 42 additions & 5 deletions packages/host/app/services/host-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ interface PublishedRealmMetadata {
currentCardUrlString: string | undefined;
}

// The host-scoped form of a routing rule as the realm-server injects it
// into the config meta tag: every path (and any realm-relative redirect
// target) is already prefixed with the realm's mount pathname, so a
// redirect target can be handed straight to a location change.
export type HostScopedRoutingRule =
| { path: string; id: string }
| { path: string; redirectTo: string; statusCode: number };

export default class HostModeService extends Service {
@service declare hostModeStateService: HostModeStateService;
@service declare operatorModeStateService: OperatorModeStateService;
Expand Down Expand Up @@ -123,12 +131,15 @@ export default class HostModeService extends Service {
// hostRoutingRules — so the first-render decision in the index route
// is synchronous and the field is part of the typed config surface
// rather than a window global.
get hostRoutingMap(): { path: string; id: string }[] {
get hostRoutingMap(): HostScopedRoutingRule[] {
let map = (config as { hostRoutingMap?: unknown }).hostRoutingMap;
return Array.isArray(map) ? (map as { path: string; id: string }[]) : [];
return Array.isArray(map) ? (map as HostScopedRoutingRule[]) : [];
}

// Returns the target card id if `path` matches a routing rule, else null.
// Returns the routing rule matching `path`, else null. A serve rule
// carries the target card `id` to render; a redirect rule carries the
// `redirectTo` target the SPA should navigate to instead of rendering
// anything (see `redirectTo` below).
// `path` is the URL pathname on the host (what Ember's `/*path` catch-all
// route delivers — e.g. `<user>/<realm>/whitepaper` for a request to
// `https://host/<user>/<realm>/whitepaper`); a leading slash is added if
Expand All @@ -143,13 +154,39 @@ export default class HostModeService extends Service {
// trailing slashes, preserves the root `/`) before comparing, so
// `/realm` ↔ `/realm/` resolve and the client agrees with how the server
// map builder and the editor normalize.
resolveRoutedPath(path: string): string | null {
resolveRoutedPath(path: string): HostScopedRoutingRule | null {
let normalized = path.startsWith('/') ? path : `/${path}`;
let canonical = normalizeRoutingPath(normalized);
let rule = this.hostRoutingMap.find(
(r) => normalizeRoutingPath(r.path) === canonical,
);
return rule ? rule.id : null;
return rule ?? null;
}

// SPA-side counterpart of the server's HTTP redirect for a redirect
// routing rule: a full-page navigation to matched paths gets the 3xx
// from serve-index, but an in-app transition never leaves the SPA, so
// the index route calls this instead. `replace` mirrors an HTTP
// redirect, which leaves no history entry for the redirecting URL.
// Goes through ember-window-mock's `window` so tests can intercept the
// navigation.
//
// `queryParams` is the transition target's query (from
// `transition.to.queryParams`) — NOT `window.location.search`, which
// with HistoryLocation still shows the URL being navigated away from
// while the transition is in flight. Matching serve-index's semantics,
// it carries over only when the redirect target declares no query of
// its own.
redirectTo(target: string, queryParams?: Record<string, unknown>) {
let url = new URL(target, this.hostModeOrigin ?? window.location.origin);
Comment thread
backspace marked this conversation as resolved.
Outdated
if (!url.search && queryParams) {
for (let [key, value] of Object.entries(queryParams)) {
if (value != null) {
url.searchParams.set(key, String(value));
}
Comment thread
backspace marked this conversation as resolved.
}
}
window.location.replace(url.href);
}

get currentCardId() {
Expand Down
Loading
Loading