From 518d66f43a21ea496f528f1e4bec8979671580ff Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 11:23:03 +0100 Subject: [PATCH 1/8] fix(ci): push feature-doc PRs with a bypass-eligible PAT The generate-features workflow has failed on every run since 2026-05-12 (76 consecutive failures). Generation itself succeeds; the create-pull-request step is what fails, because it force-pushes as github-actions[bot] (Write role) and the agent-block ruleset rejects create/update/force-push on every ref outside refs/heads/edenai-assistant/**: remote: error: GH013: Repository rule violations found remote: - Cannot force-push to this branch remote: - Cannot update this protected ref Same failure and same fix as update-dates.yml (#50), which was never applied here. Falls back to GITHUB_TOKEN so the step still runs without the secret. --- .github/workflows/generate-features.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/generate-features.yml b/.github/workflows/generate-features.yml index 3b60f68..637ef32 100644 --- a/.github/workflows/generate-features.yml +++ b/.github/workflows/generate-features.yml @@ -34,6 +34,13 @@ jobs: if: steps.changes.outputs.changed == 'true' uses: peter-evans/create-pull-request@v6 with: + # Use a PAT so the branch push runs as a bypass-eligible actor. + # GITHUB_TOKEN authenticates as github-actions[bot] (Write role), + # which the `agent-block` ruleset rejects when creating or + # force-pushing branches outside refs/heads/edenai-assistant/**. + # The PAT must belong to a user/app with Triage, Maintain, or Admin + # role (the ruleset's bypass list). Same fix as update-dates.yml. + token: ${{ secrets.DOCS_BOT_PAT || secrets.GITHUB_TOKEN }} commit-message: "docs: update auto-generated AI feature pages" branch: auto/update-feature-docs title: "docs: update AI feature documentation" From 97367e9def7b7c4ad160a9559872033954f6921e Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 11:58:18 +0100 Subject: [PATCH 2/8] fix(generate-features): preserve schema dates instead of hardcoding them The generator wrote a fixed dateModified="2026-05-06" into every page it produced, so each run reset the value that update-dates.yml had bumped. PR #13 shows the effect: 24 of its 27 files change nothing except dateModified going backwards from 2026-05-07 to 2026-05-06. Read both dates back off the existing page and reuse them; only a page that does not exist yet gets today's date. dateModified stays owned by update-dates.yml, which bumps it when content actually changes. Verified against the live API: regenerating all 36 pages now produces zero date-only diffs. --- scripts/generate_features.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 68dbaff..8995926 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -14,6 +14,7 @@ import shutil import tempfile import urllib.request +from datetime import datetime, timezone from pathlib import Path # -------------------------- @@ -436,6 +437,27 @@ def _js_str(s: str) -> str: return "`" + escaped + "`" +def _existing_schema_dates(feature: str, sf_name: str) -> tuple[str | None, str | None]: + """Read datePublished / dateModified back off an already-generated page. + + The generator must not mint these itself. `update-dates.yml` owns + dateModified and bumps it only when a page's content actually changed, so + regenerating with a fixed date would drag the freshness signal backwards on + every page on every run. + """ + path = FEATURES_DIR / feature / f"{slug(sf_name)}.mdx" + try: + text = path.read_text() + except OSError: + return None, None + + def find(key: str) -> str | None: + match = re.search(rf'{key}="([^"]*)"', text) + return match.group(1) if match else None + + return find("datePublished"), find("dateModified") + + def _render_techarticle_schema(feature: str, sf_name: str, fullname: str, description: str) -> str: """Render the TechArticleSchema MDX component for a feature page.""" section, about, extra_kw = _FEATURE_SCHEMA_META.get( @@ -444,6 +466,11 @@ def _render_techarticle_schema(feature: str, sf_name: str, fullname: str, descri keywords = ["Eden AI", "AI API"] + extra_kw keywords_js = "[" + ", ".join(_js_str(k) for k in keywords) + "]" path = f"v3/expert-models/features/{feature}/{slug(sf_name)}" + # Preserve the dates already on the page; only a brand-new page gets today's. + today = f"{datetime.now(timezone.utc):%Y-%m-%d}T00:00:00Z" + date_published, date_modified = _existing_schema_dates(feature, sf_name) + date_published = date_published or today + date_modified = date_modified or date_published return ( 'import { TechArticleSchema } from "/snippets/TechArticleSchema.mdx";\n\n' "\n" ) From 883b71232d0a8b0eff1e1dc0c82ace9c25d4c868 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 12:01:36 +0100 Subject: [PATCH 3/8] fix(generate-features): locate the Expert Models nav group by search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_docs_json walked navigation → V3 → tab "V3 Documentation" → group "V3 Documentation" → pages, looking for a nested "Expert Models" dict. But "Expert Models" is a sibling top-level group of "V3 Documentation", not a child of it, so the loop matched nothing and the function silently no-opped. New feature pages were written to disk and never added to the nav. Against the live API that currently hides 10 pages: web/ (7 firecrawl subfeatures), image/face-recognition and video/deepfake-detection-async. Search the V3 tabs for the group instead of assuming a fixed depth, and exit non-zero if it is missing — quietly skipping the update is what hid this. The legacy "AI Features" removal stays scoped to the V3 Documentation group so the V3 API Reference tab keeps its own "AI Features" group. --- scripts/generate_features.py | 74 ++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 8995926..47eff47 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -604,6 +604,23 @@ def _build_feature_subgroups(features: list[dict]) -> list[dict]: return subgroups +def _find_group(node: object, name: str) -> dict | None: + """Depth-first search the navigation tree for a group dict named `name`.""" + if isinstance(node, dict): + if node.get("group") == name: + return node + for value in node.values(): + found = _find_group(value, name) + if found is not None: + return found + elif isinstance(node, list): + for item in node: + found = _find_group(item, name) + if found is not None: + return found + return None + + def update_docs_json(features: list[dict]) -> None: """Read docs.json, update the feature subgroups inside Expert Models, write back.""" with open(DOCS_JSON_PATH, "r") as f: @@ -611,39 +628,32 @@ def update_docs_json(features: list[dict]) -> None: feature_subgroups = _build_feature_subgroups(features) - # Find V3 Documentation tab → its groups → Expert Models group versions = docs.get("navigation", {}).get("versions", []) - for version in versions: - if version.get("version") != "V3": - continue - tabs = version.get("tabs", []) - for tab in tabs: - if tab.get("tab") != "V3 Documentation": - continue - groups = tab.get("groups", []) - for group in groups: - if group.get("group") != "V3 Documentation": - continue - pages = group.get("pages", []) - - # Remove old standalone "AI Features" entry if present - pages[:] = [ - p for p in pages - if not (isinstance(p, dict) and p.get("group") == "AI Features") - ] - - # Find the Expert Models group and replace its feature subgroups - for p in pages: - if isinstance(p, dict) and p.get("group") == "Expert Models": - expert_pages = p.get("pages", []) - # Keep non-feature pages (e.g. fallback, webhooks, listing-models) - static_pages = [ - ep for ep in expert_pages - if isinstance(ep, str) - ] - # Replace with static pages + new feature subgroups - p["pages"] = static_pages + feature_subgroups - break + v3 = next((v for v in versions if v.get("version") == "V3"), None) + if v3 is None: + raise SystemExit("ERROR: docs.json has no V3 version in navigation.") + + # Remove the old standalone "AI Features" entry if present. Scoped to the + # V3 Documentation group on purpose — the V3 API Reference tab has its own + # legitimate "AI Features" group that must survive. + v3_docs_group = _find_group(v3.get("tabs", []), "V3 Documentation") + if v3_docs_group is not None: + pages = v3_docs_group.get("pages", []) + pages[:] = [ + p for p in pages + if not (isinstance(p, dict) and p.get("group") == "AI Features") + ] + + # "Expert Models" has sat at more than one depth in docs.json over time, so + # search for it instead of assuming a fixed nesting. Quietly skipping the + # update is what previously let new feature pages ship with no nav entry. + expert = _find_group(v3.get("tabs", []), "Expert Models") + if expert is None: + raise SystemExit("ERROR: docs.json has no 'Expert Models' group; cannot update feature nav.") + + # Keep non-feature pages (e.g. webhooks, listing-models, provider-parameters) + static_pages = [ep for ep in expert.get("pages", []) if isinstance(ep, str)] + expert["pages"] = static_pages + feature_subgroups # Atomic write: write to temp file then replace, so a crash can't corrupt docs.json fd, tmp_path = tempfile.mkstemp(dir=DOCS_JSON_PATH.parent, suffix=".json") From ecc9ff2209ca4e6245193eb09e56133fa8804c91 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 12:42:39 +0100 Subject: [PATCH 4/8] fix(generate-features): render French unit names in English The /v3/info API returns some price units as "seconde", which flowed verbatim into published pricing tables ("$0.05 per seconde"). Currently affects 13 rows across the video pages. --- scripts/generate_features.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 47eff47..02fb133 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -173,10 +173,16 @@ def strip_html(text: str) -> str: return _HTML_TAG_RE.sub("", text) +# The /v3/info API returns some unit names in French; normalise them so the +# published pricing tables read in English. +_UNIT_ALIASES = {"seconde": "second", "secondes": "second"} + + def format_price(price: float, quantity: int, unit_type: str) -> str: """Return a human-readable price string.""" if price == 0: return "Free" + unit_type = _UNIT_ALIASES.get(unit_type, unit_type) if quantity == 1: return f"${price:g} per {unit_type}" return f"${price:g} per {quantity:,} {unit_type}s" From 2ccef80be116052d91c5b2c0e54d7a63bcec2479 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 16:56:14 +0100 Subject: [PATCH 5/8] fix(generate-features): escape pipes in Markdown table cells An unescaped pipe in a cell value ends the cell early and shifts every later column left, silently corrupting the row. The API now returns a union type for image/generation: `array[file_input | object]`, which rendered as Type=`array[file_input`, Required=`object]`, Description=`No`, with the real description pushed into a phantom fifth column that renderers drop. Escape cell values in both the schema and provider tables. --- scripts/generate_features.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 02fb133..79b9b86 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -282,15 +282,26 @@ def build_input_json(fields: list[dict], required_only: bool = False) -> dict[st # MDX generation # -------------------- +def _cell(text: str) -> str: + """Escape a value for use inside a Markdown table cell. + + An unescaped pipe ends the cell early and shifts every later column left, + which corrupts the row silently. A union type such as + `array[file_input | object]` pushed "Required" into the type column and the + description into a phantom fifth column that renderers drop. + """ + return str(text).replace("|", "\\|") + + def _render_fields_rows(fields: list[dict], depth: int = 0) -> list[str]: """Recursively render schema fields as table rows, expanding nested objects.""" rows = [] indent = "    " * depth # visual indentation per nesting level for f in fields: - name = f.get("name", "") - ftype = f.get("type", "") + name = _cell(f.get("name", "")) + ftype = _cell(f.get("type", "")) required = "Yes" if f.get("required") else "No" - desc = strip_html(f.get("description", "")) + desc = _cell(strip_html(f.get("description", ""))) if ftype == "array" and "items" in f: item_type = f["items"].get("type", "") @@ -299,7 +310,7 @@ def _render_fields_rows(fields: list[dict], depth: int = 0) -> list[str]: rows.append(f"| {indent}**{name}** | array[object] | {required} | {desc} |") rows.extend(_render_fields_rows(nested_fields, depth=depth + 1)) else: - ftype = f"array[{item_type}]" if item_type else "array" + ftype = f"array[{_cell(item_type)}]" if item_type else "array" rows.append(f"| {indent}{name} | {ftype} | {required} | {desc} |") elif ftype == "object" and "fields" in f: rows.append(f"| {indent}**{name}** | object | {required} | {desc} |") @@ -340,7 +351,7 @@ def render_providers_table(models: list[dict]) -> str: pricing.get("price_unit_quantity", 1), pricing.get("price_unit_type", "request"), ) - lines.append(f"| {label} | `{model_str}` | {price_str} |") + lines.append(f"| {_cell(label)} | `{_cell(model_str)}` | {_cell(price_str)} |") return "\n".join(lines) + "\n" From 505370547f270b5387455edc5a4d1eaabfa41b07 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 16:59:11 +0100 Subject: [PATCH 6/8] fix(generate-features): derive articleSection from the nav label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav group title was derived from the API while the JSON-LD articleSection was hardcoded in _FEATURE_SCHEMA_META, so the two drifted. video's nav label became "Video Features" as soon as it gained a second subfeature, while its pages still declared articleSection "Video Generation Features" and keyword "video generation" — wrong for deepfake detection. A brand-new category hit the same gap from the other side: web had no entry at all, so its 7 pages fell back to generic metadata and a "cube" icon. feature_section_name() is now the single source of truth for both the nav group and articleSection, so they cannot diverge and a new category needs no entry to be labelled correctly. _FEATURE_SCHEMA_META keeps only `about` and `keywords`, which have no counterpart in /v3/info (per category it exposes just name, fullname — a repeat of name — description, and subfeatures), plus a `web` entry and a globe icon. Verified: articleSection matches the nav group for all 7 categories. --- scripts/generate_features.py | 53 +++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 79b9b86..4cd8a70 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -42,6 +42,7 @@ "translation": "language", "audio": "volume-high", "speech": "volume-high", + "web": "globe", } # --------------------- @@ -128,6 +129,17 @@ def _common_word_prefix(strings: list[str]) -> str: return " ".join(prefix_words) +def feature_section_name(feature: dict) -> str: + """Label for a feature category: nav group title and JSON-LD articleSection. + + Single source of truth for both. Previously the nav derived this from the API + while the schema block hardcoded it, so they silently drifted apart — video + became "Video Features" in the nav the moment it gained a second subfeature + while its pages still claimed "Video Generation Features". + """ + return f"{derive_display_name(feature)} Features" + + def derive_icon(feature_name: str) -> str: """Pick an icon based on keyword matching against the feature name.""" name_lower = feature_name.lower() @@ -431,14 +443,20 @@ def build_code_example(feature: str, subfeature: str, models: list[dict], detail """ -# Section / about / keyword mapping for TechArticle schema (per feature category) +# `about` / keyword mapping for the TechArticle schema, per feature category. +# These are SEO concepts with no counterpart in /v3/info (it exposes only name, +# fullname, description and subfeatures per category, and fullname just repeats +# name), so they are maintained by hand. articleSection is NOT listed here — it +# is derived via feature_section_name() so it cannot drift from the nav label. +# A category missing from this map still generates; it just gets generic values. _FEATURE_SCHEMA_META = { - "text": ("Text Features", "NLP API", ["text analysis", "NLP"]), - "ocr": ("OCR Features", "OCR API", ["OCR", "document parsing"]), - "image": ("Image Features", "Image AI API", ["image analysis", "computer vision"]), - "translation": ("Translation Features", "Translation API", ["translation", "multilingual"]), - "audio": ("Audio Features", "Audio AI API", ["speech to text", "text to speech"]), - "video": ("Video Generation Features", "Video AI API", ["video generation"]), + "text": ("NLP API", ["text analysis", "NLP"]), + "ocr": ("OCR API", ["OCR", "document parsing"]), + "image": ("Image AI API", ["image analysis", "computer vision"]), + "translation": ("Translation API", ["translation", "multilingual"]), + "audio": ("Audio AI API", ["speech to text", "text to speech"]), + "video": ("Video AI API", ["video generation", "video analysis"]), + "web": ("Web Data API", ["web scraping", "web search"]), } @@ -475,11 +493,11 @@ def find(key: str) -> str | None: return find("datePublished"), find("dateModified") -def _render_techarticle_schema(feature: str, sf_name: str, fullname: str, description: str) -> str: +def _render_techarticle_schema( + feature: str, sf_name: str, fullname: str, description: str, section: str +) -> str: """Render the TechArticleSchema MDX component for a feature page.""" - section, about, extra_kw = _FEATURE_SCHEMA_META.get( - feature, ("Expert Models", "AI API", ["expert models"]) - ) + about, extra_kw = _FEATURE_SCHEMA_META.get(feature, ("AI API", ["expert models"])) keywords = ["Eden AI", "AI API"] + extra_kw keywords_js = "[" + ", ".join(_js_str(k) for k in keywords) + "]" path = f"v3/expert-models/features/{feature}/{slug(sf_name)}" @@ -504,7 +522,9 @@ def _render_techarticle_schema(feature: str, sf_name: str, fullname: str, descri ) -def generate_subfeature_page(feature: str, subfeature_info: dict, detail: dict) -> str: +def generate_subfeature_page( + feature: str, subfeature_info: dict, detail: dict, section: str +) -> str: """Generate the full MDX content for a single subfeature page.""" sf_name = subfeature_info["name"] fullname = subfeature_info.get("fullname", sf_name) @@ -524,7 +544,9 @@ def generate_subfeature_page(feature: str, subfeature_info: dict, detail: dict) truncated_desc = truncate_at_sentence(description, 200) safe_title = escape_frontmatter(fullname) safe_desc = escape_frontmatter(truncated_desc) - schema_block = _render_techarticle_schema(feature, sf_name, fullname, truncated_desc) + schema_block = _render_techarticle_schema( + feature, sf_name, fullname, truncated_desc, section + ) page = f"""--- title: "{safe_title}" @@ -607,13 +629,12 @@ def _build_feature_subgroups(features: list[dict]) -> list[dict]: subgroups = [] for feat in features: fname = feat["name"] - display = derive_display_name(feat) subpages = [] for sf in feat.get("subfeatures", []): sf_slug = slug(sf["name"]) subpages.append(f"v3/expert-models/features/{fname}/{sf_slug}") subgroups.append({ - "group": f"{display} Features", + "group": feature_section_name(feat), "icon": derive_icon(fname), "expanded": False, "pages": subpages, @@ -750,7 +771,7 @@ def main() -> None: print(f" Warning: could not fetch detail for {fname}/{sf_name}: {e}") detail = {} - content = generate_subfeature_page(fname, sf, detail) + content = generate_subfeature_page(fname, sf, detail, feature_section_name(feat)) (feat_dir / f"{sf_slug}.mdx").write_text(content) print(" Generating index page...") index_content = generate_index_page(features) From 2f6f29c7bc09d41bea5a48b67a9538f9f8c7b577 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 18:06:34 +0100 Subject: [PATCH 7/8] fix(generate-features): harden output against unusual API data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by auditing every live schema plus a synthetic new-category run. 1. Collapse whitespace in table cells. A newline in a cell value terminates the row mid-sentence and ends the table, spilling the remainder into the page as loose prose. Two live descriptions do this today, so main is publishing two broken tables right now: the tts `voice` field and explicit_content `subcategory`. _cell() now collapses whitespace runs before escaping pipes. 2. Never overwrite a good page with a gutted one. A failed detail fetch left detail={}, which renders "_No schema information available._" and drops both schema tables — so one transient 5xx during the daily run would open a PR that silently strips documentation. Now the existing page is kept, and if there is no page to keep the run aborts instead of publishing an empty one. 3. Don't double the plural when the API already sends one: a unit_type of "tokens" rendered as "per 1,000 tokenss". Latent today (every live unit is singular), but the next new unit would hit it. Verified: 1,070 table rows across 36 pages, zero malformed. A synthetic unknown category generates pages, derives articleSection, lands in the nav with the fallback icon, and escapes pipes in provider labels; simulated API failures preserve or abort as intended; a removed subfeature is cleaned from disk and nav. --- scripts/generate_features.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 4cd8a70..586a33a 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -197,7 +197,9 @@ def format_price(price: float, quantity: int, unit_type: str) -> str: unit_type = _UNIT_ALIASES.get(unit_type, unit_type) if quantity == 1: return f"${price:g} per {unit_type}" - return f"${price:g} per {quantity:,} {unit_type}s" + # Don't double the plural if the API already sends one ("tokens" -> "tokenss"). + plural = unit_type if unit_type.endswith("s") else f"{unit_type}s" + return f"${price:g} per {quantity:,} {plural}" def provider_from_model(model_str: str) -> str: @@ -301,8 +303,13 @@ def _cell(text: str) -> str: which corrupts the row silently. A union type such as `array[file_input | object]` pushed "Required" into the type column and the description into a phantom fifth column that renderers drop. + + A newline is worse: it terminates the row mid-sentence and ends the table, + dumping the remainder into the page as loose prose. Two live descriptions do + this today (the tts `voice` field and explicit_content `subcategory`), so + collapse all whitespace runs before escaping. """ - return str(text).replace("|", "\\|") + return " ".join(str(text).split()).replace("|", "\\|") def _render_fields_rows(fields: list[dict], depth: int = 0) -> list[str]: @@ -764,15 +771,28 @@ def main() -> None: sf_slug = slug(sf_name) print(f" Generating {fname}/{sf_slug}.mdx ...") + page_path = feat_dir / f"{sf_slug}.mdx" + # Fetch detailed schema try: detail = fetch_subfeature_detail(fname, sf_name) except Exception as e: - print(f" Warning: could not fetch detail for {fname}/{sf_name}: {e}") - detail = {} + # Never overwrite a good page with a gutted one. Without the + # detail response there are no input/output fields, so the page + # would publish "_No schema information available._" and lose its + # schema tables — a transient API hiccup would otherwise open a + # PR that silently strips documentation. + if page_path.exists(): + print(f" Warning: could not fetch detail for {fname}/{sf_name}: {e}") + print(f" Keeping existing {page_path.relative_to(DOCS_ROOT)} unchanged.") + continue + raise SystemExit( + f"ERROR: no detail for {fname}/{sf_name} and no existing page to keep ({e}). " + "Aborting rather than publishing a page with no schema." + ) content = generate_subfeature_page(fname, sf, detail, feature_section_name(feat)) - (feat_dir / f"{sf_slug}.mdx").write_text(content) + page_path.write_text(content) print(" Generating index page...") index_content = generate_index_page(features) (FEATURES_DIR / "index.mdx").write_text(index_content) From a4dabfa6a984510b041d940e46a287a36b467910 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Mon, 27 Jul 2026 19:15:35 +0100 Subject: [PATCH 8/8] fix(generate-features): address review findings All four from CodeRabbit on #80; the first two are regressions from the earlier commits in this PR. - Escape articleSection. Deriving it from the API replaced a hardcoded literal with attacker-adjacent data written straight into articleSection="...", so a quote in a category name would end the attribute and break the MDX. Render it through _js_str() as a template literal, like title/description/about. Verified quotes, backticks, ${ and backslashes all come out inert. - Don't stamp today's date on an existing page that has dateModified but no datePublished; that dated publication after modification. Fall back to the page's own dateModified. - Only swallow fetch failures. The keep-the-old-page path caught bare Exception, so a bug in this script or a malformed response would be reported as a successful run with a stale page. Restrict to URLError / TimeoutError / JSONDecodeError and chain the abort with `from e`. - Normalise unit_type before the alias lookup, so " Seconde " and "SECONDE" resolve like "seconde" instead of reaching the output raw. Verified: 1,070 table rows across 36 pages still well-formed, zero date churn, a simulated TypeError now propagates while a URLError still preserves the page. --- scripts/generate_features.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/generate_features.py b/scripts/generate_features.py index 586a33a..9696e33 100644 --- a/scripts/generate_features.py +++ b/scripts/generate_features.py @@ -13,6 +13,7 @@ import re import shutil import tempfile +import urllib.error import urllib.request from datetime import datetime, timezone from pathlib import Path @@ -62,6 +63,12 @@ def fetch_all_features() -> list[dict]: return data.get("features", []) +# Failures we accept from fetch_subfeature_detail. Anything else — a bug here, an +# unexpected response shape — must propagate rather than reach the keep-the-old-page +# path, where it would be swallowed and the run would still report success. +_FETCH_ERRORS = (urllib.error.URLError, TimeoutError, json.JSONDecodeError) + + def fetch_subfeature_detail(feature: str, subfeature: str) -> dict: url = f"{INFO_ENDPOINT}/{feature}/{subfeature}?format=simplified" return fetch_json(url) @@ -194,7 +201,7 @@ def format_price(price: float, quantity: int, unit_type: str) -> str: """Return a human-readable price string.""" if price == 0: return "Free" - unit_type = _UNIT_ALIASES.get(unit_type, unit_type) + unit_type = _UNIT_ALIASES.get(unit_type.strip().lower(), unit_type.strip()) if quantity == 1: return f"${price:g} per {unit_type}" # Don't double the plural if the API already sends one ("tokens" -> "tokenss"). @@ -511,7 +518,9 @@ def _render_techarticle_schema( # Preserve the dates already on the page; only a brand-new page gets today's. today = f"{datetime.now(timezone.utc):%Y-%m-%d}T00:00:00Z" date_published, date_modified = _existing_schema_dates(feature, sf_name) - date_published = date_published or today + # An existing page missing datePublished must not be stamped with today: that + # would date publication after modification. Fall back to its dateModified. + date_published = date_published or date_modified or today date_modified = date_modified or date_published return ( 'import { TechArticleSchema } from "/snippets/TechArticleSchema.mdx";\n\n' @@ -519,7 +528,7 @@ def _render_techarticle_schema( f" title={{{_js_str(fullname)}}}\n" f" description={{{_js_str(description)}}}\n" f' path="{path}"\n' - f' articleSection="{section}"\n' + f" articleSection={{{_js_str(section)}}}\n" f" about={{{_js_str(about)}}}\n" f' proficiencyLevel="Intermediate"\n' f" keywords={{{keywords_js}}}\n" @@ -776,7 +785,7 @@ def main() -> None: # Fetch detailed schema try: detail = fetch_subfeature_detail(fname, sf_name) - except Exception as e: + except _FETCH_ERRORS as e: # Never overwrite a good page with a gutted one. Without the # detail response there are no input/output fields, so the page # would publish "_No schema information available._" and lose its @@ -789,7 +798,7 @@ def main() -> None: raise SystemExit( f"ERROR: no detail for {fname}/{sf_name} and no existing page to keep ({e}). " "Aborting rather than publishing a page with no schema." - ) + ) from e content = generate_subfeature_page(fname, sf, detail, feature_section_name(feat)) page_path.write_text(content)