blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 9ac8801e9a85907fe9b52482c7fb296af16cad71
parent bc2768e6bd9dcb505c109caa95e40115f0a80861
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 05:01:58 +0900

fix: accept what the guard wrongly refused, and say how to fix the rest

- An <abbr> title may contain ">", which is text inside quotes to both
CommonMark and the browser. Whitespace in allowed tags is now the
HTML tokenizer's set rather than \s, which also matched a no-break
space the browser reads as part of the name.
- Satteri percent-encodes the brackets of an IPv6 host, which no URL
parser accepts. The brackets are put back in the authority of an
http(s) link and the repaired address ships; a link that still does
not parse gets its own message instead of a wrong one about schemes.
- The messages now give the working fix: a backslash for a literal < in
prose, <kbd> for a key, the escapes that keep braces at the end of a
heading (each tested; escaping only the opening brace does not work),
and a second list marker instead of an HTML comment between lists.

Diffstat:
Msrc/lib/satteri-guard.mjs | 54+++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 41 insertion(+), 13 deletion(-)

diff --git a/src/lib/satteri-guard.mjs b/src/lib/satteri-guard.mjs
@@ -26,11 +26,25 @@ const LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
 
 /* The allowed tags, removed in one pass. Whatever is left must not open markup the way the
    HTML tokenizer defines it: `<` followed by a letter, `/`, `!` or `?`. A `<` before a
-   space or a digit is text to a browser, so "a < b" and "<3" stay legal. */
-const ALLOWED_TAG =
-  /<\/?(?:kbd|mark|sub|sup|ins)\s*>|<\/abbr\s*>|<abbr\s+title\s*=\s*(?:"[^"<>]*"|'[^'<>]*')\s*>|<br\s*\/?>/gi;
+   space or a digit is text to a browser, so "a < b" and "<3" stay legal.
+   Whitespace is the tokenizer's set, not JavaScript's \s, which would also take a
+   no-break space the browser reads as part of a name. A quoted title may hold `>`, which
+   is text inside quotes to both CommonMark and the browser, but not `<`: that keeps a match
+   from reaching into another tag, so every `<` is an allowed tag or is left to be caught. */
+const WS = '[\\t\\n\\f\\r ]';
+const ALLOWED_TAG = new RegExp(
+  `<\\/?(?:kbd|mark|sub|sup|ins)${WS}*>|<\\/abbr${WS}*>` +
+    `|<abbr${WS}+title${WS}*=${WS}*(?:"[^"<]*"|'[^'<]*')${WS}*>|<br${WS}*\\/?>`,
+  'gi',
+);
 const OPENS_MARKUP = /<[A-Za-z!?/]/;
 
+/* Sätteri percent-encodes [ and ] anywhere in a link destination, so http://[::1]:8080/
+   arrives as http://%5B::1%5D:8080/, which neither the URL parser nor a browser accepts.
+   The brackets are put back in the authority of an http(s) or protocol-relative link only,
+   and that repaired address is what ships. */
+const IPV6_HOST = /^((?:https?:)?\/\/(?:[^/?#@[\]]*@)?)%5B([0-9A-Fa-f:.]+)%5D(?=[:/?#]|$)/i;
+
 const clip = (s) => (s.length > 80 ? `${s.slice(0, 77)}...` : s);
 const resolve = (value) => {
   try { return new URL(String(value), SITE); } catch { return null; }
@@ -39,7 +53,9 @@ const resolve = (value) => {
 const commentError = (text) =>
   new Error(
     `satteri-guard: HTML comment ${JSON.stringify(clip(text))} would be published in the ` +
-      'page source. Delete it; the repository is public as well.',
+      'page source, and the repository is public as well. Delete it. If it was separating ' +
+      'two lists, start the second with a different marker instead ("-" then "*", or "1." ' +
+      'then "1)"): deleting the comment alone merges them into one list.',
   );
 
 export default function satteriGuard() {
@@ -52,8 +68,10 @@ export default function satteriGuard() {
       if (OPENS_MARKUP.test(html.replace(ALLOWED_TAG, '')))
         throw new Error(
           `satteri-guard: raw HTML ${JSON.stringify(clip(html))} is not allowed. ` +
-            'Put markup you are quoting in backticks or a code fence; only <kbd>, <mark>, ' +
-            '<abbr title>, <sub>, <sup>, <ins> and <br> may be written as HTML.',
+            'A literal < in prose takes a backslash (\\<Inception>, List\\<String>). Code, ' +
+            'commands, placeholders and quoted markup go in backticks or a code fence, and a ' +
+            'key goes in <kbd>. Only <kbd>, <mark>, <abbr title>, <sub>, <sup>, <ins> and ' +
+            '<br> may be written as HTML.',
         );
     },
     comment(node) {
@@ -67,21 +85,31 @@ export default function satteriGuard() {
           if (extra.length)
             throw new Error(
               `satteri-guard: heading ${JSON.stringify(clip(ctx.textContent(node)))} carries ` +
-                `${extra.join(', ')}. {…} after a heading may only set #id and .class.`,
+                `${extra.join(', ')}. A {…} at the end of a heading is read as attributes, and ` +
+                'may only set #id and .class. If the braces are text, escape the closing one ' +
+                '({a,b,c\\}), put them in backticks, or end the line with " ##". Escaping ' +
+                'only the opening one (\\{) does not work.',
             );
         },
       },
       {
         filter: ['a'],
-        visit(node) {
-          const href = node.properties?.href;
-          if (href == null) return;
-          const url = resolve(href);
-          if (!url || !LINK_PROTOCOLS.has(url.protocol))
+        visit(node, ctx) {
+          if (node.properties?.href == null) return;
+          const href = String(node.properties.href);
+          const repaired = href.replace(IPV6_HOST, '$1[$2]');
+          const url = resolve(repaired);
+          if (!url)
+            throw new Error(
+              `satteri-guard: link to ${JSON.stringify(clip(href))} could not be parsed as a URL. ` +
+                'Check the host and the port.',
+            );
+          if (!LINK_PROTOCOLS.has(url.protocol))
             throw new Error(
-              `satteri-guard: link to ${JSON.stringify(clip(String(href)))} is not allowed. ` +
+              `satteri-guard: link to ${JSON.stringify(clip(href))} is not allowed. ` +
                 'Links may be http, https, mailto or a path on this site.',
             );
+          if (repaired !== href) ctx.setProperty(node, 'href', repaired);
         },
       },
       {