"""Fetch and normalize the icon set for the diagram-design skill. Pulls SVGs from Tabler Icons (MIT), Simple Icons (CC0), log-z/logos (MIT), and Devicon (MIT), normalizes them to a 24x24 viewBox using `currentColor` so they inherit ink from context, and emits two artifacts: - skills/diagram-design/references/primitive-icons.md (catalog with snippets) - skills/diagram-design/assets/icons.html (visual gallery) Run: python scripts/build-icons.py Reqs: stdlib only (urllib, re, pathlib) Re-run any time the manifest below changes. The generated files are committed; users of the skill never need to run this script. """ from __future__ import annotations import pathlib import re import sys import urllib.request import urllib.error REPO = pathlib.Path(__file__).resolve().parent.parent MD_OUT = REPO / "skills" / "diagram-design" / "references" / "primitive-icons.md" HTML_OUT = REPO / "skills" / "diagram-design" / "assets" / "icons.html" VENDOR_DIR = REPO / "scripts" / "vendor" / "icons" def vendor_path(slot: str, source: str, source_id: str) -> pathlib.Path: """Return the local cache path for a given icon.""" if source == "tabler": return VENDOR_DIR / "tabler" / f"{source_id}.svg" if source == "simple": return VENDOR_DIR / "simple" / f"{source_id}.svg" if source == "logz": return VENDOR_DIR / "logz" / f"{source_id}.svg" if source.startswith("devicon"): variant = source.split(":")[1] if ":" in source else "plain" return VENDOR_DIR / "devicon" / f"{source_id}-{variant}.svg" # "url" source — key by slot name so the cache is human-readable safe = re.sub(r"[^\w\-]", "_", slot) return VENDOR_DIR / "url" / f"{safe}.svg" TABLER_URL = "https://raw.githubusercontent.com/tabler/tabler-icons/main/icons/outline/{name}.svg" TABLER_URL_FALLBACK = "https://raw.githubusercontent.com/tabler/tabler-icons/main/icons/{name}.svg" SI_URL = "https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/{slug}.svg" SI_CDN_URL = "https://cdn.jsdelivr.net/npm/simple-icons/icons/{slug}.svg" LOGZ_URL = "https://raw.githubusercontent.com/log-z/logos/main/website-logos/{name}.svg" DEVICON_URL = "https://raw.githubusercontent.com/devicons/devicon/master/icons/{name}/{name}-{variant}.svg" # For "url" source: source_id is the full URL; blurb should note license # (slot_name, source, source_id, blurb) # slot_name = what users call the icon # source = "tabler" or "simple" # source_id = upstream filename / slug (no extension) ICONS: dict[str, list[tuple[str, str, str, str]]] = { "Compute": [ ("laptop", "tabler", "device-laptop", "User laptop or workstation."), ("phone", "tabler", "device-mobile", "Mobile phone or tablet client."), ("desktop", "tabler", "device-desktop", "Desktop computer."), ("server", "tabler", "server", "Physical server or VM host."), ("container", "tabler", "package", "Container image or running instance."), ("vm", "tabler", "cube", "Virtual machine."), ], "People": [ ("user", "tabler", "user", "End user or single actor."), ("users", "tabler", "users", "Group / cohort / team."), ("admin", "tabler", "user-shield", "Privileged user / admin."), ("robot", "tabler", "robot", "Bot, agent, or automated process."), ], "Network": [ ("cloud", "tabler", "cloud", "Cloud provider or boundary."), ("internet", "tabler", "world", "Public internet."), ("cdn", "tabler", "world-www", "CDN or edge cache."), ("firewall", "tabler", "wall", "Firewall or perimeter control."), ("vpn", "tabler", "shield-lock", "VPN or encrypted tunnel."), ("load-balancer", "tabler", "arrows-split", "Load balancer / traffic split."), ("gateway", "tabler", "door-enter", "API gateway or ingress door."), ("dns", "tabler", "tag", "DNS / name resolution."), ], "Data": [ ("database", "tabler", "database", "Relational or document database."), ("file", "tabler", "file", "Generic file."), ("log", "tabler", "file-text", "Log file / event stream."), ("queue", "tabler", "stack-2", "Message queue / FIFO."), ("cache", "tabler", "bolt", "Cache layer."), ("bucket", "tabler", "bucket", "Object storage / S3 bucket."), ("backup", "tabler", "device-floppy", "Backup or snapshot."), ("search", "tabler", "search", "Search index / query."), ], "Kubernetes": [ ("pod", "tabler", "hexagon", "Pod (smallest deployable unit)."), ("node", "tabler", "topology-star", "Cluster node."), ("service", "tabler", "world-cog", "K8s service / virtual endpoint."), ("deployment", "tabler", "rocket", "Deployment rollout."), ("ingress", "tabler", "arrow-right-rhombus", "Ingress controller / route in."), ("volume", "tabler", "device-sd-card", "Persistent volume."), ], "Action": [ ("api", "tabler", "braces", "API surface / endpoint."), ("request", "tabler", "arrow-right", "Outbound request."), ("response", "tabler", "arrow-left", "Inbound response."), ("sync", "tabler", "refresh", "Sync / reconcile loop."), ("lock", "tabler", "lock", "Locked / authenticated."), ("key", "tabler", "key", "Key / secret."), ("alert", "tabler", "alert-triangle", "Warning / paged alert."), ], "DevOps": [ ("git-branch", "tabler", "git-branch", "Branch / fork point."), ("terminal", "tabler", "terminal", "Shell / CLI."), ("pipeline", "tabler", "git-merge", "CI/CD pipeline."), ("bug", "tabler", "bug", "Bug / defect."), ("monitoring", "tabler", "chart-line", "Metrics / observability."), ("test", "tabler", "test-pipe", "Test / experiment."), ], "Brand": [ # Stroke-style brand outlines (Tabler) where available — match the rest of the set ("docker", "tabler", "brand-docker", "Docker engine / image."), ("terraform", "tabler", "brand-terraform", "Terraform IaC."), ("aws", "tabler", "brand-aws", "Amazon Web Services."), ("azure", "tabler", "brand-azure", "Microsoft Azure."), ("github", "tabler", "brand-github", "GitHub."), # Filled silhouettes (Simple Icons) for brands Tabler doesn't ship ("kubernetes", "simple", "kubernetes", "Kubernetes."), ("gcp", "simple", "googlecloud", "Google Cloud."), ("postgres", "simple", "postgresql", "PostgreSQL."), ("redis", "logz", "redis", "Redis."), ("nginx", "simple", "nginx", "Nginx."), ("gitea", "simple", "gitea", "Gitea self-hosted git."), ("keycloak", "simple", "keycloak", "Keycloak identity / SSO."), ("active-directory", "tabler", "address-book", "Active Directory / LDAP identity directory."), ("minio", "simple", "minio", "MinIO S3-compatible object storage."), ("mysql", "logz", "mysql", "MySQL."), ("oracle", "simple", "oracle", "Oracle Database."), ("sqlserver", "simple", "microsoftsqlserver", "Microsoft SQL Server."), ("sqlite", "simple", "sqlite", "SQLite embedded database."), ("hive", "simple", "apachehive", "Apache Hive data warehouse."), ("starrocks", "logz", "starrocks", "StarRocks MPP analytical DB."), ], "Data stack": [ ("nifi", "simple", "apachenifi", "Apache NiFi data flow."), ("airflow", "simple", "apacheairflow", "Apache Airflow scheduler / DAG runner."), ("hop", "url", "https://hop.apache.org/img/hop-logo.svg", "Apache Hop data orchestration / ETL."), ("pentaho", "url", "https://cdn.worldvectorlogo.com/logos/pentaho-2.svg", "Pentaho PDI (Kettle) ETL & data integration."), ("dagster", "url", "https://cdn.prod.website-files.com/681399f654933b29e12fb8bd/6a04996c4dd28c8ad73bbf3d_Dagster%20Icon.svg", "Dagster data orchestration platform."), ("trino", "simple", "trino", "Trino distributed SQL query engine."), ("superset", "simple", "apachesuperset", "Apache Superset BI / dashboards."), ("redash", "simple", "redash", "Redash open-source BI & dashboards."), ("tableau", "simple", "tableau", "Tableau data visualization."), ("powerbi", "simple", "powerbi", "Microsoft Power BI."), ("jupyter", "simple", "jupyter", "Jupyter / JupyterLab notebooks."), ], "Language": [ ("python", "simple", "python", "Python."), ("r", "simple", "r", "R statistical language."), ("sql", "tabler", "sql", "SQL / generic relational query."), ], "Statistical tools": [ ("spss", "devicon:plain", "spss", "IBM SPSS Statistics."), ("sas", "url", "https://upload.wikimedia.org/wikipedia/commons/1/10/SAS_logo_horiz.svg", "SAS analytics platform."), ("stata", "url", "https://icon.icepanel.io/Technology/svg/Stata.svg", "Stata statistical software."), ("rstudio", "devicon:plain", "rstudio", "RStudio / Posit IDE for R and Python."), ("qgis", "simple", "qgis", "QGIS open-source GIS platform."), ], "File formats": [ ("excel", "tabler", "file-type-xls", "Microsoft Excel spreadsheet."), ("csv", "tabler", "file-type-csv", "Comma-separated values file."), ("txt", "tabler", "file-type-txt", "Plain text file."), ], } def fetch(url: str) -> str | None: try: req = urllib.request.Request( url, headers={"User-Agent": "diagram-design-build/1.0 (https://github.com)"} ) with urllib.request.urlopen(req, timeout=15) as r: return r.read().decode("utf-8") except urllib.error.HTTPError: return None except urllib.error.URLError as e: print(f" network error for {url}: {e}", file=sys.stderr) return None def fetch_tabler(name: str) -> str | None: return fetch(TABLER_URL.format(name=name)) or fetch(TABLER_URL_FALLBACK.format(name=name)) def fetch_simple(slug: str) -> str | None: return fetch(SI_URL.format(slug=slug)) or fetch(SI_CDN_URL.format(slug=slug)) def fetch_logz(name: str) -> str | None: return fetch(LOGZ_URL.format(name=name)) def fetch_devicon(name: str, variant: str = "plain") -> str | None: return fetch(DEVICON_URL.format(name=name, variant=variant)) def fetch_url(url: str) -> str | None: return fetch(url) def normalize_tabler(raw: str) -> str: """Extract inner geometry from a Tabler SVG and wrap with our normalized attrs.""" inner = re.search(r"]*>(.*?)", raw, flags=re.DOTALL | re.IGNORECASE) if not inner: raise ValueError("no in Tabler payload") body = inner.group(1).strip() # Drop tabler-icons class wrapper paths (icon-tabler placeholder path that does nothing) body = re.sub(r'', "", body) body = re.sub(r"\s+", " ", body).strip() return ( '' ) def normalize_simple(raw: str) -> str: """Extract path from a Simple Icons SVG; force fill=currentColor.""" inner = re.search(r"]*>(.*?)", raw, flags=re.DOTALL | re.IGNORECASE) if not inner: raise ValueError("no in Simple Icons payload") body = inner.group(1).strip() body = re.sub(r"\s+", " ", body).strip() return ( '' ) def normalize_url(raw: str) -> str: """Generic normalizer for directly-fetched SVGs (icepanel, vendor CDNs, etc.). Extracts inner paths, rewrites fill/stroke colours to currentColor, and preserves the original viewBox. Rules applied: - White / near-white fills (#fff, #ffffff, white) are kept as-is — they act as contrast cutouts in 2-colour logos (e.g. white letter on coloured circle). Every other colour becomes currentColor. - inline fill/stroke colours are normalized without discarding other styles. - Inkscape layer translate() transforms are stripped (canvas offset artefact). """ vb_match = re.search( r'\bviewBox\s*=\s*(["\'])(.*?)\1', raw, flags=re.IGNORECASE ) viewbox = vb_match.group(2).strip() if vb_match else "0 0 128 128" inner = re.search(r"]*>(.*?)", raw, flags=re.DOTALL | re.IGNORECASE) if not inner: raise ValueError("no in payload") body = inner.group(1) body = re.sub(r"]*>.*?", "", body, flags=re.DOTALL | re.IGNORECASE) body = re.sub(r"]*>.*?", "", body, flags=re.DOTALL | re.IGNORECASE) body = re.sub(r"]*>.*?", "", body, flags=re.DOTALL | re.IGNORECASE) # Strip Inkscape canvas-offset translate transforms from group wrappers body = re.sub(r'\btransform="translate\([^"]+\)"', '', body) def _normalized_fill(value: str) -> str: """Keep white; turn everything else into currentColor.""" hex_val = value.lower().strip() if hex_val in ("#fff", "#ffffff", "white", "#fefefe", "#fdfdfd"): return "#fff" return "currentColor" def _normalize_inline_style(match: re.Match) -> str: quote, declarations = match.group(1), match.group(2) declarations = re.sub( r'(\bfill\s*:\s*)(#[0-9a-fA-F]{3,8}|white)\b', lambda fill: f"{fill.group(1)}{_normalized_fill(fill.group(2))}", declarations, flags=re.IGNORECASE, ) declarations = re.sub( r'(\bstroke\s*:\s*)#[0-9a-fA-F]{3,8}\b', lambda stroke: f"{stroke.group(1)}currentColor", declarations, flags=re.IGNORECASE, ) return f"style={quote}{declarations}{quote}" # Normalize supported inline colours while preserving unrelated declarations. body = re.sub( r'style\s*=\s*(["\'])(.*?)\1', _normalize_inline_style, body, flags=re.IGNORECASE, ) # Rewrite standalone fill="#rrggbb" attributes. body = re.sub( r'\bfill\s*=\s*(["\'])(#[0-9a-fA-F]{3,8}|white)\1', lambda m: f'fill="{_normalized_fill(m.group(2))}"', body, flags=re.IGNORECASE, ) body = re.sub( r'\bstroke\s*=\s*(["\'])#[0-9a-fA-F]{3,8}\1', 'stroke="currentColor"', body, flags=re.IGNORECASE, ) body = re.sub( r'\bfill\s*=\s*(["\'])none\1', '', body, flags=re.IGNORECASE, ) body = re.sub(r"\s+", " ", body).strip() return ( f'' ) def normalize_devicon(raw: str) -> str: """Devicon SVGs have colored fills and a 128x128 or 32x32 viewBox. Strip fills/classes and rewrite to 24x24 currentColor.""" viewbox_match = re.search( r'\bviewBox\s*=\s*(["\'])(.*?)\1', raw, flags=re.IGNORECASE ) viewbox = viewbox_match.group(2).strip() if viewbox_match else "0 0 128 128" inner = re.search(r"]*>(.*?)", raw, flags=re.DOTALL | re.IGNORECASE) if not inner: raise ValueError("no in Devicon payload") body = inner.group(1) body = re.sub(r"]*>.*?", "", body, flags=re.DOTALL | re.IGNORECASE) body = re.sub(r"]*>.*?", "", body, flags=re.DOTALL | re.IGNORECASE) body = re.sub(r'\bfill="#[0-9a-fA-F]{3,8}"', 'fill="currentColor"', body) body = re.sub(r'\bstroke="#[0-9a-fA-F]{3,8}"', 'stroke="currentColor"', body) body = re.sub(r'\bclass="[^"]*"', 'fill="currentColor"', body) body = re.sub(r"\s+", " ", body).strip() return ( f'' ) def normalize_logz(raw: str) -> str: """log-z/logos SVGs are 100x100 with embedded ", raw, flags=re.DOTALL | re.IGNORECASE) if not inner: raise ValueError("no in log-z payload") body = inner.group(1) body = re.sub(r"]*>.*?", "", body, flags=re.DOTALL | re.IGNORECASE) body = re.sub(r'\bclass="st\d+"', 'fill="currentColor"', body) body = re.sub(r'\bfill="#[0-9a-fA-F]{3,8}"', 'fill="currentColor"', body) body = re.sub(r"\s+", " ", body).strip() return ( '' ) def build() -> tuple[list[str], list[str]]: md_chunks: list[str] = [] gallery_chunks: list[str] = [] misses: list[str] = [] md_chunks.append( "# Icons (primitive)\n\n" "A monochrome 24×24 icon library for IT/cloud diagrams. Each icon " "uses `currentColor` so it inherits ink from its parent SVG and " "adapts to the editorial skin or any user-onboarded brand palette.\n\n" "## Usage\n\n" "Find the icon by name (the `### name` headings below). Copy the " "fenced `` snippet into your diagram. Default size is 24×24; " "wrap in `` to position and " "resize. Set `color`, `fill`, or `stroke` on the parent group/SVG to " "control color.\n\n" "Generic icons are stroked (1.5px, hairline, like the rest of the " "skill); brand silhouettes are filled. Don't mix the two styles in " "the same diagram unnecessarily.\n\n" ) for category, items in ICONS.items(): md_chunks.append(f"## {category}\n\n") gallery_chunks.append(f'

{category}

') for slot, source, source_id, blurb in items: vpath = vendor_path(slot, source, source_id) if source == "tabler": normalize = normalize_tabler attribution = f"Tabler Icons / `{source_id}` (MIT)" elif source == "simple": normalize = normalize_simple attribution = f"Simple Icons / `{source_id}` (CC0)" elif source == "logz": normalize = normalize_logz attribution = f"log-z/logos / `{source_id}` (MIT)" elif source.startswith("devicon"): variant = source.split(":")[1] if ":" in source else "plain" normalize = normalize_devicon attribution = f"Devicon / `{source_id}-{variant}` (MIT)" elif source == "url": normalize = normalize_url domain = re.sub(r"https?://([^/]+)/.*", r"\1", source_id) attribution = f"Direct fetch / `{domain}` — verify license before use" else: raise ValueError(f"unknown source: {source}") if vpath.exists(): print(f" local {source}/{source_id} -> {slot}", file=sys.stderr) raw = vpath.read_text(encoding="utf-8") else: print(f" fetching {source}/{source_id} -> {slot}", file=sys.stderr) if source == "tabler": raw = fetch_tabler(source_id) elif source == "simple": raw = fetch_simple(source_id) elif source == "logz": raw = fetch_logz(source_id) elif source.startswith("devicon"): variant = source.split(":")[1] if ":" in source else "plain" raw = fetch_devicon(source_id, variant) elif source == "url": raw = fetch_url(source_id) if raw is not None: vpath.parent.mkdir(parents=True, exist_ok=True) vpath.write_text(raw, encoding="utf-8") if raw is None: misses.append(f"{slot} <- {source}/{source_id}") md_chunks.append( f"### {slot}\n_NOT FOUND — please pick a fallback_\n\n" ) gallery_chunks.append( f'
∅
' f'
{slot}
' ) continue try: snippet = normalize(raw) except ValueError as e: misses.append(f"{slot} <- {source}/{source_id} (parse: {e})") continue md_chunks.append( f"### {slot}\n{blurb}\n\n" f"```svg\n{snippet}\n```\n\n" f"Source: {attribution}\n\n" ) gallery_chunks.append( f'
{snippet}
' f'
{slot}
' ) gallery_chunks.append("
") md_chunks.append( "---\n\n" "## License attribution\n\n" "- **Tabler Icons** — MIT — https://github.com/tabler/tabler-icons\n" "- **Simple Icons** — CC0 — https://github.com/simple-icons/simple-icons\n" "- **Devicon** — MIT — https://github.com/devicons/devicon\n" "- **log-z/logos** — MIT — https://github.com/log-z/logos\n\n" "All libraries' licenses permit redistribution, including in this " "repository's MIT-licensed source. Brand logos retain their " "respective trademarks; this set is for documentation and " "illustrative use only.\n" ) return md_chunks, gallery_chunks, misses GALLERY_TEMPLATE = """ Icon library · Diagram Design

Icons · Diagram Design

Icon library

A small, monochrome library for IT/cloud illustrations. Stroked icons from Tabler Icons (MIT); brand silhouettes from Simple Icons (CC0). Every icon uses currentColor so it inherits ink from its parent.

{SECTIONS}
""" def main() -> int: print("Building icon library…", file=sys.stderr) md_chunks, gallery_chunks, misses = build() MD_OUT.parent.mkdir(parents=True, exist_ok=True) MD_OUT.write_text("".join(md_chunks), encoding="utf-8") HTML_OUT.parent.mkdir(parents=True, exist_ok=True) html = GALLERY_TEMPLATE.replace("{SECTIONS}", "\n".join(gallery_chunks)) HTML_OUT.write_text(html, encoding="utf-8") total = sum(len(items) for items in ICONS.values()) print(f"\nDone. {total - len(misses)}/{total} fetched.", file=sys.stderr) print(f" primitive-icons.md: {MD_OUT}", file=sys.stderr) print(f" icons.html: {HTML_OUT}", file=sys.stderr) if misses: print(f"\n {len(misses)} miss(es) — review and pick fallbacks:", file=sys.stderr) for m in misses: print(f" - {m}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())