Dispatch work item (5) asked for useTextHighlight in library/. RULE 2 wants a named SECOND consumer before anything moves there, and it exists: regions-dashboard's UupgsList and people-groups-grid both consume the same Fuse-compatible shim, both read match.indices, both wrap hits in <span class="search-highlight">, and both append the tail only on the last range. Line for line the same construction, apart from variable names. So library/composables/useTextHighlight.js exports highlightRanges(value, indices), and UupgsList calls it. Its local escapeHtml goes too: every caller was inside the highlight body that moved. ★ ESCAPING NOW COMES FROM library/utils/html.js, AND THAT IS A CHANGE FOR BOTH CALLERS, stated rather than smuggled. Three implementations existed and differed: library/utils/html.js escapes & < > " ' AND backtick; returns '' for null UupgsList no backtick; guarded null via String(v ?? '') people-groups-grid no backtick; NO NULL GUARD — s.replace(null) THROWS Both deltas move in the safe direction — one more character escaped, one crash removed — and neither changes the output of any value that renders today. The gate pins both, with controls proving each delta is a real difference rather than a no-op: the old copy really does leave a backtick unescaped, and their escaper really does throw on null. people-groups-grid is NOT migrated here: it is another owner's file, so it is handed over as source. The gate asserts it still builds its own highlighter, so this check states the truth rather than implying the job is finished. check:text-highlight is registered (script line, gates:post token, hand-inserted) and negative-tested: an implementation that escaped nothing fails it, and the output is asserted to depend on the indices.
48 lines
2.7 KiB
JavaScript
48 lines
2.7 KiB
JavaScript
/**
|
|
* highlightRanges(value, indices) — wrap each matched index range in
|
|
* <span class="search-highlight">, escaping every segment around it.
|
|
*
|
|
* WHY THIS IS SHARED. Two plugins built the same construction independently, line for line apart from
|
|
* variable names: regions-dashboard/src/UupgsList.vue and people-groups-grid/people-groups-grid.vue.
|
|
* Both consume the same Fuse-compatible shim (library/components/search/fuzzy.js), both read
|
|
* `match.indices`, both wrap with the same class, and both append the tail only on the last range.
|
|
* RULE 2 is satisfied by a NAMED second consumer, not by a guess that one might appear.
|
|
*
|
|
* The construction is dx-life's (their UupgsList.vue:336-348) and is ported rather than invented: walk
|
|
* the ranges in order, escape the gap before each hit, wrap the hit, and append the remaining tail once
|
|
* at the end. Indices come from the SEARCHED corpus (which may be accent-stripped) and are applied to
|
|
* the ORIGINAL string, which is why the caller passes both rather than re-deriving one from the other.
|
|
*
|
|
* ★ ESCAPING COMES FROM library/utils/html.js, AND THAT IS A DELIBERATE CHANGE FOR BOTH CALLERS.
|
|
* Each plugin had hand-rolled its own escapeHtml, and the three differed in ways worth naming:
|
|
* library/utils/html.js escapes & < > " ' AND backtick, and returns '' for null/undefined
|
|
* UupgsList escapes & < > " ' (no backtick), guards null via String(v ?? '')
|
|
* people-groups-grid escapes & < > " ' (no backtick), and has NO NULL GUARD AT ALL —
|
|
* `s.replace(...)` on null/undefined THROWS
|
|
* Both deltas move in the safe direction: one more character escaped, and a throw removed. Neither
|
|
* changes the output for any value that renders today. Stated here because "I swapped your escaper"
|
|
* is exactly the kind of quiet substitution that should never be discovered by a reader later.
|
|
*
|
|
* @param {string} value the ORIGINAL text to render
|
|
* @param {Array<[number, number]>} indices inclusive [start, end] pairs, in order
|
|
* @returns {string} HTML: escaped text with <span class="search-highlight"> around each range
|
|
*/
|
|
import { escapeHtml } from '../utils/html.js'
|
|
|
|
export function highlightRanges(value, indices) {
|
|
const original = String(value ?? '')
|
|
if (!Array.isArray(indices) || indices.length === 0) return escapeHtml(original)
|
|
let cursor = 0
|
|
let out = ''
|
|
indices.forEach((idx, i) => {
|
|
const [start, end] = idx
|
|
const isLast = i === indices.length - 1
|
|
out += escapeHtml(original.slice(cursor, start))
|
|
+ '<span class="search-highlight">' + escapeHtml(original.slice(start, end + 1)) + '</span>'
|
|
+ (isLast ? escapeHtml(original.slice(end + 1)) : '')
|
|
cursor = end + 1
|
|
})
|
|
return out
|
|
}
|
|
|
|
export default highlightRanges
|