Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
106 lines
3.6 KiB
Python
106 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
||
"""Gate on the two message rules that cannot be caught at runtime.
|
||
|
||
1. **Every locale carries every key.** Paraglide falls back to the base
|
||
locale for a missing message, which means an untranslated string ships
|
||
silently as German inside an English interface. A missing key is a build
|
||
failure here instead.
|
||
|
||
2. **No em or en dashes in UI copy.** They are hard to type, inconsistent
|
||
across the app when hand-written, and in German they collide with the
|
||
Gedankenstrich convention. Commas, colons or a second sentence do the
|
||
job. Prose in docs/ and comments is unaffected: this only reads the
|
||
message files.
|
||
|
||
Run by `make lint`, so both rules hold for every route the migration
|
||
touches rather than only where someone remembered.
|
||
"""
|
||
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
MESSAGES = Path(__file__).resolve().parents[1] / "messages"
|
||
BASE_LOCALE = "de"
|
||
DASHES = {"—": "em dash", "–": "en dash"}
|
||
|
||
|
||
def load(path: Path) -> dict[str, object]:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
return {key: value for key, value in data.items() if not key.startswith("$")}
|
||
|
||
|
||
def strings_in(value: object) -> list[str]:
|
||
"""Every translatable string inside a message.
|
||
|
||
A message is either a plain string or a list of variants, each with a
|
||
`match` object mapping a selector to a string (see docs/i18n.md,
|
||
pluralization). Declarations and selectors are machinery, not copy, so
|
||
only the match values are checked for dashes.
|
||
"""
|
||
if isinstance(value, str):
|
||
return [value]
|
||
if isinstance(value, list):
|
||
return [
|
||
text
|
||
for variant in value
|
||
if isinstance(variant, dict)
|
||
for text in variant.get("match", {}).values()
|
||
if isinstance(text, str)
|
||
]
|
||
return []
|
||
|
||
|
||
def main() -> int:
|
||
files = sorted(MESSAGES.glob("*.json"))
|
||
if not files:
|
||
print(f"check-messages: no message files in {MESSAGES}", file=sys.stderr)
|
||
return 1
|
||
|
||
catalogs = {path.stem: load(path) for path in files}
|
||
if BASE_LOCALE not in catalogs:
|
||
print(f"check-messages: missing base locale {BASE_LOCALE}.json", file=sys.stderr)
|
||
return 1
|
||
|
||
problems: list[str] = []
|
||
base_keys = set(catalogs[BASE_LOCALE])
|
||
|
||
for locale, catalog in sorted(catalogs.items()):
|
||
if locale == BASE_LOCALE:
|
||
continue
|
||
for key in sorted(base_keys - set(catalog)):
|
||
problems.append(
|
||
f"{locale}.json: missing message '{key}' "
|
||
f"(present in {BASE_LOCALE}.json): a missing translation "
|
||
f"would silently ship as {BASE_LOCALE}"
|
||
)
|
||
for key in sorted(set(catalog) - base_keys):
|
||
problems.append(
|
||
f"{locale}.json: message '{key}' has no counterpart in "
|
||
f"{BASE_LOCALE}.json: the source language defines the set"
|
||
)
|
||
|
||
for locale, catalog in sorted(catalogs.items()):
|
||
for key, value in sorted(catalog.items()):
|
||
for char, name in DASHES.items():
|
||
if any(char in text for text in strings_in(value)):
|
||
problems.append(
|
||
f"{locale}.json: message '{key}' contains an {name} "
|
||
f"({char}): use a comma, a colon, or two sentences"
|
||
)
|
||
|
||
if problems:
|
||
print("check-messages: FAILED", file=sys.stderr)
|
||
for problem in problems:
|
||
print(f" {problem}", file=sys.stderr)
|
||
return 1
|
||
|
||
total = len(base_keys)
|
||
locales = ", ".join(sorted(catalogs))
|
||
print(f"check-messages: {total} messages complete in {locales}, no dashes")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|