diff --git a/Skill/dev-core-concept.md b/Skill/dev-core-concept.md index 7378178..bec61fd 100644 --- a/Skill/dev-core-concept.md +++ b/Skill/dev-core-concept.md @@ -41,6 +41,7 @@ Boxel is a composable card-based system where information lives in self-containe Needs own identity? → CardDef with linksTo Referenced from multiple places? → CardDef with linksTo Referencing a file (image, doc, etc.)? → FileDef subtype with linksTo +Have a URL that points at a card or realm file? → linksTo/linksToMany — NEVER StringField/UrlField Just compound data? → FieldDef with contains ``` diff --git a/Skill/dev-quick-reference.md b/Skill/dev-quick-reference.md index 221e46c..4066b3a 100644 --- a/Skill/dev-quick-reference.md +++ b/Skill/dev-quick-reference.md @@ -59,7 +59,7 @@ import ColorField from '@cardstack/base/color'; import EmailField from '@cardstack/base/email'; import PercentageField from '@cardstack/base/percentage'; import PhoneNumberField from '@cardstack/base/phone-number'; -import UrlField from '@cardstack/base/url'; +import UrlField from '@cardstack/base/url'; // external URLs only — a URL pointing at a card or realm file is a linksTo, never a UrlField import AddressField from '@cardstack/base/address'; // ⚠️ EXTENDING BASE FIELDS: To customize a base field, import it and extend: diff --git a/index.md b/index.md index 513f3c6..f3e3ac7 100644 --- a/index.md +++ b/index.md @@ -89,6 +89,7 @@ Every skill lives in `skills/` and auto-activates on its description triggers - **🚨 `linksToMany` JSON uses indexed top-level keys** (`"activityFeed.0": { "links": { "self": "../foo" } }`), never an array under `links.self` (Cardinal Rule 13). The array shape is rejected with `"instance ... is not a card resource document"`. - **🚨 `linksTo` fields never appear in `attributes` — not even as `null`** (Cardinal Rule 14). A link lives under `relationships`, keyed by its field path (nested fields use dotted keys: `"cardInfo.theme"`); an empty link is `{ "links": { "self": null } }` or the key is omitted. A `null` attribute writes successfully — then every read of the instance fails with `linkTo field ... cannot deserialize non-relationship value null` until the raw JSON is repaired. - **🚨 Any function a template *calls* must be an arrow-function property, never a class method** (Cardinal Rule 15). Glimmer invokes `(this.isActive note)` / `{{fn this.method}}` unbound, so a class method throws during render and **freezes the whole application** until reload; `{{on}}` handlers mask the same mistake by merely breaking one handler. Getters are safe — the template reads them off `this`. Details: [`boxel-workspace-cardinal-rules/SKILL.md`](skills/boxel-workspace-cardinal-rules/SKILL.md) #11. +- **A URL pointing at a realm resource is a link, never a string** (Cardinal Rule 16). If a field's value is the URL of a card instance or a realm file, model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files) — never as `StringField`/`UrlField`. A string-typed realm URL bypasses the index (no invalidation, no broken-link detection, no traversal) and rots silently when the target moves. The complement of Cardinal Rule 12: external URLs never in relationships, realm URLs never in string attributes. Two carve-outs where a string is correct: a `FileDef`'s own `id`/`url`/`sourceUrl` descriptor fields, and a `hostRoutingRules` public nav path. Details: [`base-field-catalog.md`](skills/boxel/references/base-field-catalog.md) "Realm-resource URLs — always a relationship, never a string". - **🚨 Building a kit is a sequential checklist with verification gates — lint is NOT the gate.** The seven gates, in order: (1) Stage-0 planning (thunk-by-default `() => Class` for kit-internal links; per-format content matrices), (2) import audit (base fields are **default** exports; never `ImageDef` from `@cardstack/base/image`), (3) CDN-verify every icon, (4) push per-file — no atomic batches on fresh realms (this is about `boxel realm push` from a shell; it is not a reason to write files one per turn — see below), (5) module-load probe (`get-card-type-schema` must return `status: ready`), (6) typed-search count gate (`boxel search` is the truth source, not lint), (7) render smoke test per CardDef. Exact commands and failure signatures: [`indexing-operations.md`](skills/boxel-environment/references/indexing-operations.md). - **Query traps that silently return zero rows** (Cardinal Rules 5–7): `filter: { type: ref }` to select all cards of a type — never a bare `{ on: ref }` (`on` only scopes predicates); custom sort fields require `on: ref`; build refs with `codeRef()` and import the `realmURL` Symbol from `@cardstack/runtime-common` (never `Symbol.for('realmURL')`). Details: [`query-systems.md`](skills/boxel/references/query-systems.md). - **Format choice = who owns the cell size, not what the cell looks like.** `@format='embedded'` lets the child decide its height — use for lists, feeds, roster rows. `@format='fitted'` makes the child fill a parent-controlled box — use for uniform tile grids (portraits, calendar cells). Picking fitted for a list with short content leaves empty boxes below each row. The fix is the format choice, upstream of any CSS. See "Picking the format" in [`delegated-render-control.md`](skills/boxel-ui-guidelines/references/delegated-render-control.md). diff --git a/skills/boxel-patterns/patterns/attach-remote-image/README.md b/skills/boxel-patterns/patterns/attach-remote-image/README.md index fd6b5ce..75d561c 100644 --- a/skills/boxel-patterns/patterns/attach-remote-image/README.md +++ b/skills/boxel-patterns/patterns/attach-remote-image/README.md @@ -120,6 +120,7 @@ The relationship link points at a real ImageDef instance in the realm — never ## Gotchas - **NEVER cross-mix.** Don't put an external URL into `relationships.heroImage.links.self`. That's the realm-bricking shape. The relationship is for in-realm card identifiers only. +- **Cross-mixing is wrong in the other direction too.** Don't put a realm resource URL (a card instance or realm file the realm serves) into the `UrlField` side. A string-typed realm URL bypasses the index — no invalidation, no broken-link detection, no traversal — and rots silently when the target moves. In-realm targets always go through the `linksTo` side; the `UrlField` side is for external URLs only. - **Use `UrlField`, not `StringField`, and not `MaybeBase64Field`.** `UrlField` (from `@cardstack/base/url`) extends `StringField` with URL-shape validation in edit mode. The base `cardInfo` field uses `MaybeBase64Field` for historical reasons (it also accepts inline base64) — don't follow that lead in new code; `UrlField` is the canonical choice for an external HTTP(S) URL. - **Empty string vs null.** When clearing the URL, set the JSON value to `null` not `""` — empty strings can confuse downstream consumers expecting truthiness. diff --git a/skills/boxel-patterns/patterns/build-site-config-with-theme/README.md b/skills/boxel-patterns/patterns/build-site-config-with-theme/README.md index 3e891c7..ae0637d 100644 --- a/skills/boxel-patterns/patterns/build-site-config-with-theme/README.md +++ b/skills/boxel-patterns/patterns/build-site-config-with-theme/README.md @@ -28,6 +28,7 @@ export class SiteConfig extends CardDef { ``` **Gotchas:** +- `pageUrl` as `UrlField` is correct here and is not a violation of the realm-resource-URL rule (`boxel` Cardinal Rule 16): the value is a curated public path routed via `hostRoutingRules` (`/about`, `/pricing`), not a card identifier. Decoupling the public URL from the card id is the point of the routing mechanism — do not rewrite it as `linksTo` to a page card. - For production realm files, keep `PageConfig`, `SiteConfig`, and each page shell in separate `.gts` files. The example co-locates them only to show the pattern in one place. - Preserve `cardInfo.theme` as the override in computed `cardTheme`: `this.cardInfo?.theme ?? this.site?.brandGuide ?? null`. - Sort nav entries in the rendering component, not in the JSON instance. Use `showInNav` + `navOrder`. diff --git a/skills/boxel-workspace-cardinal-rules/SKILL.md b/skills/boxel-workspace-cardinal-rules/SKILL.md index eb61912..2d70ce9 100644 --- a/skills/boxel-workspace-cardinal-rules/SKILL.md +++ b/skills/boxel-workspace-cardinal-rules/SKILL.md @@ -27,7 +27,7 @@ convention to follow when picking the field type: a `*At` suffix (`createdAt`, `publishedAt`) means `DateTimeField`; a `*Date`/`*On` suffix or bare `dob` means `DateField`. -## 2. Never put an external URL in `relationships..links.self` +## 2. Never put an external URL in `relationships..links.self` — and never a realm URL in a string field If a `linksTo`/`linksToMany` field's JSON `links.self` points at a URL the indexer can't parse as a card (an external website, an image CDN URL, anything not a card @@ -38,6 +38,22 @@ use the pair pattern instead: `linksTo(ImageDef)` (or a similar file/media field `contains(UrlField)` as two separate fields, never one relationship pointing straight at an external URL. +**The rule cuts both ways.** If a field's value is the URL of a card instance or a +realm file — an absolute realm URL, a relative path like `../Theme/foo`, or any URL +a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for +files), never as a `StringField` or `UrlField` attribute. The string version writes +fine, indexes fine, and even renders as a clickable link — then rots silently: the +index never invalidates the referrer when the target changes, broken-link +diagnostics can't see it, `<@fields.X />` can't render the target, and queries can't +traverse it. When the target moves or is deleted, nothing reports the dangling +reference. Two carve-outs where a string is correct: a `FileDef` subtype's own +`id`/`url`/`sourceUrl` descriptor fields hold the realm file URL as strings by +design, and a curated public path routed via `hostRoutingRules` (a nav target like +`/about`) is a routed path, not a resource identifier. Everything else `UrlField` +holds should be an external (non-realm) URL. Details: +`boxel/references/base-field-catalog.md` "Realm-resource URLs — always a +relationship, never a string". + ## 3. `linksToMany` JSON uses indexed top-level keys, never an array Correct: diff --git a/skills/boxel/SKILL.md b/skills/boxel/SKILL.md index 708dd14..cba935c 100644 --- a/skills/boxel/SKILL.md +++ b/skills/boxel/SKILL.md @@ -30,6 +30,7 @@ You are generating idiomatic Boxel: **Card Definitions** in `.gts` (Glimmer Type | 13 | **🚨 `linksToMany` JSON shape uses INDEXED KEYS, never an array under `links.self`.** Each linked item in a `linksToMany` field gets its own top-level relationship key with an indexed suffix. Correct: `"activityFeed.0": { "links": { "self": "..." } }`, `"activityFeed.1": { "links": { "self": "..." } }`. WRONG (and the host rejects with "instance ... is not a card resource document"): `"activityFeed": { "links": { "self": ["...", "..."] } }`. The array-inside-`self` shape is intuitive but not valid Boxel JSON:API — `links.self` is a single string per JSON:API spec, and Boxel's encoding of "many" is indexed top-level keys. See `references/core-patterns.md` "JSON:API instance shapes". | | 14 | **🚨 `linksTo` fields never appear in `attributes` — not even as `null`.** A `linksTo` field is serialized under `relationships`, keyed by its field path — for a linksTo nested inside a contained field, a dotted key: `"cardInfo.theme": { "links": { "self": "../Theme/foo" } }`. An empty link is `{ "links": { "self": null } }`, or omit the key entirely. Writing `"cardInfo": { "theme": null }` (or any value for the link) into `attributes` passes lint and writes successfully — then every read of the instance throws `linkTo field 'theme' cannot deserialize non-relationship value null` until the raw JSON is repaired by hand. | | 15 | **🚨 Any function a template *calls* must be an arrow-function property, never a class method.** Glimmer invokes template-called functions (`{{if (this.isActive note) ...}}`, `{{fn this.method}}`) without binding `this`; a class body is always strict mode, so `this` is `undefined` and the first property access throws **during render**, which poisons Ember's renderer — the whole application freezes until reload. Lint passes, and `{{on}}` handlers mask the same mistake (there it merely breaks one handler). Write `isActive = (note: string) => this.activeNotes.has(note);`, never `isActive(note: string) { ... }`. Getters are safe (the template reads them off `this`); `@action` methods also bind, but arrow properties are the convention. See `boxel-workspace-cardinal-rules/SKILL.md` #11. | +| 16 | **A URL that points at a Boxel realm resource is a link, never a string.** If a field's value is the URL of a card instance or a realm file — an absolute realm URL (`https:///Person/jane`), a relative path (`../Theme/foo`), or any URL a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files), **NEVER** as `contains(StringField)` or `contains(UrlField)`. A string-typed realm URL is invisible to the index: no invalidation when the target changes, no broken-link detection, no `<@fields.X />` rendering of the target, no query traversal — the reference silently goes stale when the target moves or is deleted. This is the exact complement of Rule 12: external URLs never go in relationships, and realm-resource URLs never go in string attributes. Two carve-outs: a `FileDef` subtype's own `id`/`url`/`sourceUrl` descriptor fields are strings that hold the realm file URL by design — populate them as strings, never convert them to relationships; and a curated public path routed via `hostRoutingRules` (a nav target like `/about`) is a routed path, not a resource identifier — `UrlField` is correct there. See `references/base-field-catalog.md` "Realm-resource URLs — always a relationship, never a string". | > **Rules 5–7 are the silent-zero-rows traps.** No error is thrown; the response is just empty, every time. Memorize them before writing any query. Full reference: [`references/query-systems.md`](references/query-systems.md). > @@ -41,6 +42,7 @@ You are generating idiomatic Boxel: **Card Definitions** in `.gts` (Glimmer Type ``` Needs own identity / referenced from multiple places? → CardDef + linksTo +URL pointing at a card or realm file? → linksTo / linksToMany — never StringField/UrlField (Rule 16) Image / document / file asset? → FileDef subtype + linksTo (see boxel-file-def) Generated/uploaded media payload? → Write bytes with WriteBinaryFileCommand, then linksTo FileDef/ImageDef/PngDef. Never StringField data URI. Compound data only AND list of ~1–3 items? → FieldDef + containsMany diff --git a/skills/boxel/references/base-field-catalog.md b/skills/boxel/references/base-field-catalog.md index 83e5972..4d87b79 100644 --- a/skills/boxel/references/base-field-catalog.md +++ b/skills/boxel/references/base-field-catalog.md @@ -12,7 +12,7 @@ Every field listed here is importable from a stable specifier and ready to use w | `BigIntegerField` | `'@cardstack/base/big-integer'` | For values beyond `Number.MAX_SAFE_INTEGER`. | | `TextAreaField` | `'@cardstack/base/text-area'` | Multi-line plain text (sub-page). For paragraphs that aren't markdown. | | `EmailField` | `'@cardstack/base/email'` | Validates as `user@domain`. Renders as `mailto:` link. | -| `UrlField` | `'@cardstack/base/url'` | Validates URL shape. Renders as `` in non-edit modes. | +| `UrlField` | `'@cardstack/base/url'` | Validates URL shape. Renders as `` in non-edit modes. **External URLs only** — a URL that points at a realm resource (a card instance or realm file) must be a `linksTo` / `linksToMany` field instead, never a string. | | `PhoneNumberField` | `'@cardstack/base/phone-number'` | Country code + national number. Compound field. | | `EthereumAddressField` | `'@cardstack/base/ethereum-address'` | Web3 address with checksum validation. | | `ColorField` | `'@cardstack/base/color'` | Renders a color swatch + picker in edit mode. | @@ -85,9 +85,28 @@ For a `linksToMany(ImageDef)` gallery, the URL twin is `containsMany(UrlField)`: - `relationships..links.self` is for card identifiers — relative paths (`"../Theme/foo"`) or absolute realm URLs only. - External URLs (Unsplash, S3, CDN, any `https://` URL pointing at non-card content) go in `attributes.URL` on the URL-twin field. - Uploaded card-side images go in the linked ImageDef as a normal `linksTo` relationship. +- The rule cuts both ways: a URL that points at a **realm resource** never goes in a `UrlField`/`StringField` attribute — see "Realm-resource URLs — always a relationship, never a string" below. **Future direction (not implemented yet):** a single compound `Image` FieldDef that wraps either a URL or an ImageDef link and exposes a unified `.src` accessor. Until then, use the pair-of-fields approach above. +### 🔗 Realm-resource URLs — always a relationship, never a string + +The general rule, not image-specific: if a field's value is the URL of a **realm resource** — a card instance or a realm file, whether as an absolute realm URL, a relative path, or any URL a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files), never as `contains(StringField)` or `contains(UrlField)`. This applies to any pointer field: `author`, `owner`, `relatedDoc`, `parentProject`, and so on. + +Both versions pass lint, write, index, and render a clickable link. The string version then decays with no error: + +- The index only tracks relationship links as dependencies — nothing re-indexes the referrer when the target changes, and no `brokenLinks` diagnostic fires when the target is deleted. +- The template cannot render the target card (`<@fields.author @format='embedded' />` needs a relationship). +- Queries cannot traverse it (`author.name` filters need a relationship). +- Relationship links are relative paths that survive a realm copy or rename; the string holds an absolute URL that keeps pointing at the old realm. + +**Two carve-outs — string is correct there:** + +- A `FileDef` subtype's own `id` / `url` / `sourceUrl` descriptor fields are strings that hold the realm file URL by design. Populate them as strings (`new ImageDef({ id: fileIdentifier, url: fileIdentifier, sourceUrl: fileIdentifier, ... })`) — never try to convert them into relationships. The relationship lives one level up: the card's field pointing at the `ImageDef` is the `linksTo`. +- A curated public path routed via `hostRoutingRules` (a nav target like `/about` or `/pricing` in a site config) is a routed path, not a resource identifier — decoupling the public URL from the card id is the point of the routing mechanism. `UrlField` is correct there. See the `build-site-config-with-theme` and `link-host-mode-paths` patterns. + +Everything else `UrlField` holds should be an external (non-realm) URL. + ### 🔴 DateField vs DateTimeField — the schema-vs-value contract The most common silent failure pattern in Boxel card families: declaring `contains(DateField)` in the .gts but writing an ISO datetime (`"2026-06-13T15:30:00Z"`) in the JSON instance, OR declaring `contains(DateTimeField)` and writing only `"2026-06-13"`. @@ -211,6 +230,7 @@ These extend `FileDef` and must be used with `linksTo`, never `contains`. See `b Need text? ├── Single line, generic → StringField ├── Email/URL/phone → EmailField / UrlField / PhoneNumberField +│ (UrlField = external URLs only; a realm resource URL is a linksTo, see below) ├── Multi-line plain → TextAreaField └── Markdown ├── Stored on the card → MarkdownField (or RichMarkdownField for editor chrome) @@ -231,6 +251,10 @@ Need a date? Need an image / file? └── Always linksTo(ImageDef) or specific subtype. NEVER contains(ImageDef). +Need to reference another card or a realm file (even if you have it as a URL)? +└── Always linksTo / linksToMany. NEVER StringField or UrlField holding the URL. + (Carve-outs: a FileDef's own id/url/sourceUrl fields; hostRoutingRules nav paths — see the section above.) + Need bounded choices? └── enumField(StringField, { options: [...] }) diff --git a/skills/glossary.md b/skills/glossary.md index a4c2fd9..53de8fe 100644 --- a/skills/glossary.md +++ b/skills/glossary.md @@ -369,6 +369,7 @@ In rough priority order: - **Query traps** — `filter: { type: ref }` (not `on`); custom sort fields need `on`; `codeRef(here, …)` + `realmURL` Symbol from `@cardstack/runtime-common`. → `boxel/references/query-systems.md` - **🔴 DateField vs DateTimeField — silent-renders-then-crashes trap.** `contains(DateField)` value MUST be `YYYY-MM-DD`; `contains(DateTimeField)` value MUST include `T`. Mismatches pass lint + write + index, then crash at render as `RangeError: Invalid time value`. `*At` → DateTimeField; `*Date`/`*On`/`hireDate`/`dob` → DateField. → `boxel/references/base-field-catalog.md` - **🚨 Image URLs in relationship links BRICK the realm.** External URLs (`https://images.unsplash.com/...`) in `relationships..links.self` cause `JSON.parse` to throw on the fetched JPEG bytes; the error message's NULL byte poisons the postgres JSONB write; the entire indexing transaction rolls back. Use the `cardInfo` pair pattern: `heroImage = linksTo(ImageDef)` + `heroImageURL = contains(UrlField)` (UrlField from `@cardstack/base/url`, not MaybeBase64Field, not StringField); external URLs go on the attribute side. → `boxel/references/base-field-catalog.md` "Image fields — the URL/ImageDef pair pattern" +- **Realm-resource URLs are never StringField/UrlField.** The complement of the rule above: a URL pointing at a card instance or a realm file is always a `linksTo`/`linksToMany` relationship (`FileDef` subtype for files). A string-typed realm URL bypasses the index — no invalidation, no broken-link detection, no traversal — and rots silently when the target moves. Carve-outs: a `FileDef`'s own `id`/`url`/`sourceUrl` descriptor fields, and a `hostRoutingRules` public nav path — strings by design. → `boxel/references/base-field-catalog.md` "Realm-resource URLs — always a relationship, never a string" - **🚨 `linksToMany` JSON shape uses INDEXED KEYS, never an array.** Each item is its own top-level relationship: `"activityFeed.0": { "links": { "self": "..." } }`, not `"activityFeed": { "links": { "self": ["...", "..."] } }`. Array-in-self causes "not a card resource document". - **🚨 `linksTo` never in `attributes`.** Links live under `relationships`, keyed by field path (dotted keys for nested fields: `"cardInfo.theme"`); an empty link is `{ "links": { "self": null } }` or the key is omitted. A `null` attribute writes fine, then every read of the instance throws `cannot deserialize non-relationship value`. - **🚨 Template-invoked functions must be arrow properties.** A plain class method called from a template (`(this.isActive note)`, `{{fn this.method}}`) is invoked unbound; `this` is `undefined`, the property access throws *during render*, and Ember's renderer is unrecoverable — the whole app freezes until reload. Getters are safe (read off `this`, not called). → `boxel-workspace-cardinal-rules/SKILL.md` #11