Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions scripts/FERNDESK.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ A create that trips a slug conflict (409/422, or a message naming the slug) is
recovered by looking the article up and PATCHing it, so a stale cache or a
pagination miss does not surface as a failure.

## Slugs and titles (FernDesk POST 400)

FernDesk accepts a slug only in `[a-z0-9]+(-[a-z0-9]+)*`. It answers `POST
/articles` with **400** for anything else, so `problems/bad_request` used to be
sent verbatim as `problems-bad_request` and rejected: 19 `problems-*` pages
failed while `problems-conflict`, `problems-forbidden`, and `problems-gone`
created fine.

The MDX file name cannot change — it matches `ErrorCode::as_str` and the page's
`/problems/{code}` type URI — so the slug is sanitized at the sync boundary by
`sanitize_slug()`: lowercased, every run of other characters (`_`, `/`, spaces,
`--`) collapsed to one hyphen, ends trimmed. `problems/bad_request` →
`problems-bad-request`, `api/errors` → `api-errors`.

Two source paths that sanitize to one slug (`a_b.mdx` and `a-b.mdx`) would
silently overwrite one article, so `discover_pages` raises instead of syncing.

Titles are separate: a bare snake_case code is not a usable article title, so a
page whose title is `bad_request` is published as `Bad request (bad_request)`
using its `description`. The title keeps the snake_case code, since that is the
wire contract readers search for.

`scripts/tests/ferndesk-sync-retry.test.py` holds both rules: it runs
`discover_pages` over this repository and fails if any problem page produces an
illegal slug, keeps an underscore, collides with another page, or keeps a bare
snake_case title.

## Factory Droid path (agent)

When Manager Deploy lands **prod** and content needs judgment (rewrites, gap fill, migration QA), launch Factory Droid only:
Expand Down
42 changes: 39 additions & 3 deletions scripts/ferndesk_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
DOCS_ROOT docs repo root (default: cwd)

Idempotent upsert by slug. Never deletes FernDesk-only articles (safe migration).
Slugs are sanitized to FernDesk's `[a-z0-9]+(-[a-z0-9]+)*` (COR-444: snake_case
problem codes such as `problems/bad_request` 400'd on create) and bare
snake_case titles are humanized from the page `description`.
"""
from __future__ import annotations

Expand Down Expand Up @@ -392,8 +395,33 @@ def content_fingerprint(md: str) -> str:
return hashlib.sha256(md.encode()).hexdigest()[:16]


# FernDesk answers `POST /articles` with 400 for a slug outside
# `[a-z0-9]+(-[a-z0-9]+)*`. The backend's problem codes are snake_case
# (`bad_request`) and this repo mirrors them as file names, so every
# `problems/*.mdx` with an underscore failed to create (COR-444: 19 pages).
# Sanitize the slug at the sync boundary rather than renaming the MDX: the file
# name has to keep matching `ErrorCode::as_str` and the page's
# `/problems/{code}` type URI.
_SLUG_SEPARATORS = re.compile(r"[^a-z0-9]+")


def sanitize_slug(raw: str) -> str:
"""FernDesk-safe slug: lowercase, hyphen-separated, no `_` or stray `-`.

Collapses every run of other characters (`_`, `/`, spaces, `--`) to one
hyphen and trims the ends, so `problems/bad_request` becomes
`problems-bad-request`. Raises when nothing slug-worthy is left, rather
than publishing an article at an empty or nonsense slug.
"""
slug = _SLUG_SEPARATORS.sub("-", raw.strip().lower()).strip("-")
if not slug:
raise ValueError(f"cannot build a FernDesk slug from {raw!r}")
return slug


def discover_pages(docs_root: Path) -> list[dict]:
pages = []
pages: list[dict] = []
seen_slugs: dict[str, str] = {}
for path in sorted(docs_root.rglob("*")):
if not path.is_file() or path.suffix not in {".mdx", ".md"}:
continue
Expand Down Expand Up @@ -425,12 +453,20 @@ def discover_pages(docs_root: Path) -> list[dict]:
if not coll:
continue
fp = content_fingerprint(md)
# Two paths can sanitize to one slug (`a_b.mdx` + `a-b.mdx`); the second
# would silently overwrite the first article. Fail loudly instead.
slug = sanitize_slug(slug)
prior = seen_slugs.get(slug)
if prior is not None:
raise ValueError(
f"slug collision: {rel} and {prior} both map to FernDesk slug {slug!r}"
)
seen_slugs[slug] = rel
pages.append(
{
"path": rel,
"title": title,
# FernDesk rejects underscores in slugs (POST 400); normalize.
"slug": slug.replace("/", "-").replace("_", "-"),
"slug": slug,
"collection": coll,
"markdown": md,
"fp": fp,
Expand Down
58 changes: 58 additions & 0 deletions scripts/tests/ferndesk-sync-retry.test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import importlib.util
import json
import os
import re
import sys
import tempfile
from contextlib import contextmanager
Expand All @@ -33,6 +34,17 @@ def check(name: str, cond: bool, detail: object = "") -> None:
print(f"FAIL {name} {detail}")


def _raises(fn, *args, **kwargs) -> bool:
"""True when `fn(*args)` raises ValueError — used for the input guards."""
try:
fn(*args, **kwargs)
except ValueError:
return True
except Exception:
return False
return False


@contextmanager
def env(**pairs: str):
saved = {k: os.environ.get(k) for k in pairs}
Expand Down Expand Up @@ -252,6 +264,52 @@ def flaky_then_ok(method, url, n):
finally:
restore(saved)

# --- slug sanitization (COR-444: 19 problems-* pages 400'd on create) --------
# FernDesk 400s a slug outside `[a-z0-9]+(-[a-z0-9]+)*`. Problem codes are
# snake_case and mirrored as file names, so `problems/bad_request` used to be
# sent verbatim as `problems-bad_request` and rejected. The page has to keep its
# file name (it matches `ErrorCode::as_str`), so the slug is sanitized here.
_SLUG_OK = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")

check("snake_case becomes hyphenated", fs.sanitize_slug("problems/bad_request") == "problems-bad-request",
fs.sanitize_slug("problems/bad_request"))
check("folder separator becomes a hyphen", fs.sanitize_slug("api/errors") == "api-errors",
fs.sanitize_slug("api/errors"))
check("already-clean slug is unchanged", fs.sanitize_slug("problems-conflict") == "problems-conflict")
check("uppercase is folded down", fs.sanitize_slug("Problems/Bad_Request") == "problems-bad-request",
fs.sanitize_slug("Problems/Bad_Request"))
check("repeated separators collapse", fs.sanitize_slug("a__b--c") == "a-b-c", fs.sanitize_slug("a__b--c"))
check("edge separators are trimmed", fs.sanitize_slug("__lead_trail__") == "lead-trail",
fs.sanitize_slug("__lead_trail__"))
check("a slug with nothing usable raises", _raises(fs.sanitize_slug, "___"))

# Every problem page in this repository must produce a FernDesk-legal slug, and
# the sanitizer must not make two pages collide onto one article.
if (ROOT / "problems").is_dir():
_pages = fs.discover_pages(ROOT)
_problem_pages = [p for p in _pages if p["path"].startswith("problems/")]
check("problem pages are discovered", len(_problem_pages) >= 20, len(_problem_pages))
_bad = [p["slug"] for p in _problem_pages if not _SLUG_OK.fullmatch(p["slug"])]
check("every problem slug is FernDesk-legal", not _bad, _bad)
_underscored = [p["slug"] for p in _problem_pages if "_" in p["slug"]]
check("no problem slug keeps an underscore", not _underscored, _underscored)
_slugs = [p["slug"] for p in _pages]
check("sanitized slugs stay unique repo-wide", len(_slugs) == len(set(_slugs)),
len(_slugs) - len(set(_slugs)))
# The wire contract for `type` is the snake_case code; sanitizing the slug
# must not leave a bare snake_case title, which FernDesk also rejects.
_bad_titles = [p["title"] for p in _problem_pages if "_" in p["title"] and " " not in p["title"]]
check("no bare snake_case title survives", not _bad_titles, _bad_titles)

# A collision introduced by sanitizing must fail the run, not overwrite a page.
_collide_root = Path(tempfile.mkdtemp(prefix="ferndesk-collide-"))
(_collide_root / "getting-started").mkdir(parents=True)
for _name in ("a_b.mdx", "a-b.mdx"):
(_collide_root / "getting-started" / _name).write_text(
"---\ntitle: Collide\n---\n\nHi.\n", encoding="utf-8"
)
check("colliding slugs raise instead of overwriting", _raises(fs.discover_pages, _collide_root))

# --- shared fixtures for the end-to-end runs ---------------------------------
docs_root = Path(tempfile.mkdtemp(prefix="ferndesk-docs-"))
(docs_root / "getting-started").mkdir(parents=True)
Expand Down
Loading