Skip to content

Commit ee15520

Browse files
committed
FM-SYNCCAL-ROOT-FIX: add F-PR3-4 footgun + test-template-roots.mjs Files row (4/4)
1 parent 0d3d7d0 commit ee15520

1 file changed

Lines changed: 26 additions & 0 deletions

File tree

.ai.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ All sync modules use a `_syncing` boolean flag to prevent infinite loops:
6565
| `chronicle-package.json` | Repo-root serving descriptor (schema v1). Chronicle's `foundry_vtt` sub-plugin reads this from the extracted release zip via `PostInstallHook` to learn how to serve this package: which fields to rewrite, what URL templates to emit, whether per-campaign signed tokens are required. Source of truth for the install/update flow's URL shape. |
6666
| `tools/check-package-descriptor.mjs` | CI script. Validates `chronicle-package.json` against `module.json` (id match, file paths resolve, endpoint templates carry required placeholders). Run by `.github/workflows/check-descriptor.yml` on every push + PR. |
6767
| `tools/test-lang-expand.mjs` | CI guard for `lang/en.json` (FM-LANG-COLLISION-FIX). Simulates Foundry's `expandObject` and fails loud on any string-vs-object collision or duplicate-after-expansion path that would cause Foundry to reject the whole lang file at boot. See F-LANG-1 footgun below. |
68+
| `tools/test-template-roots.mjs` | CI guard for ApplicationV2 PARTS templates (FM-SYNCCAL-ROOT-FIX). Discovers every `PARTS.*.template` binding in `scripts/*.mjs`, statically analyzes each referenced `.hbs` (recursing through every `{{#if/unless/each/with}}` branch), and asserts max-root-count == 1 — Foundry's `_parsePartHTML` rejects multi-root parts at render time. See F-PR3-4 footgun below. |
6869
| `scripts/calendar-sync.mjs` | Adapter pattern for Calendaria and SimpleCalendar. 0-indexed↔1-indexed conversion |
6970
| `scripts/shop-widget.mjs` | Shop window (Application v1). Context menu, drag-to-character-sheet, real-time updates |
7071
| `scripts/actor-sync.mjs` | Actor ↔ character entity bidirectional sync. System adapter loading, hook registration |
@@ -478,6 +479,31 @@ operator sees no error. The pure validator in
478479
save path blocks on `Errors.RootMustBeGroup`,
479480
`Errors.EmptyGroup`, and `Errors.NotAnObject`.
480481

482+
### F-PR3-4: ApplicationV2 PARTS templates must render exactly one root HTML element
483+
484+
Foundry's `_parsePartHTML` (foundry.mjs:32135) rejects any `static PARTS`
485+
template whose rendered output contains more than one root HTML element
486+
— or zero — with `Error: Template part 'X' must render a single HTML
487+
element.` Foundry catches this only at app render time; the JSON parses,
488+
the Handlebars compiles, the app object instantiates fine. The crash
489+
fires on `app.render()`.
490+
491+
Worst part: the failure mode depends on which branch of `{{#if}}/{{else}}`
492+
fires. PR 3's `sync-calendar.hbs` had a `degraded` branch that emitted
493+
one `<section>` (clean) and an `else` branch that emitted three siblings
494+
(`<header>`, `<main>`, `<footer>`) — the app worked in degraded mode
495+
during PR 3 dev (no Calendaria connected, so the degraded branch fired)
496+
and crashed the moment the operator opened it against a real Calendaria.
497+
498+
Same footgun class as F-LANG-1 (silent Foundry-runtime contract that
499+
Node tests can't catch by accident). Fix: wrap the entire template body
500+
in a single root element (e.g. `<div class="sync-calendar-root">`) so
501+
EVERY branch — degraded, year-view, month-view, with or without import
502+
banner — emits exactly one root. **CI guard at
503+
`tools/test-template-roots.mjs`** statically analyzes every PARTS-
504+
registered template across the repo, recurses through every
505+
`{{#if/unless/each/with}}` branch, and asserts max-root-count == 1.
506+
481507
### F-LANG-1: `lang/en.json` cannot mix dotted-flat keys with sibling literal-key strings
482508

483509
Foundry's `Localization._loadTranslationFile` runs the parsed JSON

0 commit comments

Comments
 (0)