Skip to content

feat(surface): Basis-Template-Bundle und Block-Vertrag - #161

Merged
BechsteinDigital merged 6 commits into
mainfrom
feat/surface-basis-bundle
Aug 6, 2026
Merged

feat(surface): Basis-Template-Bundle und Block-Vertrag#161
BechsteinDigital merged 6 commits into
mainfrom
feat/surface-basis-bundle

Conversation

@BechsteinDigital

Copy link
Copy Markdown
Owner

1. Why is this change necessary?

Eine Surface hatte zwei Zustände: die eingebaute SPA-Schale — ein leeres div mit einem
Skript daran — oder ein Plugin, das ein vollständiges HTML-Dokument von Grund auf schreibt.
Dazwischen lag nichts. Wer eine Fläche bauen wollte, fing beim doctype an, und jeder fing
anders an.

Shopwares Storefront hat es leichter: Sie weiß, dass sie ein Shop ist. Eine Surface ist ein
Zugang, keine Art von Seite — das Basis-Template muss also Struktur liefern, ohne Bedeutung
vorwegzunehmen.

Dazu der Block-Vertrag: die Registrierungsfläche, aus der später die Elemente des Editors
werden (Design §4.3).

2. What does this change do, exactly?

Ein Host-Bundle, adressiert wie jedes andere.

{% extends "@callora/layout/page.njk" %}
{% set page_title = "Kunden" %}
{% block page_content %}{% endblock %}

Die reservierte Id callora antwortet vor der Scope-Prüfung und vor dem Provider.
Das heißt: kein Ketteneintrag nötig — ein Template soll den Host, in dem es ohnehin läuft,
nicht als Abhängigkeit deklarieren müssen — und ein Plugin, das sich so nennt, kann das
Basis-Template nicht ersetzen, das alle anderen erweitern. Die Templates liegen als
EmbeddedResource im Assembly, damit eine Auslieferung sie nicht halb mitbringen kann.

base.njk trägt sechzehn Blöcke von base_doctype bis base_scripts. Jede Region ist ein
include, jede include-Datei hat innen ihre eigenen feineren Blöcke. Darauf drei Layouts
(page, sidebar, blank), zwei Seiten (index, error), zwei Komponenten als Makros und
ein Stylesheet, das ausschließlich --cal-*-Properties mit Fallback nutzt.

Die eingebaute Schale ist jetzt eine Zeile{% extends "@callora/page/app.njk" %}.
Eine frische Fläche sieht denselben Kopf, dieselbe Navigation und dieselben Theme-Tokens wie
eine gestaltete. Beide Beitragswege bleiben offen: serverseitig aufgelöste Views werden zu
Inseln (im HTML, überleben ohne JavaScript), clientseitig registrierte mounten in
#callora-app. mountSurface bedient ohnehin beide in einem Durchlauf.

Slots statt Blöcke, wo mehrere beitragen. An drei Stellen stand ein Block, wo ein Slot
hingehört — head, Overlay, Body-Ende. Ein Block gehört genau einem Template; zwei Plugins,
die beide eine Schriftart in den <head> wollen, hätten einander leise verdrängt. Neu:
surface.head, surface.overlay, surface.body.end.

Der Block-Vertrag. Ein Block ist eine View mit Editor-Metadaten, kein zweites Ding: seine
Id IST die View-Id IST das Insel-Attribut. Drei Entscheidungen korrigieren die Vorbilder — die
Kategorie ist ein freier String mit eigenem Registrierungspunkt statt Shopwares geschlossenem
XSD-Enum; slot ist ein Control-Typ statt eines eigenen Feldes, womit Shopwares
Block/Element-Trennung verschwindet (ein Element ist ein Block im Slot eines anderen — deshalb
kein elements/-Verzeichnis); und die Typliste ist offen, die Gestalt-Typen sind es
nicht: colorToken, spacingToken, typeToken, variant sind reserviert, weil ein
beigetragener freier Farbwähler die Guardrail in einer Registrierung aushebeln würde.

Kein callora_extends. Ursprünglich als nächster Baustein geplant. Shopware braucht
sw_extends, weil dort die Template-Überschreibung der einzige Beitragsweg ist — also muss
diese Tür für beliebig viele gleichzeitig funktionieren. Bei uns beantwortet der Slot dieselbe
Frage, und besser: serverseitig aufgelöst, nach Claims gefiltert, geordnet. Eine
Ketten-Auflösung im Loader entfällt damit.

3. Describe each step to reproduce the issue or behaviour.

Auf main eine Surface anlegen und aufrufen: ein leeres div mit einem Skript. Auf diesem
Branch: Kopf, Navigation aus den Beiträgen der Plugins, Theme-Tokens als
Custom-Properties, ein Leerzustand statt einer weißen Seite.

Für den Vertrag: In einem Plugin registerBlock({ …, controls: { accent: { type: 'colorToken' } } })
— der Typ wird angenommen. registerControlType('colorToken') — abgelehnt und in
registry.problems vermerkt.

4. Please link to the relevant issues (if any).

Setzt ops/specs/2026-08-06-admin-sdk-und-surface-composer-design.md §4.3 und den
Basis-Template-Teil um.

Was die Tests gefunden haben

Zwei Reihenfolge-Tests wären auch grün gewesen, wenn der erste String ganz fehlte
IndexOf liefert −1, und −1 ist kleiner als alles.

Die Kontext-Attribute waren zuerst ein Makro. Ein {% from %} auf Template-Ebene ist in
Nunjucks-Blöcken nicht sichtbar, und bei throwOnUndefined: false rendert der Aufruf dann
leer, ohne etwas zu sagen — jede Fläche wäre still ohne Kontext ausgeliefert worden und die
Hydration auf Defaults zurückgefallen.

Der Drift-Test zwischen SDK und Runtime fand beim ersten Lauf sein eigenes Suchmuster als
Fehler (generische Interfaces). Gegenproben in beide Richtungen belegen jetzt, dass er echte
Drift meldet.

Performance

Jeder Render parste das komplette Nunjucks-Bundle neu. Jint kann einmal in ein
Prepared<Script> parsen, das jede Engine ausführt — die Isolation bleibt, weil Parsen einen
AST erzeugt und keinen Zustand. Ein trivialer Render fiel von 50 auf 8 ms.

Dabei entstand ein Bug, den die Suite fand: Die Statics werden lazy initialisiert, beim ersten
Execute — also innerhalb der Engine, mit laufender Timeout-Uhr. Der erste Render zahlte
das Parsen, jeder parallele Thread wartete auf den Type-Initializer, ebenfalls mit laufender
Uhr. Das kostete sieben Tests pro Lauf; jetzt werden sie vor der Engine-Erstellung berührt.

Der Render-Timeout geht von 2 auf 5 s, gemessen statt geschätzt: Bei zwei Sekunden verliert
die Suite 11 bis 19 Tests pro Lauf, bei fünf keinen in sieben Läufen. Die Kette rendert in
~110 ms; sechzehn davon auf einen Kern serialisiert sind bereits 1,8 s. Er bounded eine
Endlosschleife, er ist kein Performance-Budget.

Offen: Die ~110 ms sind Nunjucks' eigene Template-Kompilierung im Interpreter. Sie zur
Bauzeit vorzukompilieren wäre der nächste große Hebel — eigenes Vorhaben.

BechsteinDigital and others added 6 commits August 6, 2026 18:04
src/Core/wwwroot/workspace/default/index.html stammt aus einer frühen Phase, in der
die tenant-seitige Oberfläche eine handgeschriebene HTML-Seite war. Sie wird von
keinem Code referenziert, von keinem Test berührt und liegt unter einem Pfad, der
seit der Umbenennung etwas anderes bedeutet: der Publisher schreibt nach
wwwroot/plugin-assets/, eine Fläche heißt surface, und ein Workspace ist der
Behälter.

Was heute an dieser Stelle steht, kommt aus dem Renderer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eine Surface hatte bisher zwei Zustände: die eingebaute SPA-Schale — ein leeres div
mit einem Skript daran — oder ein Plugin, das ein vollständiges HTML-Dokument von
Grund auf schreibt. Dazwischen lag nichts. Wer eine Fläche bauen wollte, fing beim
doctype an, und jeder fing anders an.

Jetzt bringt der Host ein eigenes Template-Bundle mit, adressiert wie jedes andere:

    {% extends "@callora/layout/page.njk" %}
    {% set page_title = "Kunden" %}
    {% block page_content %}…{% endblock %}

Die reservierte Id `callora` antwortet vor der Scope-Prüfung und vor dem Provider.
Das heißt: sie braucht keinen Ketteneintrag — ein Template soll den Host, in dem es
ohnehin läuft, nicht als Abhängigkeit deklarieren müssen — und ein Plugin, das sich
so nennt, kann das Basis-Template nicht ersetzen, das alle anderen erweitern. Die
Templates liegen als EmbeddedResource im Assembly statt als publizierte Assets: eine
Auslieferung kann sie damit nicht halb mitbringen.

BLOCKSTRUKTUR
base.njk trägt sechzehn Blöcke von base_doctype bis base_scripts. Jede Region ist ein
include, jede include-Datei hat innen ihre eigenen feineren Blöcke. Das trennt zwei
Arten von Änderung: eine ganze Region ersetzen geht über den Block, etwas INNERHALB
einer Region ändern über das Überschreiben ihrer Datei. Letzteres kann heute nur der
primäre Plugin der Kette — das Gegenstück zu Shopwares sw_extends fehlt noch und ist
der nächste Schritt.

Darauf drei Layouts (page, sidebar, blank), zwei Seiten (index, error) und zwei
Komponenten als Makros. Das Basis-Stylesheet nutzt ausschließlich --cal-*-Properties
mit Fallback, sodass eine Fläche ohne jedes Theme lesbar rendert und eine mit Theme
genau das überschreibt, was sie gesetzt hat.

Was das Basis-Template NICHT entscheidet: wofür die Fläche da ist. Kein Dashboard,
keine Produktliste, keine feste Navigationsform. Shopwares Storefront weiß, dass sie
ein Shop ist; eine Surface ist ein Zugang, keine Art von Seite.

WAS DIE TESTS GEFUNDEN HABEN
Zwei Reihenfolge-Tests wären auch grün gewesen, wenn der erste String ganz gefehlt
hätte — IndexOf liefert -1, und -1 ist kleiner als alles. Jetzt prüfen sie erst die
Anwesenheit. Vier Gegenproben belegen, dass die Tests bei einem echten Bruch rot
werden.

Und die Suite lief in Timeouts: die Kette rendert sechs Templates statt einem, und
jeder Render parste bisher das komplette Nunjucks-Bundle neu. Jint kann einmal in ein
Prepared<Script> parsen, das jede Engine ausführt — die Isolation bleibt, weil Parsen
einen AST erzeugt und keinen Zustand. Ein trivialer Render fiel damit von 50 auf 8 ms,
die volle Kette liegt bei ~110 ms, und die Suite von 1m33s auf 47s. Der Timeout geht
auf 5 s: er misst Wall-Clock, also wird ein Render langsamer, wenn fünfzehn andere um
dieselbe CPU konkurrieren, ohne mehr zu tun. Er bleibt eine DoS-Schranke gegen
Endlosschleifen, keine Performance-Erwartung.

Offen: die ~110 ms sind Nunjucks' eigene Template-Kompilierung im Interpreter. Sie zur
Bauzeit vorzukompilieren wäre der nächste große Hebel — eigenes Vorhaben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Das Basis-Bundle war da, aber unsichtbar: Wer eine Fläche anlegte, bekam weiterhin
das leere div mit einem Skript daran. Die Schale ist jetzt eine Zeile —
{% extends "@callora/page/app.njk" %} — und damit sieht eine frische Fläche denselben
Kopf, dieselbe Navigation und dieselben Theme-Tokens wie eine gestaltete.

Beide Beitragswege bleiben offen, weil beide existieren und keiner falsch ist:
serverseitig aufgelöste Views werden zu Inseln (im HTML, überleben ohne JavaScript),
clientseitig registrierte mounten in #callora-app. mountSurface bedient ohnehin beide
in einem Durchlauf — die Modi schließen sich nicht aus.

SpaRoot ist kein const mehr. Bei einer Konstante gehört der WERT zur öffentlichen API
und wird in jeden Konsumenten einkompiliert; jede Änderung an der Schale wäre ein
Bruch. Was der Host per Default rendert, ist Implementierung. Der PublicAPI-Analyzer
hat darauf bestanden und hatte recht.

WAS DIE TESTS GEFUNDEN HABEN
Die Kontext-Attribute waren zuerst ein Makro. Ein {% from %} auf Template-Ebene ist in
Nunjucks-Blöcken NICHT sichtbar — Blöcke werden als eigene Funktionen kompiliert — und
bei throwOnUndefined: false rendert der Aufruf dann leer, ohne etwas zu sagen. Eine
Fläche hätte still ohne Kontext ausgeliefert und die Hydration wäre auf Defaults
zurückgefallen. Jetzt ein Include, das den umgebenden Kontext liest und überall
funktioniert.

Die Attribute stehen bewusst zweimal im Dokument: readSurfaceContext liest den EIGENEN
Datensatz des App-Roots, Inseln laufen über den nächsten [data-workspace]-Vorfahren
nach oben. Eine Gegenprobe belegt, dass der Test beißt, wenn der App-Root sie verliert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Das Basis-Template hatte an drei Stellen einen Block, wo ein Slot hingehört: head,
Overlay und Body-Ende. Ein Block gehört genau einem Template — zwei Plugins, die
beide eine Schriftart oder ein Meta-Tag in den <head> wollen, hätten einander
verdrängt, und das leise.

Genau dieser Fall ist der Grund für Shopwares sw_extends: Dort ist die
Template-Überschreibung der einzige Weg, zur Storefront beizutragen, also muss diese
eine Tür für beliebig viele gleichzeitig funktionieren — sw_extends löst den Namen
gegen die Bundle-Kette auf statt gegen die Datei, sodass Plugin B die Fassung von A
erweitert und nicht das Original.

Bei uns beantwortet der Slot dieselbe Frage, und besser: serverseitig aufgelöst, nach
Claims gefiltert, geordnet, beliebig viele. Eine Ketten-Auflösung im Loader braucht es
dafür nicht.

Neu: surface.head, surface.overlay, surface.body.end. Die Blöcke daneben bleiben —
sie sind für das eine Template, das eine Region ganz ersetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntime rendert

Ein Block ist eine View mit Editor-Metadaten, kein zweites Ding: seine Id IST die
View-Id IST das Insel-Attribut. Ihn zu registrieren registriert auch die View, damit
ein im Editor platzierter Block und eine serverseitig gerenderte Insel dieselbe
Komponente auflösen — zwei Registries wären zwei Gelegenheiten auseinanderzulaufen.

    registerBlockCategory({ id: 'telephony', label: 'Telefonie', icon: 'phone' })
    registerBlock({
      id: 'communication.call-list',
      label: 'Anrufliste',
      category: 'telephony',
      requires: ['communication.active-call/v1'],
      component: CallListBlock,
      controls: { title: { type: 'text', label: 'Überschrift' } },
    })

DREI ENTSCHEIDUNGEN, DIE VORBILDER KORRIGIEREN

Die Kategorie ist ein freier String mit eigenem Registrierungspunkt. Shopwares
geschlossenes XSD-Enum ist der Fehler, den wir nicht wiederholen: Ein Plugin, das eine
Kategorie erfindet, soll dafür keine Host-Änderung brauchen. Ein Block, dessen
Kategorie niemand registriert hat, erscheint trotzdem — unbenannt, aber da; ihn
fallenzulassen hinge seine Sichtbarkeit an der Ladereihenfolge.

`slot` ist ein Control-Typ, kein eigenes Feld (Webflows Idee). Verschachtelung fällt
damit aus demselben Mechanismus heraus — und mit ihr verschwindet Shopwares
Block/Element-Trennung: Was dort ein Element heißt, ist bei uns ein Block im Slot
eines anderen. Deshalb gibt es kein elements/-Verzeichnis.

Die Typliste ist offen, die Gestalt-Typen sind es nicht. Ein Plugin kann einen
Rufnummern-Picker beitragen; colorToken, spacingToken, typeToken und variant sind
reserviert und werden abgelehnt. Sie wählen ausschließlich aus --cal-*, und ein
beigetragener freier Farbwähler hätte die Guardrail in einer Registrierung ausgehebelt.

GEGEN DIE DUPLIKATION
Das SDK deklariert den Vertrag ein zweites Mal — es ist ein eigenständiges Paket und
kann nicht aus der Runtime importieren. Ein Test vergleicht die Feldnamen beider
Seiten und die geschlossene Gestalt-Menge. Nicht die Typen, aber die Namen fangen den
Fehler, der wirklich passiert: jemand erweitert eine Datei. Gegenproben in beide
Richtungen belegen, dass er beißt.

EIN BUG AUS DEM VORIGEN COMMIT
Die Prepared<Script>-Statics werden lazy initialisiert — beim ersten Execute, also
INNERHALB der Engine, mit laufender Timeout-Uhr. Der erste Render zahlte damit das
Parsen des kompletten Nunjucks-Bundles und jeder parallele Thread wartete auf den
Type-Initializer, ebenfalls mit laufender Uhr. Das kostete sieben Tests pro Lauf.
Jetzt werden sie vor der Engine-Erstellung berührt.

Der Timeout bleibt bei 5 s, aber aus gemessenem Grund statt aus Vorsicht: Bei zwei
Sekunden verliert die Suite 11 bis 19 Tests pro Lauf, bei fünf keinen in sieben
Läufen. Die Kette braucht real mehr als ein Ein-Datei-Template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant