PageSourceSearch

https://n8k99.com/static/townsquare/widget/utils.mjs

js n8k99.com collected 2026-10-06 18:22:53 UTC 6,582 bytes, 224 lines download raw bytes

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.