From c99cfdd750f2ee05a60c38077a4ccd127995261e Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 29 Oct 2026 07:57:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(tech-autolink):=20=E9=95=B7=E6=96=87?= =?UTF-8?q?=E3=81=A7=20maxStep=20=E3=82=92=E8=B6=85=E3=81=88=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=82=88=E3=81=86=E5=80=99=E8=A3=9C=E9=A7=86=E5=8B=95?= =?UTF-8?q?=E3=81=AE=E8=B5=B0=E6=9F=BB=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本文を 1 文字ずつ舐める convertLinks は 1 文字 ≒ 45〜100 steps かかり、 1000 字前後で AiScript の maxStep (100k) を超えて「リンクに変換」が エラーで落ちていた (autoConvert オンなら投稿時の interruptor も同様)。 各記法の先頭文字列 (cve- / jvn / RJ… / sm…) の出現位置だけを str.index_of で 拾い、その前後 (英数字の隣接・バッククォートの偶奇・トークン内の URL / # ・ 同じ行の Markdown リンク) を候補ごとに検査する方式にした。接頭辞の無い コミットハッシュは 40 文字おきの標本が 16 進のときだけ前後に広げて連なりを 求める。1 回の変換は 100 件まで。 旧実装をオラクルにした 252 ケースで出力一致。3000 字の最悪ケース (ハッシュ最密) で 68k steps、候補が無ければ 2k steps。 Co-Authored-By: Claude Fable 5.1 --- public/registry/index.json | 12 +- public/registry/plugins.json | 6 +- .../registry/plugins/tech-autolink/api.json | 2 +- .../registry/plugins/tech-autolink/meta.json | 2 +- .../registry/plugins/tech-autolink/plugin.is | 218 +++++++++++------- public/registry/queries.json | 2 +- public/registry/skills.json | 2 +- public/registry/themes.json | 2 +- public/registry/widgets.json | 4 +- 9 files changed, 150 insertions(+), 100 deletions(-) diff --git a/public/registry/index.json b/public/registry/index.json index 3087f23..dfe9183 100644 --- a/public/registry/index.json +++ b/public/registry/index.json @@ -1,24 +1,24 @@ { "version": 1, - "updatedAt": "2026-10-28T19:25:13.411Z", + "updatedAt": "2026-10-28T19:45:51.793Z", "plugins": { "count": 42, - "updatedAt": "2026-10-28T19:25:13.411Z" + "updatedAt": "2026-10-28T19:45:51.793Z" }, "themes": { "count": 77, - "updatedAt": "2026-10-28T19:25:13.411Z" + "updatedAt": "2026-10-28T19:45:51.793Z" }, "widgets": { "count": 70, - "updatedAt": "2026-10-28T19:25:13.411Z" + "updatedAt": "2026-10-28T19:45:51.793Z" }, "skills": { "count": 44, - "updatedAt": "2026-10-28T19:25:13.411Z" + "updatedAt": "2026-10-28T19:45:51.793Z" }, "queries": { "count": 35, - "updatedAt": "2026-10-28T19:25:13.411Z" + "updatedAt": "2026-10-28T19:45:51.793Z" } } diff --git a/public/registry/plugins.json b/public/registry/plugins.json index f8320b5..c84b294 100644 --- a/public/registry/plugins.json +++ b/public/registry/plugins.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T19:25:13.411Z", + "updatedAt": "2026-10-28T19:45:51.793Z", "plugins": [ { "id": "ai-actions", @@ -1868,7 +1868,7 @@ { "id": "tech-autolink", "name": "技術系オートリンク", - "version": "1.0.0", + "version": "1.1.0", "author": "hitalin", "description": "CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにするプラグイン。misskey-tempura の自動リンク化を、リモートにも届く Markdown リンクとして再現したもの", "category": "posting", @@ -1883,7 +1883,7 @@ ], "sourceUrl": "https://store.notedeck.io/registry/plugins/tech-autolink/plugin.is", "apiUrl": "https://store.notedeck.io/registry/plugins/tech-autolink/api.json", - "sha512": "4eaf43dbd5b2c4a7f0ed7dc39ba33459f0796e0da846ff044500a22a007c5ebfd20e8296dd65ebcfdc549ecef75bc6c12af3e0425f76c1b9413f2b539d0507f8", + "sha512": "77dd5639c150ff34661ab3ce28de12128f4985af745dd0f6876fc970c7a93711bf07d19d4f0ea1d585e567d5bf635343d9c66ddc1d73ff078b89aaf549a0608f", "createdAt": "2026-07-31T15:01:56+09:00", "updatedAt": "2026-09-25T18:20:03+09:00", "authorUrl": "https://github.com/hitalin", diff --git a/public/registry/plugins/tech-autolink/api.json b/public/registry/plugins/tech-autolink/api.json index 93da7de..c501396 100644 --- a/public/registry/plugins/tech-autolink/api.json +++ b/public/registry/plugins/tech-autolink/api.json @@ -1 +1 @@ -{"type":"plugin","data":"/// @ 1.2.1\n\n// 技術系オートリンク — CVE / JVN / DLsite / ニコニコ / コミットハッシュを自動リンク化\n//\n// misskey-tempura (taiyme 由来の tms/rj-number) が表示時に行っている\n// 自動リンク化を、投稿時のテキスト変換として再現したもの。\n// 表示時変換と違い Markdown リンクとして本文に残るため、\n// このプラグインを入れていない閲覧者やリモートのサーバーでもリンクになる。\n//\n// 誤爆を避けるため、以下は変換しない:\n// - コード (` / ```) の中\n// - URL の中、Markdown リンク ([ラベル](URL)) の中、ハッシュタグ\n// - 前後に英数字が隣接しているもの (XCVE-2024-1234 / CVE-2024-1234x など)\n\n### {\n\tname: \"技術系オートリンク\"\n\tversion: \"1.0.0\"\n\tauthor: \"@hitalin\"\n\tdescription: \"CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにします\"\n\tconfig: {\n\t\tsecurity: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"セキュリティ情報 (CVE / JVN / JVNVU / JVNTA / JVNDB)\"\n\t\t\tdefault: true\n\t\t}\n\t\tdlsite: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"DLsite (RJ / RE / VJ / VE / BJ / BE / RG など)\"\n\t\t\tdefault: true\n\t\t}\n\t\tnicovideo: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"ニコニコ動画 (sm / nm / so)\"\n\t\t\tdefault: true\n\t\t}\n\t\tgithubCommit: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"GitHub コミットハッシュ (40 桁)\"\n\t\t\tdefault: true\n\t\t}\n\t\tautoConvert: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"投稿時に自動で変換する\"\n\t\t\tdescription: \"オフの場合は投稿フォームのプラグインメニューから手動で変換します\"\n\t\t\tdefault: false\n\t\t}\n\t}\n}\n\nlet DIGITS = \"0123456789\"\nlet HEXCHARS = \"0123456789abcdefABCDEF\"\nlet WORDCHARS = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n\n@isWordChar(c) {\n\t// 空文字に対して incl は true を返すため明示的に除く\n\tc != \"\" && WORDCHARS.incl(c)\n}\n\n@isBreak(c) {\n\tc == \" \" || c == \" \" || c == Str:lf\n}\n\n@sub(chars, from, to) {\n\tchars.slice(from, to).join(\"\")\n}\n\n// chars[start] から allowed に含まれる文字を読み進める\n@readRun(chars, start, allowed) {\n\tvar i = start\n\tloop {\n\t\tif (i >= chars.len) break\n\t\tif (!allowed.incl(chars[i])) break\n\t\ti += 1\n\t}\n\t{\n\t\tvalue: sub(chars, start, i)\n\t\tnext: i\n\t}\n}\n\n// chars の i 文字目から word が (大文字小文字を無視して) 続くか\n@startsWith(chars, i, word) {\n\tlet w = word.split()\n\tif (i + w.len > chars.len) return false\n\tvar ok = true\n\tfor (let k, w.len) {\n\t\tif (chars[i + k].lower() != w[k].lower()) ok = false\n\t}\n\tok\n}\n\n// --- 各記法のマッチャー: 成立すれば { label, url, next }、しなければ null ---\n\n// CVE-2024-1234\n@matchCve(chars, i) {\n\tif (!startsWith(chars, i, \"CVE-\")) return null\n\tlet year = readRun(chars, i + 4, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len < 4) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.cve.org/CVERecord?id={label.upper()}`\n\t\tnext: seq.next\n\t}\n}\n\n// JVNDB-2024-000001\n@matchJvndb(chars, i) {\n\tif (!startsWith(chars, i, \"JVNDB-\")) return null\n\tlet year = readRun(chars, i + 6, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len != 6) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvndb.jvn.jp/en/contents/{year.value}/{label.upper()}.html`\n\t\tnext: seq.next\n\t}\n}\n\n// JVN#12345678 / JVNVU#12345678 / JVNTA#12345678\n@matchJvnFamily(chars, i, prefix, path) {\n\tif (!startsWith(chars, i, prefix)) return null\n\tlet body = readRun(chars, i + prefix.len, `#{DIGITS}`)\n\tif (body.value.len < 8) return null\n\tlet label = sub(chars, i, body.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvn.jp/{path}/{label.replace(\"#\", \"\").upper()}/index.html`\n\t\tnext: body.next\n\t}\n}\n\n// RJ123456 (作品) / RG12345 (サークル)\n@matchDlsite(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tif (!\"RVB\".incl(chars[i])) return null\n\tlet kind = chars[i + 1]\n\tif (\"JE\".incl(kind)) {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 6) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/work/=/product_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tif (kind == \"G\") {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 5) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/circle/profile/=/maker_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tnull\n}\n\n// sm9 / nm12345 / so12345\n@matchNico(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tlet head = `{chars[i]}{chars[i + 1]}`\n\tif (head != \"sm\" && head != \"nm\" && head != \"so\") return null\n\tlet num = readRun(chars, i + 2, DIGITS)\n\tif (num.value.len < 1) return null\n\tlet label = sub(chars, i, num.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.nicovideo.jp/watch/{label}`\n\t\tnext: num.next\n\t}\n}\n\n// 40 桁ちょうどの 16 進 (SHA-1)。64 桁の SHA-256 などは長さで弾かれる\n@matchCommitHash(chars, i) {\n\tlet hex = readRun(chars, i, HEXCHARS)\n\tif (hex.value.len != 40) return null\n\t{\n\t\tlabel: hex.value\n\t\turl: `https://github.com/search?q={hex.value}&type=commits`\n\t\tnext: hex.next\n\t}\n}\n\n// 前方一致が長いものから試す\n@matchAny(chars, i, opts) {\n\tvar hit = null\n\tif (opts.security) {\n\t\tif (hit == null) hit = matchJvndb(chars, i)\n\t\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNVU\", \"vu\")\n\t\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNTA\", \"ta\")\n\t\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVN\", \"jp\")\n\t\tif (hit == null) hit = matchCve(chars, i)\n\t}\n\tif (opts.dlsite && hit == null) hit = matchDlsite(chars, i)\n\tif (opts.nicovideo && hit == null) hit = matchNico(chars, i)\n\tif (opts.githubCommit && hit == null) hit = matchCommitHash(chars, i)\n\thit\n}\n\n// --- 変換対象外の範囲を読み飛ばすための判定 ---\n\n// [ラベル](URL) の終端。成立しなければ null\n@readMdLink(chars, i) {\n\tvar j = i + 1\n\tloop {\n\t\tif (j >= chars.len) return null\n\t\tlet c = chars[j]\n\t\tif (c == \"]\") break\n\t\tif (c == Str:lf) return null\n\t\tj += 1\n\t}\n\tif (j + 1 >= chars.len || chars[j + 1] != \"(\") return null\n\tvar k = j + 2\n\tloop {\n\t\tif (k >= chars.len) return null\n\t\tlet c = chars[k]\n\t\tif (c == \")\") break\n\t\tif (isBreak(c)) return null\n\t\tk += 1\n\t}\n\tk + 1\n}\n\n// 空白までを 1 塊として読み飛ばす終端 (URL / ハッシュタグ用)\n@readUntilBreak(chars, i) {\n\tvar j = i\n\tloop {\n\t\tif (j >= chars.len) break\n\t\tif (isBreak(chars[j])) break\n\t\tj += 1\n\t}\n\tj\n}\n\n@convertLinks(text, opts) {\n\tlet chars = text.split()\n\tvar buf = \"\"\n\tvar count = 0\n\tvar i = 0\n\t// ` の数でコード内かどうかを判定する (``` も 1 文字ずつ反転して辻褄が合う)\n\tvar inCode = false\n\tloop {\n\t\tif (i >= chars.len) break\n\t\tlet ch = chars[i]\n\n\t\tif (ch == \"`\") {\n\t\t\tinCode = !inCode\n\t\t\tbuf = `{buf}{ch}`\n\t\t\ti += 1\n\t\t} elif (inCode) {\n\t\t\tbuf = `{buf}{ch}`\n\t\t\ti += 1\n\t\t} elif (ch == \"[\" && readMdLink(chars, i) != null) {\n\t\t\tlet end = readMdLink(chars, i)\n\t\t\tbuf = `{buf}{sub(chars, i, end)}`\n\t\t\ti = end\n\t\t} elif (startsWith(chars, i, \"https://\") || startsWith(chars, i, \"http://\")) {\n\t\t\tlet end = readUntilBreak(chars, i)\n\t\t\tbuf = `{buf}{sub(chars, i, end)}`\n\t\t\ti = end\n\t\t} elif (ch == \"#\" && (i == 0 || isBreak(chars[i - 1]))) {\n\t\t\tlet end = readUntilBreak(chars, i)\n\t\t\tbuf = `{buf}{sub(chars, i, end)}`\n\t\t\ti = end\n\t\t} elif (i > 0 && isWordChar(chars[i - 1])) {\n\t\t\t// 語の途中からは拾わない\n\t\t\tbuf = `{buf}{ch}`\n\t\t\ti += 1\n\t\t} else {\n\t\t\tlet hit = matchAny(chars, i, opts)\n\t\t\t// 記法の直後に英数字が続くものは誤検出とみなす\n\t\t\tlet tail = if (hit == null || hit.next >= chars.len) \"\" else chars[hit.next]\n\t\t\tif (hit == null || isWordChar(tail)) {\n\t\t\t\tbuf = `{buf}{ch}`\n\t\t\t\ti += 1\n\t\t\t} else {\n\t\t\t\tbuf = `{buf}[{hit.label}]({hit.url})`\n\t\t\t\tcount += 1\n\t\t\t\ti = hit.next\n\t\t\t}\n\t\t}\n\t}\n\t{\n\t\ttext: buf\n\t\tcount: count\n\t}\n}\n\n@currentOpts() {\n\t{\n\t\tsecurity: Plugin:config.security\n\t\tdlsite: Plugin:config.dlsite\n\t\tnicovideo: Plugin:config.nicovideo\n\t\tgithubCommit: Plugin:config.githubCommit\n\t}\n}\n\nPlugin:register_post_form_action(\"リンクに変換\", @(form, update) {\n\tlet text = if (form.text == null) \"\" else form.text\n\tlet result = convertLinks(text, currentOpts())\n\tif (result.count == 0) {\n\t\tMk:dialog(\"技術系オートリンク\", \"リンクにできる記法が見つかりません\", \"info\")\n\t\treturn null\n\t}\n\tupdate(\"text\", result.text)\n\tMk:toast(`{result.count} 件をリンクにしました`, \"success\")\n})\n\nPlugin:register_note_post_interruptor(@(note) {\n\tif (Plugin:config.autoConvert && note.text != null) {\n\t\tnote.text = convertLinks(note.text, currentOpts()).text\n\t}\n\tnote\n})\n"} +{"type":"plugin","data":"/// @ 1.2.1\n\n// 技術系オートリンク — CVE / JVN / DLsite / ニコニコ / コミットハッシュを自動リンク化\n//\n// misskey-tempura (taiyme 由来の tms/rj-number) が表示時に行っている\n// 自動リンク化を、投稿時のテキスト変換として再現したもの。\n// 表示時変換と違い Markdown リンクとして本文に残るため、\n// このプラグインを入れていない閲覧者やリモートのサーバーでもリンクになる。\n//\n// 誤爆を避けるため、以下は変換しない:\n// - コード (` / ```) の中\n// - URL の中、Markdown リンク ([ラベル](URL)) の中、ハッシュタグ\n// - 前後に英数字が隣接しているもの (XCVE-2024-1234 / CVE-2024-1234x など)\n//\n// 本文を 1 文字ずつ舐めると 1000 字前後で maxStep (100k) を超えるので、\n// 各記法の先頭文字列 (CVE- / JVN / RJ / sm …) の出現位置だけを str.index_of で拾い、\n// その前後だけを検査する。コミットハッシュだけは接頭辞が無いので 16 進の連なりを数える。\n\n### {\n\tname: \"技術系オートリンク\"\n\tversion: \"1.1.0\"\n\tauthor: \"@hitalin\"\n\tdescription: \"CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにします\"\n\tconfig: {\n\t\tsecurity: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"セキュリティ情報 (CVE / JVN / JVNVU / JVNTA / JVNDB)\"\n\t\t\tdefault: true\n\t\t}\n\t\tdlsite: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"DLsite (RJ / RE / VJ / VE / BJ / BE / RG など)\"\n\t\t\tdefault: true\n\t\t}\n\t\tnicovideo: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"ニコニコ動画 (sm / nm / so)\"\n\t\t\tdefault: true\n\t\t}\n\t\tgithubCommit: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"GitHub コミットハッシュ (40 桁)\"\n\t\t\tdefault: true\n\t\t}\n\t\tautoConvert: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"投稿時に自動で変換する\"\n\t\t\tdescription: \"オフの場合は投稿フォームのプラグインメニューから手動で変換します\"\n\t\t\tdefault: false\n\t\t}\n\t}\n}\n\nlet DIGITS = \"0123456789\"\nlet HEXCHARS = \"0123456789abcdefABCDEF\"\nlet WORDCHARS = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n\n@isWordChar(c) {\n\t// 空文字に対して incl は true を返すため明示的に除く\n\tc != \"\" && WORDCHARS.incl(c)\n}\n\n@isBreak(c) {\n\tc == \" \" || c == \" \" || c == Str:lf\n}\n\n@sub(chars, from, to) {\n\tchars.slice(from, to).join(\"\")\n}\n\n// chars[start] から allowed に含まれる文字を読み進める\n@readRun(chars, start, allowed) {\n\tvar i = start\n\tloop {\n\t\tif (i >= chars.len) break\n\t\tif (!allowed.incl(chars[i])) break\n\t\ti += 1\n\t}\n\t{\n\t\tvalue: sub(chars, start, i)\n\t\tnext: i\n\t}\n}\n\n// chars の i 文字目から word が (大文字小文字を無視して) 続くか\n@startsWith(chars, i, word) {\n\tlet n = word.split().len\n\tif (i + n > chars.len) return false\n\tsub(chars, i, i + n).lower() == word.lower()\n}\n\n// --- 各記法のマッチャー: 成立すれば { label, url, next }、しなければ null ---\n\n// CVE-2024-1234\n@matchCve(chars, i) {\n\tif (!startsWith(chars, i, \"CVE-\")) return null\n\tlet year = readRun(chars, i + 4, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len < 4) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.cve.org/CVERecord?id={label.upper()}`\n\t\tnext: seq.next\n\t}\n}\n\n// JVNDB-2024-000001\n@matchJvndb(chars, i) {\n\tif (!startsWith(chars, i, \"JVNDB-\")) return null\n\tlet year = readRun(chars, i + 6, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len != 6) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvndb.jvn.jp/en/contents/{year.value}/{label.upper()}.html`\n\t\tnext: seq.next\n\t}\n}\n\n// JVN#12345678 / JVNVU#12345678 / JVNTA#12345678\n@matchJvnFamily(chars, i, prefix, path) {\n\tif (!startsWith(chars, i, prefix)) return null\n\tlet body = readRun(chars, i + prefix.len, `#{DIGITS}`)\n\tif (body.value.len < 8) return null\n\tlet label = sub(chars, i, body.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvn.jp/{path}/{label.replace(\"#\", \"\").upper()}/index.html`\n\t\tnext: body.next\n\t}\n}\n\n// RJ123456 (作品) / RG12345 (サークル)\n@matchDlsite(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tif (!\"RVB\".incl(chars[i])) return null\n\tlet kind = chars[i + 1]\n\tif (\"JE\".incl(kind)) {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 6) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/work/=/product_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tif (kind == \"G\") {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 5) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/circle/profile/=/maker_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tnull\n}\n\n// sm9 / nm12345 / so12345\n@matchNico(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tlet head = `{chars[i]}{chars[i + 1]}`\n\tif (head != \"sm\" && head != \"nm\" && head != \"so\") return null\n\tlet num = readRun(chars, i + 2, DIGITS)\n\tif (num.value.len < 1) return null\n\tlet label = sub(chars, i, num.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.nicovideo.jp/watch/{label}`\n\t\tnext: num.next\n\t}\n}\n\n// JVN 系は前方一致が長いものから試す\n@matchJvn(chars, i) {\n\tvar hit = matchJvndb(chars, i)\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNVU\", \"vu\")\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNTA\", \"ta\")\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVN\", \"jp\")\n\thit\n}\n\n// --- 変換対象外の判定 (候補ごとに前後を見る) ---\n\n// 1 回の変換で処理する上限。同じ記法を数百個並べた本文でもステップ上限に届かないようにする\nlet MAX_LINKS = 100\n\n// [ラベル](URL) の終端。成立しなければ null\n@readMdLink(chars, i) {\n\tvar j = i + 1\n\tloop {\n\t\tif (j >= chars.len) return null\n\t\tlet c = chars[j]\n\t\tif (c == \"]\") break\n\t\tif (c == Str:lf) return null\n\t\tj += 1\n\t}\n\tif (j + 1 >= chars.len || chars[j + 1] != \"(\") return null\n\tvar k = j + 2\n\tloop {\n\t\tif (k >= chars.len) return null\n\t\tlet c = chars[k]\n\t\tif (c == \")\") break\n\t\tif (isBreak(c)) return null\n\t\tk += 1\n\t}\n\tk + 1\n}\n\n// 空白区切りの直前部分 (候補を含むトークンの先頭から候補まで)\n@tokenBefore(before) {\n\tlet a = before.split(Str:lf)\n\tlet b = a[a.len - 1].split(\" \")\n\tlet c = b[b.len - 1].split(\" \")\n\tc[c.len - 1]\n}\n\n// start〜next の範囲をリンクにしてよいか\n@isLinkable(chars, start, next) {\n\t// 前後に英数字が隣接しているものは語の一部とみなす\n\tif (start > 0 && isWordChar(chars[start - 1])) return false\n\tif (next < chars.len && isWordChar(chars[next])) return false\n\tlet before = sub(chars, 0, start)\n\t// ` の数が奇数ならコードの中 (``` も 1 文字ずつ数えて辻褄が合う)\n\tif ((before.split(\"`\").len - 1) % 2 == 1) return false\n\t// URL / ハッシュタグを含むトークンの中\n\tlet tok = tokenBefore(before)\n\tif (tok.incl(\"https://\") || tok.incl(\"http://\")) return false\n\tif (tok.starts_with(\"#\")) return false\n\t// 同じ行の [ラベル](URL) の中\n\tlet lines = before.split(Str:lf)\n\tlet lineStart = start - lines[lines.len - 1].split().len\n\tvar q = chars.index_of(\"[\", lineStart)\n\tloop {\n\t\tif (q < 0 || q >= start) break\n\t\tlet end = readMdLink(chars, q)\n\t\tif (end != null && end > start) return false\n\t\tq = chars.index_of(\"[\", q + 1)\n\t}\n\ttrue\n}\n\n// 候補位置 p で記法 (matcher) が成立し、変換してよければ hits に積む\n@collect(hits, chars, p, matcher) {\n\tif (hits.len >= MAX_LINKS) return null\n\tif (p > 0 && isWordChar(chars[p - 1])) return null\n\tlet hit = matcher(chars, p)\n\tif (hit == null) return null\n\tif (!isLinkable(chars, p, hit.next)) return null\n\thits.push({ start: p, next: hit.next, label: hit.label, url: hit.url })\n}\n\n// haystack 中の needle の出現位置すべてを候補にする\n// (str.index_of と split() はどちらもコードポイント単位なので添字を混ぜてよい)\n@collectByNeedle(hits, chars, haystack, needle, matcher) {\n\tvar p = haystack.index_of(needle, 0)\n\tloop {\n\t\tif (p < 0 || hits.len >= MAX_LINKS) break\n\t\tcollect(hits, chars, p, matcher)\n\t\tp = haystack.index_of(needle, p + 1)\n\t}\n}\n\n// 接頭辞の無いコミットハッシュ (SHA-1、64 桁の SHA-256 などは長さで弾く) は、16 進がちょうど 40 文字連なる位置を候補にする。\n// 40 文字の連なりは 40 文字おきの標本 (39, 79, …) のどれかを必ず含むので、標本が 16 進のときだけ\n// 前後に広げて連なりの範囲を求める (本文全体を 1 文字ずつ見ない)\n@collectHashes(hits, chars) {\n\tlet n = chars.len\n\tvar j = 39\n\tloop {\n\t\tif (j >= n || hits.len >= MAX_LINKS) break\n\t\tif (HEXCHARS.incl(chars[j])) {\n\t\t\tvar s = j\n\t\t\tloop {\n\t\t\t\tif (s > 0 && HEXCHARS.incl(chars[s - 1])) s -= 1\n\t\t\t\telse break\n\t\t\t}\n\t\t\tvar e = j + 1\n\t\t\tloop {\n\t\t\t\tif (e < n && HEXCHARS.incl(chars[e])) e += 1\n\t\t\t\telse break\n\t\t\t}\n\t\t\tif (e - s == 40) {\n\t\t\t\t// 範囲は確定しているので readRun で読み直さない\n\t\t\t\tlet hex = sub(chars, s, e)\n\t\t\t\tcollect(hits, chars, s, @(c, p) {\n\t\t\t\t\t{ label: hex, url: `https://github.com/search?q={hex}&type=commits`, next: e }\n\t\t\t\t})\n\t\t\t}\n\t\t\t// 次の連なりは e + 1 以降にしか始まらない\n\t\t\tj = e + 40\n\t\t} else {\n\t\t\tj += 40\n\t\t}\n\t}\n}\n\n@convertLinks(text, opts) {\n\tlet chars = text.split()\n\t// 大文字小文字を無視する記法は小文字化した本文で探す (lower で長さが変わる文字があれば諦めて原文で探す)\n\tvar lowered = text.lower()\n\tif (lowered.len != text.len) lowered = text\n\tvar hits = []\n\tif (opts.security) {\n\t\tcollectByNeedle(hits, chars, lowered, \"jvn\", matchJvn)\n\t\tcollectByNeedle(hits, chars, lowered, \"cve-\", matchCve)\n\t}\n\tif (opts.dlsite) {\n\t\teach (let needle, [\"RJ\", \"RE\", \"VJ\", \"VE\", \"BJ\", \"BE\", \"RG\"]) {\n\t\t\tcollectByNeedle(hits, chars, text, needle, matchDlsite)\n\t\t}\n\t}\n\tif (opts.nicovideo) {\n\t\teach (let needle, [\"sm\", \"nm\", \"so\"]) {\n\t\t\tcollectByNeedle(hits, chars, text, needle, matchNico)\n\t\t}\n\t}\n\tif (opts.githubCommit) collectHashes(hits, chars)\n\n\t// 出現順に並べ、重なるもの (同じ位置に複数の記法が成立した場合など) は先のものを優先する\n\thits.sort(@(a, b) { a.start - b.start })\n\tvar parts = []\n\tvar count = 0\n\tvar segStart = 0\n\teach (let hit, hits) {\n\t\tif (hit.start >= segStart) {\n\t\t\tparts.push(sub(chars, segStart, hit.start))\n\t\t\tparts.push(`[{hit.label}]({hit.url})`)\n\t\t\tcount += 1\n\t\t\tsegStart = hit.next\n\t\t}\n\t}\n\tparts.push(sub(chars, segStart, chars.len))\n\t{\n\t\ttext: parts.join(\"\")\n\t\tcount: count\n\t}\n}\n\n@currentOpts() {\n\t{\n\t\tsecurity: Plugin:config.security\n\t\tdlsite: Plugin:config.dlsite\n\t\tnicovideo: Plugin:config.nicovideo\n\t\tgithubCommit: Plugin:config.githubCommit\n\t}\n}\n\nPlugin:register_post_form_action(\"リンクに変換\", @(form, update) {\n\tlet text = if (form.text == null) \"\" else form.text\n\tlet result = convertLinks(text, currentOpts())\n\tif (result.count == 0) {\n\t\tMk:dialog(\"技術系オートリンク\", \"リンクにできる記法が見つかりません\", \"info\")\n\t\treturn null\n\t}\n\tupdate(\"text\", result.text)\n\tMk:toast(`{result.count} 件をリンクにしました`, \"success\")\n})\n\nPlugin:register_note_post_interruptor(@(note) {\n\tif (Plugin:config.autoConvert && note.text != null) {\n\t\tnote.text = convertLinks(note.text, currentOpts()).text\n\t}\n\tnote\n})\n"} diff --git a/public/registry/plugins/tech-autolink/meta.json b/public/registry/plugins/tech-autolink/meta.json index a3ba4a5..c4962e1 100644 --- a/public/registry/plugins/tech-autolink/meta.json +++ b/public/registry/plugins/tech-autolink/meta.json @@ -1,7 +1,7 @@ { "id": "tech-autolink", "name": "技術系オートリンク", - "version": "1.0.0", + "version": "1.1.0", "author": "hitalin", "authorUrl": "https://github.com/hitalin", "description": "CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにするプラグイン。misskey-tempura の自動リンク化を、リモートにも届く Markdown リンクとして再現したもの", diff --git a/public/registry/plugins/tech-autolink/plugin.is b/public/registry/plugins/tech-autolink/plugin.is index 8cfc0bb..9f97bb6 100644 --- a/public/registry/plugins/tech-autolink/plugin.is +++ b/public/registry/plugins/tech-autolink/plugin.is @@ -11,10 +11,14 @@ // - コード (` / ```) の中 // - URL の中、Markdown リンク ([ラベル](URL)) の中、ハッシュタグ // - 前後に英数字が隣接しているもの (XCVE-2024-1234 / CVE-2024-1234x など) +// +// 本文を 1 文字ずつ舐めると 1000 字前後で maxStep (100k) を超えるので、 +// 各記法の先頭文字列 (CVE- / JVN / RJ / sm …) の出現位置だけを str.index_of で拾い、 +// その前後だけを検査する。コミットハッシュだけは接頭辞が無いので 16 進の連なりを数える。 ### { name: "技術系オートリンク" - version: "1.0.0" + version: "1.1.0" author: "@hitalin" description: "CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにします" config: { @@ -80,13 +84,9 @@ let WORDCHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // chars の i 文字目から word が (大文字小文字を無視して) 続くか @startsWith(chars, i, word) { - let w = word.split() - if (i + w.len > chars.len) return false - var ok = true - for (let k, w.len) { - if (chars[i + k].lower() != w[k].lower()) ok = false - } - ok + let n = word.split().len + if (i + n > chars.len) return false + sub(chars, i, i + n).lower() == word.lower() } // --- 各記法のマッチャー: 成立すれば { label, url, next }、しなければ null --- @@ -179,34 +179,19 @@ let WORDCHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" } } -// 40 桁ちょうどの 16 進 (SHA-1)。64 桁の SHA-256 などは長さで弾かれる -@matchCommitHash(chars, i) { - let hex = readRun(chars, i, HEXCHARS) - if (hex.value.len != 40) return null - { - label: hex.value - url: `https://github.com/search?q={hex.value}&type=commits` - next: hex.next - } -} - -// 前方一致が長いものから試す -@matchAny(chars, i, opts) { - var hit = null - if (opts.security) { - if (hit == null) hit = matchJvndb(chars, i) - if (hit == null) hit = matchJvnFamily(chars, i, "JVNVU", "vu") - if (hit == null) hit = matchJvnFamily(chars, i, "JVNTA", "ta") - if (hit == null) hit = matchJvnFamily(chars, i, "JVN", "jp") - if (hit == null) hit = matchCve(chars, i) - } - if (opts.dlsite && hit == null) hit = matchDlsite(chars, i) - if (opts.nicovideo && hit == null) hit = matchNico(chars, i) - if (opts.githubCommit && hit == null) hit = matchCommitHash(chars, i) +// JVN 系は前方一致が長いものから試す +@matchJvn(chars, i) { + var hit = matchJvndb(chars, i) + if (hit == null) hit = matchJvnFamily(chars, i, "JVNVU", "vu") + if (hit == null) hit = matchJvnFamily(chars, i, "JVNTA", "ta") + if (hit == null) hit = matchJvnFamily(chars, i, "JVN", "jp") hit } -// --- 変換対象外の範囲を読み飛ばすための判定 --- +// --- 変換対象外の判定 (候補ごとに前後を見る) --- + +// 1 回の変換で処理する上限。同じ記法を数百個並べた本文でもステップ上限に届かないようにする +let MAX_LINKS = 100 // [ラベル](URL) の終端。成立しなければ null @readMdLink(chars, i) { @@ -230,67 +215,132 @@ let WORDCHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" k + 1 } -// 空白までを 1 塊として読み飛ばす終端 (URL / ハッシュタグ用) -@readUntilBreak(chars, i) { - var j = i +// 空白区切りの直前部分 (候補を含むトークンの先頭から候補まで) +@tokenBefore(before) { + let a = before.split(Str:lf) + let b = a[a.len - 1].split(" ") + let c = b[b.len - 1].split(" ") + c[c.len - 1] +} + +// start〜next の範囲をリンクにしてよいか +@isLinkable(chars, start, next) { + // 前後に英数字が隣接しているものは語の一部とみなす + if (start > 0 && isWordChar(chars[start - 1])) return false + if (next < chars.len && isWordChar(chars[next])) return false + let before = sub(chars, 0, start) + // ` の数が奇数ならコードの中 (``` も 1 文字ずつ数えて辻褄が合う) + if ((before.split("`").len - 1) % 2 == 1) return false + // URL / ハッシュタグを含むトークンの中 + let tok = tokenBefore(before) + if (tok.incl("https://") || tok.incl("http://")) return false + if (tok.starts_with("#")) return false + // 同じ行の [ラベル](URL) の中 + let lines = before.split(Str:lf) + let lineStart = start - lines[lines.len - 1].split().len + var q = chars.index_of("[", lineStart) loop { - if (j >= chars.len) break - if (isBreak(chars[j])) break - j += 1 + if (q < 0 || q >= start) break + let end = readMdLink(chars, q) + if (end != null && end > start) return false + q = chars.index_of("[", q + 1) } - j + true } -@convertLinks(text, opts) { - let chars = text.split() - var buf = "" - var count = 0 - var i = 0 - // ` の数でコード内かどうかを判定する (``` も 1 文字ずつ反転して辻褄が合う) - var inCode = false +// 候補位置 p で記法 (matcher) が成立し、変換してよければ hits に積む +@collect(hits, chars, p, matcher) { + if (hits.len >= MAX_LINKS) return null + if (p > 0 && isWordChar(chars[p - 1])) return null + let hit = matcher(chars, p) + if (hit == null) return null + if (!isLinkable(chars, p, hit.next)) return null + hits.push({ start: p, next: hit.next, label: hit.label, url: hit.url }) +} + +// haystack 中の needle の出現位置すべてを候補にする +// (str.index_of と split() はどちらもコードポイント単位なので添字を混ぜてよい) +@collectByNeedle(hits, chars, haystack, needle, matcher) { + var p = haystack.index_of(needle, 0) loop { - if (i >= chars.len) break - let ch = chars[i] + if (p < 0 || hits.len >= MAX_LINKS) break + collect(hits, chars, p, matcher) + p = haystack.index_of(needle, p + 1) + } +} - if (ch == "`") { - inCode = !inCode - buf = `{buf}{ch}` - i += 1 - } elif (inCode) { - buf = `{buf}{ch}` - i += 1 - } elif (ch == "[" && readMdLink(chars, i) != null) { - let end = readMdLink(chars, i) - buf = `{buf}{sub(chars, i, end)}` - i = end - } elif (startsWith(chars, i, "https://") || startsWith(chars, i, "http://")) { - let end = readUntilBreak(chars, i) - buf = `{buf}{sub(chars, i, end)}` - i = end - } elif (ch == "#" && (i == 0 || isBreak(chars[i - 1]))) { - let end = readUntilBreak(chars, i) - buf = `{buf}{sub(chars, i, end)}` - i = end - } elif (i > 0 && isWordChar(chars[i - 1])) { - // 語の途中からは拾わない - buf = `{buf}{ch}` - i += 1 - } else { - let hit = matchAny(chars, i, opts) - // 記法の直後に英数字が続くものは誤検出とみなす - let tail = if (hit == null || hit.next >= chars.len) "" else chars[hit.next] - if (hit == null || isWordChar(tail)) { - buf = `{buf}{ch}` - i += 1 - } else { - buf = `{buf}[{hit.label}]({hit.url})` - count += 1 - i = hit.next +// 接頭辞の無いコミットハッシュ (SHA-1、64 桁の SHA-256 などは長さで弾く) は、16 進がちょうど 40 文字連なる位置を候補にする。 +// 40 文字の連なりは 40 文字おきの標本 (39, 79, …) のどれかを必ず含むので、標本が 16 進のときだけ +// 前後に広げて連なりの範囲を求める (本文全体を 1 文字ずつ見ない) +@collectHashes(hits, chars) { + let n = chars.len + var j = 39 + loop { + if (j >= n || hits.len >= MAX_LINKS) break + if (HEXCHARS.incl(chars[j])) { + var s = j + loop { + if (s > 0 && HEXCHARS.incl(chars[s - 1])) s -= 1 + else break + } + var e = j + 1 + loop { + if (e < n && HEXCHARS.incl(chars[e])) e += 1 + else break } + if (e - s == 40) { + // 範囲は確定しているので readRun で読み直さない + let hex = sub(chars, s, e) + collect(hits, chars, s, @(c, p) { + { label: hex, url: `https://github.com/search?q={hex}&type=commits`, next: e } + }) + } + // 次の連なりは e + 1 以降にしか始まらない + j = e + 40 + } else { + j += 40 + } + } +} + +@convertLinks(text, opts) { + let chars = text.split() + // 大文字小文字を無視する記法は小文字化した本文で探す (lower で長さが変わる文字があれば諦めて原文で探す) + var lowered = text.lower() + if (lowered.len != text.len) lowered = text + var hits = [] + if (opts.security) { + collectByNeedle(hits, chars, lowered, "jvn", matchJvn) + collectByNeedle(hits, chars, lowered, "cve-", matchCve) + } + if (opts.dlsite) { + each (let needle, ["RJ", "RE", "VJ", "VE", "BJ", "BE", "RG"]) { + collectByNeedle(hits, chars, text, needle, matchDlsite) + } + } + if (opts.nicovideo) { + each (let needle, ["sm", "nm", "so"]) { + collectByNeedle(hits, chars, text, needle, matchNico) + } + } + if (opts.githubCommit) collectHashes(hits, chars) + + // 出現順に並べ、重なるもの (同じ位置に複数の記法が成立した場合など) は先のものを優先する + hits.sort(@(a, b) { a.start - b.start }) + var parts = [] + var count = 0 + var segStart = 0 + each (let hit, hits) { + if (hit.start >= segStart) { + parts.push(sub(chars, segStart, hit.start)) + parts.push(`[{hit.label}]({hit.url})`) + count += 1 + segStart = hit.next } } + parts.push(sub(chars, segStart, chars.len)) { - text: buf + text: parts.join("") count: count } } diff --git a/public/registry/queries.json b/public/registry/queries.json index efbdf92..9459b11 100644 --- a/public/registry/queries.json +++ b/public/registry/queries.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T19:25:13.411Z", + "updatedAt": "2026-10-28T19:45:51.793Z", "queries": [ { "id": "bot-only", diff --git a/public/registry/skills.json b/public/registry/skills.json index 815648c..4c4f625 100644 --- a/public/registry/skills.json +++ b/public/registry/skills.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T19:25:13.411Z", + "updatedAt": "2026-10-28T19:45:51.793Z", "skills": [ { "id": "aiscript-surgeon", diff --git a/public/registry/themes.json b/public/registry/themes.json index cb0b04d..b5beb21 100644 --- a/public/registry/themes.json +++ b/public/registry/themes.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T19:25:13.411Z", + "updatedAt": "2026-10-28T19:45:51.793Z", "themes": [ { "id": "adwaita-dark", diff --git a/public/registry/widgets.json b/public/registry/widgets.json index d17f583..e19cb08 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T19:25:13.411Z", + "updatedAt": "2026-10-28T19:45:51.793Z", "widgets": [ { "id": "aiscript-health", @@ -424,7 +424,7 @@ "apiUrl": "https://store.notedeck.io/registry/widgets/discord-presence/api.json", "sha512": "2b682319b8751ed771e26cc7c441b65182dad51530751e32cf5374ae8d8e18617bd461301c607f32f4db48621006e33dc1da722ca8124f87535a21916f1de4e6", "createdAt": "2026-10-29T04:15:03+09:00", - "updatedAt": "2026-10-29T04:15:03+09:00", + "updatedAt": "2026-10-29T04:25:33+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { From 0cc828b99951d765735dc919f3ee74b112c90c86 Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 29 Oct 2026 08:08:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(tech-autolink):=20100=20=E4=BB=B6?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E9=99=90=E3=82=92=E8=A8=98=E6=B3=95=E9=A0=86?= =?UTF-8?q?=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E5=87=BA=E7=8F=BE=E9=A0=86?= =?UTF-8?q?=E3=81=A7=E5=BD=93=E3=81=A6=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 記法ごとに集めて 100 件で打ち切ると、本文先頭の CVE が後方の JVN 100 件に 押し出される (CodeRabbit の指摘)。本文を 200 字ずつ区切り、区間内の候補を 全記法ぶん集めて出現順に並べてから上限を当てるようにした。区間内の検証量は 区間長で抑えられるので、3000 字の最悪ケースでも 70k steps に収まる。 Co-Authored-By: Claude Fable 5.1 --- public/registry/index.json | 12 +- public/registry/plugins.json | 8 +- .../registry/plugins/tech-autolink/api.json | 2 +- .../registry/plugins/tech-autolink/plugin.is | 114 ++++++++++-------- public/registry/queries.json | 2 +- public/registry/skills.json | 2 +- public/registry/themes.json | 2 +- public/registry/widgets.json | 2 +- 8 files changed, 81 insertions(+), 63 deletions(-) diff --git a/public/registry/index.json b/public/registry/index.json index c11ab70..e315cfb 100644 --- a/public/registry/index.json +++ b/public/registry/index.json @@ -1,24 +1,24 @@ { "version": 1, - "updatedAt": "2026-10-28T23:01:33.357Z", + "updatedAt": "2026-10-28T23:08:30.161Z", "plugins": { "count": 43, - "updatedAt": "2026-10-28T23:01:33.357Z" + "updatedAt": "2026-10-28T23:08:30.161Z" }, "themes": { "count": 77, - "updatedAt": "2026-10-28T23:01:33.357Z" + "updatedAt": "2026-10-28T23:08:30.161Z" }, "widgets": { "count": 70, - "updatedAt": "2026-10-28T23:01:33.357Z" + "updatedAt": "2026-10-28T23:08:30.161Z" }, "skills": { "count": 44, - "updatedAt": "2026-10-28T23:01:33.357Z" + "updatedAt": "2026-10-28T23:08:30.161Z" }, "queries": { "count": 35, - "updatedAt": "2026-10-28T23:01:33.357Z" + "updatedAt": "2026-10-28T23:08:30.161Z" } } diff --git a/public/registry/plugins.json b/public/registry/plugins.json index 11aaf4f..a70ffbc 100644 --- a/public/registry/plugins.json +++ b/public/registry/plugins.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T23:01:33.357Z", + "updatedAt": "2026-10-28T23:08:30.161Z", "plugins": [ { "id": "ai-actions", @@ -83,8 +83,8 @@ "sourceUrl": "https://store.notedeck.io/registry/plugins/etherscan-autolink/plugin.is", "apiUrl": "https://store.notedeck.io/registry/plugins/etherscan-autolink/api.json", "sha512": "2232153b654aafcd0d63e304d9cfeeed0b2021e6e0395e3bdb7089a5cc045547b4ddb36371b08b580fa48f94a141e47e2d7b810cc3434fe0c344ddf6b010ab1c", - "createdAt": "2026-10-28T23:01:33.839Z", - "updatedAt": "2026-10-28T23:01:33.839Z", + "createdAt": "2026-10-29T04:34:22+09:00", + "updatedAt": "2026-10-29T04:35:41+09:00", "authorUrl": "https://github.com/hitalin", "license": "MIT", "locales": { @@ -1932,7 +1932,7 @@ ], "sourceUrl": "https://store.notedeck.io/registry/plugins/tech-autolink/plugin.is", "apiUrl": "https://store.notedeck.io/registry/plugins/tech-autolink/api.json", - "sha512": "77dd5639c150ff34661ab3ce28de12128f4985af745dd0f6876fc970c7a93711bf07d19d4f0ea1d585e567d5bf635343d9c66ddc1d73ff078b89aaf549a0608f", + "sha512": "e5e2d778c1b30c7a34bd4de6cc3a34f88b839887794f569f3989e04723bfa1b981c3c50a275e14d0538bde393f88dc9344f4d5f3a44fe1670ab5ab60439c56ec", "createdAt": "2026-07-31T15:01:56+09:00", "updatedAt": "2026-10-29T07:57:04+09:00", "authorUrl": "https://github.com/hitalin", diff --git a/public/registry/plugins/tech-autolink/api.json b/public/registry/plugins/tech-autolink/api.json index c501396..d76ccbe 100644 --- a/public/registry/plugins/tech-autolink/api.json +++ b/public/registry/plugins/tech-autolink/api.json @@ -1 +1 @@ -{"type":"plugin","data":"/// @ 1.2.1\n\n// 技術系オートリンク — CVE / JVN / DLsite / ニコニコ / コミットハッシュを自動リンク化\n//\n// misskey-tempura (taiyme 由来の tms/rj-number) が表示時に行っている\n// 自動リンク化を、投稿時のテキスト変換として再現したもの。\n// 表示時変換と違い Markdown リンクとして本文に残るため、\n// このプラグインを入れていない閲覧者やリモートのサーバーでもリンクになる。\n//\n// 誤爆を避けるため、以下は変換しない:\n// - コード (` / ```) の中\n// - URL の中、Markdown リンク ([ラベル](URL)) の中、ハッシュタグ\n// - 前後に英数字が隣接しているもの (XCVE-2024-1234 / CVE-2024-1234x など)\n//\n// 本文を 1 文字ずつ舐めると 1000 字前後で maxStep (100k) を超えるので、\n// 各記法の先頭文字列 (CVE- / JVN / RJ / sm …) の出現位置だけを str.index_of で拾い、\n// その前後だけを検査する。コミットハッシュだけは接頭辞が無いので 16 進の連なりを数える。\n\n### {\n\tname: \"技術系オートリンク\"\n\tversion: \"1.1.0\"\n\tauthor: \"@hitalin\"\n\tdescription: \"CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにします\"\n\tconfig: {\n\t\tsecurity: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"セキュリティ情報 (CVE / JVN / JVNVU / JVNTA / JVNDB)\"\n\t\t\tdefault: true\n\t\t}\n\t\tdlsite: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"DLsite (RJ / RE / VJ / VE / BJ / BE / RG など)\"\n\t\t\tdefault: true\n\t\t}\n\t\tnicovideo: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"ニコニコ動画 (sm / nm / so)\"\n\t\t\tdefault: true\n\t\t}\n\t\tgithubCommit: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"GitHub コミットハッシュ (40 桁)\"\n\t\t\tdefault: true\n\t\t}\n\t\tautoConvert: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"投稿時に自動で変換する\"\n\t\t\tdescription: \"オフの場合は投稿フォームのプラグインメニューから手動で変換します\"\n\t\t\tdefault: false\n\t\t}\n\t}\n}\n\nlet DIGITS = \"0123456789\"\nlet HEXCHARS = \"0123456789abcdefABCDEF\"\nlet WORDCHARS = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n\n@isWordChar(c) {\n\t// 空文字に対して incl は true を返すため明示的に除く\n\tc != \"\" && WORDCHARS.incl(c)\n}\n\n@isBreak(c) {\n\tc == \" \" || c == \" \" || c == Str:lf\n}\n\n@sub(chars, from, to) {\n\tchars.slice(from, to).join(\"\")\n}\n\n// chars[start] から allowed に含まれる文字を読み進める\n@readRun(chars, start, allowed) {\n\tvar i = start\n\tloop {\n\t\tif (i >= chars.len) break\n\t\tif (!allowed.incl(chars[i])) break\n\t\ti += 1\n\t}\n\t{\n\t\tvalue: sub(chars, start, i)\n\t\tnext: i\n\t}\n}\n\n// chars の i 文字目から word が (大文字小文字を無視して) 続くか\n@startsWith(chars, i, word) {\n\tlet n = word.split().len\n\tif (i + n > chars.len) return false\n\tsub(chars, i, i + n).lower() == word.lower()\n}\n\n// --- 各記法のマッチャー: 成立すれば { label, url, next }、しなければ null ---\n\n// CVE-2024-1234\n@matchCve(chars, i) {\n\tif (!startsWith(chars, i, \"CVE-\")) return null\n\tlet year = readRun(chars, i + 4, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len < 4) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.cve.org/CVERecord?id={label.upper()}`\n\t\tnext: seq.next\n\t}\n}\n\n// JVNDB-2024-000001\n@matchJvndb(chars, i) {\n\tif (!startsWith(chars, i, \"JVNDB-\")) return null\n\tlet year = readRun(chars, i + 6, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len != 6) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvndb.jvn.jp/en/contents/{year.value}/{label.upper()}.html`\n\t\tnext: seq.next\n\t}\n}\n\n// JVN#12345678 / JVNVU#12345678 / JVNTA#12345678\n@matchJvnFamily(chars, i, prefix, path) {\n\tif (!startsWith(chars, i, prefix)) return null\n\tlet body = readRun(chars, i + prefix.len, `#{DIGITS}`)\n\tif (body.value.len < 8) return null\n\tlet label = sub(chars, i, body.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvn.jp/{path}/{label.replace(\"#\", \"\").upper()}/index.html`\n\t\tnext: body.next\n\t}\n}\n\n// RJ123456 (作品) / RG12345 (サークル)\n@matchDlsite(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tif (!\"RVB\".incl(chars[i])) return null\n\tlet kind = chars[i + 1]\n\tif (\"JE\".incl(kind)) {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 6) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/work/=/product_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tif (kind == \"G\") {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 5) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/circle/profile/=/maker_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tnull\n}\n\n// sm9 / nm12345 / so12345\n@matchNico(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tlet head = `{chars[i]}{chars[i + 1]}`\n\tif (head != \"sm\" && head != \"nm\" && head != \"so\") return null\n\tlet num = readRun(chars, i + 2, DIGITS)\n\tif (num.value.len < 1) return null\n\tlet label = sub(chars, i, num.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.nicovideo.jp/watch/{label}`\n\t\tnext: num.next\n\t}\n}\n\n// JVN 系は前方一致が長いものから試す\n@matchJvn(chars, i) {\n\tvar hit = matchJvndb(chars, i)\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNVU\", \"vu\")\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNTA\", \"ta\")\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVN\", \"jp\")\n\thit\n}\n\n// --- 変換対象外の判定 (候補ごとに前後を見る) ---\n\n// 1 回の変換で処理する上限。同じ記法を数百個並べた本文でもステップ上限に届かないようにする\nlet MAX_LINKS = 100\n\n// [ラベル](URL) の終端。成立しなければ null\n@readMdLink(chars, i) {\n\tvar j = i + 1\n\tloop {\n\t\tif (j >= chars.len) return null\n\t\tlet c = chars[j]\n\t\tif (c == \"]\") break\n\t\tif (c == Str:lf) return null\n\t\tj += 1\n\t}\n\tif (j + 1 >= chars.len || chars[j + 1] != \"(\") return null\n\tvar k = j + 2\n\tloop {\n\t\tif (k >= chars.len) return null\n\t\tlet c = chars[k]\n\t\tif (c == \")\") break\n\t\tif (isBreak(c)) return null\n\t\tk += 1\n\t}\n\tk + 1\n}\n\n// 空白区切りの直前部分 (候補を含むトークンの先頭から候補まで)\n@tokenBefore(before) {\n\tlet a = before.split(Str:lf)\n\tlet b = a[a.len - 1].split(\" \")\n\tlet c = b[b.len - 1].split(\" \")\n\tc[c.len - 1]\n}\n\n// start〜next の範囲をリンクにしてよいか\n@isLinkable(chars, start, next) {\n\t// 前後に英数字が隣接しているものは語の一部とみなす\n\tif (start > 0 && isWordChar(chars[start - 1])) return false\n\tif (next < chars.len && isWordChar(chars[next])) return false\n\tlet before = sub(chars, 0, start)\n\t// ` の数が奇数ならコードの中 (``` も 1 文字ずつ数えて辻褄が合う)\n\tif ((before.split(\"`\").len - 1) % 2 == 1) return false\n\t// URL / ハッシュタグを含むトークンの中\n\tlet tok = tokenBefore(before)\n\tif (tok.incl(\"https://\") || tok.incl(\"http://\")) return false\n\tif (tok.starts_with(\"#\")) return false\n\t// 同じ行の [ラベル](URL) の中\n\tlet lines = before.split(Str:lf)\n\tlet lineStart = start - lines[lines.len - 1].split().len\n\tvar q = chars.index_of(\"[\", lineStart)\n\tloop {\n\t\tif (q < 0 || q >= start) break\n\t\tlet end = readMdLink(chars, q)\n\t\tif (end != null && end > start) return false\n\t\tq = chars.index_of(\"[\", q + 1)\n\t}\n\ttrue\n}\n\n// 候補位置 p で記法 (matcher) が成立し、変換してよければ hits に積む\n@collect(hits, chars, p, matcher) {\n\tif (hits.len >= MAX_LINKS) return null\n\tif (p > 0 && isWordChar(chars[p - 1])) return null\n\tlet hit = matcher(chars, p)\n\tif (hit == null) return null\n\tif (!isLinkable(chars, p, hit.next)) return null\n\thits.push({ start: p, next: hit.next, label: hit.label, url: hit.url })\n}\n\n// haystack 中の needle の出現位置すべてを候補にする\n// (str.index_of と split() はどちらもコードポイント単位なので添字を混ぜてよい)\n@collectByNeedle(hits, chars, haystack, needle, matcher) {\n\tvar p = haystack.index_of(needle, 0)\n\tloop {\n\t\tif (p < 0 || hits.len >= MAX_LINKS) break\n\t\tcollect(hits, chars, p, matcher)\n\t\tp = haystack.index_of(needle, p + 1)\n\t}\n}\n\n// 接頭辞の無いコミットハッシュ (SHA-1、64 桁の SHA-256 などは長さで弾く) は、16 進がちょうど 40 文字連なる位置を候補にする。\n// 40 文字の連なりは 40 文字おきの標本 (39, 79, …) のどれかを必ず含むので、標本が 16 進のときだけ\n// 前後に広げて連なりの範囲を求める (本文全体を 1 文字ずつ見ない)\n@collectHashes(hits, chars) {\n\tlet n = chars.len\n\tvar j = 39\n\tloop {\n\t\tif (j >= n || hits.len >= MAX_LINKS) break\n\t\tif (HEXCHARS.incl(chars[j])) {\n\t\t\tvar s = j\n\t\t\tloop {\n\t\t\t\tif (s > 0 && HEXCHARS.incl(chars[s - 1])) s -= 1\n\t\t\t\telse break\n\t\t\t}\n\t\t\tvar e = j + 1\n\t\t\tloop {\n\t\t\t\tif (e < n && HEXCHARS.incl(chars[e])) e += 1\n\t\t\t\telse break\n\t\t\t}\n\t\t\tif (e - s == 40) {\n\t\t\t\t// 範囲は確定しているので readRun で読み直さない\n\t\t\t\tlet hex = sub(chars, s, e)\n\t\t\t\tcollect(hits, chars, s, @(c, p) {\n\t\t\t\t\t{ label: hex, url: `https://github.com/search?q={hex}&type=commits`, next: e }\n\t\t\t\t})\n\t\t\t}\n\t\t\t// 次の連なりは e + 1 以降にしか始まらない\n\t\t\tj = e + 40\n\t\t} else {\n\t\t\tj += 40\n\t\t}\n\t}\n}\n\n@convertLinks(text, opts) {\n\tlet chars = text.split()\n\t// 大文字小文字を無視する記法は小文字化した本文で探す (lower で長さが変わる文字があれば諦めて原文で探す)\n\tvar lowered = text.lower()\n\tif (lowered.len != text.len) lowered = text\n\tvar hits = []\n\tif (opts.security) {\n\t\tcollectByNeedle(hits, chars, lowered, \"jvn\", matchJvn)\n\t\tcollectByNeedle(hits, chars, lowered, \"cve-\", matchCve)\n\t}\n\tif (opts.dlsite) {\n\t\teach (let needle, [\"RJ\", \"RE\", \"VJ\", \"VE\", \"BJ\", \"BE\", \"RG\"]) {\n\t\t\tcollectByNeedle(hits, chars, text, needle, matchDlsite)\n\t\t}\n\t}\n\tif (opts.nicovideo) {\n\t\teach (let needle, [\"sm\", \"nm\", \"so\"]) {\n\t\t\tcollectByNeedle(hits, chars, text, needle, matchNico)\n\t\t}\n\t}\n\tif (opts.githubCommit) collectHashes(hits, chars)\n\n\t// 出現順に並べ、重なるもの (同じ位置に複数の記法が成立した場合など) は先のものを優先する\n\thits.sort(@(a, b) { a.start - b.start })\n\tvar parts = []\n\tvar count = 0\n\tvar segStart = 0\n\teach (let hit, hits) {\n\t\tif (hit.start >= segStart) {\n\t\t\tparts.push(sub(chars, segStart, hit.start))\n\t\t\tparts.push(`[{hit.label}]({hit.url})`)\n\t\t\tcount += 1\n\t\t\tsegStart = hit.next\n\t\t}\n\t}\n\tparts.push(sub(chars, segStart, chars.len))\n\t{\n\t\ttext: parts.join(\"\")\n\t\tcount: count\n\t}\n}\n\n@currentOpts() {\n\t{\n\t\tsecurity: Plugin:config.security\n\t\tdlsite: Plugin:config.dlsite\n\t\tnicovideo: Plugin:config.nicovideo\n\t\tgithubCommit: Plugin:config.githubCommit\n\t}\n}\n\nPlugin:register_post_form_action(\"リンクに変換\", @(form, update) {\n\tlet text = if (form.text == null) \"\" else form.text\n\tlet result = convertLinks(text, currentOpts())\n\tif (result.count == 0) {\n\t\tMk:dialog(\"技術系オートリンク\", \"リンクにできる記法が見つかりません\", \"info\")\n\t\treturn null\n\t}\n\tupdate(\"text\", result.text)\n\tMk:toast(`{result.count} 件をリンクにしました`, \"success\")\n})\n\nPlugin:register_note_post_interruptor(@(note) {\n\tif (Plugin:config.autoConvert && note.text != null) {\n\t\tnote.text = convertLinks(note.text, currentOpts()).text\n\t}\n\tnote\n})\n"} +{"type":"plugin","data":"/// @ 1.2.1\n\n// 技術系オートリンク — CVE / JVN / DLsite / ニコニコ / コミットハッシュを自動リンク化\n//\n// misskey-tempura (taiyme 由来の tms/rj-number) が表示時に行っている\n// 自動リンク化を、投稿時のテキスト変換として再現したもの。\n// 表示時変換と違い Markdown リンクとして本文に残るため、\n// このプラグインを入れていない閲覧者やリモートのサーバーでもリンクになる。\n//\n// 誤爆を避けるため、以下は変換しない:\n// - コード (` / ```) の中\n// - URL の中、Markdown リンク ([ラベル](URL)) の中、ハッシュタグ\n// - 前後に英数字が隣接しているもの (XCVE-2024-1234 / CVE-2024-1234x など)\n//\n// 本文を 1 文字ずつ舐めると 1000 字前後で maxStep (100k) を超えるので、\n// 各記法の先頭文字列 (CVE- / JVN / RJ / sm …) の出現位置だけを str.index_of で拾い、\n// その前後だけを検査する。コミットハッシュだけは接頭辞が無いので 16 進の連なりを数える。\n\n### {\n\tname: \"技術系オートリンク\"\n\tversion: \"1.1.0\"\n\tauthor: \"@hitalin\"\n\tdescription: \"CVE / JVN / JVNVU / JVNTA / JVNDB / DLsite / ニコニコ動画 / GitHub コミットハッシュを投稿時に自動でリンクにします\"\n\tconfig: {\n\t\tsecurity: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"セキュリティ情報 (CVE / JVN / JVNVU / JVNTA / JVNDB)\"\n\t\t\tdefault: true\n\t\t}\n\t\tdlsite: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"DLsite (RJ / RE / VJ / VE / BJ / BE / RG など)\"\n\t\t\tdefault: true\n\t\t}\n\t\tnicovideo: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"ニコニコ動画 (sm / nm / so)\"\n\t\t\tdefault: true\n\t\t}\n\t\tgithubCommit: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"GitHub コミットハッシュ (40 桁)\"\n\t\t\tdefault: true\n\t\t}\n\t\tautoConvert: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"投稿時に自動で変換する\"\n\t\t\tdescription: \"オフの場合は投稿フォームのプラグインメニューから手動で変換します\"\n\t\t\tdefault: false\n\t\t}\n\t}\n}\n\nlet DIGITS = \"0123456789\"\nlet HEXCHARS = \"0123456789abcdefABCDEF\"\nlet WORDCHARS = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n\n@isWordChar(c) {\n\t// 空文字に対して incl は true を返すため明示的に除く\n\tc != \"\" && WORDCHARS.incl(c)\n}\n\n@isBreak(c) {\n\tc == \" \" || c == \" \" || c == Str:lf\n}\n\n@sub(chars, from, to) {\n\tchars.slice(from, to).join(\"\")\n}\n\n// chars[start] から allowed に含まれる文字を読み進める\n@readRun(chars, start, allowed) {\n\tvar i = start\n\tloop {\n\t\tif (i >= chars.len) break\n\t\tif (!allowed.incl(chars[i])) break\n\t\ti += 1\n\t}\n\t{\n\t\tvalue: sub(chars, start, i)\n\t\tnext: i\n\t}\n}\n\n// chars の i 文字目から word が (大文字小文字を無視して) 続くか\n@startsWith(chars, i, word) {\n\tlet n = word.split().len\n\tif (i + n > chars.len) return false\n\tsub(chars, i, i + n).lower() == word.lower()\n}\n\n// --- 各記法のマッチャー: 成立すれば { label, url, next }、しなければ null ---\n\n// CVE-2024-1234\n@matchCve(chars, i) {\n\tif (!startsWith(chars, i, \"CVE-\")) return null\n\tlet year = readRun(chars, i + 4, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len < 4) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.cve.org/CVERecord?id={label.upper()}`\n\t\tnext: seq.next\n\t}\n}\n\n// JVNDB-2024-000001\n@matchJvndb(chars, i) {\n\tif (!startsWith(chars, i, \"JVNDB-\")) return null\n\tlet year = readRun(chars, i + 6, DIGITS)\n\tif (year.value.len != 4) return null\n\tif (year.next >= chars.len || chars[year.next] != \"-\") return null\n\tlet seq = readRun(chars, year.next + 1, DIGITS)\n\tif (seq.value.len != 6) return null\n\tlet label = sub(chars, i, seq.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvndb.jvn.jp/en/contents/{year.value}/{label.upper()}.html`\n\t\tnext: seq.next\n\t}\n}\n\n// JVN#12345678 / JVNVU#12345678 / JVNTA#12345678\n@matchJvnFamily(chars, i, prefix, path) {\n\tif (!startsWith(chars, i, prefix)) return null\n\tlet body = readRun(chars, i + prefix.len, `#{DIGITS}`)\n\tif (body.value.len < 8) return null\n\tlet label = sub(chars, i, body.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://jvn.jp/{path}/{label.replace(\"#\", \"\").upper()}/index.html`\n\t\tnext: body.next\n\t}\n}\n\n// RJ123456 (作品) / RG12345 (サークル)\n@matchDlsite(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tif (!\"RVB\".incl(chars[i])) return null\n\tlet kind = chars[i + 1]\n\tif (\"JE\".incl(kind)) {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 6) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/work/=/product_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tif (kind == \"G\") {\n\t\tlet num = readRun(chars, i + 2, DIGITS)\n\t\tif (num.value.len < 5) return null\n\t\tlet label = sub(chars, i, num.next)\n\t\treturn {\n\t\t\tlabel: label\n\t\t\turl: `https://www.dlsite.com/home/circle/profile/=/maker_id/{label}.html`\n\t\t\tnext: num.next\n\t\t}\n\t}\n\tnull\n}\n\n// sm9 / nm12345 / so12345\n@matchNico(chars, i) {\n\tif (i + 1 >= chars.len) return null\n\tlet head = `{chars[i]}{chars[i + 1]}`\n\tif (head != \"sm\" && head != \"nm\" && head != \"so\") return null\n\tlet num = readRun(chars, i + 2, DIGITS)\n\tif (num.value.len < 1) return null\n\tlet label = sub(chars, i, num.next)\n\t{\n\t\tlabel: label\n\t\turl: `https://www.nicovideo.jp/watch/{label}`\n\t\tnext: num.next\n\t}\n}\n\n// JVN 系は前方一致が長いものから試す\n@matchJvn(chars, i) {\n\tvar hit = matchJvndb(chars, i)\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNVU\", \"vu\")\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVNTA\", \"ta\")\n\tif (hit == null) hit = matchJvnFamily(chars, i, \"JVN\", \"jp\")\n\thit\n}\n\n// --- 変換対象外の判定 (候補ごとに前後を見る) ---\n\n// [ラベル](URL) の終端。成立しなければ null\n@readMdLink(chars, i) {\n\tvar j = i + 1\n\tloop {\n\t\tif (j >= chars.len) return null\n\t\tlet c = chars[j]\n\t\tif (c == \"]\") break\n\t\tif (c == Str:lf) return null\n\t\tj += 1\n\t}\n\tif (j + 1 >= chars.len || chars[j + 1] != \"(\") return null\n\tvar k = j + 2\n\tloop {\n\t\tif (k >= chars.len) return null\n\t\tlet c = chars[k]\n\t\tif (c == \")\") break\n\t\tif (isBreak(c)) return null\n\t\tk += 1\n\t}\n\tk + 1\n}\n\n// 空白区切りの直前部分 (候補を含むトークンの先頭から候補まで)\n@tokenBefore(before) {\n\tlet a = before.split(Str:lf)\n\tlet b = a[a.len - 1].split(\" \")\n\tlet c = b[b.len - 1].split(\" \")\n\tc[c.len - 1]\n}\n\n// start〜next の範囲をリンクにしてよいか\n@isLinkable(chars, start, next) {\n\t// 前後に英数字が隣接しているものは語の一部とみなす\n\tif (start > 0 && isWordChar(chars[start - 1])) return false\n\tif (next < chars.len && isWordChar(chars[next])) return false\n\tlet before = sub(chars, 0, start)\n\t// ` の数が奇数ならコードの中 (``` も 1 文字ずつ数えて辻褄が合う)\n\tif ((before.split(\"`\").len - 1) % 2 == 1) return false\n\t// URL / ハッシュタグを含むトークンの中\n\tlet tok = tokenBefore(before)\n\tif (tok.incl(\"https://\") || tok.incl(\"http://\")) return false\n\tif (tok.starts_with(\"#\")) return false\n\t// 同じ行の [ラベル](URL) の中\n\tlet lines = before.split(Str:lf)\n\tlet lineStart = start - lines[lines.len - 1].split().len\n\tvar q = chars.index_of(\"[\", lineStart)\n\tloop {\n\t\tif (q < 0 || q >= start) break\n\t\tlet end = readMdLink(chars, q)\n\t\tif (end != null && end > start) return false\n\t\tq = chars.index_of(\"[\", q + 1)\n\t}\n\ttrue\n}\n\n// 候補位置 p で記法 (matcher) が成立し、変換してよければ hits に積む\n@collect(hits, chars, p, matcher) {\n\tif (p > 0 && isWordChar(chars[p - 1])) return null\n\tlet hit = matcher(chars, p)\n\tif (hit == null) return null\n\tif (!isLinkable(chars, p, hit.next)) return null\n\thits.push({ start: p, next: hit.next, label: hit.label, url: hit.url })\n}\n\n// 接頭辞の無いコミットハッシュ (SHA-1、64 桁の SHA-256 などは長さで弾く) は、16 進がちょうど\n// 40 文字連なる位置を候補にする。40 文字の連なりは 40 文字おきの標本 (from+39, from+79, …) の\n// どれかを必ず含むので、標本が 16 進のときだけ前後に広げて連なりの範囲を求める\n// (本文全体を 1 文字ずつ見ない)。from 以降で最初の連なり { s, e } を返す。無ければ null\n@nextHashRun(chars, from) {\n\tlet n = chars.len\n\tvar j = from + 39\n\tloop {\n\t\tif (j >= n) break\n\t\tif (HEXCHARS.incl(chars[j])) {\n\t\t\tvar s = j\n\t\t\tloop {\n\t\t\t\tif (s > from && HEXCHARS.incl(chars[s - 1])) s -= 1\n\t\t\t\telse break\n\t\t\t}\n\t\t\tvar e = j + 1\n\t\t\tloop {\n\t\t\t\tif (e < n && HEXCHARS.incl(chars[e])) e += 1\n\t\t\t\telse break\n\t\t\t}\n\t\t\tif (e - s == 40) return { s: s, e: e }\n\t\t\t// 次の連なりは e + 1 以降にしか始まらない\n\t\t\tj = e + 40\n\t\t} else {\n\t\t\tj += 40\n\t\t}\n\t}\n\tnull\n}\n\n// 1 回の変換で処理する上限。同じ記法を数百個並べた本文でもステップ上限に届かないようにする。\n// 本文を CHUNK 文字ずつ区切り、区間内の候補を全記法ぶん集めて出現順に並べてから上限を当てる\n// (記法ごとに打ち切ると、後方の記法が先頭の候補を押し出してしまう)\nlet MAX_LINKS = 100\nlet CHUNK = 200\n\n@convertLinks(text, opts) {\n\tlet chars = text.split()\n\t// 大文字小文字を無視する記法は小文字化した本文で探す (lower で長さが変わる文字があれば諦めて原文で探す)\n\tvar lowered = text.lower()\n\tif (lowered.len != text.len) lowered = text\n\n\t// 記法ごとの検索カーソル。優先順 (同じ位置に複数成立したときの順) に並べる\n\t// (str.index_of と split() はどちらもコードポイント単位なので添字を混ぜてよい)\n\tvar sources = []\n\t@addSource(haystack, needle, matcher) {\n\t\tlet pos = haystack.index_of(needle, 0)\n\t\tif (pos >= 0) sources.push({ haystack: haystack, needle: needle, matcher: matcher, pos: pos })\n\t}\n\tif (opts.security) {\n\t\taddSource(lowered, \"jvn\", matchJvn)\n\t\taddSource(lowered, \"cve-\", matchCve)\n\t}\n\tif (opts.dlsite) {\n\t\teach (let needle, [\"RJ\", \"RE\", \"VJ\", \"VE\", \"BJ\", \"BE\", \"RG\"]) addSource(text, needle, matchDlsite)\n\t}\n\tif (opts.nicovideo) {\n\t\teach (let needle, [\"sm\", \"nm\", \"so\"]) addSource(text, needle, matchNico)\n\t}\n\tvar hashRun = if (opts.githubCommit) nextHashRun(chars, 0) else null\n\n\tvar hits = []\n\tvar lastNext = 0\n\tvar a = 0\n\tloop {\n\t\tif (a >= chars.len || hits.len >= MAX_LINKS) break\n\t\tlet b = a + CHUNK\n\t\tvar chunk = []\n\t\teach (let src, sources) {\n\t\t\tloop {\n\t\t\t\tif (src.pos < 0 || src.pos >= b) break\n\t\t\t\tcollect(chunk, chars, src.pos, src.matcher)\n\t\t\t\tsrc.pos = src.haystack.index_of(src.needle, src.pos + 1)\n\t\t\t}\n\t\t}\n\t\tloop {\n\t\t\tif (hashRun == null || hashRun.s >= b) break\n\t\t\tlet hex = sub(chars, hashRun.s, hashRun.e)\n\t\t\tlet e = hashRun.e\n\t\t\tcollect(chunk, chars, hashRun.s, @(c, p) {\n\t\t\t\t{ label: hex, url: `https://github.com/search?q={hex}&type=commits`, next: e }\n\t\t\t})\n\t\t\thashRun = nextHashRun(chars, e + 1)\n\t\t}\n\t\t// 出現順に並べ、重なるもの (同じ位置に複数の記法が成立した場合など) は先のものを優先する\n\t\tif (chunk.len > 1) chunk.sort(@(x, y) { x.start - y.start })\n\t\teach (let hit, chunk) {\n\t\t\tif (hits.len < MAX_LINKS && hit.start >= lastNext) {\n\t\t\t\thits.push(hit)\n\t\t\t\tlastNext = hit.next\n\t\t\t}\n\t\t}\n\t\ta = b\n\t}\n\n\tvar parts = []\n\tvar segStart = 0\n\teach (let hit, hits) {\n\t\tparts.push(sub(chars, segStart, hit.start))\n\t\tparts.push(`[{hit.label}]({hit.url})`)\n\t\tsegStart = hit.next\n\t}\n\tparts.push(sub(chars, segStart, chars.len))\n\t{\n\t\ttext: parts.join(\"\")\n\t\tcount: hits.len\n\t}\n}\n\n@currentOpts() {\n\t{\n\t\tsecurity: Plugin:config.security\n\t\tdlsite: Plugin:config.dlsite\n\t\tnicovideo: Plugin:config.nicovideo\n\t\tgithubCommit: Plugin:config.githubCommit\n\t}\n}\n\nPlugin:register_post_form_action(\"リンクに変換\", @(form, update) {\n\tlet text = if (form.text == null) \"\" else form.text\n\tlet result = convertLinks(text, currentOpts())\n\tif (result.count == 0) {\n\t\tMk:dialog(\"技術系オートリンク\", \"リンクにできる記法が見つかりません\", \"info\")\n\t\treturn null\n\t}\n\tupdate(\"text\", result.text)\n\tMk:toast(`{result.count} 件をリンクにしました`, \"success\")\n})\n\nPlugin:register_note_post_interruptor(@(note) {\n\tif (Plugin:config.autoConvert && note.text != null) {\n\t\tnote.text = convertLinks(note.text, currentOpts()).text\n\t}\n\tnote\n})\n"} diff --git a/public/registry/plugins/tech-autolink/plugin.is b/public/registry/plugins/tech-autolink/plugin.is index 9f97bb6..068be92 100644 --- a/public/registry/plugins/tech-autolink/plugin.is +++ b/public/registry/plugins/tech-autolink/plugin.is @@ -190,9 +190,6 @@ let WORDCHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" // --- 変換対象外の判定 (候補ごとに前後を見る) --- -// 1 回の変換で処理する上限。同じ記法を数百個並べた本文でもステップ上限に届かないようにする -let MAX_LINKS = 100 - // [ラベル](URL) の終端。成立しなければ null @readMdLink(chars, i) { var j = i + 1 @@ -250,7 +247,6 @@ let MAX_LINKS = 100 // 候補位置 p で記法 (matcher) が成立し、変換してよければ hits に積む @collect(hits, chars, p, matcher) { - if (hits.len >= MAX_LINKS) return null if (p > 0 && isWordChar(chars[p - 1])) return null let hit = matcher(chars, p) if (hit == null) return null @@ -258,29 +254,19 @@ let MAX_LINKS = 100 hits.push({ start: p, next: hit.next, label: hit.label, url: hit.url }) } -// haystack 中の needle の出現位置すべてを候補にする -// (str.index_of と split() はどちらもコードポイント単位なので添字を混ぜてよい) -@collectByNeedle(hits, chars, haystack, needle, matcher) { - var p = haystack.index_of(needle, 0) - loop { - if (p < 0 || hits.len >= MAX_LINKS) break - collect(hits, chars, p, matcher) - p = haystack.index_of(needle, p + 1) - } -} - -// 接頭辞の無いコミットハッシュ (SHA-1、64 桁の SHA-256 などは長さで弾く) は、16 進がちょうど 40 文字連なる位置を候補にする。 -// 40 文字の連なりは 40 文字おきの標本 (39, 79, …) のどれかを必ず含むので、標本が 16 進のときだけ -// 前後に広げて連なりの範囲を求める (本文全体を 1 文字ずつ見ない) -@collectHashes(hits, chars) { +// 接頭辞の無いコミットハッシュ (SHA-1、64 桁の SHA-256 などは長さで弾く) は、16 進がちょうど +// 40 文字連なる位置を候補にする。40 文字の連なりは 40 文字おきの標本 (from+39, from+79, …) の +// どれかを必ず含むので、標本が 16 進のときだけ前後に広げて連なりの範囲を求める +// (本文全体を 1 文字ずつ見ない)。from 以降で最初の連なり { s, e } を返す。無ければ null +@nextHashRun(chars, from) { let n = chars.len - var j = 39 + var j = from + 39 loop { - if (j >= n || hits.len >= MAX_LINKS) break + if (j >= n) break if (HEXCHARS.incl(chars[j])) { var s = j loop { - if (s > 0 && HEXCHARS.incl(chars[s - 1])) s -= 1 + if (s > from && HEXCHARS.incl(chars[s - 1])) s -= 1 else break } var e = j + 1 @@ -288,60 +274,92 @@ let MAX_LINKS = 100 if (e < n && HEXCHARS.incl(chars[e])) e += 1 else break } - if (e - s == 40) { - // 範囲は確定しているので readRun で読み直さない - let hex = sub(chars, s, e) - collect(hits, chars, s, @(c, p) { - { label: hex, url: `https://github.com/search?q={hex}&type=commits`, next: e } - }) - } + if (e - s == 40) return { s: s, e: e } // 次の連なりは e + 1 以降にしか始まらない j = e + 40 } else { j += 40 } } + null } +// 1 回の変換で処理する上限。同じ記法を数百個並べた本文でもステップ上限に届かないようにする。 +// 本文を CHUNK 文字ずつ区切り、区間内の候補を全記法ぶん集めて出現順に並べてから上限を当てる +// (記法ごとに打ち切ると、後方の記法が先頭の候補を押し出してしまう) +let MAX_LINKS = 100 +let CHUNK = 200 + @convertLinks(text, opts) { let chars = text.split() // 大文字小文字を無視する記法は小文字化した本文で探す (lower で長さが変わる文字があれば諦めて原文で探す) var lowered = text.lower() if (lowered.len != text.len) lowered = text - var hits = [] + + // 記法ごとの検索カーソル。優先順 (同じ位置に複数成立したときの順) に並べる + // (str.index_of と split() はどちらもコードポイント単位なので添字を混ぜてよい) + var sources = [] + @addSource(haystack, needle, matcher) { + let pos = haystack.index_of(needle, 0) + if (pos >= 0) sources.push({ haystack: haystack, needle: needle, matcher: matcher, pos: pos }) + } if (opts.security) { - collectByNeedle(hits, chars, lowered, "jvn", matchJvn) - collectByNeedle(hits, chars, lowered, "cve-", matchCve) + addSource(lowered, "jvn", matchJvn) + addSource(lowered, "cve-", matchCve) } if (opts.dlsite) { - each (let needle, ["RJ", "RE", "VJ", "VE", "BJ", "BE", "RG"]) { - collectByNeedle(hits, chars, text, needle, matchDlsite) - } + each (let needle, ["RJ", "RE", "VJ", "VE", "BJ", "BE", "RG"]) addSource(text, needle, matchDlsite) } if (opts.nicovideo) { - each (let needle, ["sm", "nm", "so"]) { - collectByNeedle(hits, chars, text, needle, matchNico) + each (let needle, ["sm", "nm", "so"]) addSource(text, needle, matchNico) + } + var hashRun = if (opts.githubCommit) nextHashRun(chars, 0) else null + + var hits = [] + var lastNext = 0 + var a = 0 + loop { + if (a >= chars.len || hits.len >= MAX_LINKS) break + let b = a + CHUNK + var chunk = [] + each (let src, sources) { + loop { + if (src.pos < 0 || src.pos >= b) break + collect(chunk, chars, src.pos, src.matcher) + src.pos = src.haystack.index_of(src.needle, src.pos + 1) + } + } + loop { + if (hashRun == null || hashRun.s >= b) break + let hex = sub(chars, hashRun.s, hashRun.e) + let e = hashRun.e + collect(chunk, chars, hashRun.s, @(c, p) { + { label: hex, url: `https://github.com/search?q={hex}&type=commits`, next: e } + }) + hashRun = nextHashRun(chars, e + 1) } + // 出現順に並べ、重なるもの (同じ位置に複数の記法が成立した場合など) は先のものを優先する + if (chunk.len > 1) chunk.sort(@(x, y) { x.start - y.start }) + each (let hit, chunk) { + if (hits.len < MAX_LINKS && hit.start >= lastNext) { + hits.push(hit) + lastNext = hit.next + } + } + a = b } - if (opts.githubCommit) collectHashes(hits, chars) - // 出現順に並べ、重なるもの (同じ位置に複数の記法が成立した場合など) は先のものを優先する - hits.sort(@(a, b) { a.start - b.start }) var parts = [] - var count = 0 var segStart = 0 each (let hit, hits) { - if (hit.start >= segStart) { - parts.push(sub(chars, segStart, hit.start)) - parts.push(`[{hit.label}]({hit.url})`) - count += 1 - segStart = hit.next - } + parts.push(sub(chars, segStart, hit.start)) + parts.push(`[{hit.label}]({hit.url})`) + segStart = hit.next } parts.push(sub(chars, segStart, chars.len)) { text: parts.join("") - count: count + count: hits.len } } diff --git a/public/registry/queries.json b/public/registry/queries.json index 3bd48cd..5de59f3 100644 --- a/public/registry/queries.json +++ b/public/registry/queries.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T23:01:33.357Z", + "updatedAt": "2026-10-28T23:08:30.161Z", "queries": [ { "id": "bot-only", diff --git a/public/registry/skills.json b/public/registry/skills.json index 123eaa9..e2f7f94 100644 --- a/public/registry/skills.json +++ b/public/registry/skills.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T23:01:33.357Z", + "updatedAt": "2026-10-28T23:08:30.161Z", "skills": [ { "id": "aiscript-surgeon", diff --git a/public/registry/themes.json b/public/registry/themes.json index cba6a98..eff471d 100644 --- a/public/registry/themes.json +++ b/public/registry/themes.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T23:01:33.357Z", + "updatedAt": "2026-10-28T23:08:30.161Z", "themes": [ { "id": "adwaita-dark", diff --git a/public/registry/widgets.json b/public/registry/widgets.json index 4af58a1..00cb9b6 100644 --- a/public/registry/widgets.json +++ b/public/registry/widgets.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-28T23:01:33.357Z", + "updatedAt": "2026-10-28T23:08:30.161Z", "widgets": [ { "id": "aiscript-health",