diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8ada080..99eeb15 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "diagram-design", "metadata": { - "description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io and Mermaid import at a chosen format, size, and level of detail" + "description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import" }, "owner": { "name": "Cathryn Lavery" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b137ed9..464222f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", - "description": "Editorial-quality technical and product diagrams — 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io and Mermaid import at a chosen format, size, and level of detail", - "version": "2.2.0", + "description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import", + "version": "2.3.0", "homepage": "https://github.com/cathrynlavery/diagram-design", "repository": "https://github.com/cathrynlavery/diagram-design", "license": "MIT", @@ -14,6 +14,8 @@ "editorial", "drawio", "mermaid", - "import" + "import", + "animation", + "semantic-patterns" ] } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index de3a389..2f7eb9a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", - "description": "Editorial-quality technical and product diagrams - 27 types rendered as standalone HTML with inline SVG, skinnable to match your brand, plus draw.io and Mermaid import at a chosen format, size, and level of detail", - "version": "2.2.0", + "description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import", + "version": "2.3.0", "author": { "name": "Cathryn Lavery", "url": "https://github.com/cathrynlavery" @@ -18,13 +18,15 @@ "editorial", "drawio", "mermaid", - "import" + "import", + "animation", + "semantic-patterns" ], "skills": "./skills/", "interface": { "displayName": "Diagram Design", "shortDescription": "Create editorial technical and product diagrams as standalone HTML.", - "longDescription": "Generate architecture, flowchart, sequence, state machine, ER, timeline, swimlane, quadrant, nested, tree, layers, venn, and pyramid diagrams with an opinionated editorial design system.", + "longDescription": "Generate branded technical and product diagrams with semantic system patterns, optional accessible motion, and an opinionated editorial design system.", "developerName": "Cathryn Lavery", "category": "Productivity", "capabilities": [ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d71447c..0877b4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,38 +40,72 @@ jobs: python-version: ${{ matrix.python-version }} - name: Verify accessible SVG contract + if: always() id: a11y run: python scripts/test-lint-a11y.py + - name: Verify semantic pattern documentation + if: always() + id: semantic_docs + run: python scripts/verify-semantic-motion.py --markdown-only + + - name: Verify animated example structure + if: always() + id: animated_example + run: python scripts/verify-semantic-motion.py --example-only + + - name: Lint animated example skin + if: always() + id: animated_lint + run: python scripts/lint-skin.py skills/diagram-design/assets/example-policy-trace-animated.html + + - name: Verify every shipped motion file + if: always() + id: shipped_motion + run: python scripts/verify-motion.py --shipped + - name: Verify URL SVG normalization + if: always() id: url_icons run: python scripts/test-build-icons-quoted-attributes.py - name: Verify Devicon normalization + if: always() id: devicon run: python scripts/test-build-icons-devicon.py - name: Verify mojibake repair utility + if: always() id: mojibake run: python scripts/test-fix-mojibake.py - name: Run skin linter + if: always() id: skin run: python scripts/lint-skin.py --all --baseline - name: Verify sequence OAuth specs + if: always() id: oauth run: python scripts/verify-sequence-oauth.py - name: Verify draw.io import script + if: always() id: drawio run: python scripts/verify-drawio-import.py - name: Verify Mermaid import script + if: always() id: mermaid run: python scripts/verify-mermaid-import.py + - name: Verify optional motion contract + if: always() + id: motion + run: python scripts/test-verify-motion.py + - name: Verify generated icon assets + if: always() id: icons shell: bash run: | @@ -88,6 +122,10 @@ jobs: echo "| Verification Gate | Outcome |" >> $GITHUB_STEP_SUMMARY echo "|---|---|" >> $GITHUB_STEP_SUMMARY echo "| SVG Accessibility Contract | ${{ steps.a11y.outcome == 'success' && '✅ Passed' || (steps.a11y.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Semantic Pattern Documentation | ${{ steps.semantic_docs.outcome == 'success' && '✅ Passed' || (steps.semantic_docs.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Animated Example Structure | ${{ steps.animated_example.outcome == 'success' && '✅ Passed' || (steps.animated_example.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Animated Example Skin | ${{ steps.animated_lint.outcome == 'success' && '✅ Passed' || (steps.animated_lint.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Shipped Motion HTML | ${{ steps.shipped_motion.outcome == 'success' && '✅ Passed' || (steps.shipped_motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| URL SVG Normalization | ${{ steps.url_icons.outcome == 'success' && '✅ Passed' || (steps.url_icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Devicon Normalization | ${{ steps.devicon.outcome == 'success' && '✅ Passed' || (steps.devicon.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Mojibake Repair Utility | ${{ steps.mojibake.outcome == 'success' && '✅ Passed' || (steps.mojibake.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY @@ -95,4 +133,5 @@ jobs: echo "| Sequence Diagram Grammar | ${{ steps.oauth.outcome == 'success' && '✅ Passed' || (steps.oauth.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Draw.io Import Extractor | ${{ steps.drawio.outcome == 'success' && '✅ Passed' || (steps.drawio.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Mermaid Import Extractor | ${{ steps.mermaid.outcome == 'success' && '✅ Passed' || (steps.mermaid.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Optional Motion Contract | ${{ steps.motion.outcome == 'success' && '✅ Passed' || (steps.motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 4c4cc40..935bd3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .claude/ .mcp.json +__pycache__/ +*.py[cod] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3264c57..5f6719f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,21 +30,29 @@ Every validation gate below must pass before a PR is ready. They also run automa | What it checks | Command | |---|---| | Accessible SVG contract (unit tests for the a11y linter) | `python3 scripts/test-lint-a11y.py` | +| Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` | +| Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` | | Skin conformance of every example (colors, fonts, a11y) | `python3 scripts/lint-skin.py --all --baseline` | | A single file, e.g. a new example | `python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html` | | Sequence-doc consistency (ATL fragments, budgets) | `python3 scripts/verify-sequence-oauth.py` | | draw.io import path (real extractor vs fixtures + docs sync) | `python3 scripts/verify-drawio-import.py` | | Mermaid import path (grammars, adversarial input, caps, docs sync) | `python3 scripts/verify-mermaid-import.py` | +| Optional motion contract (fallbacks, controls, budgets, determinism) | `python3 scripts/test-verify-motion.py` | +| Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` | | Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files | Run them all at once before pushing: ```bash python3 scripts/test-lint-a11y.py \ + && python3 scripts/verify-semantic-motion.py --markdown-only \ + && python3 scripts/verify-semantic-motion.py --example-only \ + && python3 scripts/verify-motion.py --shipped \ && python3 scripts/lint-skin.py --all --baseline \ && python3 scripts/verify-sequence-oauth.py \ && python3 scripts/verify-drawio-import.py \ - && python3 scripts/verify-mermaid-import.py + && python3 scripts/verify-mermaid-import.py \ + && python3 scripts/test-verify-motion.py ``` ### If a gate fails @@ -86,6 +94,8 @@ python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html New examples should be added to the gallery (`assets/index.html`) so they stay browsable. +Motion is opt-in. Start from `skills/diagram-design/assets/template-motion.html`, follow `references/animation.md`, and run `python3 scripts/verify-motion.py ` plus `python3 scripts/test-verify-motion.py`. A motion file must preserve complete no-JavaScript, reduced-motion, print, screenshot, and export states. + ## Adding a new diagram type 1. Write `skills/diagram-design/references/type-.md` — layout conventions, anti-patterns, and a worked pattern for that type. Mirror an existing reference's structure. diff --git a/README.md b/README.md index 181c55d..3e56763 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ *New in 2.0 — the Loop: flywheels with a shared-memory hub. The dashed lines are the write-backs.* -27 types. One agent skill for Claude Code, Codex, and Pi. Your brand in 60 seconds — the skill reads your website and maps colors + fonts to every diagram. Already have diagrams in draw.io or Mermaid? Point it at the source and it redraws them at the format, size, and level of detail your audience needs. +*New in 2.3: semantic system patterns and optional accessible motion, while static output stays the default.* + +27 visual types. One agent skill for Claude Code, Codex, and Pi. Semantic patterns describe behavior separately from layout, so a queue, policy trace, or trust boundary can use the nearest existing type without expanding the type count. Static HTML remains the default; optional motion is available for ordered explanations. The skill also redraws draw.io or Mermaid sources at a chosen format, size, and detail level. No Figma. No generic rounded boxes. No 30-minute color-picking sessions. @@ -26,7 +28,7 @@ So I built a Claude Code skill for it. Twenty-seven types, editorial quality, ma ## What it makes -All 27 diagrams ship in three variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser — no build step, no JS, no external images. +All 27 diagrams ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency. @@ -149,6 +151,8 @@ You: "yes, apply it" Every new diagram now uses your colors. Your website's paper color becomes the diagram background. Your CTA color becomes the focal accent. Your body font stack becomes the node label family. +Brand matching also emits a fidelity receipt: sampled URLs, exact color roles, font families and weights, font source URLs, and any fallback. Public site fonts are used directly and verified after rendering rather than silently replaced with generic system fonts. + ### What gets extracted | Detected from your site | Becomes | @@ -206,6 +210,12 @@ cp skills/diagram-design/assets/template.html my-diagram.html # minimal l cp skills/diagram-design/assets/template-full.html my-diagram.html # editorial with summary cards ``` +### Semantic patterns and optional motion + +When behavior matters, the skill chooses a semantic pattern first and a visual type second. The seven routed patterns cover fan-in queues and bottlenecks, repeated stage slots, unstructured-input transformation, paired policy traces, secure paved roads, governance catalogs, and compensating security layers. Each pattern defines its triggers, primitives, budget, anti-patterns, static fallback, and nearest visual type in [`semantic-patterns.md`](skills/diagram-design/references/semantic-patterns.md). + +Motion is optional and does not create another diagram type. [`animation.md`](skills/diagram-design/references/animation.md) defines `none`, `reveal`, `step`, and `loop` modes with a complete static first frame, deterministic timing, and controls when interaction is available. Reduced-motion output shows the complete static frame and hides/disables playback controls. Motion HTML uses the exact reviewed controller from `template-motion.html`; arbitrary inline scripts are rejected. The default is `none`: ordinary output remains static and script-free. [`example-policy-trace-animated.html`](skills/diagram-design/assets/example-policy-trace-animated.html) is the self-contained interactive example. + --- ## Import from draw.io or Mermaid @@ -289,7 +299,7 @@ Both formats are diagram-only — editorial cards and headers from `-full` varia ## Architecture -Progressive disclosure. `SKILL.md` is a lean index — it tells the agent how to pick a type and where to look for detail. Every type lives in its own reference file, loaded only when relevant. +Progressive disclosure. `SKILL.md` routes behavior first when needed, then layout. Semantic, type, and animation references load only when relevant. ``` diagram-design/ @@ -305,6 +315,8 @@ diagram-design/ │ ├── SKILL.md — philosophy, selection guide, checklist │ ├── references/ — loaded only when a type or primitive is chosen │ │ ├── style-guide.md — single source of truth for colors + fonts +│ │ ├── semantic-patterns.md — behavior patterns independent of layout +│ │ ├── animation.md — optional motion + accessibility contract │ │ ├── onboarding.md — the URL-to-tokens flow │ │ ├── import-drawio.md — draw.io redraw procedure │ │ ├── import-mermaid.md — Mermaid redraw procedure @@ -338,6 +350,7 @@ diagram-design/ │ ├── example-quadrant-consultant.html │ ├── example-import-drawio.html │ ├── example-import-mermaid.html +│ ├── example-policy-trace-animated.html │ └── example-sequence-oauth*.html ├── scripts/fixtures/ │ ├── sample-flowchart.mmd @@ -346,12 +359,13 @@ diagram-design/ └── docs/screenshots/ — images used in this README ``` -This keeps the agent's working context tight (only load what you need) and makes the skill easy to extend — drop a new `type-.md` and wire it into the selection guide. The skill ships with 37 reference files covering every diagram type, primitive, and utility. +This keeps the agent's working context tight: routine diagrams load one type reference; behavior-rich diagrams add the routed semantic reference; animation adds its contract only when selected. ### Contributing / skin lint Before submitting a new example, run `python3 scripts/lint-skin.py `. The repository-wide check `python3 scripts/lint-skin.py --all --baseline` must stay green. +Semantic routing must pass `python3 scripts/verify-semantic-motion.py --markdown-only`; the animated example has a separate `--example-only` gate. Every shipped motion template/example must also pass `python3 scripts/verify-motion.py --shipped`. The linter's `a11y` category rejects diagram SVGs without a resolving accessible name, an empty or misplaced title/description, or unsafe bare `title` / `desc` IDs. If you touch the draw.io import path, `python3 scripts/verify-drawio-import.py` must also pass — @@ -365,19 +379,21 @@ All pull requests and pushes are automatically validated across Linux, Windows, ### What loads when -At startup, the agent sees only the skill name and description. When a request matches, it loads `SKILL.md`; type references are pulled in only when relevant. This keeps the skill fast even with 37 reference files. +At startup, the agent sees only the skill name and description. When a request matches, it loads `SKILL.md`; semantic, type, and animation references are pulled in only when relevant. | You ask for… | Agent loads | |---|---| | "Make me a flowchart" | `SKILL.md` + `references/type-flowchart.md` | | "Build an architecture diagram" | `SKILL.md` + `references/type-architecture.md` | +| "Compare why these two policy requests differ" | `SKILL.md` + `references/semantic-patterns.md` + `references/type-flowchart.md` | +| "Animate that policy trace" | Prior selection + `references/animation.md` | | "Onboard this skill to my site" | `SKILL.md` + `references/onboarding.md` + `references/style-guide.md` | | "Add an editorial callout to this diagram" | `SKILL.md` + `references/primitive-annotation.md` | | "Give me a hand-drawn version" | `SKILL.md` + `references/primitive-sketchy.md` | | "Give me a terminal / CLI-window version" | `SKILL.md` + `references/primitive-terminal.md` | | "Redraw this .drawio file for my deck" | `SKILL.md` + `references/import-drawio.md` + `references/output-spec.md` + the chosen type's reference | | "Redraw this Mermaid block for my deck" | `SKILL.md` + `references/import-mermaid.md` + `references/output-spec.md` + the chosen type's reference | -| Routine diagram-making (any of the 27 diagrams) | Only `SKILL.md` + that one type's reference | +| Routine static diagram-making (any of the 27 visual types) | Only `SKILL.md` + that one type's reference | No matter how many types exist, the agent only reads the one you need. Add a new type tomorrow and nothing else changes. diff --git a/scripts/lint-skin.py b/scripts/lint-skin.py index c2c3154..02a3218 100644 --- a/scripts/lint-skin.py +++ b/scripts/lint-skin.py @@ -6,17 +6,20 @@ skin. Use ``--all --baseline`` to skip those documented legacy files. """ import argparse +import hashlib import re import sys from dataclasses import dataclass, field from html.parser import HTMLParser from pathlib import Path +from urllib.parse import parse_qs, urlparse ROOT = Path(__file__).resolve().parent.parent STYLE_GUIDE = ROOT / "skills/diagram-design/references/style-guide.md" ASSET_DIR = ROOT / "skills/diagram-design/assets" BASELINE = ROOT / "scripts/lint-skin-baseline.txt" +MOTION_TEMPLATE = ASSET_DIR / "template-motion.html" HEX_RE = re.compile( r"(?[^>]*)>", re.IGNORECASE | re.DOTALL) +SCRIPT_BLOCK_RE = re.compile( + r"[^>]*)>(?P.*?)", + re.IGNORECASE | re.DOTALL, ) -URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?https?://", re.IGNORECASE) +SRC_HTTP_RE = re.compile(r"\bsrc\s*=\s*(['\"])\s*(?:https?:)?//", re.IGNORECASE) +IMPORT_HTTP_RE = re.compile( + r"@import\s+(?:url\(\s*)?(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE +) +URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE) +IMPORT_ANY_RE = re.compile(r"@import\b", re.IGNORECASE) +URL_ANY_RE = re.compile(r"url\(\s*([^)]+?)\s*\)", re.IGNORECASE) LINK_RE = re.compile(r"]*>", re.IGNORECASE | re.DOTALL) HREF_RE = re.compile(r"\bhref\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL) +REL_RE = re.compile(r"\brel\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL) FONT_CSS_RE = re.compile(r"font-family\s*:\s*([^;}]+)", re.IGNORECASE) FONT_ATTR_RE = re.compile( r"\bfont-family\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL @@ -52,6 +62,137 @@ ALLOWED_FONTS = { CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"} +def normalized_controller(body): + """Return a platform-stable representation of an inline controller.""" + return body.replace("\r\n", "\n").replace("\r", "\n").strip() + + +def canonical_controller_digest(): + """Hash the one reviewed controller shipped by the motion template.""" + source = MOTION_TEMPLATE.read_text(encoding="utf-8") + blocks = list(SCRIPT_BLOCK_RE.finditer(source)) + if len(blocks) != 1: + raise RuntimeError("template-motion.html must contain exactly one controller") + body = normalized_controller(blocks[0].group("body")) + return hashlib.sha256(body.encode("utf-8")).hexdigest() + + +def google_fonts_families(url): + """Return exact families from an approved Google Fonts css2 stylesheet.""" + try: + parsed = urlparse(url) + port = parsed.port + except ValueError: + return set() + if ( + parsed.scheme.casefold() != "https" + or parsed.hostname is None + or parsed.hostname.casefold() != "fonts.googleapis.com" + or port not in (None, 443) + or parsed.path != "/css2" + or parsed.fragment + ): + return set() + families = set() + for value in parse_qs(parsed.query, keep_blank_values=True).get("family", []): + family = value.split(":", 1)[0].strip() + if family: + families.add(family.casefold()) + return families + + +class ResourceParser(HTMLParser): + """Collect remote resource dependencies without executing the document.""" + + RESOURCE_ATTRS = { + "base": ("href",), + "link": ("href",), + "script": ("src",), + "img": ("src", "srcset"), + "iframe": ("src",), + "object": ("data",), + "embed": ("src",), + "image": ("href", "xlink:href"), + "use": ("href", "xlink:href"), + "video": ("src", "poster"), + "audio": ("src",), + "source": ("src", "srcset"), + } + + def __init__(self): + super().__init__(convert_charrefs=True) + self.remote: list[tuple[int, str, str, str]] = [] + self.executable: list[tuple[int, str, str]] = [] + self.fonts: set[str] = set() + + @staticmethod + def is_remote(value): + try: + parsed = urlparse(value.strip()) + except ValueError: + return True + return bool(parsed.scheme) or bool(parsed.netloc) + + def handle_starttag(self, tag, attrs): + tag = tag.casefold() + data = {name.casefold(): value or "" for name, value in attrs} + line, _column = self.getpos() + for name, value in data.items(): + compact = "".join(character for character in value if ord(character) > 32).casefold() + if ( + name.startswith("on") + or name == "srcdoc" + or tag == "base" + or compact.startswith("javascript:") + or compact.startswith("data:text/html") + ): + self.executable.append((line, tag, name)) + for attr in self.RESOURCE_ATTRS.get(tag, ()): + value = data.get(attr, "").strip() + candidates = ( + [item.strip().split()[0] for item in value.split(",") if item.strip()] + if attr == "srcset" + else [value] + ) + for candidate in candidates: + if not candidate or not self.is_remote(candidate): + continue + rel = data.get("rel", "").casefold().split() + approved_fonts = ( + google_fonts_families(candidate) + if tag == "link" and attr == "href" and "stylesheet" in rel + else set() + ) + if approved_fonts: + self.fonts.update(approved_fonts) + else: + self.remote.append((line, tag, attr, candidate)) + + +def style_guide_families(): + """Read only the Family cells in the style guide's Typography table.""" + markdown = STYLE_GUIDE.read_text(encoding="utf-8") + match = re.search( + r"^## Typography\s*$.*?^\| Role \| Family \|.*?\n(?P.*?)(?=^### |^## |\Z)", + markdown, + re.MULTILINE | re.DOTALL, + ) + if not match: + return set() + families = set() + for line in match.group("rows").splitlines(): + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0].startswith("`"): + continue + family_stack = re.sub(r"\s*\([^)]*\)\s*$", "", cells[1]).strip(" `*") + family_stack = re.sub(r"\s+\*[^*]+\*\s*$", "", family_stack).strip() + for family in family_stack.split(","): + family = family.strip().strip("'\"") + if family: + families.add(family.casefold()) + return families + + @dataclass class SvgTextElement: tag: str @@ -346,20 +487,44 @@ def diagram_slug(path): return path.stem.removeprefix("example-") -def named_families(value): +def named_families(value, allowed_fonts): families = [] for raw_family in value.split(","): family = raw_family.strip().strip("'\"").strip() lowered = family.casefold() if not family or lowered in CSS_FONT_KEYWORDS or lowered.startswith("var("): continue - if lowered not in ALLOWED_FONTS: + if lowered not in allowed_fonts: families.append(family) return families def lint_text(text, colors, rgb_triplets, expected_slug): findings = lint_accessible_svgs(text, expected_slug) + allowed_fonts = ALLOWED_FONTS | style_guide_families() + + resources = ResourceParser() + resources.feed(text) + resources.close() + allowed_fonts.update(resources.fonts) + for line, tag, attr, _value in resources.remote: + findings.append( + ( + line, + 0, + "external-asset", + f"external resource in <{tag}> {attr} is not allowed", + ) + ) + for line, tag, attr in resources.executable: + findings.append( + ( + line, + 0, + "script", + f"executable attribute {attr} on <{tag}> is not allowed", + ) + ) def add(offset, category, message): findings.append((line_number(text, offset), offset, category, message)) @@ -384,35 +549,73 @@ def lint_text(text, colors, rgb_triplets, expected_slug): for match in BLACK_RGB_RE.finditer(text): add(match.start(), "pure-black", "pure black rgb(0,0,0) is not allowed") - for match in SCRIPT_RE.finditer(text): - add(match.start(), "script", "\n" + motion_svg = VALID_SVG.replace("fixture-title", "motion-controls-title").replace( + "fixture-desc", "motion-controls-desc" + ) + require_pass( + "motion-controls", + motion_svg + canonical_script, + directory, + ) + duplicate_svg = VALID_SVG.replace( + "fixture-title", "duplicate-controller-title" + ).replace("fixture-desc", "duplicate-controller-desc") + require_lint_finding( + "duplicate-controller", + duplicate_svg + canonical_script + canonical_script, + "script: only one canonical motion controller is allowed", + directory, + ) + unscoped_svg = VALID_SVG.replace("fixture-title", "unscoped-script-title").replace( + "fixture-desc", "unscoped-script-desc" + ) + require_lint_finding( + "unscoped-script", + unscoped_svg + "\n", + "script: only the canonical data-diagram-controls attribute is allowed", + directory, + ) + controller_attacks = { + "network-controller": "fetch('/collect');", + "dynamic-html-controller": "document.body.innerHTML = '

changed

';", + "import-controller": "import('./remote.js');", + "eval-controller": "eval('1 + 1');", + "navigation-controller": "location.assign('https://example.invalid');", + "modified-controller": "void 0;", + } + for name, injected in controller_attacks.items(): + attack_svg = VALID_SVG.replace( + "fixture-title", f"{name}-title" + ).replace("fixture-desc", f"{name}-desc") + modified = controller_body.replace("(() => {", f"(() => {{\n {injected}", 1) + require_lint_finding( + name, + attack_svg + f"\n", + "script: motion controller must exactly match template-motion.html", + directory, + ) + unclosed_svg = VALID_SVG.replace("fixture-title", "unclosed-script-title").replace( + "fixture-desc", "unclosed-script-desc" + ) + require_lint_finding( + "unclosed-motion-script", + unclosed_svg + f"\n', + "script: only the canonical data-diagram-controls attribute is allowed", + directory, + ) + type_svg = VALID_SVG.replace("fixture-title", "script-type-title").replace( + "fixture-desc", "script-type-desc" + ) + require_lint_finding( + "script-type-override", + type_svg + + f'\n', + "script: only the canonical data-diagram-controls attribute is allowed", + directory, + ) + + inter_svg = VALID_SVG.replace("fixture-title", "inter-tight-title").replace( + "fixture-desc", "inter-tight-desc" + ) + require_pass( + "inter-tight", + '\n' + '\n' + + inter_svg, + directory, + ) + lookalike_svg = VALID_SVG.replace( + "fixture-title", "google-fonts-lookalike-title" + ).replace("fixture-desc", "google-fonts-lookalike-desc") + require_lint_finding( + "google-fonts-lookalike", + '\n' + '\n' + + lookalike_svg, + "external-asset: external HTTP(S) is not allowed", + directory, + ) + bad_path_svg = VALID_SVG.replace( + "fixture-title", "google-fonts-bad-path-title" + ).replace("fixture-desc", "google-fonts-bad-path-desc") + require_lint_finding( + "google-fonts-bad-path", + '\n' + + bad_path_svg, + "external-asset: external HTTP(S) is not allowed", + directory, + ) + protocol_relative_svg = VALID_SVG.replace( + "fixture-title", "protocol-relative-font-title" + ).replace("fixture-desc", "protocol-relative-font-desc") + require_lint_finding( + "protocol-relative-font", + '\n' + + protocol_relative_svg, + "external-asset: external HTTP(S) is not allowed", + directory, + ) + unquoted_remote_svg = VALID_SVG.replace( + "fixture-title", "unquoted-remote-title" + ).replace("fixture-desc", "unquoted-remote-desc") + require_lint_finding( + "unquoted-remote-link", + '\n' + + unquoted_remote_svg, + "external-asset: external resource in href is not allowed", + directory, + ) + bypass_svg = VALID_SVG.replace("fixture-title", "resource-bypass-title").replace( + "fixture-desc", "resource-bypass-desc" + ) + require_lint_finding( + "srcset-second-candidate", + '\n' + + bypass_svg, + "external-asset: external resource in srcset is not allowed", + directory, + ) + require_lint_finding( + "protocol-relative-css-url", + '\n' + + bypass_svg, + "external-asset: non-fragment CSS url() is not allowed", + directory, + ) + require_lint_finding( + "event-handler", + '
\n' + bypass_svg, + "script: executable attribute onclick on
is not allowed", + directory, + ) + require_lint_finding( + "javascript-url", + '\n' + bypass_svg, + "script: executable attribute src on \n' + bypass_svg, + "script: executable attribute src on \n' + + bypass_svg, + "script: executable attribute srcdoc on