From c81745d92954e24b89212b93d0a11de3fb2da5b0 Mon Sep 17 00:00:00 2001 From: Orkhan Ahmadov Date: Fri, 15 May 2026 15:12:55 +0200 Subject: [PATCH 1/2] WIP --- apps/docs/.vitepress/config.ts | 82 +++++---- apps/docs/de/getting-started/installation.md | 2 +- .../accessibility/contributing-locales.md | 80 --------- .../quality/accessibility/getting-started.md | 49 ------ .../quality/accessibility/headless-usage.md | 94 ----------- apps/docs/de/quality/accessibility/index.md | 95 +++-------- apps/docs/de/quality/accessibility/options.md | 92 ----------- .../de/quality/accessibility/rule-catalog.md | 57 ++++--- .../accessibility/severity-and-fixes.md | 52 ------ apps/docs/de/quality/contributing-locales.md | 105 ++++++++++++ apps/docs/de/quality/headless-usage.md | 115 +++++++++++++ apps/docs/de/quality/index.md | 105 +++++++++++- apps/docs/de/quality/options.md | 100 +++++++++++ apps/docs/de/quality/severity-and-fixes.md | 64 +++++++ apps/docs/de/quality/structure/index.md | 37 +++++ .../docs/de/quality/structure/rule-catalog.md | 23 +++ apps/docs/getting-started/installation.md | 2 +- .../accessibility/contributing-locales.md | 80 --------- .../quality/accessibility/getting-started.md | 49 ------ apps/docs/quality/accessibility/index.md | 81 ++------- apps/docs/quality/accessibility/options.md | 92 ----------- .../quality/accessibility/rule-catalog.md | 45 +++-- .../accessibility/severity-and-fixes.md | 52 ------ apps/docs/quality/contributing-locales.md | 105 ++++++++++++ .../{accessibility => }/headless-usage.md | 49 ++++-- apps/docs/quality/index.md | 112 ++++++++++++- apps/docs/quality/options.md | 100 +++++++++++ apps/docs/quality/severity-and-fixes.md | 64 +++++++ apps/docs/quality/structure/index.md | 37 +++++ apps/docs/quality/structure/rule-catalog.md | 23 +++ apps/playground/e2e/helpers/selectors.ts | 7 + apps/playground/e2e/pages/editor.page.ts | 34 +++- apps/playground/e2e/tests/lint.spec.ts | 61 +++++++ packages/editor/src/Editor.vue | 4 +- packages/editor/src/cloud/CloudEditor.vue | 4 +- packages/editor/src/cloud/cloudConfig.ts | 5 +- .../cloud/components/CloudSaveGateModal.vue | 4 +- .../composables/useCloudInitialization.ts | 4 +- .../src/cloud/composables/useCloudSaveGate.ts | 8 +- .../editor/src/components/RightSidebar.vue | 52 +++--- .../src/components/blocks/BlockWrapper.vue | 6 +- ...BlockA11yBadge.vue => BlockIssueBadge.vue} | 8 +- ...AccessibilityPanel.vue => IssuesPanel.vue} | 32 ++-- .../src/components/toolbar/SectionToolbar.vue | 15 +- .../editor/src/composables/useEditorCore.ts | 34 ++-- ...ccessibilityLint.ts => useTemplateLint.ts} | 64 ++++--- packages/editor/src/i18n/locales/de.ts | 14 +- packages/editor/src/i18n/locales/en.ts | 14 +- packages/editor/src/i18n/locales/pt-BR.ts | 14 +- packages/editor/src/index.ts | 5 +- packages/editor/src/keys.ts | 6 +- packages/editor/src/styles/index.css | 24 +-- .../src/utils/rebalanceColumnChildren.ts | 37 +++++ .../src/utils/resolveAccessibilityOptions.ts | 19 --- .../editor/src/utils/resolveLintOptions.ts | 19 +++ packages/editor/tests/blockIssueBadge.test.ts | 109 ++++++++++++ packages/editor/tests/issuesPanel.test.ts | 156 ++++++++++++++++++ .../tests/rebalanceColumnChildren.test.ts | 92 +++++++++++ .../tests/resolveAccessibilityOptions.test.ts | 41 ----- .../editor/tests/resolveLintOptions.test.ts | 41 +++++ packages/editor/tests/sectionToolbar.test.ts | 104 ++++++++++++ ...tyLint.test.ts => useTemplateLint.test.ts} | 71 ++++++-- packages/quality/src/accessibility/index.ts | 83 +--------- .../quality/src/accessibility/messages/de.ts | 40 ++--- .../quality/src/accessibility/messages/en.ts | 40 ++--- .../rules/button-low-contrast.ts | 2 +- .../rules/button-touch-target.ts | 2 +- .../accessibility/rules/button-vague-label.ts | 2 +- .../src/accessibility/rules/heading-empty.ts | 2 +- .../rules/heading-multiple-h1.ts | 2 +- .../accessibility/rules/heading-skip-level.ts | 2 +- .../rules/img-alt-is-filename.ts | 2 +- .../accessibility/rules/img-alt-too-long.ts | 2 +- .../rules/img-decorative-needs-empty-alt.ts | 2 +- .../rules/img-linked-no-context.ts | 2 +- .../accessibility/rules/img-missing-alt.ts | 2 +- .../src/accessibility/rules/link-empty.ts | 2 +- .../accessibility/rules/link-href-empty.ts | 2 +- .../rules/link-target-blank-no-rel.ts | 2 +- .../accessibility/rules/link-vague-text.ts | 2 +- .../accessibility/rules/missing-preheader.ts | 2 +- .../src/accessibility/rules/text-all-caps.ts | 2 +- .../accessibility/rules/text-low-contrast.ts | 2 +- .../src/accessibility/rules/text-too-small.ts | 2 +- packages/quality/src/index.ts | 24 ++- packages/quality/src/run-rules.ts | 99 +++++++++++ packages/quality/src/structure/index.ts | 29 ++++ packages/quality/src/structure/messages/de.ts | 16 ++ packages/quality/src/structure/messages/en.ts | 19 +++ .../quality/src/structure/messages/index.ts | 38 +++++ .../src/structure/rules/duplicate-block-id.ts | 35 ++++ .../src/structure/rules/empty-column.ts | 37 +++++ .../src/structure/rules/empty-section.ts | 31 ++++ .../src/structure/rules/nested-section.ts | 21 +++ .../rules/section-column-mismatch.ts | 29 ++++ packages/quality/src/types.ts | 23 +-- .../accessibility/button-low-contrast.test.ts | 4 +- .../tests/accessibility/button-rules.test.ts | 24 +-- .../tests/accessibility/heading-rules.test.ts | 24 +-- .../tests/accessibility/image-rules.test.ts | 54 +++--- .../accessibility/img-missing-alt.test.ts | 12 +- .../tests/accessibility/link-empty.test.ts | 4 +- .../tests/accessibility/link-rules.test.ts | 50 +++--- .../accessibility/missing-preheader.test.ts | 8 +- .../tests/accessibility/text-rules.test.ts | 18 +- packages/quality/tests/messages.test.ts | 10 +- .../structure/duplicate-block-id.test.ts | 86 ++++++++++ .../tests/structure/empty-column.test.ts | 75 +++++++++ .../tests/structure/empty-section.test.ts | 67 ++++++++ .../quality/tests/structure/messages.test.ts | 58 +++++++ .../tests/structure/nested-section.test.ts | 49 ++++++ .../structure/section-column-mismatch.test.ts | 76 +++++++++ 112 files changed, 2997 insertions(+), 1375 deletions(-) delete mode 100644 apps/docs/de/quality/accessibility/contributing-locales.md delete mode 100644 apps/docs/de/quality/accessibility/getting-started.md delete mode 100644 apps/docs/de/quality/accessibility/headless-usage.md delete mode 100644 apps/docs/de/quality/accessibility/options.md delete mode 100644 apps/docs/de/quality/accessibility/severity-and-fixes.md create mode 100644 apps/docs/de/quality/contributing-locales.md create mode 100644 apps/docs/de/quality/headless-usage.md create mode 100644 apps/docs/de/quality/options.md create mode 100644 apps/docs/de/quality/severity-and-fixes.md create mode 100644 apps/docs/de/quality/structure/index.md create mode 100644 apps/docs/de/quality/structure/rule-catalog.md delete mode 100644 apps/docs/quality/accessibility/contributing-locales.md delete mode 100644 apps/docs/quality/accessibility/getting-started.md delete mode 100644 apps/docs/quality/accessibility/options.md delete mode 100644 apps/docs/quality/accessibility/severity-and-fixes.md create mode 100644 apps/docs/quality/contributing-locales.md rename apps/docs/quality/{accessibility => }/headless-usage.md (55%) create mode 100644 apps/docs/quality/options.md create mode 100644 apps/docs/quality/severity-and-fixes.md create mode 100644 apps/docs/quality/structure/index.md create mode 100644 apps/docs/quality/structure/rule-catalog.md create mode 100644 apps/playground/e2e/tests/lint.spec.ts rename packages/editor/src/components/canvas/{BlockA11yBadge.vue => BlockIssueBadge.vue} (87%) rename packages/editor/src/components/sidebar/{AccessibilityPanel.vue => IssuesPanel.vue} (85%) rename packages/editor/src/composables/{useAccessibilityLint.ts => useTemplateLint.ts} (62%) create mode 100644 packages/editor/src/utils/rebalanceColumnChildren.ts delete mode 100644 packages/editor/src/utils/resolveAccessibilityOptions.ts create mode 100644 packages/editor/src/utils/resolveLintOptions.ts create mode 100644 packages/editor/tests/blockIssueBadge.test.ts create mode 100644 packages/editor/tests/issuesPanel.test.ts create mode 100644 packages/editor/tests/rebalanceColumnChildren.test.ts delete mode 100644 packages/editor/tests/resolveAccessibilityOptions.test.ts create mode 100644 packages/editor/tests/resolveLintOptions.test.ts create mode 100644 packages/editor/tests/sectionToolbar.test.ts rename packages/editor/tests/{useAccessibilityLint.test.ts => useTemplateLint.test.ts} (61%) create mode 100644 packages/quality/src/run-rules.ts create mode 100644 packages/quality/src/structure/index.ts create mode 100644 packages/quality/src/structure/messages/de.ts create mode 100644 packages/quality/src/structure/messages/en.ts create mode 100644 packages/quality/src/structure/messages/index.ts create mode 100644 packages/quality/src/structure/rules/duplicate-block-id.ts create mode 100644 packages/quality/src/structure/rules/empty-column.ts create mode 100644 packages/quality/src/structure/rules/empty-section.ts create mode 100644 packages/quality/src/structure/rules/nested-section.ts create mode 100644 packages/quality/src/structure/rules/section-column-mismatch.ts create mode 100644 packages/quality/tests/structure/duplicate-block-id.test.ts create mode 100644 packages/quality/tests/structure/empty-column.test.ts create mode 100644 packages/quality/tests/structure/empty-section.test.ts create mode 100644 packages/quality/tests/structure/messages.test.ts create mode 100644 packages/quality/tests/structure/nested-section.test.ts create mode 100644 packages/quality/tests/structure/section-column-mismatch.test.ts diff --git a/apps/docs/.vitepress/config.ts b/apps/docs/.vitepress/config.ts index eb88e3ad..5fea16a2 100644 --- a/apps/docs/.vitepress/config.ts +++ b/apps/docs/.vitepress/config.ts @@ -3,7 +3,7 @@ import { defineConfig, type DefaultTheme } from "vitepress"; const enNav: DefaultTheme.NavItem[] = [ { text: "Guide", link: "/getting-started/installation" }, { text: "API", link: "/api/editor" }, - { text: "Accessibility", link: "/quality/accessibility/" }, + { text: "Quality", link: "/quality/" }, { text: "Cloud", link: "/cloud/" }, { text: "Playground", link: "https://play.templatical.com" }, ]; @@ -12,33 +12,29 @@ const enSidebar: DefaultTheme.SidebarMulti = { "/quality/": [ { text: "Quality", - items: [{ text: "Overview", link: "/quality/" }], + items: [ + { text: "Overview", link: "/quality/" }, + { text: "Options", link: "/quality/options" }, + { text: "Severity & fixes", link: "/quality/severity-and-fixes" }, + { text: "Headless usage", link: "/quality/headless-usage" }, + { + text: "Contributing locales", + link: "/quality/contributing-locales", + }, + ], }, { text: "Accessibility", items: [ { text: "Overview", link: "/quality/accessibility/" }, - { - text: "Getting Started", - link: "/quality/accessibility/getting-started", - }, - { - text: "Rule catalog", - link: "/quality/accessibility/rule-catalog", - }, - { text: "Options", link: "/quality/accessibility/options" }, - { - text: "Severity & fixes", - link: "/quality/accessibility/severity-and-fixes", - }, - { - text: "Headless usage", - link: "/quality/accessibility/headless-usage", - }, - { - text: "Contributing locales", - link: "/quality/accessibility/contributing-locales", - }, + { text: "Rule catalog", link: "/quality/accessibility/rule-catalog" }, + ], + }, + { + text: "Structure", + items: [ + { text: "Overview", link: "/quality/structure/" }, + { text: "Rule catalog", link: "/quality/structure/rule-catalog" }, ], }, ], @@ -147,7 +143,7 @@ const enSidebar: DefaultTheme.SidebarMulti = { const deNav: DefaultTheme.NavItem[] = [ { text: "Anleitung", link: "/de/getting-started/installation" }, { text: "API", link: "/de/api/editor" }, - { text: "Barrierefreiheit", link: "/de/quality/accessibility/" }, + { text: "Qualität", link: "/de/quality/" }, { text: "Cloud", link: "/de/cloud/" }, { text: "Playground", link: "https://play.templatical.com" }, ]; @@ -156,33 +152,35 @@ const deSidebar: DefaultTheme.SidebarMulti = { "/de/quality/": [ { text: "Qualität", - items: [{ text: "Überblick", link: "/de/quality/" }], + items: [ + { text: "Überblick", link: "/de/quality/" }, + { text: "Optionen", link: "/de/quality/options" }, + { + text: "Schweregrade & Fixes", + link: "/de/quality/severity-and-fixes", + }, + { text: "Headless-Nutzung", link: "/de/quality/headless-usage" }, + { + text: "Lokalen beitragen", + link: "/de/quality/contributing-locales", + }, + ], }, { text: "Barrierefreiheit", items: [ { text: "Überblick", link: "/de/quality/accessibility/" }, - { - text: "Erste Schritte", - link: "/de/quality/accessibility/getting-started", - }, { text: "Regelkatalog", link: "/de/quality/accessibility/rule-catalog", }, - { text: "Optionen", link: "/de/quality/accessibility/options" }, - { - text: "Schweregrad & Korrekturen", - link: "/de/quality/accessibility/severity-and-fixes", - }, - { - text: "Headless-Nutzung", - link: "/de/quality/accessibility/headless-usage", - }, - { - text: "Lokale beitragen", - link: "/de/quality/accessibility/contributing-locales", - }, + ], + }, + { + text: "Struktur", + items: [ + { text: "Überblick", link: "/de/quality/structure/" }, + { text: "Regelkatalog", link: "/de/quality/structure/rule-catalog" }, ], }, ], diff --git a/apps/docs/de/getting-started/installation.md b/apps/docs/de/getting-started/installation.md index 335f4923..43f8321a 100644 --- a/apps/docs/de/getting-started/installation.md +++ b/apps/docs/de/getting-started/installation.md @@ -90,7 +90,7 @@ Der Editor lädt vier optionale Peers zur Laufzeit per dynamischem `import()`, a | Peer | Wann geladen | Installieren, wenn Sie | | ---------------------------- | ---------------------------------------------- | ---------------------------------------- | | `@templatical/renderer` | Erster Aufruf von `editor.toMjml()` | MJML-Export aus dem Browser benötigen | -| `@templatical/quality` | Beim Mounten des Editors (Accessibility-Panel) | Die Accessibility-Sidebar nutzen möchten | +| `@templatical/quality` | Beim Mounten des Editors (Issues-Panel) | Barrierefreiheit + Struktur-Lint in der Issues-Sidebar nutzen möchten | | `@templatical/media-library` | Erstes Öffnen des Medien-Browsers | `initCloud()` verwenden | | `pusher-js` | Cloud-Realtime-Verbindung | `initCloud()` verwenden | diff --git a/apps/docs/de/quality/accessibility/contributing-locales.md b/apps/docs/de/quality/accessibility/contributing-locales.md deleted file mode 100644 index 87479333..00000000 --- a/apps/docs/de/quality/accessibility/contributing-locales.md +++ /dev/null @@ -1,80 +0,0 @@ -# Lokale beitragen - -`@templatical/quality` liefert **zwei** lokalisierungsabhängige Datensätze, beide nach Sprache geschlüsselt: - -1. **Regel-Meldungen** (`src/accessibility/messages/{locale}.ts`) – die menschenlesbaren Strings, die die Editor-Sidebar pro Issue rendert. -2. **Vague-Text-Wörterbücher** (`src/accessibility/dictionaries/{locale}.ts`) – die Phrasenlisten, die `link-vague-text`, `button-vague-label` und `img-linked-no-context` nutzen. - -Beide spiegeln das Locale-Set des Editors: Jede OSS-Locale, die `@templatical/editor` unterstützt, sollte eine passende Meldungs-Map und ein Wörterbuch haben. - -## Dateilayout - -``` -packages/quality/src/accessibility/messages/ - en.ts ← Quelle der Wahrheit (implizit typisiert) - de.ts ← annotiert mit `typeof en` - index.ts ← exportiert formatMessage(), getMessages() - -packages/quality/src/accessibility/dictionaries/ - en.ts - de.ts - index.ts ← exportiert getDictionary(), normalizeForMatch() -``` - -## Eine Locale hinzufügen - -Sie brauchen **beides**: eine Meldungs-Map und eine Wörterbuch-Datei. Dateien einfach ablegen – sie werden automatisch erkannt. Das Locale-Register wird zur Compile-Zeit über `import.meta.glob` gebaut, es gibt keine `MESSAGES`- oder `DICTIONARIES`-Map zu pflegen. - -Folgen Sie für beide Dateien dem `typeof en`-Muster. Die Annotation ist der Vertrag: jede fehlende Schlüssel, jeder Extraschlüssel oder ein falscher Typ scheitert an `pnpm run typecheck`. Der Laufzeit-Paritätstest (`tests/messages.test.ts`) prüft zusätzlich, dass `{name}`-Platzhalter zwischen Locales pro Schlüssel exakt übereinstimmen. - -### 1. Regel-Meldungen - -Legen Sie `messages/.ts` an und übersetzen Sie jeden Wert – `{name}`-Platzhalter exakt erhalten: - -```ts -// messages/pt.ts -import type en from "./en"; - -const pt: typeof en = { - "img-missing-alt": - "Imagem sem texto alternativo. Adicione uma descrição curta ou marque a imagem como decorativa.", - "img-alt-too-long": - "Texto alternativo tem {length} caracteres; mantenha abaixo de {max}.", - // …ein Schlüssel pro Regel -}; - -export default pt; -``` - -### 2. Vague-Text-Wörterbuch - -Legen Sie `dictionaries/.ts` an: - -```ts -// dictionaries/pt.ts -import type en from "./en"; - -const pt: typeof en = { - vagueLinkText: ["clique aqui", "aqui", "leia mais", "saiba mais"], - vagueButtonLabels: ["clique aqui", "clique", "enviar"], - linkedImageActionHints: ["compre", "leia", "veja", "baixe", "descubra"], -}; - -export default pt; -``` - -Das war's – `SUPPORTED_MESSAGE_LOCALES` und `SUPPORTED_DICTIONARY_LOCALES` spiegeln die neue Locale automatisch. Kein Register-Edit, kein Test-Update. - -## Phrasen-Richtlinien - -- **Match, kein Regex.** Die Vague-Text-Regeln normalisieren den Anker-/Button-Text – Kleinschreibung, Whitespace zusammenfassen, führende/nachfolgende Nicht-Alphanumerik (Satzzeichen, Pfeile, dekorative Anführungen) entfernen – und prüfen dann `phrases.includes(text)`. So fallen `"Hier klicken!"`, `"→ hier klicken"` und `"»hier klicken«"` alle auf `hier klicken` zusammen und treffen denselben Wörterbucheintrag. Fügen Sie keine Satzzeichen-Varianten hinzu – sie sind redundant. Jeder Eintrag ist ein exakter Phrasen-Match; versuchen Sie nicht, Regex-Muster zu kodieren. -- **Nur Kleinschreibung.** Der Vergleich auf Eingabeseite ist case-insensitiv. -- **Häufig, nicht erschöpfend.** Ziel ist, die häufigsten vagen Phrasen zu erwischen, in die Native-Autoren typischerweise verfallen. Eine Liste mit 50 Einträgen schadet mehr, als sie nützt (Falsch-Positive). -- **Englische Phrasen nicht übersetzen.** Das Wörterbuch ist eine sprachenübergreifende Vereinigung – die Phrasen jeder registrierten Locale matchen unabhängig von der aktiven `locale`-Option. Ihre `pt.ts` braucht also nur portugiesische Phrasen; das englische `click here` ist bereits über die Vereinigung abgedeckt. -- **Keine Regional-Duplikate.** `de-AT` löst auf dieselbe Vereinigung auf; ein Eintrag pro Sprache. -- **`linkedImageActionHints` ist token- statt phrasenbasiert.** `img-linked-no-context` zerlegt den Alt-Text an Nicht-Buchstaben/Ziffer-Grenzen und prüft jedes Token einzeln gegen die Hint-Liste. Tragen Sie **einzelne Aktionsverben** in der Form ein, in der Autoren sie tatsächlich schreiben (`"kaufen"`, `"buy"`, `"compre"`) – keine Mehrwort-Phrasen. Ein Eintrag wie `"jetzt kaufen"` wird nie matchen, weil Tokens einzeln geprüft werden. - -## Wie das Matching abläuft - -- **Vague-Text-Wörterbuch** – `getDictionary(locale)` liefert eine Vereinigung aller registrierten Locale-Phrasen (und Action-Hints). Das `locale`-Argument wird aus API-Symmetriegründen akzeptiert, ändert die zurückgegebene Menge aber derzeit nicht; eine vage Phrase ist universell vage, und ein Aktionsverb in irgendeiner registrierten Sprache zählt als Link-Ziel-Kontext – Erkennung ist also bewusst sprachenübergreifend. -- **Regel-Meldungen** – `formatMessage(locale, ruleId, params?)` löst das lokalisierte Meldungs-Template über `messages/{locale}.ts` auf und interpoliert `{name}`-Platzhalter. Fällt auf Englisch zurück, wenn die Locale nicht mitgeliefert wird. diff --git a/apps/docs/de/quality/accessibility/getting-started.md b/apps/docs/de/quality/accessibility/getting-started.md deleted file mode 100644 index 720db5a3..00000000 --- a/apps/docs/de/quality/accessibility/getting-started.md +++ /dev/null @@ -1,49 +0,0 @@ -# Erste Schritte - -## Installation - -::: code-group -```bash [npm] -npm install @templatical/quality -``` -```bash [pnpm] -pnpm add @templatical/quality -``` -```bash [yarn] -yarn add @templatical/quality -``` -```bash [bun] -bun add @templatical/quality -``` -::: - -## In den Editor einbinden - -Übergeben Sie `accessibility` an `init()` oder `initCloud()`: - -```ts -import { init } from "@templatical/editor"; - -const editor = init({ - container: "#editor", - locale: "de", - accessibility: { - rules: { - "img-missing-alt": "warning", // Standard 'error' abschwächen - "text-all-caps": "off", // komplett deaktivieren - }, - thresholds: { - minFontSize: 16, - }, - }, -}); -``` - -Der Sidebar-Tab und die Inline-Canvas-Badges erscheinen automatisch, sobald der optionale Peer aufgelöst wird. Bei `accessibility.disabled === true` lädt der Editor das Paket nie per Lazy-Load – kein Chunk-Download, keine UI. - -## Wie geht's weiter? - -- Im [Regelkatalog](./rule-catalog) jede Prüfung nachschlagen. -- Schweregrade, Schwellwerte und das `disabled`-Flag in den [Optionen](./options) anpassen. -- In [Schweregrad & Korrekturen](./severity-and-fixes) lesen, wie Auto-Fix-Patches im Editor angewendet werden. -- Außerhalb des Editors linten? Siehe [Headless-Nutzung](./headless-usage) für CI-Validierung. diff --git a/apps/docs/de/quality/accessibility/headless-usage.md b/apps/docs/de/quality/accessibility/headless-usage.md deleted file mode 100644 index d1020a98..00000000 --- a/apps/docs/de/quality/accessibility/headless-usage.md +++ /dev/null @@ -1,94 +0,0 @@ -# Headless-Nutzung - -`@templatical/quality` ist ausschließlich JSON-basiert und hat keine DOM-Abhängigkeit – derselbe Lint läuft also in jedem Node.js-Kontext: CI, Build-Pipelines, serverseitige Validierung, Batch-Jobs. - -## Vor dem Speichern validieren - -Lehnen Sie Template-JSON, das den Linter nicht besteht, an der Stelle ab, an der es in Ihr System gelangt – CMS-Save-Handler, API-Endpunkt, Ingest-Job: - -```ts -import { lintAccessibility } from "@templatical/quality"; -import type { TemplateContent } from "@templatical/types"; - -export function assertValid(content: TemplateContent): void { - const errors = lintAccessibility(content).filter( - (i) => i.severity === "error", - ); - if (errors.length > 0) { - throw new Error( - `Template fails accessibility checks:\n${errors - .map((e) => ` [${e.ruleId}] ${e.message}`) - .join("\n")}`, - ); - } -} -``` - -## CI-Schutz für gespeicherte Templates - -Speichert Ihre Anwendung `TemplateContent`-JSON in einer Datenbank, lassen Sie den Linter in CI gegen jede gespeicherte Fixture laufen, damit keine Regressionen ausgeliefert werden: - -```ts -// scripts/lint-templates.ts -import { lintAccessibility } from "@templatical/quality"; -import { templates } from "../fixtures/templates"; - -const SEVERITY_RANK = { error: 3, warning: 2, info: 1 }; - -let failed = 0; - -for (const [name, content] of Object.entries(templates)) { - const issues = lintAccessibility(content).filter( - (i) => SEVERITY_RANK[i.severity] >= SEVERITY_RANK.warning, - ); - if (issues.length === 0) { - console.log(`✔ ${name}: clean`); - continue; - } - failed++; - console.error(`✖ ${name}: ${issues.length} issue(s)`); - for (const issue of issues) { - const where = issue.blockId ? `block ${issue.blockId}` : "template"; - console.error(` [${issue.severity}] ${issue.ruleId} (${where}): ${issue.message}`); - } -} - -if (failed > 0) process.exit(1); -``` - -Mit `tsx scripts/lint-templates.ts` ausführen und in den CI-Workflow einhängen. Der Templatical-Playground macht genau das – siehe `apps/playground/scripts/lint-templates.ts` im Repo. - -## Eigene Schweregrad-Policy - -Ein Team möchte vielleicht in CI nur Errors, in der Entwicklung aber die volle Info-Stufe: - -```ts -const SEVERITIES = process.env.CI - ? ["error"] - : ["error", "warning", "info"]; - -const issues = lintAccessibility(content).filter((i) => - SEVERITIES.includes(i.severity), -); -``` - -## Eigene Regeln bauen - -Sie können eigene Walker mit denselben Primitiven komponieren, die das Paket mitbringt: - -```ts -import { walkBlocks, getContrastRatio } from "@templatical/quality"; - -walkBlocks(content, (block, ctx) => { - if (block.type === "title" && block.color) { - const ratio = getContrastRatio(block.color, ctx.resolvedBackgroundColor); - if (ratio < 4.5) { - console.warn(`Heading ${block.id} contrast ${ratio.toFixed(2)}:1`); - } - } -}); -``` - -`walkBlocks` löst pro Block den nächstgelegenen opaken Vorfahren-Hintergrund auf – Kontrastprüfungen "funktionieren einfach", ohne dass Sie die Section-/Column-Traversierung neu implementieren müssen. - -Soll Ihre eigene Regel mit den Built-ins zusammenspielen (Schweregrad-Overrides, lokalisierte Meldungen, der Editor-Sidebar), implementieren Sie das `Rule`-Interface – `block` / `template` geben einen `RuleHit` zurück (`blockId`, optionale `params`, optionaler `fix`), und der Orchestrator kombiniert ihn mit den `meta`-Daten der Regel und dem Meldungs-Template der aktiven Locale. diff --git a/apps/docs/de/quality/accessibility/index.md b/apps/docs/de/quality/accessibility/index.md index 9bb8bb0b..e29767b1 100644 --- a/apps/docs/de/quality/accessibility/index.md +++ b/apps/docs/de/quality/accessibility/index.md @@ -1,93 +1,38 @@ # Barrierefreiheits-Linter -Der Barrierefreiheits-Linter ist die erste Funktion in [`@templatical/quality`](../). Es ist ein MIT-lizenzierter Barrierefreiheits-Prüfer für Templatical-E-Mail-Templates, der auf dem JSON-`TemplateContent`-Blockbaum arbeitet, im Browser oder in Node.js läuft und ohne Vue- oder DOM-Abhängigkeiten ausgeliefert wird – dasselbe Paket validiert Templates also sowohl im Editor als auch als CI-Gate auf gespeicherten Fixtures. +`lintAccessibility(content, options?)` ist der Barrierefreiheits-Checker in [`@templatical/quality`](../). Er arbeitet auf dem JSON-`TemplateContent`-Blockbaum, läuft im Browser oder in Node.js und kommt ohne Vue- oder DOM-Abhängigkeiten aus — dasselbe Paket validiert Templates im Editor und als CI-Gate für gespeicherte Fixtures. ## Warum -Barrierefreiheit bei E-Mails ist tatsächlich unterversorgt. Die meisten Builder verstecken Barrierefreiheit hinter einer Bezahlschranke, führen oberflächliche Tonalitätsprüfungen durch oder lassen sie ganz weg. Wir fangen die Autorenfehler ab, die täglich wiederkehren: +E-Mail-Barrierefreiheit ist tatsächlich unterversorgt. Die meisten Builder verstecken sie hinter Paywalls, prüfen nur oberflächlich auf Tonalität oder ignorieren sie ganz. Wir erwischen die Autorenfehler, die sich täglich wiederholen: -- Fehlender oder dateiname-artiger Alt-Text -- Text und Buttons mit zu geringem Kontrast -- Vage Link- / CTA-Texte ("hier klicken", "mehr lesen") -- Übersprungene Überschriftenebenen, die das Dokumentgerüst zerstören -- Winziger Fließtext, überdimensionierte Großbuchstaben-Blöcke, zu kleine Touch-Ziele +- Fehlender oder dateinamenartiger Alt-Text +- Niedriger Kontrast bei Text und Buttons +- Vage Link- / CTA-Texte („hier klicken", „mehr erfahren") +- Übersprungene Überschriften-Ebenen, die das Dokumentgliederungs-Outline brechen +- Zu kleiner Fließtext, übergroße Großbuchstaben-Blöcke, zu kleine Touch-Ziele - `target="_blank"`-Links ohne `rel="noopener"` - Dekorative Bilder mit übrig gebliebenem Alt-Text -Probleme erkennen, während Sie schreiben – nicht erst, nachdem Empfänger kaputten Alt-Text, unleserlichen Kontrast oder vage CTAs sehen. Jede Regel reagiert auf eine klare, benannte Bedingung; die Ausgabe ist vorhersehbar und bleibt es, während sich Templates weiterentwickeln. Dieselben Prüfungen decken sich mit dem European Accessibility Act (durchsetzbar ab Juni 2025). +Probleme beim Schreiben fangen, nicht erst nachdem Empfänger kaputten Alt-Text, unlesbaren Kontrast oder vage CTAs sehen. Jede Regel feuert auf eine klare, benannte Bedingung — die Ausgabe ist vorhersehbar und bleibt es, während Templates wachsen. Dieselben Checks decken sich mit dem European Accessibility Act (durchsetzbar ab Juni 2025). -## Architektur +## API - - - - - - - - - TemplateContent - JSON-Blockbaum - aus Editor oder DB - - - - - lintAccessibility() - Block-Regeln - + Template-Regeln - - - - - A11yIssue[] - Schweregrad · Meldung · - blockId · optionaler Fix - - - Verwendet von - - - Sidebar-Panel - im Editor - - Canvas-Badges - Symbol pro Block - - Headless / CI - gespeicherte Templates - +```ts +import { lintAccessibility } from "@templatical/quality"; -Das Paket macht keine Vorgaben zur UI. Das `useAccessibilityLint`-Composable des Editors lädt `@templatical/quality` per Lazy-Import, entprellt das erneute Linten bei Inhaltsänderungen und schleust `applyFix(issue)` durch den vorhandenen Block-Update-Pfad des Editors – Korrekturen landen also als saubere Undo-Einträge. - -## Installation - -::: code-group -```bash [npm] -npm install @templatical/quality -``` -```bash [pnpm] -pnpm add @templatical/quality -``` -```bash [yarn] -yarn add @templatical/quality -``` -```bash [bun] -bun add @templatical/quality +const issues = lintAccessibility(content, options?); +// issues: LintIssue[] — jeder Eintrag hat eine ruleId, die mit "a11y." beginnt ``` -::: -Das Paket ist ein **optionaler Peer** von `@templatical/editor`. Installieren Sie es, um den Sidebar-Tab und die Canvas-Badges zu aktivieren. Lassen Sie es weg, bleibt der Editor schlank – der dynamische Import ist gegated und tree-shakeable, sodass der Linter-Chunk nie heruntergeladen wird. +Die Funktion nimmt ein `TemplateContent` und ein optionales [`LintOptions`](../options)-Objekt. Sie liefert ein flaches Array von `LintIssue`-Objekten mit `ruleId`, `severity`, `message`, `blockId` und optional einem `fix`-Patch. -::: tip CDN-Nutzer -Wenn Sie Templatical per CDN laden, gibt es nichts zu installieren. Das CDN-Bundle des Editors liefert `@templatical/quality` als separaten code-split-Chunk aus, der automatisch per Lazy-Load geladen wird, sobald der Linter aktiv ist. -::: +Im Editor lädt das `useTemplateLint`-Composable `@templatical/quality` per dynamischem Import, entprellt das Re-Linting bei Inhaltsänderungen und routet `applyFix(issue)` über den Block-Update-Pfad des Editors — Fixes landen so als ordentliche Undo-Einträge. Barrierefreiheits-Issues erscheinen im **Issues**-Sidebar-Tab neben Struktur-Issues. ## Schnellzugriff -- [Erste Schritte](./getting-started) – erster Lint-Aufruf (Headless), Anbindung an den Editor. -- [Regelkatalog](./rule-catalog) – jede Regel mit Schweregrad, Begründung und Beispielen. -- [Optionen](./options) – `disabled`, `locale`, `rules`, `thresholds`. -- [Schweregrad & Korrekturen](./severity-and-fixes) – wie das Schweregradmodell funktioniert und wie Auto-Fix-Patches angewendet werden. -- [Headless-Nutzung](./headless-usage) – gespeicherte Templates in CI validieren. -- [Lokale beitragen](./contributing-locales) – Vague-Text-Wörterbücher für neue Sprachen ergänzen. +- [Regelkatalog](./rule-catalog) — jede Barrierefreiheits-Regel mit Schweregrad, Begründung und Beispiel. +- [Optionen](../options) — geteilt über beide Linter. +- [Schweregrade & Fixes](../severity-and-fixes) — wie das Schweregrad-Modell funktioniert und wie Auto-Fix-Patches angewendet werden. +- [Headless-Nutzung](../headless-usage) — Validierung gespeicherter Templates in CI. +- [Lokalen beitragen](../contributing-locales) — Regelnachrichten + Vague-Text-Dictionaries hinzufügen. diff --git a/apps/docs/de/quality/accessibility/options.md b/apps/docs/de/quality/accessibility/options.md deleted file mode 100644 index 8c09ea7f..00000000 --- a/apps/docs/de/quality/accessibility/options.md +++ /dev/null @@ -1,92 +0,0 @@ -# Optionen - -Die vollständige `A11yOptions`-Form, alle Felder optional: - -```ts -interface A11yOptions { - disabled?: boolean; - locale?: string; - rules?: Record; - thresholds?: Partial; -} - -type Severity = "error" | "warning" | "info" | "off"; -``` - -## `disabled` - -| Standard | `false` | -|---|---| - -Bei `true`: - -- Der Editor **importiert `@templatical/quality` nicht per Lazy-Load** – sein Chunk wird nie geladen. -- Der Sidebar-Tab für Barrierefreiheit wird **nicht registriert**. -- Die Inline-Canvas-Badges erzeugen **kein DOM**. - -Verwenden Sie das, wenn ein Mandant sich explizit ausklingt oder wenn Sie das OSS-Default-Bundle minimal halten wollen. Es gibt kein Soft-Disable – `disabled: true` ist eine vollständige, pro Instanz unumkehrbare Abschaltung. - -## `locale` - -| Standard (Headless) | `'en'` | -|---|---| -| Editor | folgt stets `init({ locale })` | - -Steuert die Meldungstexte, die der Linter zurückgibt (`messages/{locale}.ts`), und wird von den lokalisierungsabhängigen Regeln verwendet (`link-vague-text`, `button-vague-label`, `img-linked-no-context`). Fällt auf `en` zurück, wenn die Locale (oder ihre Basissprache) nicht mitgeliefert wird. - -```ts -// Headless – explizit setzen -lintAccessibility(content, { locale: "de" }); - -// Editor – Linter folgt automatisch der Editor-Locale -init({ locale: "de" }); -``` - -::: warning Editor-Modus ignoriert `accessibility.locale` -Im Editor-Modus wird die Linter-Locale auf das `locale` aus `init({ locale })` **erzwungen**. `accessibility.locale` zu setzen hat keine Wirkung – es wird auf dem Weg überschrieben. - -Headless-Aufrufer (`lintAccessibility(...)` direkt) behalten die volle Kontrolle. -::: - -::: tip Vague-Text-Wörterbücher sind sprachenübergreifend -Das Wörterbuch ist eine Vereinigung aller registrierten Locales – ein deutschsprachiges E-Mail-Template mit einem englischen `Click here`-Button löst also weiterhin `link-vague-text` / `button-vague-label` aus, und ein deutsches `Jetzt kaufen` als Alt-Text eines verlinkten Bildes erfüllt den Action-Hint-Check von `img-linked-no-context` auch in einem englischsprachigen Template. Die `locale`-Option steuert das Matching nicht; sie steuert nur den Meldungstext. -::: - -## `rules` - -| Standard | `{}` | -|---|---| - -Pro-Regel-Schweregrad-Override. Setzen Sie eine Regel auf `'off'`, um sie ganz zu deaktivieren. Setzen Sie sie auf einen anderen Schweregrad, um die Standard-Klassifikation zu beugen: - -```ts -{ - "img-missing-alt": "warning", // abschwächen - "text-all-caps": "off", // deaktivieren - "missing-preheader": "warning", // von info → warning hochstufen -} -``` - -Das Override greift, bevor die Regel läuft – deaktivierte Regeln werden also nicht einmal ausgeführt. Standard-Schweregrade siehe [Regelkatalog](./rule-catalog). - -## `thresholds` - -| Standard | siehe unten | -|---|---| - -Numerische Stellschrauben, die einige Regeln konsultieren: - -| Schwellwert | Standard | Verwendet von | -|---|---|---| -| `altMaxLength` | `125` | `img-alt-too-long` | -| `minFontSize` | `14` | `text-too-small` | -| `allCapsMinLength` | `20` | `text-all-caps` | -| `minTouchTargetPx` | `44` | `button-touch-target` | - -Einen Wert überschreiben, ohne die anderen zu verlieren – partielles Mergen ist eingebaut: - -```ts -lintAccessibility(content, { - thresholds: { minFontSize: 16 }, -}); -``` diff --git a/apps/docs/de/quality/accessibility/rule-catalog.md b/apps/docs/de/quality/accessibility/rule-catalog.md index 58d7f527..9cd2ee0e 100644 --- a/apps/docs/de/quality/accessibility/rule-catalog.md +++ b/apps/docs/de/quality/accessibility/rule-catalog.md @@ -1,53 +1,52 @@ -# Regelkatalog +# Regelkatalog Barrierefreiheit -Die 19 Regeln, die `@templatical/quality` mitliefert, gruppiert nach Prüfbereich. Jede Regel liegt in `packages/quality/src/accessibility/rules/`; Schweregrad, Meldungstexte und Wörterbücher sind pro Regel über die [Optionen](./options) anpassbar. +Die 19 Regeln, die `lintAccessibility` mitliefert, gruppiert nach Prüfbereich. Jede Regel liegt in `packages/quality/src/accessibility/rules/`; Schweregrad, Meldungstexte und Wörterbücher sind pro Regel über die [Optionen](../options) anpassbar. ## Bilder -| Regel | Standard-Schweregrad | Auto-Fix | Was geprüft wird | +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | |---|---|---|---| -| `img-missing-alt` | error | – | Fehlender Alt-Text – Screenreader sagen einen undefinierten oder leeren Alt-Text als Dateinamen an oder überspringen das Bild ganz. E-Mail-Clients blockieren Bilder zudem standardmäßig; Alt-Text ist das, was 30–50 % der Empfänger zuerst sehen. [1](https://www.w3.org/WAI/tutorials/images/) | -| `img-alt-is-filename` | warning | ja | Alt-Text sieht aus wie ein Dateiname – Dateinamen wie 'IMG_1234.jpg' oder 'Screen Shot 2026.png' tragen keine Bedeutung. Ersetzen Sie ihn durch eine kurze Beschreibung des Bildinhalts. | -| `img-alt-too-long` | warning | – | Alt-Text ist zu lang – Screenreader pausieren innerhalb des Alt-Textes nicht. Lange Alt-Strings werden zu einer Sprechwand. Bleiben Sie unter ~125 Zeichen; zusätzlichen Kontext in den umgebenden Text legen. | -| `img-decorative-needs-empty-alt` | info | ja | Dekoratives Bild hat Alt-Text – Dekorative Bilder sollten von Screenreadern übersprungen werden. `alt=''` (leer) signalisiert diese Absicht. Nicht-leerer Alt-Text auf einem dekorativen Bild ist ein Widerspruch. | -| `img-linked-no-context` | warning | – | Verlinktes Bild ohne Zielkontext – Wenn ein Bild zugleich Link ist, dient der Alt-Text als Linktext. Beschreibt er nur das Bild, raten die Nutzer, wohin der Link führt. | +| `a11y.img-missing-alt` | error | — | Fehlender Alt-Text — Screenreader sagen einen undefinierten oder leeren Alt-Text als Dateinamen vor oder überspringen das Bild ganz. E-Mail-Clients blockieren Bilder zudem oft standardmäßig; Alt-Text ist das, was 30–50 % der Empfänger zuerst sehen. [1](https://www.w3.org/WAI/tutorials/images/) | +| `a11y.img-alt-is-filename` | warning | ja | Alt-Text wirkt wie ein Dateiname — Dateinamen wie 'IMG_1234.jpg' oder 'Screen Shot 2026.png' tragen keine sinnvolle Information. Ersetze sie durch eine kurze Beschreibung dessen, was das Bild vermittelt. | +| `a11y.img-alt-too-long` | warning | — | Alt-Text ist zu lang — Screenreader machen innerhalb des Alt-Textes keine Pausen. Lange Texte werden zu einer Sprachwand. Bleibe unter ca. 125 Zeichen; zusätzlichen Kontext in den Fließtext auslagern. | +| `a11y.img-decorative-needs-empty-alt` | info | ja | Dekoratives Bild hat Alt-Text — Dekorative Bilder sollten von Screenreadern übersprungen werden. Ein leerer Alt-Text (alt='') signalisiert genau das. Ein nicht leerer Alt-Text auf einem dekorativen Bild widerspricht sich. | +| `a11y.img-linked-no-context` | warning | — | Verlinktes Bild ohne Zielkontext — Wenn ein Bild zugleich Link ist, fungiert der Alt-Text als Linktext. Wer nur das Bild beschreibt, lässt Nutzer im Unklaren über das Ziel. | ## Überschriften -| Regel | Standard-Schweregrad | Auto-Fix | Was geprüft wird | +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | |---|---|---|---| -| `heading-empty` | error | – | Überschrift ohne Text – Leere Überschriften erzeugen stille Landmarken für Screenreader-Nutzer, die per Überschrift navigieren. Entweder Text ergänzen oder den Block entfernen. | -| `heading-skip-level` | error | – | Überschriftenebene übersprungen – Überspringen von Ebenen (z. B. H1 → H3) zerstört das Dokumentgerüst, auf das assistive Technologien zur Navigation angewiesen sind. Immer nur eine Ebene weiter. | -| `heading-multiple-h1` | warning | – | Mehrere H1-Überschriften – Eine E-Mail sollte eine einzige H1 haben, die die Nachricht benennt. Mehrere H1 verwirren das Dokumentgerüst und schwächen die Landmark-Navigation. | +| `a11y.heading-empty` | error | — | Überschrift ohne Text — Leere Überschriften erzeugen stille Landmarks für Screenreader-Nutzer, die per Überschrift navigieren. Füge Text hinzu oder entferne den Block. | +| `a11y.heading-skip-level` | error | — | Überschriften-Ebene übersprungen — Sprünge in der Ebene (z. B. H1 → H3) brechen die Dokumentgliederung, auf die Hilfstechnologien zur Navigation angewiesen sind. Eine Ebene pro Schritt. | +| `a11y.heading-multiple-h1` | warning | — | Mehrere H1-Überschriften — Eine E-Mail sollte genau eine H1 haben, die die Nachricht benennt. Mehrere H1s verwirren die Gliederung und schwächen die Landmark-Navigation. | ## Links -| Regel | Standard-Schweregrad | Auto-Fix | Was geprüft wird | +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | |---|---|---|---| -| `link-empty` | error | – | Link ohne zugänglichen Text – Ein Link ohne sichtbaren Text und ohne verschachteltes Bild mit Alt-Text ist für Screenreader unsichtbar und für viele Nutzer nicht klickbar. | -| `link-vague-text` | warning | – | Vager Linktext – Phrasen wie "hier klicken" oder "mehr lesen" sagen Screenreader-Nutzern nichts, wenn sie aus dem Kontext gelistet werden. Verwenden Sie beschreibenden Linktext, der das Ziel benennt. Äußere Satzzeichen und dekorative Symbole werden vor dem Abgleich entfernt; `Hier klicken!`, `→ hier klicken` und `»hier klicken«` werden also alle erkannt. | -| `link-href-empty` | error | – | Link mit leerem href – Ein Anker ohne Ziel (leeres href oder '#') ist defekt – Empfänger klicken und nichts passiert, oder die Seite springt nach oben. | -| `link-target-blank-no-rel` | warning | ja | `target="_blank"` ohne `rel="noopener"` – Links, die in einem neuen Tab öffnen und kein `rel='noopener'`/`rel='noreferrer'` setzen, lassen das Ziel über `window.opener` die Ursprungsseite manipulieren. Eine kleine, aber reale Sicherheits-/Privatsphäre-Falle. | +| `a11y.link-empty` | error | — | Link ohne barrierefreien Text — Ein Link ohne sichtbaren Text und ohne ein verschachteltes Bild mit Alt-Text ist für Screenreader unsichtbar und für viele Nutzer nicht klickbar. | +| `a11y.link-vague-text` | warning | — | Vager Linktext — Phrasen wie „hier klicken" oder „mehr erfahren" sagen Screenreader-Nutzern im Kontextverzeichnis nichts. Verwende beschreibenden Linktext, der das Ziel benennt. Äußere Interpunktion und dekorative Symbole werden vor dem Vergleich entfernt; `hier klicken!`, `→ hier klicken` und `»hier klicken«` lösen also alle aus. | +| `a11y.link-href-empty` | error | — | Link mit leerem href — Ein Anchor ohne Ziel (leeres href oder '#') ist defekt — Empfänger klicken, und es passiert nichts, oder die Seite springt nach oben. | +| `a11y.link-target-blank-no-rel` | warning | ja | target="_blank" ohne rel="noopener" — Links, die in einem neuen Tab öffnen und kein rel='noopener' oder rel='noreferrer' haben, lassen das Ziel auf window.opener zugreifen und an der Ursprungs­seite manipulieren. Eine kleine, aber reale Sicherheits-/Datenschutz-Falle. | ## Text -| Regel | Standard-Schweregrad | Auto-Fix | Was geprüft wird | +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | |---|---|---|---| -| `text-all-caps` | warning | – | Großbuchstaben im Fließtext – Längere Großbuchstaben-Passagen werden von manchen Screenreadern Buchstabe für Buchstabe gelesen und verlangsamen das visuelle Lesen um 10–20 %. Verwenden Sie Groß-/Kleinschreibung im Fließtext; Großbuchstaben nur für kurze Labels. | -| `text-low-contrast` | error | – | Überschriftenkontrast zu niedrig – WCAG AA verlangt 4,5:1 für Fließtext und 3:1 für großen Text (18pt / ~24px). Überschriften ≥24px (H1, H2) bekommen den entspannten 3:1-Schwellwert; H3 (22px) und H4 (18px) erfordern 4,5:1. Die Lockerung für Fettschrift wird nicht angewendet – TipTap legt Fett inline im HTML ab, nicht als strukturiertes Feld. Darunter wird der Text für sehbehinderte Nutzer und bei hellem Außenlicht unleserlich. Es werden nur Title-Blöcke geprüft; die Farbe von Absätzen liegt im Inline-HTML. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | -| `text-too-small` | warning | – | Text zu klein – Fließtext unter 14px wird auf Mobilgeräten schwer lesbar. Manche Clients zoomen oder skalieren kleine Schriften zudem unvorhersehbar. Bleiben Sie bei 14px oder größer. | +| `a11y.text-all-caps` | warning | — | Fließtext in Großbuchstaben — Lange Großbuchstabenstrecken werden von manchen Screenreadern Buchstabe für Buchstabe vorgelesen und verlangsamen visuelles Lesen um 10–20 %. Für Fließtext Satzschreibweise verwenden; Großbuchstaben für kurze Labels reservieren. | +| `a11y.text-low-contrast` | error | — | Überschrift hat zu wenig Kontrast — WCAG AA verlangt 4,5:1 für Fließtext und 3:1 für großen Text (18pt / ~24px). Überschriften ≥24px (H1, H2) bekommen den entspannten 3:1-Schwellwert; H3 (22px) und H4 (18px) benötigen 4,5:1. Die Bold-Text-Entspannung wird nicht angewendet — TipTap speichert Fettdruck inline im HTML, nicht als strukturiertes Feld. Unterhalb dieser Werte wird Text für sehbehinderte Nutzer und bei hellem Außenlicht unleserlich. Nur Title-Blöcke werden geprüft; Paragraph-Farben stehen im inline HTML. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | +| `a11y.text-too-small` | warning | — | Text zu klein — Fließtext unter 14px wird auf Mobilgeräten schwer lesbar. Manche Clients zoomen automatisch oder skalieren kleine Schriften unvorhersehbar. Bei 14px oder größer bleiben. | ## Buttons -| Regel | Standard-Schweregrad | Auto-Fix | Was geprüft wird | +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | |---|---|---|---| -| `button-vague-label` | warning | – | Vages Button-Label – Ein Button mit "Hier klicken" oder "Senden" sagt nichts darüber, was passieren wird. Verwenden Sie handlungsorientierte Labels, die den Ausgang benennen ("Ticket kaufen", "Passwort zurücksetzen"). Gleiche Behandlung äußerer Satzzeichen wie bei `link-vague-text` – `Senden!`, `→ OK` und `»klick«` werden alle erkannt. | -| `button-touch-target` | warning | – | Touch-Ziel des Buttons zu klein – WCAG 2.5.5 (AAA) und die UX-Richtlinien von Apple/Google empfehlen mindestens 44×44px. Kleinere Buttons führen mobil zu Fehl-Taps. | -| `button-low-contrast` | error | – | Button-Textkontrast zu niedrig – Gleiche WCAG-AA-Schwellen wie bei `text-low-contrast`: 4,5:1 normal, 3:1 bei Buttons mit `fontSize >= 24` (WCAG-Großtext). Standard-Buttons (15px) verlangen den strikten Wert. Buttons, die das nicht erfüllen, sind für sehbehinderte Nutzer und bei hellem Außenlicht unleserlich. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | +| `a11y.button-vague-label` | warning | — | Vage Button-Beschriftung — Ein Button mit „Hier klicken" oder „Absenden" sagt dem Nutzer nichts darüber, was passieren wird. Handlungsorientierte Beschriftungen benennen das Ergebnis („Ticket kaufen", „Passwort zurücksetzen"). Gleiche Behandlung der äußeren Interpunktion wie bei `a11y.link-vague-text` — `Absenden!`, `→ OK` und `»klick«` lösen alle aus. | +| `a11y.button-touch-target` | warning | — | Button-Tippfläche zu klein — WCAG 2.5.5 (AAA) und Apple-/Google-UX-Guidelines empfehlen Tippflächen von mindestens 44×44px. Kleinere Buttons führen auf Mobilgeräten zu Fehltipps. | +| `a11y.button-low-contrast` | error | — | Buttontext-Kontrast zu niedrig — Gleiche WCAG-AA-Schwellen wie `a11y.text-low-contrast`: 4,5:1 normalerweise, 3:1 für Buttons mit `fontSize >= 24` (WCAG-Großtext). Standardgroße Buttons (15px) benötigen die strikte Ratio. Buttons, die das nicht erreichen, werden für sehbehinderte Nutzer und in hellem Außenlicht unleserlich. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | -## Struktur +## Template-Ebene -| Regel | Standard-Schweregrad | Auto-Fix | Was geprüft wird | +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | |---|---|---|---| -| `missing-preheader` | info | – | Fehlender Preheader-Text – Der Preheader ist der Vorschau-Snippet, der in den meisten Postfächern neben der Betreffzeile erscheint. Ohne ihn sehen Empfänger ein Fragment der ersten Überschrift oder ein verirrtes Alt-Tag – eine vertane Chance, Kontext zu setzen. | - +| `a11y.missing-preheader` | info | — | Fehlender Preheader-Text — Der Preheader ist der Vorschau-Schnipsel, der in den meisten Postfächern neben der Betreffzeile erscheint. Ohne ihn sehen Empfänger ein Fragment der ersten Überschrift oder ein verirrtes Alt-Tag — eine verpasste Gelegenheit, Kontext zu setzen. | diff --git a/apps/docs/de/quality/accessibility/severity-and-fixes.md b/apps/docs/de/quality/accessibility/severity-and-fixes.md deleted file mode 100644 index ae93761d..00000000 --- a/apps/docs/de/quality/accessibility/severity-and-fixes.md +++ /dev/null @@ -1,52 +0,0 @@ -# Schweregrad & Korrekturen - -## Schweregradmodell - -Jede Regel emittiert ein `A11yIssue` mit einem von vier Schweregraden: - -| Schweregrad | Bedeutung | UI | -|---|---|---| -| `error` | Harter Barrierefreiheits-Fehler. Empfänger könnten von der Nachricht ausgeschlossen werden. | Roter Punkt auf dem Canvas, Gruppe "Fehler" in der Sidebar. | -| `warning` | Wahrscheinliches Problem – beheben, sofern Sie es nicht besser wissen. | Gelber Punkt, Gruppe "Warnungen". | -| `info` | Empfehlung; kein Defekt. | Kein Canvas-Badge, Gruppe "Info". | -| `off` | Override – deaktiviert die Regel komplett. | Nichts. | - -Der Schweregrad ist pro Regel über `options.rules` konfigurierbar – die Spalte "Severity" im Katalog ist nur der Standard. - -## Auto-Fix - -Manche Regeln liefern einen `fix`-Patch, den Nutzer per Klick in der Sidebar anwenden können. Jeder Patch implementiert: - -```ts -interface A11yPatch { - description: string; - apply: (ctx: A11yPatchContext) => void; -} - -interface A11yPatchContext { - updateBlock: (blockId: string, patch: Partial) => void; - updateSettings: (patch: Partial) => void; -} -``` - -Im Editor läuft `apply` durch den vorhandenen `editor.updateBlock` / `updateSettings`-Pfad – der vom History-Interceptor umhüllt ist – sodass jede Korrektur als eigener Undo-Eintrag landet. Nutzer können mit Cmd/Strg+Z eine Korrektur zurücknehmen, ohne umliegende Arbeit zu verlieren. - -Headless-Aufrufer können einen eigenen `A11yPatchContext` bauen und Patches programmatisch anwenden: - -```ts -import { lintAccessibility } from "@templatical/quality"; - -const issues = lintAccessibility(content); -const fixable = issues.filter((i) => i.fix); - -for (const issue of fixable) { - issue.fix!.apply({ - updateBlock: (id, patch) => mutateBlock(content, id, patch), - updateSettings: (patch) => Object.assign(content.settings, patch), - }); -} -``` - -## Welche Regeln liefern eine Korrektur? - -Siehe Spalte **Auto-Fix** im [Regelkatalog](./rule-catalog). Aktuell: `img-alt-is-filename`, `img-decorative-needs-empty-alt` und `link-target-blank-no-rel`. Auto-Fixes werden konservativ ergänzt – nur, wenn die richtige Antwort eindeutig ist. diff --git a/apps/docs/de/quality/contributing-locales.md b/apps/docs/de/quality/contributing-locales.md new file mode 100644 index 00000000..9fbc04fd --- /dev/null +++ b/apps/docs/de/quality/contributing-locales.md @@ -0,0 +1,105 @@ +# Lokalen beitragen + +`@templatical/quality` liefert lokal-aware Datensätze, getrennt nach Sprache: + +1. **Barrierefreiheits-Regelnachrichten** (`src/accessibility/messages/{locale}.ts`) — die Texte, die der Editor für jedes `a11y.*`-Issue zeigt. +2. **Vague-Text-Dictionaries** (`src/accessibility/dictionaries/{locale}.ts`) — Phrasenlisten, die von `a11y.link-vague-text`, `a11y.button-vague-label` und `a11y.img-linked-no-context` genutzt werden. +3. **Struktur-Regelnachrichten** (`src/structure/messages/{locale}.ts`) — Texte für jedes `structure.*`-Issue. + +Jede Datenmenge spiegelt die Locale-Auswahl des Editors. Der Struktur-Linter hat kein Vague-Text-Dictionary-Pendant — seine Regeln sind deterministisch und sprachunabhängig, nur der Nachrichtentext muss übersetzt werden. + +## Verzeichnisstruktur + +``` +packages/quality/src/accessibility/messages/ + en.ts ← Source of Truth (implizit typisiert) + de.ts ← annotiert mit `typeof en` + index.ts ← exportiert formatMessage(), getMessages() + +packages/quality/src/accessibility/dictionaries/ + en.ts + de.ts + index.ts ← exportiert getDictionary(), normalizeForMatch() + +packages/quality/src/structure/messages/ + en.ts ← Source of Truth + de.ts ← annotiert mit `typeof en` + index.ts ← exportiert formatStructureMessage(), getStructureMessages() +``` + +## Eine Locale hinzufügen + +Du brauchst **drei** Dateien (oder zwei, falls du das Vague-Text-Dictionary auslässt): eine Message-Map pro Linter und ein Dictionary. Dateien ablegen — sie werden automatisch erkannt. Jede Locale-Registry wird zur Compile-Zeit per `import.meta.glob` gebaut, es gibt keine Map zu pflegen. + +Folge dem `typeof en`-Muster in jeder Datei. Die Annotation ist der Vertrag: jeder fehlende Key, jeder zusätzliche Key oder jeder falsche Typ lässt `pnpm run typecheck` scheitern. Laufzeit-Paritätstests prüfen zusätzlich, dass `{name}`-Platzhalter über Locales hinweg übereinstimmen. + +### 1. Barrierefreiheits-Regelnachrichten + +`accessibility/messages/.ts` ablegen und jeden Wert übersetzen — `{name}`-Platzhalter exakt beibehalten: + +```ts +// accessibility/messages/pt.ts +import type en from "./en"; + +const pt: typeof en = { + "a11y.img-missing-alt": + "Imagem sem texto alternativo. Adicione uma descrição curta ou marque a imagem como decorativa.", + "a11y.img-alt-too-long": + "Texto alternativo tem {length} caracteres; mantenha abaixo de {max}.", + // …ein Key pro Barrierefreiheits-Regel +}; + +export default pt; +``` + +### 2. Vague-Text-Dictionary + +`accessibility/dictionaries/.ts` ablegen: + +```ts +// accessibility/dictionaries/pt.ts +import type en from "./en"; + +const pt: typeof en = { + vagueLinkText: ["clique aqui", "aqui", "leia mais", "saiba mais"], + vagueButtonLabels: ["clique aqui", "clique", "enviar"], + linkedImageActionHints: ["compre", "leia", "veja", "baixe", "descubra"], +}; + +export default pt; +``` + +### 3. Struktur-Regelnachrichten + +`structure/messages/.ts` ablegen: + +```ts +// structure/messages/pt.ts +import type en from "./en"; + +const pt: typeof en = { + "structure.duplicate-block-id": + "ID de bloco aparece {count} vezes na árvore. Cada bloco precisa ter um ID único.", + "structure.section-column-mismatch": + 'Seção usa layout "{layout}" (espera {expected} colunas) mas tem {actual}. Estado corrompido.', + // …ein Key pro Struktur-Regel +}; + +export default pt; +``` + +Das war's — `SUPPORTED_MESSAGE_LOCALES`, `SUPPORTED_DICTIONARY_LOCALES` und `SUPPORTED_STRUCTURE_MESSAGE_LOCALES` reflektieren die neue Locale automatisch. Keine Registry zu editieren, kein Test zu aktualisieren. + +## Phrasen-Richtlinien (Vague-Text-Dictionary) + +- **Match, nicht Regex.** Die Vague-Text-Regeln normalisieren den Anchor- / Button-Text — kleinschreiben, Whitespace zusammenfassen, führende/abschließende nicht-alphanumerische Zeichen (Interpunktion, Pfeile, dekorative Anführungszeichen) entfernen — und testen dann `phrases.includes(text)`. „Click here!", „→ click here" und „»click here«" kollabieren also alle zu `click here` und matchen denselben Dictionary-Eintrag. Füge keine Interpunktionsvarianten hinzu — sie sind redundant. Jeder Eintrag ist trotzdem ein exakter Phrasen-Match; versuche nicht, Regex-Muster zu codieren. +- **Nur Kleinbuchstaben.** Der Vergleich ist auf der Input-Seite case-insensitive. +- **Häufig, nicht erschöpfend.** Ziel ist, die häufigsten vagen Phrasen zu erwischen, die Autoren der Sprache schreiben. Eine 50-Einträge-Liste richtet mehr Schaden an als Nutzen (False Positives). +- **Englische Phrasen nicht übersetzen.** Das Dictionary ist eine sprachübergreifende Vereinigung — die Phrasen jeder registrierten Locale matchen unabhängig von der aktiven `locale`-Option. Deine `pt.ts` braucht also nur portugiesische Phrasen; das englische `click here` ist über die Vereinigung bereits abgedeckt. +- **Keine Regions-Duplikate.** `de-AT` löst sich auf dieselbe Vereinigung auf; ein Eintrag pro Sprache. +- **`linkedImageActionHints` ist pro Token, nicht pro Phrase.** `a11y.img-linked-no-context` tokenisiert den Alt-Text an Nicht-Buchstaben/Ziffer-Grenzen und prüft jeden Token gegen die Hint-Liste. Trage **einzelne Action-Verben** in der Form ein, in der Autoren sie schreiben („buy", „kaufen", „compre") — nicht Mehrwort-Phrasen. „jetzt kaufen" wird nie matchen, weil Tokens einzeln geprüft werden. + +## Wie das Matching aufgelöst wird + +- **Vague-Text-Dictionary** — `getDictionary(locale)` liefert eine Vereinigung der Phrasen (und Action-Hints) aller registrierten Locales. Das `locale`-Argument wird der API-Symmetrie wegen akzeptiert, ändert aber aktuell nichts an der zurückgegebenen Menge — eine vage Phrase ist universell vage, und ein Action-Verb in einer beliebigen registrierten Sprache zählt als Link-Ziel-Kontext. Die Erkennung ist by design sprachübergreifend. +- **Regelnachrichten** — `formatMessage(locale, ruleId, params?)` (Barrierefreiheit) und `formatStructureMessage(locale, ruleId, params?)` (Struktur) lösen das lokalisierte Template über die jeweilige `messages/{locale}.ts`-Datei auf und interpolieren `{name}`-Platzhalter. Beide fallen auf Englisch zurück, wenn die Locale nicht gebündelt ist. diff --git a/apps/docs/de/quality/headless-usage.md b/apps/docs/de/quality/headless-usage.md new file mode 100644 index 00000000..18656070 --- /dev/null +++ b/apps/docs/de/quality/headless-usage.md @@ -0,0 +1,115 @@ +# Headless-Nutzung + +`@templatical/quality` ist ausschließlich JSON-basiert und hat keine DOM-Abhängigkeit — dieselben Linter laufen in jedem Node.js-Kontext: CI, Build-Pipelines, serverseitige Validierung, Batch-Jobs. + +Sowohl `lintAccessibility(content, options?)` als auch `lintStructure(content, options?)` liefern dieselbe `LintIssue[]`-Struktur — du kannst sie unabhängig aufrufen oder Ergebnisse zusammenführen. + +## Vor dem Speichern validieren + +Verweigere Template-JSON, das die Linter nicht besteht, dort wo es in dein System eintritt — CMS-Save-Handler, API-Endpunkt, Ingestion-Job: + +```ts +import { lintAccessibility, lintStructure } from "@templatical/quality"; +import type { TemplateContent } from "@templatical/types"; + +export function assertValid(content: TemplateContent): void { + const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), + ]; + const errors = issues.filter((i) => i.severity === "error"); + if (errors.length > 0) { + throw new Error( + `Template scheitert an Qualitätsprüfungen:\n${errors + .map((e) => ` [${e.ruleId}] ${e.message}`) + .join("\n")}`, + ); + } +} +``` + +`structure.*`-Fehler signalisieren typischerweise Datenkorruption (doppelte IDs, Layout/Children-Mismatch) und sollten ein Save immer blocken. `a11y.*`-Fehler sind Inhaltsqualität — hier kann eine weichere Policy sinnvoll sein. + +## CI-Schutz für gespeicherte Templates + +Wenn deine Anwendung `TemplateContent`-JSON in einer Datenbank speichert, lass die Linter in CI gegen jede Fixture laufen, damit Regressionen nicht durchrutschen: + +```ts +// scripts/lint-templates.ts +import { lintAccessibility, lintStructure } from "@templatical/quality"; +import { templates } from "../fixtures/templates"; + +const SEVERITY_RANK = { error: 3, warning: 2, info: 1 }; + +let failed = 0; + +for (const [name, content] of Object.entries(templates)) { + const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), + ].filter((i) => SEVERITY_RANK[i.severity] >= SEVERITY_RANK.warning); + + if (issues.length === 0) { + console.log(`OK ${name}: sauber`); + continue; + } + failed++; + console.error(`FAIL ${name}: ${issues.length} Problem(e)`); + for (const issue of issues) { + const where = issue.blockId ? `Block ${issue.blockId}` : "Template"; + console.error(` [${issue.severity}] ${issue.ruleId} (${where}): ${issue.message}`); + } +} + +if (failed > 0) process.exit(1); +``` + +Per `tsx scripts/lint-templates.ts` ausführen und in deinen CI-Workflow einbinden. Das Templatical-Playground macht genau das — siehe `apps/playground/scripts/lint-templates.ts` im Repo. + +## Nach Kategorie filtern + +Regel-IDs sind namensraum-getrennt (`a11y.*`, `structure.*`), also ist Gruppieren oder Filtern nach Linter ein `startsWith`-Check: + +```ts +const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), +]; +const a11y = issues.filter((i) => i.ruleId.startsWith("a11y.")); +const structural = issues.filter((i) => i.ruleId.startsWith("structure.")); +``` + +## Eigene Schweregrad-Policy + +Ein Team möchte in CI nur Fehler, aber in Entwicklung die volle Info-Ausgabe: + +```ts +const SEVERITIES = process.env.CI + ? ["error"] + : ["error", "warning", "info"]; + +const issues = lintAccessibility(content).filter((i) => + SEVERITIES.includes(i.severity), +); +``` + +## Eigene Regeln bauen + +Du kannst eigene Walker mit denselben Primitiven komponieren, die das Paket ausliefert: + +```ts +import { walkBlocks, getContrastRatio } from "@templatical/quality"; + +walkBlocks(content, (block, ctx) => { + if (block.type === "title" && block.color) { + const ratio = getContrastRatio(block.color, ctx.resolvedBackgroundColor); + if (ratio < 4.5) { + console.warn(`Überschrift ${block.id} Kontrast ${ratio.toFixed(2)}:1`); + } + } +}); +``` + +`walkBlocks` löst den nächsten opaken Vorfahren-Hintergrund pro Block auf — Kontrast-Checks „funktionieren einfach", ohne dass du die Section/Column-Traversierung neu implementieren musst. + +Wenn deine Custom-Regel beim Orchestrator mitmachen soll (Schweregrad-Overrides, lokalisierte Nachrichten, das Editor-Issues-Panel), implementiere das `Rule`-Interface — `block` / `template` liefern einen `RuleHit` (`blockId`, optionale `params`, optionaler `fix`), und der Orchestrator kombiniert ihn mit `meta` und dem Nachrichtentemplate der aktiven Locale. Dasselbe `runRules`-Helper-Modul betreibt sowohl `lintAccessibility` als auch `lintStructure`. diff --git a/apps/docs/de/quality/index.md b/apps/docs/de/quality/index.md index 6f29a293..f89b6457 100644 --- a/apps/docs/de/quality/index.md +++ b/apps/docs/de/quality/index.md @@ -1,7 +1,106 @@ # Qualität -`@templatical/quality` ist das Dachpaket für die Template-Qualitäts-Werkzeuge von Templatical. +`@templatical/quality` ist das Dachpaket für die Template-Qualitäts-Werkzeuge von Templatical — deterministische, ausschließlich JSON-basierte Linter, die Autorenfehler im Editor und in Headless- / CI-Prüfungen erkennen. MIT-lizenziert, ESM, kein Vue, kein DOM. -## Verfügbar +## Linter -- **[Barrierefreiheits-Linter](./accessibility/)** – deterministische Regeln, die häufige Autorenfehler erkennen (fehlender Alt-Text, niedriger Kontrast, vage CTAs, übersprungene Überschriftenebenen, …) – sowohl im Editor als auch bei Headless- / CI-Prüfungen. MIT-lizenziert, ausschließlich JSON, ESM. +| Linter | Was er erkennt | Standard-Schweregrade | +|---|---|---| +| **[Barrierefreiheit](./accessibility/)** | Fehlender Alt-Text, niedriger Kontrast, vage CTAs, übersprungene Überschriftenebenen, zu kleine Touch-Ziele, lange GROSSBUCHSTABEN, target=_blank ohne rel, fehlender Preheader, … | überwiegend error/warning | +| **[Struktur](./structure/)** | Doppelte Block-IDs, Sektionen mit falscher Spaltenanzahl, verschachtelte Sektionen, leere Sektionen, leere Spalten | überwiegend error; einige warning | + +Beide Linter liefern dieselbe `LintIssue`-Struktur und teilen sich dieselbe Optionsfläche (`LintOptions`) — Konsumenten können sie also in jeder Kombination ausführen, Ergebnisse zusammenführen und beim Gruppieren nach `ruleId`-Präfix (`a11y.*`, `structure.*`) filtern. + +## Architektur + + + + + + + + + TemplateContent + JSON-Blockbaum + aus Editor oder DB + + + lintAccessibility() + a11y.* Regeln + + lintStructure() + structure.* Regeln + + + LintIssue[] + Schweregrad · Nachricht · + blockId · optionaler fix + + Verwendet von + + Issues-Panel + Editor-Sidebar + + Canvas-Badges + Symbole pro Block + + Headless / CI + gespeicherte Templates + + +Das Paket trifft keine UI-Annahmen. Das `useTemplateLint`-Composable des Editors lädt `@templatical/quality` per dynamischem Import nach, ruft jeden exportierten Linter bei (entprellten) Inhaltsänderungen auf und führt die Ergebnisse in einen einzigen Issue-Strom zusammen, der den **Issues**-Sidebar-Tab und die Per-Block-Canvas-Badges speist. `applyFix(issue)` führt jeden Patch über den bestehenden Block-Update-Pfad des Editors aus — Fixes landen so als ordentliche Undo-Einträge. + +## Installation + +::: code-group +```bash [npm] +npm install @templatical/quality +``` +```bash [pnpm] +pnpm add @templatical/quality +``` +```bash [yarn] +yarn add @templatical/quality +``` +```bash [bun] +bun add @templatical/quality +``` +::: + +Das Paket ist ein **optionaler Peer** von `@templatical/editor`. Installiere es, um den Issues-Tab und die Canvas-Badges zu aktivieren. Lass es weg und der Editor bleibt schlank — der dynamische Import ist gegated und tree-shakeable, der Linter-Chunk wird nie geladen. + +::: tip CDN-Nutzer +Wenn du Templatical per CDN lädst, gibt es nichts zu installieren. Das Editor-CDN-Bundle liefert `@templatical/quality` als separat ausgelagerten Code-Split-Chunk aus, der automatisch nachgeladen wird, sobald Linting aktiv ist. +::: + +## Editor anbinden + +Übergib `lint` an `init()` oder `initCloud()`: + +```ts +import { init } from "@templatical/editor"; + +const editor = init({ + container: "#editor", + locale: "de", + lint: { + rules: { + "a11y.img-missing-alt": "warning", // von Standard 'error' herabstufen + "a11y.text-all-caps": "off", // komplett deaktivieren + "structure.empty-column": "info", // von warning auf info herabstufen + }, + thresholds: { minFontSize: 16 }, + }, +}); +``` + +Der Issues-Tab und die Canvas-Badges erscheinen automatisch, sobald der optionale Peer aufgelöst ist. Bei `lint.disabled === true` lädt der Editor das Paket gar nicht erst nach — kein Chunk-Download, keine UI. + +## Schnellzugriff + +- [Optionen](./options) — `disabled`, `locale`, `rules`, `thresholds` (von jedem Linter geteilt). +- [Schweregrade & Fixes](./severity-and-fixes) — Schweregrad-Modell + wie Auto-Fix-Patches im Editor landen. +- [Headless-Nutzung](./headless-usage) — Validierung gespeicherter Templates in CI / Server-Save-Handlern. +- [Lokalen beitragen](./contributing-locales) — Regel-Nachrichten + Vague-Text-Dictionaries hinzufügen. +- [Barrierefreiheits-Linter](./accessibility/) — was er erkennt, Regelkatalog. +- [Struktur-Linter](./structure/) — was er erkennt, Regelkatalog. diff --git a/apps/docs/de/quality/options.md b/apps/docs/de/quality/options.md new file mode 100644 index 00000000..4fcc4e5e --- /dev/null +++ b/apps/docs/de/quality/options.md @@ -0,0 +1,100 @@ +# Optionen + +`lintAccessibility` und `lintStructure` akzeptieren dieselbe `LintOptions`-Struktur. Jedes Feld ist optional. + +```ts +interface LintOptions { + disabled?: boolean; + locale?: string; + rules?: Record; + thresholds?: Partial; +} + +type Severity = "error" | "warning" | "info" | "off"; +``` + +Dasselbe Objekt steuert auch die `init({ lint })`-Konfiguration des Editors — jede Option hier wirkt sich über das geteilte `useTemplateLint`-Composable auf beide Linter aus. + +## `disabled` + +| Standard | `false` | +|---|---| + +Bei `true`: + +- Der Editor **lädt `@templatical/quality` nicht nach** — sein Chunk wird nie geladen. +- Der Issues-Sidebar-Tab wird **nicht registriert**. +- Die Canvas-Badges erzeugen **kein DOM**. + +Sinnvoll, wenn ein Mandant ausdrücklich verzichtet hat oder das Standard-OSS-Bundle möglichst klein bleiben soll. Es gibt kein Soft-Disable — `disabled: true` ist eine vollständige, pro Instanz nicht rückgängig zu machende Abschaltung. + +## `locale` + +| Standard (headless) | `'en'` | +|---|---| +| Editor | folgt immer `init({ locale })` | + +Steuert die Nachrichtentemplates des Linters (eine Datei pro Locale pro Linter) und wird von den lokal-aware Barrierefreiheits-Regeln genutzt (`a11y.link-vague-text`, `a11y.button-vague-label`, `a11y.img-linked-no-context`). Fällt auf `en` zurück, wenn die Locale (oder ihre Basissprache) nicht gebündelt ist. + +```ts +// Headless — explizit setzen +lintAccessibility(content, { locale: "de" }); +lintStructure(content, { locale: "de" }); + +// Editor — Linter folgt automatisch der Editor-Locale +init({ locale: "de" }); +``` + +::: warning Im Editor-Modus wird `lint.locale` ignoriert +Im Editor-Modus wird die Linter-Locale **erzwungen** auf den `locale`-Wert aus `init({ locale })`. `lint.locale` zu setzen, hat keinen Effekt — es wird auf dem Weg überschrieben. + +Headless-Aufrufer (`lintAccessibility(...)` / `lintStructure(...)` direkt) behalten volle Kontrolle. +::: + +::: tip Vague-Text-Dictionaries sind sprachübergreifend +Das Dictionary ist eine Vereinigung aller registrierten Locales — eine deutschsprachige E-Mail mit englischem `Click here`-Button schlägt `a11y.link-vague-text` / `a11y.button-vague-label` trotzdem aus, und ein deutsches `Jetzt kaufen`-Alt auf einem verlinkten Bild in englischer Locale erfüllt den Action-Hint-Check von `a11y.img-linked-no-context`. Die `locale`-Option gated das Matching nicht — sie steuert nur den Nachrichtentext. +::: + +## `rules` + +| Standard | `{}` | +|---|---| + +Schweregrad-Override pro Regel. Setze eine Regel auf `'off'`, um sie komplett zu deaktivieren. Setze sie auf einen anderen Schweregrad, um die Standardklassifikation zu verbiegen: + +```ts +{ + "a11y.img-missing-alt": "warning", // herabstufen + "a11y.text-all-caps": "off", // deaktivieren + "a11y.missing-preheader": "warning", // info → warning hochstufen + "structure.empty-column": "info", // warning → info herabstufen +} +``` + +Regel-IDs sind nach Linter namensraum-getrennt: `a11y.*` für Barrierefreiheit, `structure.*` für Struktur. Override-Schlüssel müssen die volle, mit Präfix versehene ID verwenden. + +Der Override greift, bevor die Regel läuft — deaktivierte Regeln werden also gar nicht erst ausgeführt. Standardschweregrade pro Regel: [Barrierefreiheit](./accessibility/rule-catalog) · [Struktur](./structure/rule-catalog). + +## `thresholds` + +| Standard | Siehe unten | +|---|---| + +Numerische Stellschrauben, die einige Barrierefreiheits-Regeln konsultieren. (Struktur-Regeln nutzen aktuell keine Thresholds.) + +| Threshold | Standard | Verwendet von | +|---|---|---| +| `altMaxLength` | `125` | `a11y.img-alt-too-long` | +| `minFontSize` | `14` | `a11y.text-too-small` | +| `allCapsMinLength` | `20` | `a11y.text-all-caps` | +| `minTouchTargetPx` | `44` | `a11y.button-touch-target` | + +Einen Wert überschreiben, ohne die anderen zu verlieren — partielles Merging ist eingebaut: + +```ts +lintAccessibility(content, { + thresholds: { minFontSize: 16 }, +}); +``` + +Die Konstante `DEFAULT_A11Y_THRESHOLDS` wird ebenfalls exportiert, falls du die Baseline programmatisch referenzieren willst. diff --git a/apps/docs/de/quality/severity-and-fixes.md b/apps/docs/de/quality/severity-and-fixes.md new file mode 100644 index 00000000..7d0d2f5c --- /dev/null +++ b/apps/docs/de/quality/severity-and-fixes.md @@ -0,0 +1,64 @@ +# Schweregrade & Fixes + +Beide Linter teilen sich dasselbe Schweregrad-Modell und dieselbe Patch-Struktur, daher behandelt diese Seite `lintAccessibility` und `lintStructure` gemeinsam. + +## Schweregrad-Modell + +Jede Regel emittiert ein `LintIssue` mit einem von vier Schweregraden: + +| Schweregrad | Bedeutung | UI | +|---|---|---| +| `error` | Harter Defekt. Empfänger könnten ausgeschlossen sein, oder das Template ist strukturell beschädigt. | Roter Punkt auf dem Canvas, „Fehler"-Gruppe im Issues-Panel. | +| `warning` | Wahrscheinliches Problem — beheben, außer du weißt es besser. | Gelber Punkt, „Warnungen"-Gruppe. | +| `info` | Empfehlung; kein Defekt. | Kein Canvas-Badge, „Hinweise"-Gruppe. | +| `off` | Override — deaktiviert die Regel komplett. | Nichts. | + +Schweregrade sind pro Regel über `options.rules` konfigurierbar — die dokumentierten Defaults sind nur die Basislinie. + +## Auto-Fix + +Einige Regeln liefern einen `fix`-Patch mit, den der Nutzer mit einem Klick aus dem Issues-Panel anwenden kann. Jeder Patch implementiert: + +```ts +interface LintPatch { + description: string; + apply: (ctx: LintPatchContext) => void; +} + +interface LintPatchContext { + updateBlock: (blockId: string, patch: Partial) => void; + updateSettings: (patch: Partial) => void; + removeBlock: (blockId: string) => void; +} +``` + +Im Editor läuft `apply` über den bestehenden `editor.updateBlock` / `editor.updateSettings` / `editor.removeBlock`-Pfad — der vom History-Interceptor umschlossen ist — sodass jeder Fix als eigener Undo-Eintrag landet. Nutzer können einen Fix per Cmd/Ctrl+Z rückgängig machen, ohne umliegende Arbeit zu verlieren. + +Headless-Aufrufer können einen eigenen `LintPatchContext` konstruieren und Patches programmatisch anwenden: + +```ts +import { lintAccessibility, lintStructure } from "@templatical/quality"; + +const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), +]; +const fixable = issues.filter((i) => i.fix); + +for (const issue of fixable) { + issue.fix!.apply({ + updateBlock: (id, patch) => mutateBlock(content, id, patch), + updateSettings: (patch) => Object.assign(content.settings, patch), + removeBlock: (id) => removeBlockFromTree(content, id), + }); +} +``` + +## Welche Regeln haben einen Fix? + +Siehe die **Auto-Fix**-Spalte in den jeweiligen Katalogen. Aktuell mit Auto-Fix: + +- Barrierefreiheit — `a11y.img-alt-is-filename`, `a11y.img-decorative-needs-empty-alt`, `a11y.link-target-blank-no-rel`. +- Struktur — `structure.empty-section`. + +Auto-Fixes werden konservativ ergänzt — nur wenn die richtige Antwort eindeutig ist. `structure.empty-column` hat z. B. keinen Auto-Fix, weil das Entfernen einer leeren Spalte das `columns`-Layout der Sektion ändern muss und die richtige Antwort (in Nachbar-Spalte zusammenführen vs. Sektion entfernen vs. Layout-Key ändern) von der Intention abhängt. diff --git a/apps/docs/de/quality/structure/index.md b/apps/docs/de/quality/structure/index.md new file mode 100644 index 00000000..bd1df531 --- /dev/null +++ b/apps/docs/de/quality/structure/index.md @@ -0,0 +1,37 @@ +# Struktur-Linter + +`lintStructure(content, options?)` ist der Datenintegritäts-Checker in [`@templatical/quality`](../). Er durchläuft den `TemplateContent`-Blockbaum und meldet Formen, die auf Korruption hindeuten — doppelte IDs, Sektionen, deren `columns`-Layout nicht zum `children`-Array passt, verschachtelte Sektionen (vom Renderer abgelehnt) und leere Sektionen / Spalten. + +## Warum + +Die meisten „Ist dieses Template OK?"-Werkzeuge kümmern sich um Inhaltsqualität (Alt-Text, Kontrast). Struktur-Regeln decken ein anderes Problem ab: **Kann dieses JSON überhaupt sauber rendern?** Importer (BeeFree, Unlayer, HTML) und serverseitige Custom-Editoren können Blöcke produzieren, die der Editor selbst nie erzeugen würde — verwaiste Spalten-Einträge, fehlende Block-Felder, Layout-/Children-Mismatches. Erreichen sie den Renderer, ist es meist zu spät, um sauber zu reagieren. + +Der Struktur-Linter fängt diese Probleme vor Save / vor Versand: + +- **Doppelte Block-IDs.** Baumtraversierung, Undo/Redo und Selection setzen alle voraus, dass IDs eindeutig sind. Eine doppelte ID beschädigt jede ID-basierte Operation lautlos. +- **Section-Column-Mismatch.** Eine Sektion mit `columns: "2-1"` erwartet `children.length === 2`. Hat `children` ein oder drei inner arrays, ist das Layout kaputt — meist ein UI-Bug oder ein veralteter Import. +- **Verschachtelte Sektion.** Der Renderer lehnt Sektionen innerhalb von Spalten ab. Landet eine dort, fällt sie lautlos aus dem MJML-Output. +- **Leere Sektion.** Eine Sektion ohne Blöcke rendert als leere Tabellenzeile — verschwendeter Whitespace, manchmal eine sichtbare Padding-Lücke. +- **Leere Spalte.** Eine Mehrspalten-Sektion mit einer leeren Spalte rendert in den meisten Clients unglücklich und bedeutet fast immer, dass der Autor weniger Spalten meinte. + +Diese Regeln sind deterministisch und sprachunabhängig — sie feuern auf JSON-Formen, nicht auf Phrasen. Nur der Nachrichtentext muss übersetzt werden. + +## API + +```ts +import { lintStructure } from "@templatical/quality"; + +const issues = lintStructure(content, options?); +// issues: LintIssue[] — jeder Eintrag hat eine ruleId, die mit "structure." beginnt +``` + +Gleiche Signatur wie `lintAccessibility`. Gleiche `LintOptions`-Struktur. Gleicher `LintIssue`-Rückgabewert. Du kannst beide Linter unabhängig aufrufen oder Ergebnisse zusammenführen. + +Im Editor lädt das `useTemplateLint`-Composable `@templatical/quality` per dynamischem Import und führt beide Linter bei jeder (entprellten) Inhaltsänderung aus. Struktur-Issues erscheinen im **Issues**-Sidebar-Tab neben Barrierefreiheits-Issues. + +## Schnellzugriff + +- [Regelkatalog](./rule-catalog) — jede Struktur-Regel mit Schweregrad, Begründung und Auto-Fix-Hinweis. +- [Optionen](../options) — geteilt über beide Linter. +- [Schweregrade & Fixes](../severity-and-fixes) — wie das Schweregrad-Modell funktioniert und wie Auto-Fix-Patches angewendet werden. +- [Headless-Nutzung](../headless-usage) — Validierung gespeicherter Templates in CI. diff --git a/apps/docs/de/quality/structure/rule-catalog.md b/apps/docs/de/quality/structure/rule-catalog.md new file mode 100644 index 00000000..c634dc61 --- /dev/null +++ b/apps/docs/de/quality/structure/rule-catalog.md @@ -0,0 +1,23 @@ +# Regelkatalog Struktur + +Die 5 Regeln, die `lintStructure` mitliefert. Jede Regel liegt in `packages/quality/src/structure/rules/`; Schweregrad ist pro Regel über die [Optionen](../options) anpassbar. + +## Baum-Integrität + +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | +|---|---|---|---| +| `structure.duplicate-block-id` | error | — | Zwei oder mehr Blöcke teilen sich dieselbe `id`. Baumtraversierung, Undo/Redo, Selection und jede ID-basierte Operation hängen von Eindeutigkeit ab — ein Duplikat beschädigt sie lautlos. Meist Zeichen für einen kaputten Import oder einen Clone-Pfad, der das Neugenerieren der IDs vergisst. | +| `structure.nested-section` | error | — | Ein Section-Block sitzt in der Spalte einer anderen Sektion. Der Renderer lehnt das ab — Sektionen können nicht verschachtelt werden — und die innere Sektion fällt lautlos aus dem MJML-Output. Erwischt Importer-Bugs und Copy-Paste-Unfälle. | + +## Section-Layout + +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | +|---|---|---|---| +| `structure.section-column-mismatch` | error | — | Der `columns`-Wert der Sektion impliziert eine Spaltenanzahl, die nicht zu `children.length` passt. `"1"` erwartet 1 inner array, `"2"`/`"2-1"`/`"1-2"` erwarten 2, `"3"` erwartet 3. Ein Mismatch heißt: entweder der Layout-Key oder das Children-Array ist falsch — beides liefert kaputten Render-Output. Die Section-Toolbar des Editors balanciert `children` automatisch beim Layoutwechsel; diese Regel erwischt Daten, die an der Toolbar vorbei kamen (Importe, manuelle JSON-Edits, alte Snapshots). | + +## Inhaltspräsenz + +| Regel | Standardschweregrad | Auto-Fix | Was geprüft wird | +|---|---|---|---| +| `structure.empty-section` | warning | ja | Eine Sektion hat keine Spalten, oder jede Spalte ist leer. Rendert in den meisten Clients als leere Tabellenzeile — verschwendeter Whitespace und gelegentlich eine sichtbare Padding-Lücke. Auto-Fix entfernt die Sektion. | +| `structure.empty-column` | warning | — | Eine Mehrspalten-Sektion hat mindestens eine Spalte ohne Blöcke. Rendert unglücklich (ungleichmäßige Lücken, kollabierte Spalten auf Mobilgeräten) und bedeutet fast immer, dass der Autor weniger Spalten meinte. Kein Auto-Fix, weil die richtige Antwort (in Nachbar-Spalte zusammenführen vs. Sektion entfernen vs. `columns`-Layout ändern) von der Intention abhängt. | diff --git a/apps/docs/getting-started/installation.md b/apps/docs/getting-started/installation.md index c2b16ec8..4a8cccf6 100644 --- a/apps/docs/getting-started/installation.md +++ b/apps/docs/getting-started/installation.md @@ -90,7 +90,7 @@ The editor lazy-loads four optional peers via dynamic `import()` at runtime, gat | Peer | When loaded | Install if you | | ---------------------------- | ------------------------------- | --------------------------------- | | `@templatical/renderer` | First call to `editor.toMjml()` | Need MJML export from the browser | -| `@templatical/quality` | Editor mount (a11y panel) | Want the accessibility sidebar | +| `@templatical/quality` | Editor mount (Issues panel) | Want accessibility + structure lint in the Issues sidebar | | `@templatical/media-library` | First open of the media browser | Use `initCloud()` | | `pusher-js` | Cloud realtime connect | Use `initCloud()` | diff --git a/apps/docs/quality/accessibility/contributing-locales.md b/apps/docs/quality/accessibility/contributing-locales.md deleted file mode 100644 index 254b628b..00000000 --- a/apps/docs/quality/accessibility/contributing-locales.md +++ /dev/null @@ -1,80 +0,0 @@ -# Contributing locales - -`@templatical/quality` ships **two** locale-aware data sets, both keyed by language: - -1. **Rule messages** (`src/accessibility/messages/{locale}.ts`) — the human-readable strings the editor sidebar renders for each issue. -2. **Vague-text dictionaries** (`src/accessibility/dictionaries/{locale}.ts`) — the phrase lists used by `link-vague-text`, `button-vague-label`, and `img-linked-no-context`. - -Both mirror the editor's locale set: every OSS locale supported by `@templatical/editor` should have a matching message map and dictionary. - -## File layout - -``` -packages/quality/src/accessibility/messages/ - en.ts ← source of truth (typed implicitly) - de.ts ← annotated `typeof en` - index.ts ← exports formatMessage(), getMessages() - -packages/quality/src/accessibility/dictionaries/ - en.ts - de.ts - index.ts ← exports getDictionary(), normalizeForMatch() -``` - -## Adding a locale - -You need **both** a message map and a dictionary file. Drop the files and they're picked up automatically — the locale registry is built at compile time via `import.meta.glob`, so there's no `MESSAGES` or `DICTIONARIES` map to update. - -Follow the same `typeof en` pattern for both files. The annotation is the contract: any missing key, extra key, or wrong type fails `pnpm run typecheck`. The runtime parity test (`tests/messages.test.ts`) additionally verifies that `{name}` placeholders match between locales for every key. - -### 1. Rule messages - -Drop `messages/.ts` and translate every value, preserving `{name}` placeholders exactly: - -```ts -// messages/pt.ts -import type en from "./en"; - -const pt: typeof en = { - "img-missing-alt": - "Imagem sem texto alternativo. Adicione uma descrição curta ou marque a imagem como decorativa.", - "img-alt-too-long": - "Texto alternativo tem {length} caracteres; mantenha abaixo de {max}.", - // …one key per rule -}; - -export default pt; -``` - -### 2. Vague-text dictionary - -Drop `dictionaries/.ts`: - -```ts -// dictionaries/pt.ts -import type en from "./en"; - -const pt: typeof en = { - vagueLinkText: ["clique aqui", "aqui", "leia mais", "saiba mais"], - vagueButtonLabels: ["clique aqui", "clique", "enviar"], - linkedImageActionHints: ["compre", "leia", "veja", "baixe", "descubra"], -}; - -export default pt; -``` - -That's it — `SUPPORTED_MESSAGE_LOCALES` and `SUPPORTED_DICTIONARY_LOCALES` reflect the new locale automatically. No registry edit, no test update. - -## Phrase guidelines - -- **Match, not regex.** The vague-text rules normalize the anchor / button text — lowercase, collapse whitespace, strip leading/trailing non-alphanumeric characters (punctuation, arrows, decorative quotes) — then test `phrases.includes(text)`. So `"Click here!"`, `"→ click here"`, and `"»click here«"` all collapse to `click here` and match the same dictionary entry. Don't add punctuation variants — they're redundant. Each entry is still an exact phrase match; don't try to encode regex patterns. -- **Lowercase only.** Comparison is case-insensitive on the input side. -- **Common, not exhaustive.** The point is to catch the most frequent vague phrases native authors fall into. A 50-entry list does more harm than good (false positives). -- **Don't translate English phrases.** The dictionary is a cross-locale union — every registered locale's phrases match regardless of the active `locale` option. So your `pt.ts` only needs Portuguese phrases; English `click here` is already covered by the union. -- **No region duplicates.** `de-AT` resolves to the same union; one entry per language. -- **`linkedImageActionHints` is per-token, not per-phrase.** `img-linked-no-context` tokenizes the alt text on non-letter/digit boundaries and checks each token against the hint list. Add **single action verbs** in the form authors actually write them ("buy", "kaufen", "compre"), not multi-word phrases — a phrase like `"jetzt kaufen"` will never match because tokens are checked individually. - -## How matching resolves - -- **Vague-text dictionary** — `getDictionary(locale)` returns a union of every registered locale's phrases (and action hints). The `locale` argument is accepted for API symmetry but currently doesn't change the returned set; a vague phrase is universally vague, and an action verb in any registered language counts as link-destination context, so detection is cross-locale by design. -- **Rule messages** — `formatMessage(locale, ruleId, params?)` resolves the localized message template via `messages/{locale}.ts` and interpolates `{name}` placeholders. Falls back to English when the locale isn't bundled. diff --git a/apps/docs/quality/accessibility/getting-started.md b/apps/docs/quality/accessibility/getting-started.md deleted file mode 100644 index 19c906d9..00000000 --- a/apps/docs/quality/accessibility/getting-started.md +++ /dev/null @@ -1,49 +0,0 @@ -# Getting started - -## Install - -::: code-group -```bash [npm] -npm install @templatical/quality -``` -```bash [pnpm] -pnpm add @templatical/quality -``` -```bash [yarn] -yarn add @templatical/quality -``` -```bash [bun] -bun add @templatical/quality -``` -::: - -## Wire into the editor - -Pass `accessibility` to `init()` or `initCloud()`: - -```ts -import { init } from "@templatical/editor"; - -const editor = init({ - container: "#editor", - locale: "en", - accessibility: { - rules: { - "img-missing-alt": "warning", // soften from default 'error' - "text-all-caps": "off", // turn off entirely - }, - thresholds: { - minFontSize: 16, - }, - }, -}); -``` - -The sidebar tab and inline canvas badges appear automatically once the optional peer is resolved. When `accessibility.disabled === true`, the editor never lazy-loads the package — no chunk download, no UI surface. - -## What's next - -- Browse the [rule catalog](./rule-catalog) to see every check. -- Tune severity, thresholds, and the `disabled` flag in [options](./options). -- Read [severity & fixes](./severity-and-fixes) to learn how auto-fix patches land in the editor. -- Need to lint outside the editor? See [headless usage](./headless-usage) for CI validation. diff --git a/apps/docs/quality/accessibility/index.md b/apps/docs/quality/accessibility/index.md index 7daa2943..33c1f8bc 100644 --- a/apps/docs/quality/accessibility/index.md +++ b/apps/docs/quality/accessibility/index.md @@ -1,6 +1,6 @@ # Accessibility linter -The accessibility linter is the first feature in [`@templatical/quality`](../). It's an MIT-licensed accessibility checker for Templatical email templates that operates on the JSON `TemplateContent` block tree, runs in the browser or in Node.js, and ships with no Vue or DOM dependencies — so the same package validates templates inside the editor and as a CI gate on stored fixtures. +`lintAccessibility(content, options?)` is the accessibility checker inside [`@templatical/quality`](../). It operates on the JSON `TemplateContent` block tree, runs in the browser or in Node.js, and ships with no Vue or DOM dependencies — so the same package validates templates inside the editor and as a CI gate on stored fixtures. ## Why @@ -16,78 +16,23 @@ Email accessibility is genuinely under-tooled. Most builders either bury accessi Catch problems while you're authoring, not after recipients see broken alt text, unreadable contrast, or vague CTAs. Every rule fires on a clear, named condition, so the output is predictable and stays predictable as templates evolve. The same checks align with the EU Accessibility Act (enforceable June 2025). -## Architecture +## API - - - - - - - - - TemplateContent - JSON block tree - from the editor or DB - - - - - lintAccessibility() - block-level rules - + template-level rules - - - - - A11yIssue[] - severity · message · - blockId · optional fix - - - Used by - - - Sidebar panel - in the editor - - Canvas badges - per-block icons - - Headless / CI - stored templates - +```ts +import { lintAccessibility } from "@templatical/quality"; -The package has no opinion on UI. The editor's `useAccessibilityLint` composable lazy-imports `@templatical/quality`, debounces re-lint on content changes, and wires `applyFix(issue)` through the editor's existing block-update path so fixes land as proper undo entries. - -## Install - -::: code-group -```bash [npm] -npm install @templatical/quality -``` -```bash [pnpm] -pnpm add @templatical/quality -``` -```bash [yarn] -yarn add @templatical/quality -``` -```bash [bun] -bun add @templatical/quality +const issues = lintAccessibility(content, options?); +// issues: LintIssue[] — each entry has ruleId starting with "a11y." ``` -::: -The package is an **optional peer** of `@templatical/editor`. Install it to turn on the sidebar tab and canvas badges. Skip it and the editor stays lean — the dynamic import is gated and tree-shakeable, so the linter chunk never downloads. +The function takes a `TemplateContent` and an optional [`LintOptions`](../options) object. It returns a flat array of `LintIssue` objects with `ruleId`, `severity`, `message`, `blockId`, and optionally a `fix` patch. -::: tip CDN users -If you load Templatical via CDN, there's nothing to install. The editor's CDN bundle ships `@templatical/quality` as a separate code-split chunk that lazy-loads automatically when the linter is enabled. -::: +In the editor, the `useTemplateLint` composable lazy-imports `@templatical/quality`, debounces re-lint on content changes, and wires `applyFix(issue)` through the editor's block-update path so fixes land as proper undo entries. Accessibility issues appear in the **Issues** sidebar tab alongside structure issues. ## Quick links -- [Getting started](./getting-started) — first lint call (headless), wiring into the editor. -- [Rule catalog](./rule-catalog) — every rule with severity, rationale, and examples. -- [Options](./options) — `disabled`, `locale`, `rules`, `thresholds`. -- [Severity & fixes](./severity-and-fixes) — how the severity model works and how auto-fix patches are applied. -- [Headless usage](./headless-usage) — validating stored templates in CI. -- [Contributing locales](./contributing-locales) — adding vague-text dictionaries for new languages. +- [Rule catalog](./rule-catalog) — every accessibility rule with severity, rationale, and examples. +- [Options](../options) — shared across both linters. +- [Severity & fixes](../severity-and-fixes) — how the severity model works and how auto-fix patches are applied. +- [Headless usage](../headless-usage) — validating stored templates in CI. +- [Contributing locales](../contributing-locales) — adding rule messages + vague-text dictionaries. diff --git a/apps/docs/quality/accessibility/options.md b/apps/docs/quality/accessibility/options.md deleted file mode 100644 index d21013b3..00000000 --- a/apps/docs/quality/accessibility/options.md +++ /dev/null @@ -1,92 +0,0 @@ -# Options - -The full `A11yOptions` shape, every field optional: - -```ts -interface A11yOptions { - disabled?: boolean; - locale?: string; - rules?: Record; - thresholds?: Partial; -} - -type Severity = "error" | "warning" | "info" | "off"; -``` - -## `disabled` - -| Default | `false` | -|---|---| - -When `true`: - -- The editor **does not lazy-import** `@templatical/quality` — its chunk never downloads. -- The accessibility sidebar tab is **not registered**. -- The inline canvas badges produce **no DOM**. - -Use this when a tenant has explicitly opted out, or to keep the default OSS bundle minimal. There's no soft-disable — `disabled: true` is a complete, irreversible-per-instance shut-off. - -## `locale` - -| Default (headless) | `'en'` | -|---|---| -| Editor | always matches `init({ locale })` | - -Drives the message templates the linter returns (`messages/{locale}.ts`) and is accepted by the locale-aware rules (`link-vague-text`, `button-vague-label`, `img-linked-no-context`). Falls back to `en` when the locale (or its base language) isn't bundled. - -```ts -// Headless — set explicitly -lintAccessibility(content, { locale: "de" }); - -// Editor — linter automatically follows the editor locale -init({ locale: "de" }); -``` - -::: warning Editor mode ignores `accessibility.locale` -In editor mode the linter locale is **forced** to the editor's `locale` from `init({ locale })`. Setting `accessibility.locale` has no effect — it's overwritten on the way through. - -Headless callers (`lintAccessibility(...)` directly) keep full control. -::: - -::: tip Vague-text dictionaries are cross-locale -The dictionary is a union of every registered locale, so a German-locale email with an English `Click here` button still flags `link-vague-text` / `button-vague-label`, and a German `Jetzt kaufen` alt on an English-locale linked image still satisfies `img-linked-no-context`'s action-hint check. The `locale` option doesn't gate matching — it only drives message text. -::: - -## `rules` - -| Default | `{}` | -|---|---| - -Per-rule severity override. Set a rule to `'off'` to disable it entirely. Set to a different severity to bend the default classification: - -```ts -{ - "img-missing-alt": "warning", // soften - "text-all-caps": "off", // disable - "missing-preheader": "warning", // promote from info → warning -} -``` - -The override applies before the rule runs, so disabled rules don't even execute. See the [rule catalog](./rule-catalog) for default severities. - -## `thresholds` - -| Default | See below | -|---|---| - -Numeric knobs that some rules consult: - -| Threshold | Default | Used by | -|---|---|---| -| `altMaxLength` | `125` | `img-alt-too-long` | -| `minFontSize` | `14` | `text-too-small` | -| `allCapsMinLength` | `20` | `text-all-caps` | -| `minTouchTargetPx` | `44` | `button-touch-target` | - -Override one without losing the others — partial merging is built in: - -```ts -lintAccessibility(content, { - thresholds: { minFontSize: 16 }, -}); -``` diff --git a/apps/docs/quality/accessibility/rule-catalog.md b/apps/docs/quality/accessibility/rule-catalog.md index f99e1d4a..0d399483 100644 --- a/apps/docs/quality/accessibility/rule-catalog.md +++ b/apps/docs/quality/accessibility/rule-catalog.md @@ -1,53 +1,52 @@ -# Rule catalog +# Accessibility rule catalog -The 19 rules `@templatical/quality` ships, grouped by what they check. Each rule lives in `packages/quality/src/accessibility/rules/`; severity, message templates, and dictionaries are user-overridable per [Options](./options). +The 19 rules `lintAccessibility` ships, grouped by what they check. Each rule lives in `packages/quality/src/accessibility/rules/`; severity, message templates, and dictionaries are user-overridable per [Options](../options). ## Images | Rule | Default severity | Auto-fix | What it checks | |---|---|---|---| -| `img-missing-alt` | error | — | Missing alt text — Screen readers announce undefined or empty alt as the image filename or skip the image entirely. Email clients also block images by default; alt text is what 30–50% of recipients see first. [1](https://www.w3.org/WAI/tutorials/images/) | -| `img-alt-is-filename` | warning | yes | Alt text looks like a filename — Filenames like 'IMG_1234.jpg' or 'Screen Shot 2026.png' carry no useful meaning. Replace with a short description of what the image conveys. | -| `img-alt-too-long` | warning | — | Alt text is too long — Screen readers don't pause inside alt text. Long alt strings become a wall of speech. Aim for under ~125 characters; put extra context in surrounding copy. | -| `img-decorative-needs-empty-alt` | info | yes | Decorative image has alt text — Decorative images should be skipped by screen readers. Setting alt='' (empty) signals that intent. Non-empty alt on a decorative image is a contradiction. | -| `img-linked-no-context` | warning | — | Linked image has no destination context — When an image is also a link, alt text doubles as the link label. Describing only what the image shows leaves users guessing where the link goes. | +| `a11y.img-missing-alt` | error | — | Missing alt text — Screen readers announce undefined or empty alt as the image filename or skip the image entirely. Email clients also block images by default; alt text is what 30–50% of recipients see first. [1](https://www.w3.org/WAI/tutorials/images/) | +| `a11y.img-alt-is-filename` | warning | yes | Alt text looks like a filename — Filenames like 'IMG_1234.jpg' or 'Screen Shot 2026.png' carry no useful meaning. Replace with a short description of what the image conveys. | +| `a11y.img-alt-too-long` | warning | — | Alt text is too long — Screen readers don't pause inside alt text. Long alt strings become a wall of speech. Aim for under ~125 characters; put extra context in surrounding copy. | +| `a11y.img-decorative-needs-empty-alt` | info | yes | Decorative image has alt text — Decorative images should be skipped by screen readers. Setting alt='' (empty) signals that intent. Non-empty alt on a decorative image is a contradiction. | +| `a11y.img-linked-no-context` | warning | — | Linked image has no destination context — When an image is also a link, alt text doubles as the link label. Describing only what the image shows leaves users guessing where the link goes. | ## Headings | Rule | Default severity | Auto-fix | What it checks | |---|---|---|---| -| `heading-empty` | error | — | Heading has no text — Empty headings produce silent landmarks for screen-reader users navigating by heading. Either add text or remove the block. | -| `heading-skip-level` | error | — | Heading level skipped — Skipping heading levels (e.g. H1 → H3) breaks the document outline that assistive tech relies on for navigation. Step one level at a time. | -| `heading-multiple-h1` | warning | — | Multiple H1 headings — An email should have a single H1 that names the message. Multiple H1s confuse the document outline and weaken landmark navigation. | +| `a11y.heading-empty` | error | — | Heading has no text — Empty headings produce silent landmarks for screen-reader users navigating by heading. Either add text or remove the block. | +| `a11y.heading-skip-level` | error | — | Heading level skipped — Skipping heading levels (e.g. H1 → H3) breaks the document outline that assistive tech relies on for navigation. Step one level at a time. | +| `a11y.heading-multiple-h1` | warning | — | Multiple H1 headings — An email should have a single H1 that names the message. Multiple H1s confuse the document outline and weaken landmark navigation. | ## Links | Rule | Default severity | Auto-fix | What it checks | |---|---|---|---| -| `link-empty` | error | — | Link has no accessible text — A link with no visible text and no nested image with alt is invisible to screen readers and unclickable for many users. | -| `link-vague-text` | warning | — | Vague link text — Phrases like 'click here' or 'read more' tell screen-reader users nothing when listed out of context. Use descriptive link text that names the destination. Outer punctuation and decorative symbols are stripped before matching, so `Click here!`, `→ click here`, and `»click here«` all flag. | -| `link-href-empty` | error | — | Link has empty href — An anchor with no destination (empty href or '#') is broken — recipients click and nothing happens, or the page jumps to top. | -| `link-target-blank-no-rel` | warning | yes | target="_blank" missing rel="noopener" — Links opening in a new tab without rel='noopener' or rel='noreferrer' allow the destination to read window.opener and tamper with the originating page. A small but real security/privacy footgun. | +| `a11y.link-empty` | error | — | Link has no accessible text — A link with no visible text and no nested image with alt is invisible to screen readers and unclickable for many users. | +| `a11y.link-vague-text` | warning | — | Vague link text — Phrases like 'click here' or 'read more' tell screen-reader users nothing when listed out of context. Use descriptive link text that names the destination. Outer punctuation and decorative symbols are stripped before matching, so `Click here!`, `→ click here`, and `»click here«` all flag. | +| `a11y.link-href-empty` | error | — | Link has empty href — An anchor with no destination (empty href or '#') is broken — recipients click and nothing happens, or the page jumps to top. | +| `a11y.link-target-blank-no-rel` | warning | yes | target="_blank" missing rel="noopener" — Links opening in a new tab without rel='noopener' or rel='noreferrer' allow the destination to read window.opener and tamper with the originating page. A small but real security/privacy footgun. | ## Text | Rule | Default severity | Auto-fix | What it checks | |---|---|---|---| -| `text-all-caps` | warning | — | All-caps body text — Long stretches of all-caps text are read letter-by-letter by some screen readers and slow visual reading by 10–20%. Use sentence case for body copy; reserve caps for short labels. | -| `text-low-contrast` | error | — | Heading contrast is too low — WCAG AA requires 4.5:1 for body text and 3:1 for large text (18pt / ~24px). Headings ≥24px (H1, H2) get the relaxed 3:1 threshold; H3 (22px) and H4 (18px) require 4.5:1. The bold-text relaxation isn't applied — TipTap stores bold inline in HTML, not as a structured field. Below that, text becomes unreadable for low-vision users and in bright outdoor light. Only Title blocks are checked; paragraph color lives in inline HTML. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | -| `text-too-small` | warning | — | Text is too small — Email body text below 14px becomes hard to read on mobile. Some clients also auto-zoom or scale up small fonts in unpredictable ways. Stay at 14px or larger. | +| `a11y.text-all-caps` | warning | — | All-caps body text — Long stretches of all-caps text are read letter-by-letter by some screen readers and slow visual reading by 10–20%. Use sentence case for body copy; reserve caps for short labels. | +| `a11y.text-low-contrast` | error | — | Heading contrast is too low — WCAG AA requires 4.5:1 for body text and 3:1 for large text (18pt / ~24px). Headings ≥24px (H1, H2) get the relaxed 3:1 threshold; H3 (22px) and H4 (18px) require 4.5:1. The bold-text relaxation isn't applied — TipTap stores bold inline in HTML, not as a structured field. Below that, text becomes unreadable for low-vision users and in bright outdoor light. Only Title blocks are checked; paragraph color lives in inline HTML. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | +| `a11y.text-too-small` | warning | — | Text is too small — Email body text below 14px becomes hard to read on mobile. Some clients also auto-zoom or scale up small fonts in unpredictable ways. Stay at 14px or larger. | ## Buttons | Rule | Default severity | Auto-fix | What it checks | |---|---|---|---| -| `button-vague-label` | warning | — | Vague button label — A button labeled 'Click here' or 'Submit' tells the user nothing about what will happen. Use action-oriented labels that name the outcome ('Buy ticket', 'Reset password'). Same outer-punctuation handling as `link-vague-text` — `Submit!`, `→ OK`, and `»click«` all flag. | -| `button-touch-target` | warning | — | Button touch target is too small — WCAG 2.5.5 (AAA) and Apple/Google UX guidelines recommend touch targets of at least 44×44px. Smaller buttons cause mis-taps on mobile. | -| `button-low-contrast` | error | — | Button text contrast is too low — Same WCAG AA thresholds as `text-low-contrast`: 4.5:1 normally, 3:1 for buttons with `fontSize >= 24` (WCAG large text). Default-sized buttons (15px) require the strict ratio. Buttons that fail this become unreadable for users with low vision and in bright outdoor light. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | +| `a11y.button-vague-label` | warning | — | Vague button label — A button labeled 'Click here' or 'Submit' tells the user nothing about what will happen. Use action-oriented labels that name the outcome ('Buy ticket', 'Reset password'). Same outer-punctuation handling as `a11y.link-vague-text` — `Submit!`, `→ OK`, and `»click«` all flag. | +| `a11y.button-touch-target` | warning | — | Button touch target is too small — WCAG 2.5.5 (AAA) and Apple/Google UX guidelines recommend touch targets of at least 44×44px. Smaller buttons cause mis-taps on mobile. | +| `a11y.button-low-contrast` | error | — | Button text contrast is too low — Same WCAG AA thresholds as `a11y.text-low-contrast`: 4.5:1 normally, 3:1 for buttons with `fontSize >= 24` (WCAG large text). Default-sized buttons (15px) require the strict ratio. Buttons that fail this become unreadable for users with low vision and in bright outdoor light. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) | -## Structure +## Template-level | Rule | Default severity | Auto-fix | What it checks | |---|---|---|---| -| `missing-preheader` | info | — | Missing preheader text — The preheader is the preview snippet shown beside the subject line in most inboxes. Without it, recipients see a fragment of the first heading or a stray alt tag — a missed chance to set context. | - +| `a11y.missing-preheader` | info | — | Missing preheader text — The preheader is the preview snippet shown beside the subject line in most inboxes. Without it, recipients see a fragment of the first heading or a stray alt tag — a missed chance to set context. | diff --git a/apps/docs/quality/accessibility/severity-and-fixes.md b/apps/docs/quality/accessibility/severity-and-fixes.md deleted file mode 100644 index 4dafa987..00000000 --- a/apps/docs/quality/accessibility/severity-and-fixes.md +++ /dev/null @@ -1,52 +0,0 @@ -# Severity & fixes - -## Severity model - -Every rule emits an `A11yIssue` with one of four severities: - -| Severity | Meaning | UI | -|---|---|---| -| `error` | Hard accessibility failure. Recipient may be excluded from the message. | Red dot on canvas, "Errors" group in the sidebar. | -| `warning` | Likely problem — fix unless you know better. | Yellow dot, "Warnings" group. | -| `info` | Recommendation; not a defect. | No canvas badge, "Info" group. | -| `off` | Override — disables the rule entirely. | Nothing. | - -Severity is configurable per rule via `options.rules` — the catalog's "Severity" column is just the default. - -## Auto-fix - -Some rules ship a `fix` patch that the user can apply with one click in the sidebar. Each patch implements: - -```ts -interface A11yPatch { - description: string; - apply: (ctx: A11yPatchContext) => void; -} - -interface A11yPatchContext { - updateBlock: (blockId: string, patch: Partial) => void; - updateSettings: (patch: Partial) => void; -} -``` - -In the editor, `apply` runs through the existing `editor.updateBlock` / `updateSettings` path — which is wrapped by the history interceptor — so each fix lands as its own undo entry. Users can press Cmd/Ctrl+Z to revert a fix without losing surrounding work. - -Headless callers can construct their own `A11yPatchContext` and apply patches programmatically: - -```ts -import { lintAccessibility } from "@templatical/quality"; - -const issues = lintAccessibility(content); -const fixable = issues.filter((i) => i.fix); - -for (const issue of fixable) { - issue.fix!.apply({ - updateBlock: (id, patch) => mutateBlock(content, id, patch), - updateSettings: (patch) => Object.assign(content.settings, patch), - }); -} -``` - -## Which rules ship a fix? - -See the **Auto-fix** column in the [rule catalog](./rule-catalog). Today: `img-alt-is-filename`, `img-decorative-needs-empty-alt`, and `link-target-blank-no-rel`. Auto-fixes are added conservatively — only when the right answer is unambiguous. diff --git a/apps/docs/quality/contributing-locales.md b/apps/docs/quality/contributing-locales.md new file mode 100644 index 00000000..8f584f2e --- /dev/null +++ b/apps/docs/quality/contributing-locales.md @@ -0,0 +1,105 @@ +# Contributing locales + +`@templatical/quality` ships locale-aware data sets keyed by language: + +1. **Accessibility rule messages** (`src/accessibility/messages/{locale}.ts`) — strings the editor shows for each `a11y.*` issue. +2. **Vague-text dictionaries** (`src/accessibility/dictionaries/{locale}.ts`) — phrase lists used by `a11y.link-vague-text`, `a11y.button-vague-label`, and `a11y.img-linked-no-context`. +3. **Structure rule messages** (`src/structure/messages/{locale}.ts`) — strings for each `structure.*` issue. + +Each set mirrors the editor's locale set. The structure linter has no equivalent of vague-text dictionaries — its rules are deterministic and locale-agnostic, only the message text needs translating. + +## File layout + +``` +packages/quality/src/accessibility/messages/ + en.ts ← source of truth (typed implicitly) + de.ts ← annotated `typeof en` + index.ts ← exports formatMessage(), getMessages() + +packages/quality/src/accessibility/dictionaries/ + en.ts + de.ts + index.ts ← exports getDictionary(), normalizeForMatch() + +packages/quality/src/structure/messages/ + en.ts ← source of truth + de.ts ← annotated `typeof en` + index.ts ← exports formatStructureMessage(), getStructureMessages() +``` + +## Adding a locale + +You need **three** files (or two if you're skipping the vague-text dictionary): a message map per linter and a dictionary. Drop the files and they're picked up automatically — every locale registry is built at compile time via `import.meta.glob`, so there's no map to update. + +Follow the `typeof en` pattern for every file. The annotation is the contract: any missing key, extra key, or wrong type fails `pnpm run typecheck`. Runtime parity tests verify `{name}` placeholder positions match across locales. + +### 1. Accessibility rule messages + +Drop `accessibility/messages/.ts` and translate every value, preserving `{name}` placeholders exactly: + +```ts +// accessibility/messages/pt.ts +import type en from "./en"; + +const pt: typeof en = { + "a11y.img-missing-alt": + "Imagem sem texto alternativo. Adicione uma descrição curta ou marque a imagem como decorativa.", + "a11y.img-alt-too-long": + "Texto alternativo tem {length} caracteres; mantenha abaixo de {max}.", + // …one key per accessibility rule +}; + +export default pt; +``` + +### 2. Vague-text dictionary + +Drop `accessibility/dictionaries/.ts`: + +```ts +// accessibility/dictionaries/pt.ts +import type en from "./en"; + +const pt: typeof en = { + vagueLinkText: ["clique aqui", "aqui", "leia mais", "saiba mais"], + vagueButtonLabels: ["clique aqui", "clique", "enviar"], + linkedImageActionHints: ["compre", "leia", "veja", "baixe", "descubra"], +}; + +export default pt; +``` + +### 3. Structure rule messages + +Drop `structure/messages/.ts`: + +```ts +// structure/messages/pt.ts +import type en from "./en"; + +const pt: typeof en = { + "structure.duplicate-block-id": + "ID de bloco aparece {count} vezes na árvore. Cada bloco precisa ter um ID único.", + "structure.section-column-mismatch": + 'Seção usa layout "{layout}" (espera {expected} colunas) mas tem {actual}. Estado corrompido.', + // …one key per structure rule +}; + +export default pt; +``` + +That's it — `SUPPORTED_MESSAGE_LOCALES`, `SUPPORTED_DICTIONARY_LOCALES`, and `SUPPORTED_STRUCTURE_MESSAGE_LOCALES` reflect the new locale automatically. No registry edit, no test update. + +## Phrase guidelines (vague-text dictionary) + +- **Match, not regex.** The vague-text rules normalize the anchor / button text — lowercase, collapse whitespace, strip leading/trailing non-alphanumeric characters (punctuation, arrows, decorative quotes) — then test `phrases.includes(text)`. So `"Click here!"`, `"→ click here"`, and `"»click here«"` all collapse to `click here` and match the same dictionary entry. Don't add punctuation variants — they're redundant. Each entry is still an exact phrase match; don't try to encode regex patterns. +- **Lowercase only.** Comparison is case-insensitive on the input side. +- **Common, not exhaustive.** The point is to catch the most frequent vague phrases native authors fall into. A 50-entry list does more harm than good (false positives). +- **Don't translate English phrases.** The dictionary is a cross-locale union — every registered locale's phrases match regardless of the active `locale` option. So your `pt.ts` only needs Portuguese phrases; English `click here` is already covered by the union. +- **No region duplicates.** `de-AT` resolves to the same union; one entry per language. +- **`linkedImageActionHints` is per-token, not per-phrase.** `a11y.img-linked-no-context` tokenizes the alt text on non-letter/digit boundaries and checks each token against the hint list. Add **single action verbs** in the form authors actually write them ("buy", "kaufen", "compre"), not multi-word phrases — a phrase like `"jetzt kaufen"` will never match because tokens are checked individually. + +## How matching resolves + +- **Vague-text dictionary** — `getDictionary(locale)` returns a union of every registered locale's phrases (and action hints). The `locale` argument is accepted for API symmetry but currently doesn't change the returned set; a vague phrase is universally vague, and an action verb in any registered language counts as link-destination context, so detection is cross-locale by design. +- **Rule messages** — `formatMessage(locale, ruleId, params?)` (accessibility) and `formatStructureMessage(locale, ruleId, params?)` (structure) resolve the localized template via the matching `messages/{locale}.ts` file and interpolate `{name}` placeholders. Both fall back to English when the locale isn't bundled. diff --git a/apps/docs/quality/accessibility/headless-usage.md b/apps/docs/quality/headless-usage.md similarity index 55% rename from apps/docs/quality/accessibility/headless-usage.md rename to apps/docs/quality/headless-usage.md index e1cd047c..53bd5903 100644 --- a/apps/docs/quality/accessibility/headless-usage.md +++ b/apps/docs/quality/headless-usage.md @@ -1,22 +1,26 @@ # Headless usage -`@templatical/quality` is JSON-only and has no DOM dependency, so the same lint runs in any Node.js context: CI, build pipelines, server-side validation, batch jobs. +`@templatical/quality` is JSON-only and has no DOM dependency, so the same linters run in any Node.js context: CI, build pipelines, server-side validation, batch jobs. + +Both `lintAccessibility(content, options?)` and `lintStructure(content, options?)` return the same `LintIssue[]` shape, so you can call them independently or merge results. ## Validate before storing Reject template JSON that fails the linter at the point it enters your system — CMS save handler, API endpoint, ingestion job: ```ts -import { lintAccessibility } from "@templatical/quality"; +import { lintAccessibility, lintStructure } from "@templatical/quality"; import type { TemplateContent } from "@templatical/types"; export function assertValid(content: TemplateContent): void { - const errors = lintAccessibility(content).filter( - (i) => i.severity === "error", - ); + const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), + ]; + const errors = issues.filter((i) => i.severity === "error"); if (errors.length > 0) { throw new Error( - `Template fails accessibility checks:\n${errors + `Template fails quality checks:\n${errors .map((e) => ` [${e.ruleId}] ${e.message}`) .join("\n")}`, ); @@ -24,13 +28,15 @@ export function assertValid(content: TemplateContent): void { } ``` +`structure.*` errors typically indicate data corruption (duplicate IDs, layout/children mismatch) and should always block a save. `a11y.*` errors are content quality and may warrant a softer policy. + ## CI guard for stored templates -If your application stores `TemplateContent` JSON in a database, run the linter in CI against every stored fixture so regressions can't ship: +If your application stores `TemplateContent` JSON in a database, run the linters in CI against every stored fixture so regressions can't ship: ```ts // scripts/lint-templates.ts -import { lintAccessibility } from "@templatical/quality"; +import { lintAccessibility, lintStructure } from "@templatical/quality"; import { templates } from "../fixtures/templates"; const SEVERITY_RANK = { error: 3, warning: 2, info: 1 }; @@ -38,15 +44,17 @@ const SEVERITY_RANK = { error: 3, warning: 2, info: 1 }; let failed = 0; for (const [name, content] of Object.entries(templates)) { - const issues = lintAccessibility(content).filter( - (i) => SEVERITY_RANK[i.severity] >= SEVERITY_RANK.warning, - ); + const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), + ].filter((i) => SEVERITY_RANK[i.severity] >= SEVERITY_RANK.warning); + if (issues.length === 0) { - console.log(`✔ ${name}: clean`); + console.log(`OK ${name}: clean`); continue; } failed++; - console.error(`✖ ${name}: ${issues.length} issue(s)`); + console.error(`FAIL ${name}: ${issues.length} issue(s)`); for (const issue of issues) { const where = issue.blockId ? `block ${issue.blockId}` : "template"; console.error(` [${issue.severity}] ${issue.ruleId} (${where}): ${issue.message}`); @@ -58,6 +66,19 @@ if (failed > 0) process.exit(1); Run via `tsx scripts/lint-templates.ts` and wire it into your CI workflow. The Templatical playground does exactly this — see `apps/playground/scripts/lint-templates.ts` in the repo. +## Filtering by category + +Rule IDs are namespaced (`a11y.*`, `structure.*`), so grouping or filtering by linter is a `startsWith` check: + +```ts +const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), +]; +const a11y = issues.filter((i) => i.ruleId.startsWith("a11y.")); +const structural = issues.filter((i) => i.ruleId.startsWith("structure.")); +``` + ## Custom severity policy A team may want errors-only in CI but the full info-level output in development: @@ -91,4 +112,4 @@ walkBlocks(content, (block, ctx) => { `walkBlocks` resolves the nearest opaque ancestor background per block, so contrast checks "just work" without re-implementing the section/column traversal. -If you'd like your custom rule to participate in the orchestrator alongside the built-ins (severity overrides, localized messages, the editor sidebar), implement the `Rule` interface — `block` / `template` return a `RuleHit` (`blockId`, optional `params`, optional `fix`) and the orchestrator combines it with the rule's `meta` and the active locale's message template. +If you'd like your custom rule to participate in the orchestrator alongside the built-ins (severity overrides, localized messages, the editor Issues panel), implement the `Rule` interface — `block` / `template` return a `RuleHit` (`blockId`, optional `params`, optional `fix`) and the orchestrator combines it with the rule's `meta` and the active locale's message template. The same `runRules` helper powers both `lintAccessibility` and `lintStructure`. diff --git a/apps/docs/quality/index.md b/apps/docs/quality/index.md index 66ee6f95..ebdab52c 100644 --- a/apps/docs/quality/index.md +++ b/apps/docs/quality/index.md @@ -1,7 +1,113 @@ # Quality -`@templatical/quality` is the umbrella package for Templatical's template-quality tooling. +`@templatical/quality` is the umbrella package for Templatical's template-quality tooling — deterministic, JSON-only linters that catch authoring mistakes inside the editor and in headless / CI checks. MIT-licensed, ESM, no Vue, no DOM. -## Available +## Linters -- **[Accessibility linter](./accessibility/)** — deterministic rules that catch common authoring mistakes (missing alt text, low contrast, vague CTAs, heading-skip, …) inside the editor and in headless / CI checks. MIT-licensed, JSON-only, ESM. +| Linter | What it catches | Default severities | +|---|---|---| +| **[Accessibility](./accessibility/)** | Missing alt text, low contrast, vague CTAs, heading-skip, undersized touch targets, ALL CAPS body, target=_blank missing rel, missing preheader, … | mostly error/warning | +| **[Structure](./structure/)** | Duplicate block IDs, sections with the wrong column count, nested sections, empty sections, empty columns | mostly error; some warning | + +Both linters return the same `LintIssue` shape and share the same options surface (`LintOptions`) — so consumers can run them in any combination, merge results, and filter by `ruleId` prefix (`a11y.*`, `structure.*`) when grouping. + +## Architecture + + + + + + + + + + TemplateContent + JSON block tree + from the editor or DB + + + + + lintAccessibility() + a11y.* rules + + lintStructure() + structure.* rules + + + + + LintIssue[] + severity · message · + blockId · optional fix + + + Consumed by + + + Issues panel + editor sidebar + + Canvas badges + per-block icons + + Headless / CI + stored templates + + +The package has no opinion on UI. The editor's `useTemplateLint` composable lazy-imports `@templatical/quality`, runs every exported linter on debounced content changes, and merges results into a single issues stream that drives the **Issues** sidebar tab and the per-block canvas badges. `applyFix(issue)` runs each patch through the editor's existing block-update path so fixes land as proper undo entries. + +## Install + +::: code-group +```bash [npm] +npm install @templatical/quality +``` +```bash [pnpm] +pnpm add @templatical/quality +``` +```bash [yarn] +yarn add @templatical/quality +``` +```bash [bun] +bun add @templatical/quality +``` +::: + +The package is an **optional peer** of `@templatical/editor`. Install it to turn on the Issues sidebar tab and canvas badges. Skip it and the editor stays lean — the dynamic import is gated and tree-shakeable, so the linter chunk never downloads. + +::: tip CDN users +If you load Templatical via CDN, there's nothing to install. The editor's CDN bundle ships `@templatical/quality` as a separate code-split chunk that lazy-loads automatically when linting is enabled. +::: + +## Wire into the editor + +Pass `lint` to `init()` or `initCloud()`: + +```ts +import { init } from "@templatical/editor"; + +const editor = init({ + container: "#editor", + locale: "en", + lint: { + rules: { + "a11y.img-missing-alt": "warning", // soften from default 'error' + "a11y.text-all-caps": "off", // turn off entirely + "structure.empty-column": "info", // demote to info + }, + thresholds: { minFontSize: 16 }, + }, +}); +``` + +The Issues tab and inline canvas badges appear automatically once the optional peer is resolved. When `lint.disabled === true`, the editor never lazy-loads the package — no chunk download, no UI surface. + +## Quick links + +- [Options](./options) — `disabled`, `locale`, `rules`, `thresholds` (shared by every linter). +- [Severity & fixes](./severity-and-fixes) — severity model + how auto-fix patches land in the editor. +- [Headless usage](./headless-usage) — validating stored templates in CI / server save handlers. +- [Contributing locales](./contributing-locales) — adding rule messages + vague-text dictionaries. +- [Accessibility linter](./accessibility/) — what it catches, rule catalog. +- [Structure linter](./structure/) — what it catches, rule catalog. diff --git a/apps/docs/quality/options.md b/apps/docs/quality/options.md new file mode 100644 index 00000000..cd140f96 --- /dev/null +++ b/apps/docs/quality/options.md @@ -0,0 +1,100 @@ +# Options + +`lintAccessibility` and `lintStructure` accept the same `LintOptions` shape. Every field is optional. + +```ts +interface LintOptions { + disabled?: boolean; + locale?: string; + rules?: Record; + thresholds?: Partial; +} + +type Severity = "error" | "warning" | "info" | "off"; +``` + +The same object also gates the editor's `init({ lint })` config — every option here applies to both linters via the shared `useTemplateLint` composable. + +## `disabled` + +| Default | `false` | +|---|---| + +When `true`: + +- The editor **does not lazy-import** `@templatical/quality` — its chunk never downloads. +- The Issues sidebar tab is **not registered**. +- The inline canvas badges produce **no DOM**. + +Use this when a tenant has explicitly opted out, or to keep the default OSS bundle minimal. There's no soft-disable — `disabled: true` is a complete, irreversible-per-instance shut-off. + +## `locale` + +| Default (headless) | `'en'` | +|---|---| +| Editor | always matches `init({ locale })` | + +Drives the message templates the linter returns (one file per locale per linter) and is accepted by the locale-aware accessibility rules (`a11y.link-vague-text`, `a11y.button-vague-label`, `a11y.img-linked-no-context`). Falls back to `en` when the locale (or its base language) isn't bundled. + +```ts +// Headless — set explicitly +lintAccessibility(content, { locale: "de" }); +lintStructure(content, { locale: "de" }); + +// Editor — linter automatically follows the editor locale +init({ locale: "de" }); +``` + +::: warning Editor mode ignores `lint.locale` +In editor mode the linter locale is **forced** to the editor's `locale` from `init({ locale })`. Setting `lint.locale` has no effect — it's overwritten on the way through. + +Headless callers (`lintAccessibility(...)` / `lintStructure(...)` directly) keep full control. +::: + +::: tip Vague-text dictionaries are cross-locale +The dictionary is a union of every registered locale, so a German-locale email with an English `Click here` button still flags `a11y.link-vague-text` / `a11y.button-vague-label`, and a German `Jetzt kaufen` alt on an English-locale linked image still satisfies `a11y.img-linked-no-context`'s action-hint check. The `locale` option doesn't gate matching — it only drives message text. +::: + +## `rules` + +| Default | `{}` | +|---|---| + +Per-rule severity override. Set a rule to `'off'` to disable it entirely. Set to a different severity to bend the default classification: + +```ts +{ + "a11y.img-missing-alt": "warning", // soften + "a11y.text-all-caps": "off", // disable + "a11y.missing-preheader": "warning", // promote info → warning + "structure.empty-column": "info", // demote warning → info +} +``` + +Rule IDs are namespaced by linter: `a11y.*` for accessibility rules, `structure.*` for structure rules. Override keys must use the full prefixed ID. + +The override applies before the rule runs, so disabled rules don't even execute. See each linter's rule catalog for default severities: [accessibility](./accessibility/rule-catalog) · [structure](./structure/rule-catalog). + +## `thresholds` + +| Default | See below | +|---|---| + +Numeric knobs that some accessibility rules consult. (Structure rules don't currently use thresholds.) + +| Threshold | Default | Used by | +|---|---|---| +| `altMaxLength` | `125` | `a11y.img-alt-too-long` | +| `minFontSize` | `14` | `a11y.text-too-small` | +| `allCapsMinLength` | `20` | `a11y.text-all-caps` | +| `minTouchTargetPx` | `44` | `a11y.button-touch-target` | + +Override one without losing the others — partial merging is built in: + +```ts +lintAccessibility(content, { + thresholds: { minFontSize: 16 }, +}); +``` + +The `DEFAULT_A11Y_THRESHOLDS` constant is also exported if you need to reference the baseline programmatically. diff --git a/apps/docs/quality/severity-and-fixes.md b/apps/docs/quality/severity-and-fixes.md new file mode 100644 index 00000000..6922c1ef --- /dev/null +++ b/apps/docs/quality/severity-and-fixes.md @@ -0,0 +1,64 @@ +# Severity & fixes + +Both linters share the same severity model and patch shape, so this page covers `lintAccessibility` and `lintStructure` together. + +## Severity model + +Every rule emits a `LintIssue` with one of four severities: + +| Severity | Meaning | UI | +|---|---|---| +| `error` | Hard failure. Recipient may be excluded, or the template is structurally corrupt. | Red dot on canvas, "Errors" group in the Issues panel. | +| `warning` | Likely problem — fix unless you know better. | Yellow dot, "Warnings" group. | +| `info` | Recommendation; not a defect. | No canvas badge, "Info" group. | +| `off` | Override — disables the rule entirely. | Nothing. | + +Severity is configurable per rule via `options.rules` — each rule's documented default is just the baseline. + +## Auto-fix + +Some rules ship a `fix` patch that the user can apply with one click from the Issues panel. Each patch implements: + +```ts +interface LintPatch { + description: string; + apply: (ctx: LintPatchContext) => void; +} + +interface LintPatchContext { + updateBlock: (blockId: string, patch: Partial) => void; + updateSettings: (patch: Partial) => void; + removeBlock: (blockId: string) => void; +} +``` + +In the editor, `apply` runs through the existing `editor.updateBlock` / `editor.updateSettings` / `editor.removeBlock` path — which is wrapped by the history interceptor — so each fix lands as its own undo entry. Users can press Cmd/Ctrl+Z to revert a fix without losing surrounding work. + +Headless callers can construct their own `LintPatchContext` and apply patches programmatically: + +```ts +import { lintAccessibility, lintStructure } from "@templatical/quality"; + +const issues = [ + ...lintAccessibility(content), + ...lintStructure(content), +]; +const fixable = issues.filter((i) => i.fix); + +for (const issue of fixable) { + issue.fix!.apply({ + updateBlock: (id, patch) => mutateBlock(content, id, patch), + updateSettings: (patch) => Object.assign(content.settings, patch), + removeBlock: (id) => removeBlockFromTree(content, id), + }); +} +``` + +## Which rules ship a fix? + +See the **Auto-fix** column in each catalog. Today's auto-fixable rules: + +- Accessibility — `a11y.img-alt-is-filename`, `a11y.img-decorative-needs-empty-alt`, `a11y.link-target-blank-no-rel`. +- Structure — `structure.empty-section`. + +Auto-fixes are added conservatively — only when the right answer is unambiguous. `structure.empty-column`, for example, has no auto-fix because removing an empty column requires changing the section's `columns` layout, and the right answer (merge into a sibling vs. drop the section vs. change the layout key) depends on intent. diff --git a/apps/docs/quality/structure/index.md b/apps/docs/quality/structure/index.md new file mode 100644 index 00000000..02ee471f --- /dev/null +++ b/apps/docs/quality/structure/index.md @@ -0,0 +1,37 @@ +# Structure linter + +`lintStructure(content, options?)` is the data-integrity checker inside [`@templatical/quality`](../). It walks the `TemplateContent` block tree and flags shapes that indicate corruption — duplicate IDs, sections whose `columns` layout doesn't match their `children` array, nested sections (the renderer rejects them), and empty sections / columns. + +## Why + +Most "is this template OK?" tooling cares about content quality (alt text, contrast). Structure rules cover a different problem: **can this JSON safely render at all?** Importers (BeeFree, Unlayer, HTML) and custom server-side editors can produce blocks the editor would never produce — orphan column entries, missing block fields, layout/children mismatches. By the time they reach the renderer they're usually too late to recover from cleanly. + +The structure linter catches these before save / before send: + +- **Duplicate block IDs.** Tree traversal, undo/redo, and selection all assume IDs are unique. A duplicate ID silently corrupts every operation that targets a block by ID. +- **Section column mismatch.** A section with `columns: "2-1"` expects `children.length === 2`. If `children` has one or three inner arrays, the layout is broken — usually a UI bug or a stale import. +- **Nested section.** The renderer rejects sections inside columns. If one ends up there, MJML output silently drops it. +- **Empty section.** A section with no blocks renders as a blank table row — wasted whitespace, sometimes a visible padding gap. +- **Empty column.** A multi-column section with one empty column renders awkwardly in most clients and almost always means the author intended fewer columns. + +These rules are deterministic and locale-agnostic — they fire on JSON shapes, not phrases. Only the message text needs translating. + +## API + +```ts +import { lintStructure } from "@templatical/quality"; + +const issues = lintStructure(content, options?); +// issues: LintIssue[] — each entry has ruleId starting with "structure." +``` + +Same signature as `lintAccessibility`. Same `LintOptions` shape. Same `LintIssue` return type. You can run both linters independently or merge results. + +In the editor, the `useTemplateLint` composable lazy-imports `@templatical/quality` and runs both linters on every (debounced) content change. Structure issues appear in the **Issues** sidebar tab alongside accessibility issues. + +## Quick links + +- [Rule catalog](./rule-catalog) — every structure rule with severity, rationale, and an auto-fix note. +- [Options](../options) — shared across both linters. +- [Severity & fixes](../severity-and-fixes) — how the severity model works and how auto-fix patches are applied. +- [Headless usage](../headless-usage) — validating stored templates in CI. diff --git a/apps/docs/quality/structure/rule-catalog.md b/apps/docs/quality/structure/rule-catalog.md new file mode 100644 index 00000000..ae934575 --- /dev/null +++ b/apps/docs/quality/structure/rule-catalog.md @@ -0,0 +1,23 @@ +# Structure rule catalog + +The 5 rules `lintStructure` ships. Each rule lives in `packages/quality/src/structure/rules/`; severity is user-overridable per [Options](../options). + +## Tree integrity + +| Rule | Default severity | Auto-fix | What it checks | +|---|---|---|---| +| `structure.duplicate-block-id` | error | — | Two or more blocks share the same `id`. Tree traversal, undo/redo, selection, and every block-by-ID operation rely on uniqueness; a duplicate corrupts them silently. Usually a sign of a broken import or a clone path that forgot to regenerate IDs. | +| `structure.nested-section` | error | — | A section block sits inside another section's column. The renderer rejects this — sections cannot nest — so the inner section silently drops out of the MJML output. Catches importer bugs and copy-paste accidents. | + +## Section layout + +| Rule | Default severity | Auto-fix | What it checks | +|---|---|---|---| +| `structure.section-column-mismatch` | error | — | The section's `columns` value implies a column count that doesn't match `children.length`. `"1"` expects 1 inner array, `"2"`/`"2-1"`/`"1-2"` expect 2, `"3"` expects 3. A mismatch means either the layout key or the children array is wrong — both yield broken render output. The editor's section toolbar rebalances `children` automatically when the layout changes; this rule catches data that bypassed the toolbar (imports, manual JSON edits, stale snapshots). | + +## Content presence + +| Rule | Default severity | Auto-fix | What it checks | +|---|---|---|---| +| `structure.empty-section` | warning | yes | A section has no columns, or every column is empty. Renders as a blank table row in most clients — wasted whitespace and an occasional visible padding gap. Auto-fix removes the section. | +| `structure.empty-column` | warning | — | A multi-column section has at least one column with no blocks. Renders awkwardly (uneven gaps, collapsed columns on mobile) and almost always means the author intended fewer columns. No auto-fix because the right answer (merge into a sibling vs. drop the section vs. change the `columns` layout) depends on intent. | diff --git a/apps/playground/e2e/helpers/selectors.ts b/apps/playground/e2e/helpers/selectors.ts index e9826268..18e12f9b 100644 --- a/apps/playground/e2e/helpers/selectors.ts +++ b/apps/playground/e2e/helpers/selectors.ts @@ -42,8 +42,10 @@ export const SELECTORS = { rightSidebar: ".tpl-right-sidebar", rightTabContent: "#tpl-tab-content", rightTabSettings: "#tpl-tab-settings", + rightTabIssues: "#tpl-tab-issues", rightPanelContent: "#tpl-tabpanel-content", rightPanelSettings: "#tpl-tabpanel-settings", + rightPanelIssues: "#tpl-tabpanel-issues", // Text editing textToolbar: ".tpl-text-toolbar", @@ -129,3 +131,8 @@ export function configTab(name: string) { export function configPanel(name: string) { return `#config-panel-${name}`; } + +/** Dynamic selector for an issue panel row by rule ID (e.g. "structure.empty-section"). */ +export function issueRowByRule(ruleId: string): string { + return `li:has(p:text-is("${ruleId}"))`; +} diff --git a/apps/playground/e2e/pages/editor.page.ts b/apps/playground/e2e/pages/editor.page.ts index 6c475cf0..ad9aa136 100644 --- a/apps/playground/e2e/pages/editor.page.ts +++ b/apps/playground/e2e/pages/editor.page.ts @@ -1,5 +1,10 @@ import { expect, type Locator, type Page } from "@playwright/test"; -import { SELECTORS, blockByType, paletteByType } from "../helpers/selectors"; +import { + SELECTORS, + blockByType, + issueRowByRule, + paletteByType, +} from "../helpers/selectors"; export class EditorPage { constructor(private page: Page) {} @@ -891,4 +896,31 @@ export class EditorPage { getEditorContainer(): Locator { return this.page.locator(SELECTORS.editorContainer); } + + // --- Issues panel (lint) --- + + /** + * Open the right-sidebar Issues tab. Waits for the tab button to be + * visible (it appears only when the lint composable initializes — quality + * package is an optional peer). + */ + async openIssuesTab(): Promise { + const tab = this.page.locator(SELECTORS.rightTabIssues); + await tab.waitFor(); + await tab.click(); + await this.page.locator(SELECTORS.rightPanelIssues).waitFor(); + } + + /** + * Locator for an issue row in the panel, keyed by rule ID. Each rule ID + * is rendered as monospace `

` text below the message. + */ + getIssueRow(ruleId: string): Locator { + return this.page.locator(issueRowByRule(ruleId)); + } + + /** Locator for the Fix button inside an issue row. */ + getFixButtonForRule(ruleId: string): Locator { + return this.getIssueRow(ruleId).getByRole("button", { name: "Fix" }); + } } diff --git a/apps/playground/e2e/tests/lint.spec.ts b/apps/playground/e2e/tests/lint.spec.ts new file mode 100644 index 00000000..5cb0176c --- /dev/null +++ b/apps/playground/e2e/tests/lint.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "../fixtures/editor.fixture"; +import { SELECTORS } from "../helpers/selectors"; + +test.describe("Template lint (a11y + structure)", () => { + test("empty section: warning shows, Fix button is visible and amber, click removes", async ({ + blankEditorReady: { editorPage }, + }) => { + await editorPage.dragBlockFromSidebar("section"); + expect(await editorPage.getBlocks().count()).toBe(1); + + await editorPage.openIssuesTab(); + + const issueRow = editorPage.getIssueRow("structure.empty-section"); + await expect(issueRow).toBeVisible(); + + const fixBtn = editorPage.getFixButtonForRule("structure.empty-section"); + await expect(fixBtn).toBeVisible(); + + // Guard against the `.tpl button { background: none }` specificity bug. + // Source code can look correct (class on element, CSS rule in bundle) + // while the button still computes to transparent because of a + // descendant-element reset. Only a real browser catches it. + const bg = await fixBtn.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ); + expect(bg).not.toBe("rgba(0, 0, 0, 0)"); + expect(bg).not.toBe("transparent"); + + await fixBtn.click(); + await expect(editorPage.getBlocks()).toHaveCount(0); + await expect(issueRow).toHaveCount(0); + }); + + test("a11y rule fires on blank template alongside structure rules", async ({ + blankEditorReady: { editorPage }, + }) => { + // Blank template has no preheader — a11y.missing-preheader fires at the + // template level. This verifies that both linter families run through + // the same composable and show in the same panel. + await editorPage.dragBlockFromSidebar("section"); + await editorPage.openIssuesTab(); + + await expect( + editorPage.getIssueRow("structure.empty-section"), + ).toBeVisible(); + await expect( + editorPage.getIssueRow("a11y.missing-preheader"), + ).toBeVisible(); + }); + + test("Issues tab shows a count badge with the total across categories", async ({ + blankEditorReady: { editorPage }, + page, + }) => { + await editorPage.dragBlockFromSidebar("section"); + // structure.empty-section + a11y.missing-preheader = 2 issues total + const tab = page.locator(SELECTORS.rightTabIssues); + await tab.waitFor(); + await expect(tab).toContainText(/2/); + }); +}); diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index df7aa6e2..72a3975e 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -4,7 +4,7 @@ import type { TemplaticalEditorConfig } from "./index"; import { useEditor } from "@templatical/core"; import type { TemplateContent, UiTheme } from "@templatical/types"; import { useEditorCore } from "./composables/useEditorCore"; -import { resolveAccessibilityOptions } from "./utils/resolveAccessibilityOptions"; +import { resolveLintOptions } from "./utils/resolveLintOptions"; import type { Translations } from "./i18n"; import type { UseFontsReturn } from "./composables/useFonts"; @@ -53,7 +53,7 @@ const core = useEditorCore({ mergeTags: props.config.mergeTags, displayConditions: props.config.displayConditions, onRequestMedia: props.config.onRequestMedia, - accessibility: resolveAccessibilityOptions(props.config), + lint: resolveLintOptions(props.config), onSave: props.config.onSave ? () => props.config.onSave!(JSON.parse(JSON.stringify(editor.state.content))) diff --git a/packages/editor/src/cloud/CloudEditor.vue b/packages/editor/src/cloud/CloudEditor.vue index e25fa82d..f69064bd 100644 --- a/packages/editor/src/cloud/CloudEditor.vue +++ b/packages/editor/src/cloud/CloudEditor.vue @@ -142,9 +142,9 @@ const lifecycle = useCloudLifecycle({ isDestroyed: init.isDestroyed, }); -// --- Accessibility save-gate --- +// --- Lint save-gate --- const saveGate = useCloudSaveGate({ - issues: core.accessibilityLint ? core.accessibilityLint.issues : ref([]), + issues: core.templateLint ? core.templateLint.issues : ref([]), planConfig: planConfigInstance.config, }); diff --git a/packages/editor/src/cloud/cloudConfig.ts b/packages/editor/src/cloud/cloudConfig.ts index 8d4c88d1..9bd07904 100644 --- a/packages/editor/src/cloud/cloudConfig.ts +++ b/packages/editor/src/cloud/cloudConfig.ts @@ -94,9 +94,10 @@ export interface TemplaticalCloudEditorConfig { onBeforeTestEmail?: (html: string) => string | Promise; /** - * Accessibility linter (`@templatical/quality`) configuration. Cloud + * Template linter (`@templatical/quality`) configuration. Runs every + * linter exported by the package (accessibility + structure). Cloud * additionally merges `planConfig.accessibility` from the server (server * policy wins on conflict) — this option sets the consumer-supplied baseline. */ - accessibility?: import("@templatical/quality").A11yOptions; + lint?: import("@templatical/quality").LintOptions; } diff --git a/packages/editor/src/cloud/components/CloudSaveGateModal.vue b/packages/editor/src/cloud/components/CloudSaveGateModal.vue index 468b197d..0231e549 100644 --- a/packages/editor/src/cloud/components/CloudSaveGateModal.vue +++ b/packages/editor/src/cloud/components/CloudSaveGateModal.vue @@ -1,11 +1,11 @@ diff --git a/packages/editor/src/components/sidebar/AccessibilityPanel.vue b/packages/editor/src/components/sidebar/IssuesPanel.vue similarity index 85% rename from packages/editor/src/components/sidebar/AccessibilityPanel.vue rename to packages/editor/src/components/sidebar/IssuesPanel.vue index d670f863..003bec57 100644 --- a/packages/editor/src/components/sidebar/AccessibilityPanel.vue +++ b/packages/editor/src/components/sidebar/IssuesPanel.vue @@ -1,7 +1,7 @@ @@ -43,18 +43,16 @@ function applyFix(issue: A11yIssue): void {