1// Pure text/string helpers with no browser or CDN dependencies, so they can 2// run under `bun test`. Rendering/fetching helpers live in textUtils.js. 3 4/** 5 * Capitalize the first letter of a string 6 * @param {string} string 7 * @param {boolean} lowerRest 8 * @param {boolean} firstLetterOfEveryWord 9 * @returns {string} 10 */ 11export function capitalizeFirstLetter(string, lowerRest = true, firstLetterOfEveryWord = false) { 12 if (!string) { 13 throw new Error("Input string is empty or undefined"); 14 } 15 16 if (typeof string !== "string") { 17 throw new Error("Input string is not a string"); 18 } 19 20 if (firstLetterOfEveryWord) { 21 return string 22 .split(" ") 23 .map((word) => capitalizeFirstLetter(word, lowerRest)) 24 .join(" "); 25 } 26 if (lowerRest) { 27 return string.charAt(0).toUpperCase() + string.toLowerCase().slice(1); 28 } 29 return string.charAt(0).toUpperCase() + string.slice(1); 30} 31 32/** 33 * Convert all array elements to lowercase 34 * @param {string[]} array 35 * @returns {string[]} 36 */ 37export function allToLower(array) { 38 if (!Array.isArray(array)) { 39 throw new Error("Input is not an array"); 40 } 41 return array.map((item) => item.toLowerCase()); 42} 43 44/** 45 * Convert text array to distinct paragraphs 46 * @param {string | string[]} textArray 47 * @returns {string} 48 */ 49export function turnTextArrayIntoDistinctPragraphs(textArray) { 50 if (Array.isArray(textArray)) { 51 return textArray.join("\n\n"); 52 } 53 return textArray; 54} 55 56/** 57 * 58 * @param {string} text 59 * @returns {string} 60 */ 61 62export function idFromText(text) { 63 if (!text) { 64 throw new Error("Text input is empty or undefined"); 65 } 66 67 if (typeof text !== "string") { 68 throw new Error("Text input is not a string"); 69 } 70 71 let sanitazed = capitalizeFirstLetter(text, true, true).replace(/ /g, ""); 72 // Remove special characters 73 sanitazed = sanitazed.replace(/[^\w\s]/gi, ""); 74 // remove "'", "â", ":", "(", ")", "!", "?", ".", "," 75 sanitazed = sanitazed.replace(/['â:\(\)!?,.]/gi, ""); 76 return sanitazed.trim(); 77} 78 79/** 80 * 81 * @param {string[]} array 82 * @returns {string[]} 83 */ 84export function allToId(array) { 85 if (!Array.isArray(array)) { 86 throw new Error("Input is not an array"); 87 } 88 return array.map((item) => idFromText(item)); 89} 90 91/** 92 * Strip inline markdown down to plain text: removes `**`/`*` emphasis markers 93 * and leading `#` heading markers, and reduces `[text](url)` links to their 94 * text. Used to build the crawler-facing static HTML fallback blocks (see 95 * scripts/generateSeoBlocks.js), where the words matter but formatting does not. 96 * @param {string} markdown 97 * @returns {string} 98 */ 99export function markdownToPlainText(markdown) { 100 if (typeof markdown !== "string") { 101 throw new Error("Input markdown is not a string"); 102 } 103 return ( 104 markdown 105 // Heading markers at the start of any line ("#### Title" -> "Title") 106 .replace(/^#+\s*/gm, "") 107 // Links first, so emphasis inside link text is still cleaned afterwards 108 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") 109 .replace(/\*\*/g, "") 110 .replace(/\*/g, "") 111 .trim() 112 ); 113} 114 115/** 116 * The three states one tag filter (a type or a skill chip) can hold. 117 * @typedef {"none" | "include" | "exclude"} TagFilterState 118 */ 119 120/** 121 * The state a tag filter takes after one more click. The cycle is 122 * none -> include -> exclude -> none, so the second click on a chip hides the 123 * works that carry that tag. Any unknown state counts as "none". 124 * @param {TagFilterState | undefined} current 125 * @returns {TagFilterState} 126 */ 127export function nextTagFilterState(current) { 128 if (current === "include") return "exclude"; 129 if (current === "exclude") return "none"; 130 return "include"; 131} 132 133/** 134 * Whether a work passes the type and skill chip filters. Two rules: 135 * a work must carry at least one tag of every non-empty include list (OR 136 * inside a group, AND between the two groups), and a work must carry no 137 * excluded tag. An exclusion always wins over an inclusion. 138 * 139 * The filter lists hold ids (`idFromText` output), so the work's own tags are 140 * normalized here before the comparison. 141 * @param {{ types?: string[], skills?: string[] }} work 142 * @param {{ includedTypes?: string[], excludedTypes?: string[], includedSkills?: string[], excludedSkills?: string[] }} filters 143 * @returns {boolean} 144 */ 145export function workMatchesTagFilters(work, filters) { 146 const groups = [
147 { workTags: work.types, included: filters.includedTypes, excluded: filters.excludedTypes }, 148 { workTags: work.skills, included: filters.includedSkills, excluded: filters.excludedSkills }, 149 ]; 150 151 return groups.every(({ workTags, included, excluded }) => { 152 const tagIds = allToId(Array.isArray(workTags) ? workTags : []); 153 154 if (excluded?.length && tagIds.some((tagId) => excluded.includes(tagId))) { 155 return false; 156 } 157 if (included?.length && !tagIds.some((tagId) => included.includes(tagId))) { 158 return false; 159 } 160 return true; 161 }); 162} 163 164/** 165 * Whether a work matches a free-text search query. The query is split into 166 * whitespace-separated tokens; every token must appear (case-insensitive) in 167 * the work's title, description, or skills. An empty query matches everything. 168 * Description markdown is matched raw (the rendered HTML is never stored). 169 * @param {{ title?: string, description?: string[], skills?: string[] }} work 170 * @param {string} query 171 * @returns {boolean} 172 */ 173export function workMatchesText(work, query) { 174 const normalized = (query || "").trim().toLowerCase(); 175 if (!normalized) return true; 176 177 const description = Array.isArray(work.description) ? work.description : []; 178 const skills = Array.isArray(work.skills) ? work.skills : []; 179 const haystack = [work.title, ...description, ...skills].filter(Boolean).join(" ").toLowerCase(); 180 181 return normalized.split(/\s+/).every((token) => haystack.includes(token)); 182}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.