diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f2b73a481..d6008b609 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,3 +6,8 @@ repos:
         entry: ./documentation/tools/check-glossaries
         language: python
         pass_filenames: false
+      - id: check-confusables
+        name: Check for non-ASCII confusable characters
+        entry: ./documentation/tools/check-confusables
+        language: python
+        pass_filenames: false
diff --git a/documentation/Makefile b/documentation/Makefile
index fe0574537..87a6f8a8b 100644
--- a/documentation/Makefile
+++ b/documentation/Makefile
@@ -37,6 +37,7 @@ clean:
 
 checks:
 	$(SOURCEDIR)/tools/check-glossaries --docs-dir $(SOURCEDIR)
+	$(SOURCEDIR)/tools/check-confusables --docs-dir $(SOURCEDIR)
 
 stylecheck:
 	vale sync
diff --git a/documentation/tools/check-confusables b/documentation/tools/check-confusables
new file mode 100755
index 000000000..f79ee046c
--- /dev/null
+++ b/documentation/tools/check-confusables
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+
+import argparse
+import sys
+
+from pathlib import Path
+
+
+def parse_arguments() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Check documentation sources for non-ASCII typographic "
+                    "characters that should be plain ASCII")
+
+    parser.add_argument("-d", "--docs-dir",
+                        type=Path,
+                        default=Path(__file__).resolve().parent.parent,
+                        help="Path to documentation/ directory in yocto-docs")
+
+    return parser.parse_args()
+
+
+# Map of "confusable" characters that are frequently introduced by editors,
+# word processors or copy-pasting, to their plain ASCII replacement. These
+# look almost identical to regular ASCII but break tooling, e.g. a curly
+# quote in a recipe example causes:
+#
+#   ERROR: ParseError ...: unparsed line: 'RDEPENDS:${PN} = "foo"'
+#
+# Only these characters are flagged; legitimate non-ASCII such as box-drawing
+# characters used in directory trees, accented letters in contributor names
+# and CJK characters are intentionally left alone.
+confusables = {
+    "‘": "'",      # LEFT SINGLE QUOTATION MARK
+    "’": "'",      # RIGHT SINGLE QUOTATION MARK
+    "“": '"',      # LEFT DOUBLE QUOTATION MARK
+    "”": '"',      # RIGHT DOUBLE QUOTATION MARK
+    "′": "'",      # PRIME
+    "″": '"',      # DOUBLE PRIME
+    "–": "-",      # EN DASH
+    "—": "--",     # EM DASH
+    "‐": "-",      # HYPHEN
+    "‑": "-",      # NON-BREAKING HYPHEN
+    "−": "-",      # MINUS SIGN
+    "…": "...",    # HORIZONTAL ELLIPSIS
+    " ": " ",      # NO-BREAK SPACE
+    " ": " ",      # NARROW NO-BREAK SPACE
+    "​": "",       # ZERO WIDTH SPACE
+    "﻿": "",       # ZERO WIDTH NO-BREAK SPACE / BOM
+    "­": "",       # SOFT HYPHEN
+}
+
+
+def main():
+
+    args = parse_arguments()
+    exit_code = 0
+
+    for rst_path in sorted(Path(args.docs_dir).rglob("*.rst")):
+        rel = rst_path.relative_to(args.docs_dir)
+
+        with open(rst_path, "r", encoding="utf-8") as f:
+            for lineno, line in enumerate(f, start=1):
+                for col, char in enumerate(line, start=1):
+                    if char in confusables:
+                        replacement = confusables[char]
+                        hint = (f"'{replacement}'" if replacement
+                                else "(remove)")
+                        print(f"WARNING: {rel}:{lineno}:{col}: non-ASCII "
+                              f"character U+{ord(char):04X} should be "
+                              f"replaced with {hint}")
+                        exit_code = 1
+
+    sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+    main()
