feat(surface): Basis-Template-Bundle und Block-Vertrag - #161
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Why is this change necessary?
Eine Surface hatte zwei Zustände: die eingebaute SPA-Schale — ein leeres
divmit einemSkript 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
doctypean, und jeder finganders 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.
Die reservierte Id
calloraantwortet 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
EmbeddedResourceim Assembly, damit eine Auslieferung sie nicht halb mitbringen kann.base.njkträgt sechzehn Blöcke vonbase_doctypebisbase_scripts. Jede Region ist eininclude, jede include-Datei hat innen ihre eigenen feineren Blöcke. Darauf drei Layouts(
page,sidebar,blank), zwei Seiten (index,error), zwei Komponenten als Makros undein 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.mountSurfacebedient 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;
slotist ein Control-Typ statt eines eigenen Feldes, womit ShopwaresBlock/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 esnicht:
colorToken,spacingToken,typeToken,variantsind reserviert, weil einbeigetragener freier Farbwähler die Guardrail in einer Registrierung aushebeln würde.
Kein
callora_extends. Ursprünglich als nächster Baustein geplant. Shopware brauchtsw_extends, weil dort die Template-Überschreibung der einzige Beitragsweg ist — also mussdiese 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
maineine Surface anlegen und aufrufen: ein leeresdivmit einem Skript. Auf diesemBranch: 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 inregistry.problemsvermerkt.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 denBasis-Template-Teil um.
Was die Tests gefunden haben
Zwei Reihenfolge-Tests wären auch grün gewesen, wenn der erste String ganz fehlte —
IndexOfliefert −1, und −1 ist kleiner als alles.Die Kontext-Attribute waren zuerst ein Makro. Ein
{% from %}auf Template-Ebene ist inNunjucks-Blöcken nicht sichtbar, und bei
throwOnUndefined: falserendert der Aufruf dannleer, 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 einenAST 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 zahltedas 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.