Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/extension-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,16 @@ jobs:
cp -R extension/dist/snippets "$EXTENSION_PACKAGE_DIR"/dist/
fi
cp extension/icons/icon-{16,32,48,128}.png "$EXTENSION_PACKAGE_DIR"/icons/
(cd extension-package && zip -r "../$EXTENSION_ZIP" zhtw-mcp-extension)
(cd "$EXTENSION_PACKAGE_DIR" && zip -r "../../$EXTENSION_ZIP" .)

- name: Validate distributable extension
run: |
test -f "$EXTENSION_PACKAGE_DIR/manifest.json"
test -f "$EXTENSION_PACKAGE_DIR/dist/zhtw_mcp_wasm.js"
test -f "$EXTENSION_PACKAGE_DIR/dist/zhtw_mcp_wasm_bg.wasm"
unzip -l "$EXTENSION_ZIP" manifest.json
unzip -l "$EXTENSION_ZIP" dist/zhtw_mcp_wasm.js
unzip -l "$EXTENSION_ZIP" dist/zhtw_mcp_wasm_bg.wasm

- name: Upload unpacked extension artifact
uses: actions/upload-artifact@v4
Expand Down
2 changes: 2 additions & 0 deletions extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ The generated files are written to `extension/dist/`.

The extension uses `activeTab`, so it scans only after a user gesture and only for the current active tab. Badge counts include warning and error issues; info-level findings appear in the popup but do not increase the badge.

The extension applies browser-only result filtering for common page markup patterns such as `...` and `:::`. These filters do not change the Rust CLI, MCP server, or WASM scanner rules.

## Test JavaScript helpers

```sh
Expand Down
27 changes: 26 additions & 1 deletion extension/build-wasm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,35 @@ set -eu
cd "$(dirname "$0")/.."

if ! command -v wasm-pack >/dev/null 2>&1; then
echo "wasm-pack is required. Install it from https://rustwasm.github.io/wasm-pack/installer/" >&2
echo "wasm-pack is required. Install it with: cargo install wasm-pack" >&2
exit 1
fi

rustup_path_dir=
cleanup() {
if [ -n "$rustup_path_dir" ]; then
rm -rf "$rustup_path_dir"
fi
}
trap cleanup EXIT

make_temp_dir() {
mktemp -d 2>/dev/null || mktemp -d -t zhtw-mcp-wasm
}

if command -v rustup >/dev/null 2>&1; then
rustup_rustc="$(rustup which rustc 2>/dev/null || true)"
rustup_cargo="$(rustup which cargo 2>/dev/null || true)"
if [ -n "$rustup_rustc" ] && [ -n "$rustup_cargo" ]; then
rustup target add wasm32-unknown-unknown
rustup_path_dir="$(make_temp_dir)"
ln -s "$rustup_rustc" "$rustup_path_dir/rustc"
ln -s "$rustup_cargo" "$rustup_path_dir/cargo"
PATH="$rustup_path_dir:$PATH"
export PATH
Comment thread
doggy8088 marked this conversation as resolved.
fi
fi

if [ ! -f src/engine/s2t_data.rs ]; then
python3 scripts/gen-s2t-tables.py
rustfmt src/engine/s2t_data.rs
Expand Down
25 changes: 13 additions & 12 deletions extension/src/scanner.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
let wasmModulePromise;
let scanTextBinding;
import "./shared.js";
import init, { scan_text } from "../dist/zhtw_mcp_wasm.js";

Comment thread
doggy8088 marked this conversation as resolved.
let initPromise;

const { filterExtensionIgnoredIssues } = globalThis.ZhtwExtensionShared;

async function loadWasmModule() {
if (!wasmModulePromise) {
wasmModulePromise = (async () => {
const wasmModule = await import("../dist/zhtw_mcp_wasm.js");
if (!initPromise) {
initPromise = (async () => {
const wasmUrl = chrome.runtime.getURL("dist/zhtw_mcp_wasm_bg.wasm");
await wasmModule.default(wasmUrl);
scanTextBinding = wasmModule.scan_text;
await init({ module_or_path: wasmUrl });
})();
}
try {
return await wasmModulePromise;
await initPromise;
} catch (error) {
wasmModulePromise = undefined;
scanTextBinding = undefined;
initPromise = undefined;
throw error;
}
}
Expand All @@ -28,6 +29,6 @@ export async function scanText(text, options = {}) {
);
}

const resultJson = scanTextBinding(text, JSON.stringify(options));
return JSON.parse(resultJson);
const resultJson = scan_text(text, JSON.stringify(options));
return filterExtensionIgnoredIssues(JSON.parse(resultJson), text);
}
125 changes: 125 additions & 0 deletions extension/src/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,128 @@
).length;
}

const extensionIgnoredPatterns = ["...", ":::"];

function filterExtensionIgnoredIssues(scanResult, text = "") {
const issues = Array.isArray(scanResult?.issues) ? scanResult.issues : [];
const ignoredRanges = ignoredPatternRanges(text);
const filteredIssues = issues
.map(normalizeDisplayIssue)
.filter(
(issue) =>
!shouldIgnoreExtensionIssue(issue, ignoredRanges) &&
!shouldHideExtensionIssue(issue),
);

return {
...scanResult,
issues: filteredIssues,
issue_count: filteredIssues.length,
badge_count: countBadgeIssues(filteredIssues),
severity_counts: countSeverityIssues(filteredIssues),
};
}

function shouldIgnoreExtensionIssue(issue, ignoredRanges = []) {
const found = issue?.found || "";
if (extensionIgnoredPatterns.includes(found)) {
return true;
}

const offset = Number(issue?.offset);
const length = Number(issue?.length);
if (!Number.isFinite(offset) || !Number.isFinite(length) || length <= 0) {
return false;
}
const end = offset + length;

return ignoredRanges.some(
(range) => offset < range.byteEnd && end > range.byteStart,
);
}

function shouldHideExtensionIssue(issue) {
return issue?.severity === "info" && !hasVisibleSuggestion(issue);
}

function hasVisibleSuggestion(issue) {
return Array.isArray(issue?.suggestions)
? issue.suggestions.some((suggestion) => String(suggestion).trim())
: false;
}

function normalizeDisplayIssue(issue) {
return {
...issue,
suggestions: Array.isArray(issue?.suggestions)
? issue.suggestions.filter((suggestion) => String(suggestion).trim())
: [],
};
}

function ignoredPatternRanges(text) {
if (!text) {
return [];
}

const byteOffsets = codeUnitByteOffsets(text);
const ranges = [];
for (const pattern of extensionIgnoredPatterns) {
let searchStart = 0;
while (searchStart < text.length) {
const index = text.indexOf(pattern, searchStart);
if (index < 0) {
break;
}
const byteStart = byteOffsets[index];
ranges.push({
byteStart,
byteEnd: byteOffsets[index + pattern.length],
});
searchStart = index + pattern.length;
}
Comment thread
doggy8088 marked this conversation as resolved.
}
return ranges;
}

function codeUnitByteOffsets(text) {
const offsets = new Array(text.length + 1);
let bytes = 0;
let index = 0;
offsets[0] = 0;

for (const char of text) {
bytes += utf8ByteLength(char);
const nextIndex = index + char.length;
for (
let offsetIndex = index + 1;
offsetIndex <= nextIndex;
offsetIndex += 1
) {
offsets[offsetIndex] = bytes;
}
index = nextIndex;
}

return offsets;
}

function countSeverityIssues(issues) {
return issues.reduce(
(counts, issue) => {
if (issue.severity === "error") {
counts.error += 1;
} else if (issue.severity === "warning") {
counts.warning += 1;
} else {
counts.info += 1;
}
return counts;
},
{ info: 0, warning: 0, error: 0 },
);
}

function formatBadgeText(count) {
if (!count) {
return "";
Expand All @@ -68,8 +190,11 @@
return {
byteOffsetToCodeUnit,
countBadgeIssues,
filterExtensionIgnoredIssues,
formatBadgeText,
normalizeIssue,
shouldIgnoreExtensionIssue,
shouldHideExtensionIssue,
utf8ByteLength,
};
});
117 changes: 117 additions & 0 deletions extension/test/shared.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ const assert = require("node:assert/strict");
const {
byteOffsetToCodeUnit,
countBadgeIssues,
filterExtensionIgnoredIssues,
formatBadgeText,
shouldHideExtensionIssue,
shouldIgnoreExtensionIssue,
utf8ByteLength,
} = require("../src/shared.js");

Expand Down Expand Up @@ -32,3 +35,117 @@ test("badge text is capped for dense pages", () => {
assert.equal(formatBadgeText(7), "7");
assert.equal(formatBadgeText(125), "99+");
});

test("extension ignores configured punctuation-only patterns", () => {
const text = "流程...結束\n::: note\n這個軟件";
const scanResult = {
issues: [
{
offset: utf8ByteLength("流程"),
length: utf8ByteLength("..."),
found: "...",
severity: "warning",
},
{
offset: utf8ByteLength("流程...結束\n"),
length: utf8ByteLength(":::"),
found: ":::",
severity: "error",
},
{
offset: utf8ByteLength("流程...結束\n::: note\n這個"),
length: utf8ByteLength("軟件"),
found: "軟件",
severity: "warning",
},
{
offset: 0,
length: utf8ByteLength("流程"),
found: "流程",
severity: "info",
},
],
issue_count: 4,
badge_count: 3,
severity_counts: { info: 1, warning: 2, error: 1 },
};

const filtered = filterExtensionIgnoredIssues(scanResult, text);

assert.deepEqual(
filtered.issues.map((issue) => issue.found),
["軟件"],
);
assert.equal(filtered.issue_count, 1);
assert.equal(filtered.badge_count, 1);
assert.deepEqual(filtered.severity_counts, { info: 0, warning: 1, error: 0 });
});

test("extension ignores scanner issues inside configured pattern ranges", () => {
const text = "😀提示:::內容";
const issue = {
offset: utf8ByteLength("😀提示"),
length: utf8ByteLength(":"),
found: ":",
severity: "warning",
};

const filtered = filterExtensionIgnoredIssues({ issues: [issue] }, text);

assert.equal(shouldIgnoreExtensionIssue({ found: ":::" }), true);
assert.equal(filtered.issues.length, 0);
});

test("extension hides info issues without visible suggestions", () => {
const scanResult = {
issues: [
{
offset: 0,
length: utf8ByteLength("可以說是"),
found: "可以說是",
suggestions: [""],
rule_type: "ai_style",
severity: "info",
},
{
offset: utf8ByteLength("可以說是中文"),
length: 0,
found: "",
suggestions: [" "],
rule_type: "punctuation",
severity: "info",
},
{
offset: 0,
length: utf8ByteLength("軟件"),
found: "軟件",
suggestions: ["軟體"],
rule_type: "cross_strait",
severity: "warning",
},
{
offset: 0,
length: utf8ByteLength("刪"),
found: "刪",
suggestions: [""],
rule_type: "grammar",
severity: "warning",
},
],
issue_count: 4,
badge_count: 2,
severity_counts: { info: 2, warning: 2, error: 0 },
};

const filtered = filterExtensionIgnoredIssues(scanResult, "可以說是中文軟件");

assert.equal(shouldHideExtensionIssue(scanResult.issues[0]), true);
assert.deepEqual(
filtered.issues.map((issue) => issue.found),
["軟件", "刪"],
);
assert.deepEqual(filtered.issues[1].suggestions, []);
assert.equal(filtered.issue_count, 2);
assert.equal(filtered.badge_count, 2);
assert.deepEqual(filtered.severity_counts, { info: 0, warning: 2, error: 0 });
});
Loading