scrapbox-lint
scrapboxで校閲を行うUserScript by /hata6502/hata6502.icon ESModule workerを使っているため、提示されたコードのままではFirefoxで動かせない
少しいじってみる
変更点
型定義を不完全ながら追加
refactoring
""にする
querySelector()をやめる
コメントを追加
校閲数の表示
隠れている文字の場合は、そのひとつ上のDOMを指定する
import errorになったコードを差し替える
code:js
const { runScrapboxLint } = await import("/api/code/takker/scrapbox-lint-min/script.js");
await runScrapboxLint({});
指摘箇所を赤色で表示します。
「私は私は」
「何れ」よりも「いずれ」のほうが読みやすい。
「しかし」執筆は難しい。「しかし」を連続して使うと、文の流れが不規則になりがちだ。
気軽にアイデアをアウトプット「したり」、かっちり文章を整える。「たり」が抜けている。
PDF からのコピペでありがちな「タ゛クテン」とかを見つけられる。
うーん?動かないなtakker.icon
bundle
bundleに失敗した
Denoで型チェックをしていたときには気づかなかった
DenoのIO APIに置き換えられていたから
/hata6502/hata6502.iconさんはこれをいったいどうやってbrowser向けにbundleしたのだろうか?
ええ……esm.shじゃあどうあがいたってbundleできないじゃんtakker.icon 代替策
code:template-ui(txt)
scrapbox-lint-min
code:script.js
@CODE@
code:template-worker(txt)
scrapbox-lint-worker
Firefoxからも使えるよう、IIFEに変換してある
code:script.js
@CODE@
code:script.ts
/// <reference no-default-lib="true" />
/// <reference lib="deno.ns" />
/// <reference lib="deno.unstable" />
/// <reference lib="dom" />
import type { LintOption } from "./lintOptions.ts";
import type { MessageResponse } from "./types.ts";
import type { Scrapbox } from "./deps_scrapbox.ts";
declare const WORKER_URL: string;
declare const scrapbox: Scrapbox;
エントリポイント
code:script.ts
let result: MessageResponse"result" | undefined; const worker = new Worker(WORKER_URL);
export interface ScrapboxLintProps {
/** textlintのoptionsが書かれてたJSONファイル */
lintOptionURL: string | URL;
}
/** entry point */
export const runScrapboxLint = async (props: ScrapboxLintProps) => {
document.head.insertAdjacentHTML(
"beforeend",
'<link rel="stylesheet" href="/api/code/takker/microtip/index.css">',
);
const lintOption = await getLintOption(props);
worker.addEventListener("message", (event) => {
const data = event.data;
switch (data.type) {
case "result": {
if (scrapbox.Layout !== "page" || getText() !== data.text) {
return;
}
result = data.result;
updatePins();
break;
}
// TODO: exhaustive check
}
});
code:script.ts
new PerformanceObserver(updatePins).observe({type: 'layout-shift', buffered: true});
lintPage(lintOption);
scrapbox.on("lines:changed", () => lintPage(lintOption));
scrapbox.on("layout:changed", () => lintPage(lintOption)); // トップページでは校閲情報をクリアする
scrapbox.on("project:changed", () => location.reload());
};
textlintのoptionを読み込む
code:script.ts
const getLintOption = async ({ lintOptionURL }: ScrapboxLintProps): Promise<LintOption> => {
if (!lintOptionURL) {
return {};
}
const response = await fetch(lintOptionURL);
if (!response.ok) {
throw new Error(
${response.status} ${response.statusText}
);
}
return response.json() as Promise<LintOption>;
};
utilities
code:script.ts
/** 現在のページのテキストを取得する */
const getText = () => scrapbox.Page.lines.map((line) => line.text).join("\n");
Lintを実行する
code:script.ts
const lintPage = (lintOption: LintOption) => {
result = undefined;
updatePins();
if (scrapbox.Layout !== "page") {
return;
}
worker.postMessage({ type: "lint", lintOption, text: getText() });
};
指摘箇所を表示するUIを作成・更新する
code:script.ts
const pinElements = [] as HTMLDivElement[];
const updatePins = () => {
pinElements.forEach((pinElement) => pinElement.remove());
pinElements.splice(0); // 全要素を削除する
const bodyRect = document.body.getBoundingClientRect();
const messages = result?.messages;
if (!messages) return;
messages.forEach((message) => {
const lineElement = document.getElementById(L${lineID});
const charElement = lineElement.getElementsByClassName(c-${message.column - 1})?.0; const charRect = charElement.classList.contains("empty-char-index") ?
// 隠れている文字だったら、そのひとつ上のDOMを代わりに使う
charElement.parentElement.getBoundingClientRect() :
charElement.getBoundingClientRect();
const pinElement = document.createElement("div");
pinElement.dataset.microtipPosition = "top";
pinElement.style.position = "absolute";
pinElement.style.left = ${charRect.left - bodyRect.left}px;
pinElement.style.top = ${charRect.top - bodyRect.top}px;
pinElement.style.height = ${charRect.height}px;
pinElement.style.width = ${charRect.width}px;
pinElement.style.backgroundColor = "rgba(241, 93, 105, 0.5)";
pinElement.setAttribute("aria-label", message.message);
pinElement.setAttribute("role", "tooltip");
document.body.append(pinElement);
pinElements.push(pinElement);
});
if (messages.length > 0) {
const item = ensureStatusItem();
item.textContent = lint: ${messages.length};
} else {
getStatusItem()?.remove?.();
}
};
code:script.ts
const statusBarItemId = "scrapbox-lint";
function getStatusItem() {
return document.querySelector(div[data-id="${statusBarItemId}"]);
}
function ensureStatusItem() {
let item = getStatusItem();
if (item) return item;
item ??= document.createElement('div');
item.dataset.id = statusBarItemId;
getStatusBar().append(item);
return item;
}
function getStatusBar() {
return document.getElementsByClassName('status-bar')?.0; }
worker code
code:worker.ts
/// <reference no-default-lib="true" />
/// <reference lib="deno.ns" />
/// <reference lib="deno.unstable" />
/// <reference lib="webworker" />
import { lint } from "./kohsei-san-core.ts";
import type { LintOption } from "./lintOptions.ts";
import type { MessageRequest } from "./types.ts";
declare global {
interface WorkerGlobalScope {
kuromojin: {
dicPath: string;
}
}
}
self.kuromojin = {
};
self.addEventListener("message", async (event: MessageEvent) => {
const data = event.data as MessageRequest;
switch (data.type) {
case "lint": {
const { lintOption, text } = data;
const result = await lint({ lintOption, text });
self.postMessage({
type: "result",
result,
text,
});
break;
}
// TODO: exhaustive check
}
});
lint({ lintOption: {}, text: "初回校正時でもキャッシュにヒットさせるため。" });
型定義
code:types.ts
import type { TextlintResult } from "./deps.ts";
import type { LintOption } from "./lintOptions.ts";
export interface MessageRequest {
type: "lint";
lintOption: LintOption;
text: string;
}
export interface MessageResponse {
type: "result";
result: TextlintResult;
text: string;
}
code:kohsei-san-core.ts
import {
TextlintKernel,
textlintFilterRuleURLs,
textlintPluginText,
textlintRuleGeneralNovelStyleJa,
textlintRuleJaHiraganaDaimeishi,
textlintRuleJaHiraganaFukushi,
textlintRuleJaHiraganaHojodoushi,
textlintRuleJaHiraganaKeishikimeishi,
textlintRuleJaJoyoOrJinmeiyoKanji,
textlintRuleJaKyoikuKanji,
textlintRuleJaNoMixedPeriod,
textlintRuleJaNoRedundantExpression,
textlintRuleJaNoSuccessiveWord,
textlintRuleJaNoWeakPhrase,
textlintRuleJaUnnaturalAlphabet,
textlintRuleMaxAppearenceCountOfWords,
textlintRuleNoDoubledConjunctiveParticleGa,
textlintRuleNoDroppingI,
textlintRuleNoFiller,
textlintRuleNoHankakuKana,
textlintRuleNoInsertDroppingSa,
textlintRuleNoInsertRe,
textlintRuleNoKangxiRadicals,
textlintRuleNoMixedZenkakuAndHankakuAlphabet,
textlintRuleNoZeroWidthSpaces,
textlintRulePreferTariTari,
textlintRulePresetJapanese,
textlintRulePresetJaSpacing,
textlintRulePresetJaTechnicalWriting,
textlintRulePresetJTFStyle,
textlintRuleSentenceLength,
} from "./deps.ts";
import type { TextlintResult } from "./deps.ts";
import type { LintOption } from "./lintOptions.ts";
const kernel = new TextlintKernel();
const lint = ({
lintOption,
text,
}: {
lintOption: LintOption;
text: string;
}): Promise<TextlintResult> =>
kernel.lintText(text, {
ext: '.txt',
filterRules: [
{
ruleId: 'urls',
rule: textlintFilterRuleURLs,
},
],
plugins: [
{
pluginId: 'text',
plugin: textlintPluginText,
},
],
rules: [
...(lintOption.presetJaSpacing
? Object.keys(textlintRulePresetJaSpacing.rules).map((key) => ({
ruleId: key,
rule: textlintRulePresetJaSpacing.ruleskey, options:
lintOption.presetJaSpacing?.key ?? textlintRulePresetJaSpacing.rulesConfigkey, }))
: []),
...Object.keys(textlintRulePresetJapanese.rules)
.map((key) => ({
ruleId: key,
rule: textlintRulePresetJapanese.ruleskey, options: textlintRulePresetJapanese.rulesConfigkey, })),
...(lintOption.presetJaTechnicalWriting
? Object.keys(textlintRulePresetJaTechnicalWriting.rules)
.map((key) => ({
ruleId: key,
rule: textlintRulePresetJaTechnicalWriting.ruleskey, options:
lintOption.presetJaTechnicalWriting?.key ?? textlintRulePresetJaTechnicalWriting.rulesConfigkey, }))
: []),
...(lintOption.presetJTFStyle
? Object.keys(textlintRulePresetJTFStyle.rules).map((key) => ({
ruleId: key,
rule: textlintRulePresetJTFStyle.ruleskey, options:
lintOption.presetJTFStyle?.key ?? textlintRulePresetJTFStyle.rulesConfigkey, }))
: []),
...(lintOption.generalNovelStyleJa
? [
{
ruleId: 'general-novel-style-ja',
rule: textlintRuleGeneralNovelStyleJa,
options:
typeof lintOption.generalNovelStyleJa === 'object' &&
lintOption.generalNovelStyleJa !== null
? lintOption.generalNovelStyleJa
: undefined,
},
]
: []),
{
ruleId: 'ja-hiragana-daimeishi',
rule: textlintRuleJaHiraganaDaimeishi,
},
{
ruleId: 'ja-hiragana-fukushi',
rule: textlintRuleJaHiraganaFukushi,
},
{
ruleId: 'ja-hiragana-hojodoushi',
rule: textlintRuleJaHiraganaHojodoushi,
},
{
ruleId: 'ja-hiragana-keishikimeishi',
rule: textlintRuleJaHiraganaKeishikimeishi,
},
{
ruleId: 'ja-joyo-or-jinmeiyo-kanji',
rule: textlintRuleJaJoyoOrJinmeiyoKanji,
},
...(lintOption.jaKyoikuKanji
? [
{
ruleId: 'ja-kyoiku-kanji',
rule: textlintRuleJaKyoikuKanji,
options:
typeof lintOption.jaKyoikuKanji === 'object' && lintOption.jaKyoikuKanji !== null
? lintOption.jaKyoikuKanji
: undefined,
},
]
: []),
...(lintOption.jaNoMixedPeriod
? [
{
ruleId: 'ja-no-mixed-period',
rule: textlintRuleJaNoMixedPeriod,
options:
typeof lintOption.jaNoMixedPeriod === 'object' &&
lintOption.jaNoMixedPeriod !== null
? lintOption.jaNoMixedPeriod
: undefined,
},
]
: []),
{
ruleId: 'ja-no-redundant-expression',
rule: textlintRuleJaNoRedundantExpression,
},
{
ruleId: 'ja-no-successive-word',
rule: textlintRuleJaNoSuccessiveWord,
options: {
},
},
...(lintOption.jaNoWeakPhrase
? [
{
ruleId: 'ja-no-weak-phrase',
rule: textlintRuleJaNoWeakPhrase,
options:
typeof lintOption.jaNoWeakPhrase === 'object' && lintOption.jaNoWeakPhrase !== null
? lintOption.jaNoWeakPhrase
: undefined,
},
]
: []),
{
ruleId: 'ja-unnatural-alphabet',
rule: textlintRuleJaUnnaturalAlphabet,
},
...(lintOption.maxAppearenceCountOfWords
? [
{
ruleId: 'max-appearence-count-of-words',
rule: textlintRuleMaxAppearenceCountOfWords,
options:
typeof lintOption.maxAppearenceCountOfWords === 'object' &&
lintOption.maxAppearenceCountOfWords !== null
? lintOption.maxAppearenceCountOfWords
: undefined,
},
]
: []),
{
ruleId: 'no-doubled-conjunctive-particle-ga',
rule: textlintRuleNoDoubledConjunctiveParticleGa,
},
{
ruleId: 'no-dropping-i',
rule: textlintRuleNoDroppingI,
},
...(lintOption.noFiller
? [
{
ruleId: 'no-filler',
rule: textlintRuleNoFiller,
options:
typeof lintOption.noFiller === 'object' && lintOption.noFiller !== null
? lintOption.noFiller
: undefined,
},
]
: []),
{
ruleId: 'no-hankaku-kana',
rule: textlintRuleNoHankakuKana,
},
{
ruleId: 'no-insert-dropping-sa',
rule: textlintRuleNoInsertDroppingSa,
},
{
ruleId: 'no-insert-re',
rule: textlintRuleNoInsertRe,
},
{
ruleId: 'no-kangxi-radicals',
rule: textlintRuleNoKangxiRadicals,
},
{
ruleId: 'no-mixed-zenkaku-and-hankaku-alphabet',
rule: textlintRuleNoMixedZenkakuAndHankakuAlphabet,
},
{
ruleId: 'no-zero-width-spaces',
rule: textlintRuleNoZeroWidthSpaces,
},
{
ruleId: 'prefer-tari-tari',
rule: textlintRulePreferTariTari,
},
{
ruleId: 'sentence-length',
rule: textlintRuleSentenceLength,
options: {
exclusionPatterns: [
// This line is under the CC BY-SA 4.0.
],
},
},
],
});
export { lint };
export type { LintOption };
外部依存しているmodules
code:error
/* esm.sh - error */
throw new Error("esm.sh " + "parseCJSModuleExports: Can't resolve './build/Release/re2' in '/tmp/esm-build-68a4b25e913c8e21b60dd845fc4ef83bbf7c8b4f-b91bbc49/node_modules/re2'"); export default null;
Since RE2 is not made for the browser, it will not be used, and therefore CVE-2020-7661 is still an issue on the client-side. However it is not severe since the most it would do is crash the browser tab (as on the Node.js side it would have crashed the entire process and thrown an out of memory exception).
書き換えるのが面倒だ……
仕方ない。コードを書き換えよう
code:textlint-filter-fule-urls.ts
import { urlRegexSafe } from "../url-regex-safe-deno/mod.ts";
const textlintFilterRuleURLsModule: TextlintFilterRuleModule = ({
Syntax,
getSource,
shouldIgnore,
}) => ({
const regex: RegExp = urlRegexSafe();
const text = getSource(node);
let url;
while ((url = regex.exec(text))) {
shouldIgnore([url.index, url.index + url0.length], {}); }
},
});
export { textlintFilterRuleURLsModule as default };
code:deps_scrapbox.ts
code:deps.ts
export {
default as textlintPluginText
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoDroppingI
export {
default as textlintRuleNoFiller
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoInsertDroppingSa
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoInsertRe
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoZeroWidthSpaces
export {
default as textlintFilterRuleURLs
} from "./textlint-filter-fule-urls.ts";
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleGeneralNovelStyleJa
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaHiraganaDaimeishi
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaHiraganaFukushi
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaHiraganaHojodoushi
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaHiraganaKeishikimeishi
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaJoyoOrJinmeiyoKanji
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaKyoikuKanji
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaNoMixedPeriod
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaNoRedundantExpression
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaNoSuccessiveWord
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaNoWeakPhrase
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleJaUnnaturalAlphabet
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleMaxAppearenceCountOfWords
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoDoubledConjunctiveParticleGa
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoHankakuKana
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoKangxiRadicals
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleNoMixedZenkakuAndHankakuAlphabet
// @ts-ignore 型が定義されていない。
export {
default as textlintRulePreferTariTari
// @ts-ignore 型が定義されていない。
export {
default as textlintRulePresetJaSpacing
// @ts-ignore 型が定義されていない。
export {
default as textlintRulePresetJaTechnicalWriting
// @ts-ignore 型が定義されていない。
export {
default as textlintRulePresetJapanese
// @ts-ignore 型が定義されていない。
export {
default as textlintRulePresetJTFStyle
// @ts-ignore 型が定義されていない。
export {
default as textlintRuleSentenceLength
説明はそれぞれのrepoから引用した
2022-01-10 07:00:25 型定義作るのに時間がかかりそうなので一旦スキップ
code:lintOptions.ts
export interface LintOption{
presetJaSpacing?: boolean | PresetJaSpacingInit;
presetJaTechnicalWriting?: boolean | PresetJaTechnicalWritingInit;
presetJTFStyle?: boolean | PresetJTFStyleInit;
generalNovelStyleJa?: boolean | GeneralNovelStyleJaInit;
jaKyoikuKanji?: boolean | JaKyoikuKanjiInit;
jaNoMixedPeriod?: boolean | JaNoMixedPeriodInit;
jaNoWeakPhrase?: boolean | JaNoWeakPhraseInit;
maxAppearenceCountOfWords?: boolean | MaxAppearenceCountOfWords;
noFiller?: boolean | NoFillerInit;
}
日本語周りにおけるスペースの有無を決定する textlint ルールプリセットを提供します。
code:lintOptions.ts
export interface PresetJaSpacingInit {
"ja-space-between-half-and-full-width"?: {
space?: "never" | "always";
exceptPunctuation?: boolean;
lintStyledNode?: boolean;
};
"ja-space-around-code"?: boolean | {
before?: boolean;
after?: boolean;
};
"ja-no-space-between-full-width"?: boolean;
"ja-nakaguro-or-halfwidth-space-between-katakana"?: boolean;
"ja-no-space-around-parentheses"?: boolean;
"ja-space-after-exclamation"?: boolean;
"ja-space-after-question"?: boolean;
}
技術文書向けの textlint ルールプリセットです。 code:lintOptions.ts
export interface PresetJaTechnicalWritingInit {
"sentence-length"?: boolean | {
max?: number;
skipPatterns?: string[];
skipUrlStringLink?: boolean;
};
"max-comma"?: {
max: number;
};
"max-ten"?: {
max: number;
};
"max-kanji-continuous-len"?: {
max: number;
},
"arabic-kanji-numbers"?: boolean;
"no-mix-dearu-desumasu"?: {
preferInHeader: string;
preferInBody: string;
preferInList: string;
strict: boolean;
};
"ja-no-mixed-period": {
periodMark: string;
};
"no-double-negative-ja"?: boolean;
"no-dropping-the-ra"?: boolean;
"no-doubled-conjunctive-particle-ga"?: boolean;
"no-doubled-conjunction"?: boolean;
"no-doubled-joshi"?: {
min_interval: number;
};
"no-nfd"?: boolean;
"no-invalid-control-character"?: boolean;
"no-zero-width-spaces"?: boolean;
"no-exclamation-question-mark"?: boolean;
"no-hankaku-kana"?: boolean;
"ja-no-weak-phrase"?: boolean;
"ja-no-successive-word"?: boolean;
"ja-no-abusage"?: boolean;
"ja-no-redundant-expression"?: boolean;
"ja-unnatural-alphabet"?: boolean;
"no-unmatched-pair"?: boolean;
}
code:lintOptions.ts
export interface PresetJTFStyleInit {
}
日本の小説における一般的な作法に従うための textlint ルールです。
code:lintOptions.ts
export interface GeneralNovelStyleJaInit {
}
教育漢字であることをチェックする textlint ルール code:lintOptions.ts
export interface JaKyoikuKanjiInit {
}
文末の句点(。)の統一 OR 抜けをチェックする textlint ルール
code:lintOptions.ts
export interface JaNoMixedPeriodInit {
}
弱い日本語表現の利用を禁止する textlint ルール
code:lintOptions.ts
export interface JaNoWeakPhraseInit {
}
textlint rule that check maximum appearance count of words in paragraph.
code:lintOptions.ts
export interface MaxAppearenceCountOfWords {
}
「ええと」「あの」「まあ」などのフィラー(つなぎ表現)を禁止する textlint ルール code:lintOptions.ts
export interface NoFillerInit {
}