@sdxc/spam 2026.10.6 → 2026.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/text.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  const URL_PATTERN = /\b(?:https?:\/\/(?:\[[0-9a-f:.]+\])?|www\.)[^\s"'<>()[\]]*/gi;
13
13
  /** Punctuation a sentence puts after a URL, which is never part of it. */
14
- const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
14
+ const TRAILING_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?"]);
15
15
  /**
16
16
  * The distinct links in `text`, parsed. A `www.` link is read as `http://`; a match that fails to
17
17
  * parse is dropped, so every entry has a host.
@@ -22,7 +22,7 @@ export function extractLinks(text) {
22
22
  let seen = new Set();
23
23
  let links = [];
24
24
  for (let match of text.matchAll(URL_PATTERN)) {
25
- let raw = match[0].replace(TRAILING_PUNCTUATION, "");
25
+ let raw = trimTrailingPunctuation(match[0]);
26
26
  let href = /^www\./i.test(raw) ? `http://${raw}` : raw;
27
27
  if (!URL.canParse(href))
28
28
  continue;
@@ -34,6 +34,16 @@ export function extractLinks(text) {
34
34
  }
35
35
  return links;
36
36
  }
37
+ /**
38
+ * `raw` without the sentence punctuation that ends it. Walking back from the end keeps the cost
39
+ * linear in the match however long a run of punctuation a submission packs into it.
40
+ */
41
+ function trimTrailingPunctuation(raw) {
42
+ let end = raw.length;
43
+ while (end > 0 && TRAILING_PUNCTUATION.has(raw.charAt(end - 1)))
44
+ end -= 1;
45
+ return raw.slice(0, end);
46
+ }
37
47
  /** The words of `text` with its links removed, split on anything that is not a letter or digit. */
38
48
  export function extractWords(text) {
39
49
  return text
@@ -1,9 +1,68 @@
1
- /** A BBCode link, `[url=…]` or `[url]…[/url]`. */
2
- const BBCODE_LINK = /\[url(?:=[^\]]*)?\]/i;
3
- /** An HTML anchor with an `href`. */
4
- const HTML_LINK = /<a\s[^>]*href\s*=/i;
5
- /** A Markdown inline link to an absolute URL. */
6
- const MARKDOWN_LINK = /\[[^\]]+\]\(\s*https?:\/\//i;
1
+ /** The opening of a BBCode link, `[url]` or `[url=`; the latter needs a `]` somewhere after it. */
2
+ const BBCODE_OPEN = /\[url[=\]]/i;
3
+ /** The opening of an HTML anchor, tried only where a `<` stands. */
4
+ const ANCHOR_OPEN = /<a\s/iy;
5
+ /** An `href` attribute, tried only inside an open anchor tag. */
6
+ const HREF_ATTRIBUTE = /href\s*=/iy;
7
+ /** The absolute-URL target that turns a bracketed label into a Markdown link. */
8
+ const MARKDOWN_TARGET = /\(\s*https?:\/\//iy;
9
+ /**
10
+ * Whether `content` holds a BBCode link: `[url]`, or `[url=` with a `]` after it. Only the first
11
+ * opening is checked, since a `]` after any later one also follows the first.
12
+ */
13
+ function hasBbcodeLink(content) {
14
+ let open = BBCODE_OPEN.exec(content);
15
+ if (open === null)
16
+ return false;
17
+ return open[0].endsWith("]") || content.includes("]", open.index + open[0].length);
18
+ }
19
+ /**
20
+ * Whether an HTML anchor tag in `content` carries an `href`. One pass tracks whether the scan is
21
+ * inside an anchor tag opened since the last `>`, so the cost stays linear in the submission.
22
+ */
23
+ function hasHtmlLink(content) {
24
+ let inAnchor = false;
25
+ for (let at = 0; at < content.length; at += 1) {
26
+ let character = content.charAt(at);
27
+ if (character === ">") {
28
+ inAnchor = false;
29
+ }
30
+ else if (character === "<") {
31
+ ANCHOR_OPEN.lastIndex = at;
32
+ if (ANCHOR_OPEN.test(content))
33
+ inAnchor = true;
34
+ }
35
+ else if (inAnchor && (character === "h" || character === "H")) {
36
+ HREF_ATTRIBUTE.lastIndex = at;
37
+ if (HREF_ATTRIBUTE.test(content))
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ /**
44
+ * Whether `content` holds a Markdown inline link to an absolute URL: a non-empty `[label]` followed
45
+ * by `(http…`. One pass remembers the first `[` since the last `]`, so the cost stays linear.
46
+ */
47
+ function hasMarkdownLink(content) {
48
+ let open = -1;
49
+ for (let at = 0; at < content.length; at += 1) {
50
+ let character = content.charAt(at);
51
+ if (character === "[") {
52
+ if (open === -1)
53
+ open = at;
54
+ }
55
+ else if (character === "]") {
56
+ if (open !== -1 && at - open > 1) {
57
+ MARKDOWN_TARGET.lastIndex = at + 1;
58
+ if (MARKDOWN_TARGET.test(content))
59
+ return true;
60
+ }
61
+ open = -1;
62
+ }
63
+ }
64
+ return false;
65
+ }
7
66
  /**
8
67
  * Scores BBCode links in any format, HTML anchors outside `html`, and Markdown links in `text`.
9
68
  * Each kind scores once however many links use it.
@@ -21,21 +80,21 @@ export function linkSyntax(options = {}) {
21
80
  let format = submission.format ?? "text";
22
81
  let content = submission.content;
23
82
  let signals = [];
24
- if (BBCODE_LINK.test(content)) {
83
+ if (hasBbcodeLink(content)) {
25
84
  signals.push({
26
85
  check: "link-syntax.bbcode",
27
86
  score: bbcodeScore,
28
87
  detail: "BBCode link markup",
29
88
  });
30
89
  }
31
- if (format !== "html" && HTML_LINK.test(content)) {
90
+ if (format !== "html" && hasHtmlLink(content)) {
32
91
  signals.push({
33
92
  check: "link-syntax.html",
34
93
  score: htmlScore,
35
94
  detail: `HTML anchors in a ${format} field`,
36
95
  });
37
96
  }
38
- if (format === "text" && MARKDOWN_LINK.test(content)) {
97
+ if (format === "text" && hasMarkdownLink(content)) {
39
98
  signals.push({
40
99
  check: "link-syntax.markdown",
41
100
  score: markdownScore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/spam",
3
- "version": "2026.10.6",
3
+ "version": "2026.10.10",
4
4
  "description": "Spam scoring for user content: local rules, reputation checks and a classifier",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,12 +15,12 @@
15
15
  "./workers-ai": "./dist/workers-ai.js"
16
16
  },
17
17
  "dependencies": {
18
- "@sdxc/doh": "2026.10.6",
19
- "@sdxc/email-address": "2026.10.6",
18
+ "@sdxc/doh": "2026.10.10",
19
+ "@sdxc/email-address": "2026.10.10",
20
20
  "@sdxc/result": "2026.10.6",
21
21
  "remix": "3.0.0"
22
22
  },
23
- "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
23
+ "gitHead": "b61f19c59866708a2d89a5a8f4655ebbc16941b0",
24
24
  "publishConfig": {
25
25
  "access": "public"
26
26
  },