From cf0c48d2bd4039790aabd442fb14f9e058b2d711 Mon Sep 17 00:00:00 2001 From: Alexandre Carvalheira Date: Wed, 19 Aug 2026 16:15:43 -0300 Subject: [PATCH] ci with lint and vale for docs style --- .github/workflows/docs-style.yml | 62 ++++++++++ .vale.ini | 16 +++ scripts/lint-docs.py | 113 +++++++++++++++++++ styles/Fhenix/Condescending.yml | 15 +++ styles/Fhenix/DecorativeUnicode.yml | 17 +++ styles/Fhenix/EmDash.yml | 8 ++ styles/Fhenix/Exclamation.yml | 7 ++ styles/Fhenix/Filler.yml | 15 +++ styles/Fhenix/FutureTense.yml | 7 ++ styles/Fhenix/Headings.yml | 36 ++++++ styles/Fhenix/Idioms.yml | 14 +++ styles/Fhenix/InternalNames.yml | 11 ++ styles/Fhenix/LinkText.yml | 8 ++ styles/Fhenix/Marketing.yml | 18 +++ styles/Fhenix/SentenceLength.yml | 8 ++ styles/Fhenix/SmartQuotes.yml | 10 ++ styles/Fhenix/Terms.yml | 21 ++++ styles/config/vocabularies/Fhenix/accept.txt | 17 +++ 18 files changed, 403 insertions(+) create mode 100644 .github/workflows/docs-style.yml create mode 100644 .vale.ini create mode 100755 scripts/lint-docs.py create mode 100644 styles/Fhenix/Condescending.yml create mode 100644 styles/Fhenix/DecorativeUnicode.yml create mode 100644 styles/Fhenix/EmDash.yml create mode 100644 styles/Fhenix/Exclamation.yml create mode 100644 styles/Fhenix/Filler.yml create mode 100644 styles/Fhenix/FutureTense.yml create mode 100644 styles/Fhenix/Headings.yml create mode 100644 styles/Fhenix/Idioms.yml create mode 100644 styles/Fhenix/InternalNames.yml create mode 100644 styles/Fhenix/LinkText.yml create mode 100644 styles/Fhenix/Marketing.yml create mode 100644 styles/Fhenix/SentenceLength.yml create mode 100644 styles/Fhenix/SmartQuotes.yml create mode 100644 styles/Fhenix/Terms.yml create mode 100644 styles/config/vocabularies/Fhenix/accept.txt diff --git a/.github/workflows/docs-style.yml b/.github/workflows/docs-style.yml new file mode 100644 index 00000000..fc0d5bee --- /dev/null +++ b/.github/workflows/docs-style.yml @@ -0,0 +1,62 @@ +name: Docs style + +on: + pull_request: + paths: + - '**/*.mdx' + - '.vale.ini' + - 'styles/**' + - 'scripts/lint-docs.py' + workflow_dispatch: + inputs: + scope: + description: 'Files to lint' + type: choice + default: changed + options: [changed, all] + +env: + VALE_VERSION: 3.9.6 + +jobs: + style: + name: STYLE.md + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Pages written before the linter existed still contain violations, so a + # pull request is judged only on the files it touches. Run the workflow + # manually with scope=all to see the full backlog. + - name: Collect files to lint + id: files + run: | + if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ inputs.scope }}" != "all" ]; then + git diff --name-only --diff-filter=d \ + "origin/${{ github.base_ref }}...HEAD" -- '*.mdx' > files.txt + else + git ls-files '*.mdx' > files.txt + fi + count=$(wc -l < files.txt | tr -d ' ') + echo "count=$count" >> "$GITHUB_OUTPUT" + echo "Linting $count file(s):" + cat files.txt + + - name: Install Vale + if: steps.files.outputs.count != '0' + run: | + curl -sSL "https://github.com/errata-ai/vale/releases/download/v${VALE_VERSION}/vale_${VALE_VERSION}_Linux_64-bit.tar.gz" \ + | tar -xz -C /usr/local/bin vale + vale --version + + # Frontmatter, heading hierarchy, code fences, link paths, alt text. + - name: Structure + if: steps.files.outputs.count != '0' + run: xargs -a files.txt python3 scripts/lint-docs.py + + # Voice, terminology, punctuation. Errors fail the job, warnings do not. + - name: Prose + if: always() && steps.files.outputs.count != '0' + run: xargs -a files.txt vale diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 00000000..25388ceb --- /dev/null +++ b/.vale.ini @@ -0,0 +1,16 @@ +StylesPath = styles +MinAlertLevel = suggestion + +Vocab = Fhenix + +[formats] +mdx = md + +# Only documentation pages are linted. STYLE.md and README.md are exempt: +# the style guide quotes the words it bans. +[*.mdx] +BasedOnStyles = Fhenix + +# Frontmatter, JSX props, and fenced code blocks are not prose. +TokenIgnores = (\{[^}]*\}) +BlockIgnores = (?s) *```.*?```, (?s)^---\n.*?\n--- diff --git a/scripts/lint-docs.py b/scripts/lint-docs.py new file mode 100755 index 00000000..f2588bad --- /dev/null +++ b/scripts/lint-docs.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Structural checks for Mintlify MDX pages. + +Vale covers prose. This script covers the things Vale cannot see: frontmatter, +heading hierarchy, code fences, link paths, and alt text. It is fence-aware, so +nothing inside a code block is treated as prose or markup. + +Usage: + python3 scripts/lint-docs.py [files...] # defaults to every .mdx in the repo + +Exits 1 if any error is found. See STYLE.md. +""" + +import re +import sys +from pathlib import Path + +GUIDE = "STYLE.md" + + +def parse(path: Path): + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + front, body_start = {}, 0 + if lines and lines[0].strip() == "---": + for i in range(1, len(lines)): + if lines[i].strip() == "---": + body_start = i + 1 + break + m = re.match(r'\s*([A-Za-z0-9_-]+)\s*:\s*(.*)', lines[i]) + if m: + front[m.group(1)] = m.group(2).strip().strip('"\'') + return lines, front, body_start + + +def check(path: Path): + lines, front, body_start = parse(path) + errors = [] + + def err(line_no, rule, message): + errors.append((path, line_no, rule, message)) + + # Snippets are fragments included into pages, so they carry no frontmatter. + is_snippet = "snippets" in path.parts + if not is_snippet: + if not front.get("title"): + err(1, "frontmatter", "Missing frontmatter `title`.") + if not front.get("description"): + err(1, "frontmatter", "Missing frontmatter `description`. One sentence saying what the page covers.") + + in_fence, fence_marker, fence_line = False, "", 0 + last_level = 1 # the H1 Mintlify renders from the frontmatter title + + for i in range(body_start, len(lines)): + line = lines[i] + n = i + 1 + stripped = line.strip() + + fence = re.match(r'^(```+|~~~+)(.*)$', stripped) + if fence: + marker, info = fence.group(1), fence.group(2).strip() + if not in_fence: + in_fence, fence_marker, fence_line = True, marker[:3], n + if not info: + err(n, "code-lang", "Code block has no language tag. Add one, plus a filename where the reader needs to know where the code goes.") + elif marker.startswith(fence_marker): + in_fence = False + continue + if in_fence: + continue + + heading = re.match(r'^(#{1,6})\s+(\S.*)$', line) + if heading: + level, title = len(heading.group(1)), heading.group(2) + if level == 1: + err(n, "manual-h1", "Manual H1. Mintlify renders the page H1 from the frontmatter `title`. Body headings start at `##`.") + elif level > last_level + 1: + err(n, "heading-skip", f"Heading level jumps from h{last_level} to h{level}. Do not skip levels.") + last_level = max(level, 2) if level > 1 else last_level + continue + + for match in re.finditer(r'\]\((\.\.?/[^)\s]*)\)', line): + err(n, "link-path", f"Relative link `{match.group(1)}`. Use a root-relative path, such as `/fhe-library/core-concepts/access-control`.") + for match in re.finditer(r'\]\((https?://(?:cofhe-)?docs\.fhenix\.zone[^)\s]*)\)', line): + err(n, "link-path", f"Absolute link to our own site `{match.group(1)}`. Use a root-relative path so preview deployments resolve.") + if re.search(r'!\[\s*\]\(', line): + err(n, "alt-text", "Image has no alt text. Describe what the image shows.") + for match in re.finditer(r']*>', line): + if 'alt=' not in match.group(0): + err(n, "alt-text", "`` has no `alt` attribute.") + + if in_fence: + err(fence_line, "code-fence", "Code block is never closed.") + + return errors + + +def main(argv): + paths = [Path(a) for a in argv[1:]] or sorted(Path(".").rglob("*.mdx")) + paths = [p for p in paths if p.suffix == ".mdx" and p.exists()] + errors = [] + for path in paths: + errors.extend(check(path)) + + for path, line_no, rule, message in errors: + print(f"{path}:{line_no}: error [{rule}] {message}") + + print(f"\n{len(errors)} error(s) in {len(paths)} file(s). See {GUIDE}.", file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/styles/Fhenix/Condescending.yml b/styles/Fhenix/Condescending.yml new file mode 100644 index 00000000..0f6fe3c2 --- /dev/null +++ b/styles/Fhenix/Condescending.yml @@ -0,0 +1,15 @@ +extends: existence +message: "Avoid '%s'. If the reader is stuck, the word is an insult; if not, it is noise." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#words +level: warning +ignorecase: true +tokens: + - 'simply' + - 'simple' + - 'just' + - 'easy' + - 'easily' + - 'obviously' + - 'of course' + - 'trivial(ly)?' + - 'straightforward' diff --git a/styles/Fhenix/DecorativeUnicode.yml b/styles/Fhenix/DecorativeUnicode.yml new file mode 100644 index 00000000..3f8bf4d9 --- /dev/null +++ b/styles/Fhenix/DecorativeUnicode.yml @@ -0,0 +1,17 @@ +extends: existence +message: "No decorative Unicode ('%s'). Use words: 'Shipped', 'In progress', 'Planned'." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#punctuation-and-symbols +level: error +nonword: true +tokens: + - '→' + - '←' + - '⇒' + - '✅' + - '❌' + - '✔' + - '✖' + - '✨' + - '🚀' + - '🎉' + - '🔥' diff --git a/styles/Fhenix/EmDash.yml b/styles/Fhenix/EmDash.yml new file mode 100644 index 00000000..edbd126c --- /dev/null +++ b/styles/Fhenix/EmDash.yml @@ -0,0 +1,8 @@ +extends: existence +message: "No em dashes. Rewrite with a comma, a period, a colon, or parentheses." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#punctuation-and-symbols +level: error +nonword: true +tokens: + - '—' + - ' -- ' diff --git a/styles/Fhenix/Exclamation.yml b/styles/Fhenix/Exclamation.yml new file mode 100644 index 00000000..44e63693 --- /dev/null +++ b/styles/Fhenix/Exclamation.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Exclamation mark. At most one per tab, preferably zero." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#punctuation-and-symbols +level: warning +nonword: true +tokens: + - '\w!(\s|$)' diff --git a/styles/Fhenix/Filler.yml b/styles/Fhenix/Filler.yml new file mode 100644 index 00000000..4e2f676e --- /dev/null +++ b/styles/Fhenix/Filler.yml @@ -0,0 +1,15 @@ +extends: existence +message: "Filler phrase: '%s'. Cut it or say the thing directly." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#words +level: error +ignorecase: true +tokens: + - 'in order to' + - 'it is important to note that' + - "it's important to note that" + - 'it is worth noting that' + - "it's worth noting that" + - 'please note that' + - 'needless to say' + - 'as you can see' + - 'basically' diff --git a/styles/Fhenix/FutureTense.yml b/styles/Fhenix/FutureTense.yml new file mode 100644 index 00000000..ad38525a --- /dev/null +++ b/styles/Fhenix/FutureTense.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Present tense for how the system works: '%s'. Planned behavior belongs on the future plans page, labeled as planned." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#voice +level: suggestion +ignorecase: true +tokens: + - 'will (be able to )?(send|return|compute|verify|decrypt|encrypt|queue|store|emit|call|allow)' diff --git a/styles/Fhenix/Headings.yml b/styles/Fhenix/Headings.yml new file mode 100644 index 00000000..d3222cc1 --- /dev/null +++ b/styles/Fhenix/Headings.yml @@ -0,0 +1,36 @@ +extends: capitalization +message: "Headings use sentence case: '%s'." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#page-structure +level: warning +scope: heading +match: $sentence +indicators: + - ':' +exceptions: + - CoFHE + - FHE + - FheOS + - Teecryptor + - SDK + - API + - ACL + - TEE + - ZK + - MPC + - EVM + - ERC + - ERC20 + - FHERC20 + - Solidity + - Hardhat + - Foundry + - Fhenix + - TaskManager + - CommitmentRegistry + - Threshold Network + - JavaScript + - TypeScript + - React + - Node + - GitHub + - I diff --git a/styles/Fhenix/Idioms.yml b/styles/Fhenix/Idioms.yml new file mode 100644 index 00000000..2d751b6a --- /dev/null +++ b/styles/Fhenix/Idioms.yml @@ -0,0 +1,14 @@ +extends: existence +message: "Idiom: '%s'. Say what happens instead. Idioms cost non-native readers a lookup." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#words +level: warning +ignorecase: true +tokens: + - 'out of the box' + - 'under the hood' + - 'a piece of cake' + - 'in a nutshell' + - 'the sky is the limit' + - 'hit the ground running' + - 'low-hanging fruit' + - 'bread and butter' diff --git a/styles/Fhenix/InternalNames.yml b/styles/Fhenix/InternalNames.yml new file mode 100644 index 00000000..9705ac73 --- /dev/null +++ b/styles/Fhenix/InternalNames.yml @@ -0,0 +1,11 @@ +extends: existence +message: "Possible internal name: '%s'. Internal hosts, projects, environments, and flags do not belong in public docs." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#terminology +level: warning +ignorecase: true +tokens: + - 'rehearsal' + - 'staging' + - 'aggregator' + - '[a-z0-9-]+\.internal' + - 'gcp project' diff --git a/styles/Fhenix/LinkText.yml b/styles/Fhenix/LinkText.yml new file mode 100644 index 00000000..f9cbad8d --- /dev/null +++ b/styles/Fhenix/LinkText.yml @@ -0,0 +1,8 @@ +extends: existence +message: "Vague link text: '%s'. Describe the destination." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#links-images-and-components +level: warning +scope: raw +ignorecase: true +raw: + - '\[(click here|here|this link|this page|read more|learn more|link)\]\(' diff --git a/styles/Fhenix/Marketing.yml b/styles/Fhenix/Marketing.yml new file mode 100644 index 00000000..c10a2364 --- /dev/null +++ b/styles/Fhenix/Marketing.yml @@ -0,0 +1,18 @@ +extends: existence +message: "Marketing adjective: '%s'. Describe the actual behavior instead." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#words +level: error +ignorecase: true +tokens: + - 'seamless(ly)?' + - 'blazing(ly)?' + - 'cutting-edge' + - 'state-of-the-art' + - 'best-in-class' + - 'game-chang(ing|er)' + - 'revolutionary' + - 'effortless(ly)?' + - 'lightning[- ]fast' + - 'world-class' + - 'robust' + - 'powerful' diff --git a/styles/Fhenix/SentenceLength.yml b/styles/Fhenix/SentenceLength.yml new file mode 100644 index 00000000..a4cf1968 --- /dev/null +++ b/styles/Fhenix/SentenceLength.yml @@ -0,0 +1,8 @@ +extends: occurrence +message: "Sentence is over 30 words. Aim for under 25. Split it." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#sentences-and-paragraphs +level: warning +scope: sentence +ignorecase: false +max: 30 +token: '\b(\w+)\b' diff --git a/styles/Fhenix/SmartQuotes.yml b/styles/Fhenix/SmartQuotes.yml new file mode 100644 index 00000000..1fa00a49 --- /dev/null +++ b/styles/Fhenix/SmartQuotes.yml @@ -0,0 +1,10 @@ +extends: existence +message: "Use straight quotes and apostrophes, not '%s'." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#punctuation-and-symbols +level: warning +nonword: true +tokens: + - '“' + - '”' + - '‘' + - '’' diff --git a/styles/Fhenix/Terms.yml b/styles/Fhenix/Terms.yml new file mode 100644 index 00000000..9b1b3537 --- /dev/null +++ b/styles/Fhenix/Terms.yml @@ -0,0 +1,21 @@ +extends: substitution +message: "Use '%s', not '%s'." +link: https://github.com/FhenixProtocol/fhenix-developer-docs/blob/main/STYLE.md#terminology +level: error +ignorecase: false +swap: + '\bCOFHE\b': CoFHE + '\bCofhe\b': CoFHE + '\bCoFhe\b': CoFHE + '\bFHEOS\b': FheOS + '\bFheos\b': FheOS + '\bTeeCryptor\b': Teecryptor + '\bTEECryptor\b': Teecryptor + '(?i)\bon-chain\b': onchain + '(?i)\boff-chain\b': offchain + '(?i)\bcipher text\b': ciphertext + '(?i)\bplain text\b': plaintext + '(?i)\btest ?net\b(?