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(?