1/** 2 * Pure browser helpers used during widget mount and connection setup. 3 */ 4 5import { 6 BROWSER_ID_KEY, 7 CHARACTER_COLORS, 8 DEFAULT_CHARACTER_COLOR, 9 DISPLAY_NAME_MAX, 10 PROFILE_STORAGE_KEY, 11 READING_LABEL_MAX, 12} from "./constants.mjs"; 13 14/** 15 * Stable per-browser identity used to dedupe visitors across tabs. 16 * 17 * @returns {string} 18 */ 19export function getBrowserId() { 20 try { 21 const existing = localStorage.getItem(BROWSER_ID_KEY); 22 if (existing) { 23 return existing; 24 } 25 26 const nextId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" 27 ? crypto.randomUUID() 28 : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; 29 30 localStorage.setItem(BROWSER_ID_KEY, nextId); 31 return nextId; 32 } catch { 33 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; 34 } 35} 36 37/** 38 * @param {string} value 39 * @returns {string} 40 */ 41export function normalizeCharacterColor(value) { 42 return CHARACTER_COLORS.includes(value) ? value : DEFAULT_CHARACTER_COLOR; 43} 44 45/** 46 * @param {unknown} value 47 * @returns {string} 48 */ 49export function normalizeDisplayName(value) { 50 if (typeof value !== "string") return ""; 51 return value.trim().replace(/\s+/g, " ").slice(0, DISPLAY_NAME_MAX); 52} 53 54/** 55 * @param {unknown} value 56 * @returns {string} 57 */ 58export function normalizeReadingLabel(value) { 59 if (typeof value !== "string") return ""; 60 return value.trim().replace(/\s+/g, " ").slice(0, READING_LABEL_MAX); 61} 62 63/** 64 * @param {unknown} value 65 * @returns {string} 66 */ 67export function normalizeReadingUrl(value) { 68 if (typeof value !== "string") return ""; 69 try { 70 const url = new URL(value, window.location.href); 71 return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; 72 } catch { 73 return ""; 74 } 75} 76 77/** 78 * @param {string} title 79 * @param {string} headingLabel 80 * @returns {string} 81 */ 82function cleanDocumentTitle(title, headingLabel) { 83 const siteNames = new Set([ 84 window.location.hostname.replace(/^www\./, ""), 85 headingLabel.toLowerCase(), 86 ]); 87 const parts = title.split(/\s+(?:[|ââ-]|·)\s+/).map((part) => normalizeReadingLabel(part)); 88 return parts.find((part) => part && !siteNames.has(part.toLowerCase())) || ""; 89} 90 91/** 92 * @returns {string} 93 */ 94function labelFromPath() { 95 const segment = window.location.pathname.split("/").filter(Boolean).pop() || ""; 96 if (!segment) return ""; 97 try { 98 return normalizeReadingLabel(decodeURIComponent(segment) 99 .replace(/\.[a-z0-9]+$/i, "") 100 .replace(/[-_]+/g, " ")); 101 } catch { 102 return normalizeReadingLabel(segment.replace(/[-_]+/g, " ")); 103 } 104} 105 106/** @typedef {"auto" | "light" | "dark"} WidgetTheme */ 107 108/** 109 * Resolve the widget color theme from mount options or a pre-set root attribute. 110 * 111 * `auto` (default) follows `prefers-color-scheme`. Host pages with a manual 112 * dark toggle should pass `theme: "dark"` or set `data-townsquare-theme="dark"` 113 * on the mount root before calling `mountTownSquare`. 114 * 115 * @param {HTMLElement} root 116 * @param {{ theme?: string }} [options] 117 * @returns {WidgetTheme} 118 */ 119export function resolveWidgetTheme(root, options = {}) { 120 const raw = options.theme || root.dataset.townsquareTheme || "auto"; 121 if (typeof raw !== "string") return "auto"; 122 const theme = raw.trim().toLowerCase(); 123 if (theme === "light" || theme === "dark") return theme; 124 return "auto"; 125} 126 127/** 128 * Apply the resolved theme to the mount root for token.css selectors. 129 * 130 * @param {HTMLElement} root 131 * @param {WidgetTheme} theme 132 */ 133export function applyWidgetTheme(root, theme) { 134 if (theme === "auto") { 135 root.removeAttribute("data-townsquare-theme"); 136 return; 137 } 138 root.dataset.townsquareTheme = theme; 139} 140 141/** 142 * @param {HTMLElement} root 143 * @param {{ readingLabel?: string, readingUrl?: string }} options 144 * @returns {{ readingLabel: string, readingUrl: string }} 145 */ 146export function readCurrentPage(root, options = {}) { 147 const explicit = normalizeReadingLabel(options.readingLabel || root.dataset.townsquareReadingLabel || ""); 148 const heading = document.querySelector("article h1, main h1, h1"); 149 const headingLabel = normalizeReadingLabel(heading?.textContent || ""); 150 const documentTitle = cleanDocumentTitle(document.title, headingLabel); 151 const pathLabel = labelFromPath(); 152 const metaTitle = normalizeReadingLabel( 153 document.querySelector('meta[property="og:title"], meta[name="twitter:title"]')?.getAttribute("content") || "", 154 ); 155 156 return { 157 readingLabel: explicit || documentTitle || pathLabel || metaTitle || headingLabel || normalizeReadingLabel(document.title),
158 readingUrl: normalizeReadingUrl(options.readingUrl || root.dataset.townsquareReadingUrl || window.location.href), 159 }; 160} 161 162/** 163 * @returns {{ displayName: string, color: string }} 164 */ 165export function getStoredProfile() { 166 try { 167 const parsed = JSON.parse(sessionStorage.getItem(PROFILE_STORAGE_KEY) || "{}"); 168 const data = parsed && typeof parsed === "object" ? parsed : {}; 169 return { 170 displayName: normalizeDisplayName(data.displayName), 171 color: normalizeCharacterColor(data.color), 172 }; 173 } catch { 174 return { displayName: "", color: DEFAULT_CHARACTER_COLOR }; 175 } 176} 177 178/** 179 * @param {{ displayName: string, color: string }} profile 180 * @returns {{ displayName: string, color: string }} 181 */ 182export function saveStoredProfile(profile) { 183 const normalized = { 184 displayName: normalizeDisplayName(profile.displayName), 185 color: normalizeCharacterColor(profile.color), 186 }; 187 try { 188 sessionStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(normalized)); 189 } catch { 190 // The server still keeps the in-memory profile for the connected session. 191 } 192 return normalized; 193} 194 195/** 196 * Normalize a server origin string for WebSocket URL construction. 197 * 198 * @param {string} origin 199 * @returns {string} 200 */ 201export function normalizeOrigin(origin) { 202 const normalized = new URL(origin, window.location.href); 203 normalized.hash = ""; 204 normalized.search = ""; 205 normalized.pathname = normalized.pathname.replace(/\/$/, ""); 206 return normalized.toString().replace(/\/$/, ""); 207} 208 209/** 210 * Build the WebSocket URL for a TownSquare server origin and socket path. 211 * 212 * @param {string} serverOrigin 213 * @param {string} socketPath 214 * @param {string} [siteKey] 215 * @returns {string} 216 */ 217export function buildSocketUrl(serverOrigin, socketPath, siteKey = "") { 218 const url = new URL(socketPath, `${serverOrigin}/`); 219 url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; 220 if (siteKey) { 221 url.searchParams.set("siteKey", siteKey); 222 } 223 return url.toString(); 224}
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.