PageSourceSearch

https://triunitystudios.com/js/utils/textCore.js

js triunitystudios.com collected 2026-10-02 01:47:15 UTC 5,851 bytes, 182 lines download raw bytes

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.