diff --git a/__TEST__/hyperaudio-lite-extension.test.js b/__TEST__/hyperaudio-lite-extension.test.js
new file mode 100644
index 0000000..0822be8
--- /dev/null
+++ b/__TEST__/hyperaudio-lite-extension.test.js
@@ -0,0 +1,123 @@
+/**
+ * @jest-environment jsdom
+ *
+ * Tests for the search half of js/hyperaudio-lite-extension.js — needle
+ * normalisation, phrase matching across spans, and mark placement on words
+ * containing internal punctuation (#260).
+ */
+
+const {
+ searchPhrase,
+ findRawRange,
+ clearPreviousSearch,
+} = require("../js/hyperaudio-lite-extension");
+
+// Build a transcript of [data-m] word spans; a ["speaker", text] tuple emits
+// a speaker span (data-d 0), mirroring transcripts produced by the editor.
+function buildTranscript(words) {
+ const spans = words
+ .map((w, i) => {
+ if (w[0] === "speaker") {
+ return `${w[1]} `;
+ }
+ return `${w[0]} `;
+ })
+ .join("");
+ document.body.innerHTML = `
`;
+}
+
+const marks = () =>
+ Array.from(document.querySelectorAll("mark.search-mark")).map(
+ (m) => m.textContent
+ );
+
+const matchedSpans = () =>
+ Array.from(document.querySelectorAll(".search-match")).map((s) =>
+ s.textContent.trim()
+ );
+
+describe("findRawRange", () => {
+ test("plain substring", () => {
+ expect(findRawRange("Lorem ", "lorem")).toEqual([0, 5]);
+ });
+
+ test("skips punctuation inside the match", () => {
+ expect(findRawRange("[SPEAKER-2] ", "[speaker2]")).toEqual([0, 11]);
+ });
+
+ test("leaves trailing punctuation outside the range", () => {
+ // the trailing "." is not consumed once the needle is exhausted
+ expect(findRawRange("U.S. ", "us")).toEqual([0, 3]);
+ });
+
+ test("leaves leading punctuation outside the range", () => {
+ expect(findRawRange("-well ", "well")).toEqual([1, 5]);
+ });
+
+ test("returns null when the needle is absent", () => {
+ expect(findRawRange("Lorem ", "ipsum")).toBeNull();
+ });
+});
+
+describe("searchPhrase", () => {
+ test("marks a plain word, punctuation outside the mark", () => {
+ buildTranscript([["Lorem,"], ["ipsum"]]);
+ searchPhrase("lorem");
+ expect(marks()).toEqual(["Lorem"]);
+ expect(matchedSpans()).toEqual(["Lorem,"]);
+ });
+
+ test("marks a speaker label containing a dash (#260)", () => {
+ buildTranscript([["speaker", "[SPEAKER-2]"], ["Hello"]]);
+ searchPhrase("[speaker-2]");
+ expect(marks()).toEqual(["[SPEAKER-2]"]);
+ expect(matchedSpans()).toEqual(["[SPEAKER-2]"]);
+ });
+
+ test("marks a hyphenated word", () => {
+ buildTranscript([["a"], ["well-known"], ["fact"]]);
+ searchPhrase("well-known");
+ expect(marks()).toEqual(["well-known"]);
+ });
+
+ test("marks dotted abbreviations", () => {
+ buildTranscript([["the"], ["U.S."], ["economy"]]);
+ searchPhrase("U.S.");
+ // the final "." follows the last needle character, so it stays outside
+ expect(marks()).toEqual(["U.S"]);
+ });
+
+ test("multi-word phrases still mark consecutive spans", () => {
+ buildTranscript([["one"], ["two"], ["three"]]);
+ searchPhrase("two three");
+ expect(marks()).toEqual(["two", "three"]);
+ expect(matchedSpans()).toEqual(["two", "three"]);
+ });
+
+ test("a new search clears previous marks", () => {
+ buildTranscript([["alpha"], ["beta"]]);
+ searchPhrase("alpha");
+ searchPhrase("beta");
+ expect(marks()).toEqual(["beta"]);
+ expect(matchedSpans()).toEqual(["beta"]);
+ });
+
+ test("an empty or punctuation-only query marks nothing", () => {
+ buildTranscript([["alpha"], ["beta"]]);
+ searchPhrase("alpha");
+ searchPhrase("---");
+ expect(marks()).toEqual([]);
+ });
+});
+
+describe("clearPreviousSearch", () => {
+ test("unwraps marks and merges the text nodes back", () => {
+ buildTranscript([["Lorem,"]]);
+ searchPhrase("lorem");
+ clearPreviousSearch();
+ const span = document.querySelector("[data-m]");
+ expect(document.querySelectorAll("mark").length).toBe(0);
+ expect(span.childNodes.length).toBe(1);
+ expect(span.textContent).toBe("Lorem, ");
+ });
+});
diff --git a/changelog.md b/changelog.md
index c693ba4..741c8d9 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,9 @@
+# Version 2.6.2 (unreleased)
+
+## Fixed
+
+- **Search marks land on words with internal punctuation** (#260). `searchPhrase()` compares punctuation-stripped text, but `highlightSubstring()` looked the stripped needle up in the *raw* span text with `indexOf`, so a query like `speaker-2` matched `[SPEAKER-2]` (the span gained `search-match`) yet never received its ``. The highlighter now walks the raw text, skipping punctuation inside the match, so the mark covers the visible word (dash included) while leading/trailing punctuation stays outside. Affected any match containing a stripped character: hyphenated words, dotted abbreviations, bracketed speaker labels.
+
# Version 2.6.1
Patch release: the npm package now includes `caption.js`.
diff --git a/js/hyperaudio-lite-extension.js b/js/hyperaudio-lite-extension.js
index fbf208e..c6dea7c 100644
--- a/js/hyperaudio-lite-extension.js
+++ b/js/hyperaudio-lite-extension.js
@@ -1,5 +1,5 @@
/*! (C) The Hyperaudio Project. MIT @license: en.wikipedia.org/wiki/MIT_License. */
-/*! Version 2.5.1 */
+/*! Version 2.6.2 */
'use strict';
@@ -9,6 +9,8 @@
// highlight covers the query, not the whole word.
const SEARCH_PUNCT = /[.,\-\/#!$%\^&\*;:{}=_`~()\?\s]/g;
+// Non-global copy for single-character tests (a /g regex is stateful in .test()).
+const SEARCH_PUNCT_CHAR = new RegExp(SEARCH_PUNCT.source);
const normalise = (text) => text.toLowerCase().replace(SEARCH_PUNCT, '');
const clearPreviousSearch = () => {
@@ -21,15 +23,43 @@ const clearPreviousSearch = () => {
});
};
+// Find the range of `original` whose normalised form matches `needle` (already
+// lowercased and punctuation-stripped): walk the raw text consuming needle
+// characters and skipping punctuation inside the match (#260). Returns
+// [start, end) indices into `original`, or null when the needle isn't present.
+const findRawRange = (original, needle) => {
+ const lower = original.toLowerCase();
+ for (let start = 0; start < lower.length; start++) {
+ if (lower[start] !== needle[0]) continue;
+ let oi = start;
+ let ni = 0;
+ while (oi < lower.length && ni < needle.length) {
+ if (lower[oi] === needle[ni]) {
+ oi++;
+ ni++;
+ } else if (SEARCH_PUNCT_CHAR.test(lower[oi])) {
+ oi++;
+ } else {
+ break;
+ }
+ }
+ if (ni === needle.length) return [start, oi];
+ }
+ return null;
+};
+
// Wrap the first occurrence of `needle` (case-insensitive) inside `span`'s
-// text with . Punctuation stays outside the mark.
+// text with . Leading and trailing punctuation stay
+// outside the mark; punctuation inside the match (the dash in "SPEAKER-2" for
+// the needle "speaker2") is included, so the visible word is highlighted
+// whole even though matching compares punctuation-stripped text (#260).
const highlightSubstring = (span, needle) => {
const original = span.textContent;
- const idx = original.toLowerCase().indexOf(needle);
- if (idx < 0) return;
- const before = original.slice(0, idx);
- const hit = original.slice(idx, idx + needle.length);
- const after = original.slice(idx + needle.length);
+ const range = findRawRange(original, needle);
+ if (range === null) return;
+ const before = original.slice(0, range[0]);
+ const hit = original.slice(range[0], range[1]);
+ const after = original.slice(range[1]);
span.textContent = '';
if (before) span.append(before);
const mark = document.createElement('mark');
@@ -83,3 +113,8 @@ window.addEventListener('load', () => {
}
});
});
+
+// Export for testing or module usage
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { searchPhrase, highlightSubstring, findRawRange, normalise, clearPreviousSearch };
+}