From 306579d214cabcf120b1808112dc5963a3f992f8 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:22 +0100 Subject: [PATCH 01/12] add script to generate allowed external urls --- .github/scripts/generateAllowedUrls.ts | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/scripts/generateAllowedUrls.ts diff --git a/.github/scripts/generateAllowedUrls.ts b/.github/scripts/generateAllowedUrls.ts new file mode 100644 index 000000000000..08da048e499e --- /dev/null +++ b/.github/scripts/generateAllowedUrls.ts @@ -0,0 +1,89 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Script to extract all URLs from help articles and generate a whitelist. + * Run this at build time to update the allowed URLs list. + * + * Usage: npx ts-node docs/scripts/generateAllowedUrls.ts + */ + +const DOCS_DIR = path.join(__dirname, '..', '..', 'docs'); +const OUTPUT_FILE = path.join(DOCS_DIR, 'assets', 'js', 'allowedExternalUrls.json'); + +// Regex to match URLs in markdown +// Matches: [text](url), , and bare URLs +const URL_PATTERNS = [ + /\[.*?\]\((https?:\/\/[^)\s]+)\)/g, // Markdown links [text](url) + /<(https?:\/\/[^>\s]+)>/g, // Angle bracket URLs + /(?"']+)/g, // Bare URLs +]; + +function findMarkdownFiles(dir: string): string[] { + const files: string[] = []; + const items = fs.readdirSync(dir, {withFileTypes: true}); + + for (const item of items) { + const fullPath = path.join(dir, item.name); + if (item.isDirectory() && !item.name.startsWith('_site')) { + files.push(...findMarkdownFiles(fullPath)); + } else if (item.isFile() && item.name.endsWith('.md')) { + files.push(fullPath); + } + } + return files; +} + +function extractUrls(content: string): Set { + const urls = new Set(); + + for (const pattern of URL_PATTERNS) { + const regex = new RegExp(pattern.source, pattern.flags); + let match; + while ((match = regex.exec(content)) !== null) { + // Get the captured group (URL) or the full match + const url = match[1] || match[0]; + // Clean up trailing punctuation that might be captured + const cleanUrl = url.replace(/[.,;:!?)]+$/, ''); + if (cleanUrl.startsWith('http')) { + urls.add(cleanUrl); + } + } + } + return urls; +} + +function main() { + console.log('Scanning markdown files for URLs...'); + + const allUrls = new Set(); + const markdownFiles = findMarkdownFiles(DOCS_DIR); + + console.log(`Found ${markdownFiles.length} markdown files`); + + for (const file of markdownFiles) { + const content = fs.readFileSync(file, 'utf-8'); + const urls = extractUrls(content); + urls.forEach((url) => allUrls.add(url)); + } + + // Filter out Expensify URLs (check domain properly) and sort + const urlList = Array.from(allUrls) + .filter((url) => { + try { + const hostname = new URL(url).hostname; + return hostname !== 'expensify.com' && !hostname.endsWith('.expensify.com'); + } catch { + return false; + } + }) + .sort(); + + console.log(`Found ${urlList.length} unique URLs`); + + // Write to JSON file + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(urlList, null, 2)); + console.log(`Written to ${OUTPUT_FILE}`); +} + +main(); From ad92f51d0f664240818ec6838303d982af906dfc Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:23 +0100 Subject: [PATCH 02/12] add ci step to generate ai allowed urls whitelist --- .github/workflows/deployExpensifyHelp.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 488303bf7d2a..f2b7b0975aeb 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -55,6 +55,9 @@ jobs: - name: Enforce iframe and Cloudflare CDN usage run: ./.github/scripts/enforceVideoFormats.sh + - name: Generate allowed URLs whitelist for AI search + run: npx ts-node .github/scripts/generateAllowedUrls.ts + - name: Build with Jekyll uses: actions/jekyll-build-pages@0143c158f4fa0c5dcd99499a5d00859d79f70b0e with: From d8b019f81ba361463c857688846e98ff39f7a449 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:23 +0100 Subject: [PATCH 03/12] add allowed external urls list --- docs/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/.gitignore b/docs/.gitignore index 217151928969..e4d81b682b9c 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -4,3 +4,4 @@ _site .jekyll-metadata vendor _data/routes.yml +assets/js/allowedExternalUrls.json From 5358a2538595076bd7f7e52826c40f43c740ae91 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:23 +0100 Subject: [PATCH 04/12] add dompurify cdn script tag --- docs/_layouts/default.html | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index bc36b7ed707d..71a079807698 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -14,6 +14,7 @@ + From 413b350a140da211b8a85009a1c8c58d12accf17 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:24 +0100 Subject: [PATCH 05/12] fetch allowed domains and filter external links --- docs/assets/js/main.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 328b3572ea1d..77778d85a3a6 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -78,6 +78,35 @@ function injectFooterCopyright() { const SEARCH_API_URL = 'https://www.expensify.com/api/SearchHelpsite'; const ASK_AI_API_URL = 'https://www.expensify.com/api/AskHelpsiteAI'; +let allowedDomains = []; +fetch('/assets/js/allowedExternalUrls.json') + .then((response) => response.json()) + .then((urls) => { + allowedDomains = urls.map((url) => { + try { + return new URL(url).hostname; + } catch { + return null; + } + }).filter(Boolean); + }) + .catch(() => {}); + +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute('href')) { + const href = node.getAttribute('href'); + try { + const hostname = new URL(href).hostname; + const isExpensifyLink = hostname === 'expensify.com' || hostname.endsWith('.expensify.com'); + if (!isExpensifyLink && !allowedDomains.includes(hostname)) { + node.remove(); + } + } catch { + node.remove(); + } + } +}); + function getTitleFromURL(url) { return url.split('/').pop().replace(/-/g, ' '); } From 16dfa35da7d84969423524ca4e05c436f163db31 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:24 +0100 Subject: [PATCH 06/12] sanitize inner html with dompurify --- docs/assets/js/main.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 77778d85a3a6..827049bd250a 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -213,7 +213,10 @@ function askHelpsiteAI(query) { const template = cloneTemplate('ai-response-template'); const content = template.querySelector('.ai-content'); - content.innerHTML = answer; + content.innerHTML = DOMPurify.sanitize(answer, { + ALLOWED_TAGS: ['p', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'a', 'code', 'pre'], + ALLOWED_ATTR: ['href', 'target', 'rel'], + }); const showMoreButton = template.querySelector('.ai-show-more'); aiContainer.innerHTML = ''; From 2cba22577527769638fa727974d7188a59aa5ea3 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:29:24 +0100 Subject: [PATCH 07/12] add script to generate allowed urls using ts-node --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b659fa39ee8e..97b9d221973b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build-staging": "tsx ./node_modules/.bin/webpack-cli --config config/webpack/webpack.common.ts --env file=.env.staging && tsx ./scripts/combine-web-sourcemaps.ts", "build-adhoc": "tsx ./node_modules/.bin/webpack-cli --config config/webpack/webpack.common.ts --env file=.env.adhoc && tsx ./scripts/combine-web-sourcemaps.ts", "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts", + "generateAllowedUrls": "ts-node .github/scripts/generateAllowedUrls.ts", "detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts", "ios-build": "bundle exec fastlane ios build_unsigned", "ios-hybrid-build": "bundle exec fastlane ios build_unsigned_hybrid", From fe1288489fd2b300c1f8c867bdbb26e9d7eab5bf Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 17 Feb 2026 17:35:51 +0100 Subject: [PATCH 08/12] prettier --- docs/assets/js/main.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 827049bd250a..aa562b53ccc1 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -82,13 +82,15 @@ let allowedDomains = []; fetch('/assets/js/allowedExternalUrls.json') .then((response) => response.json()) .then((urls) => { - allowedDomains = urls.map((url) => { - try { - return new URL(url).hostname; - } catch { - return null; - } - }).filter(Boolean); + allowedDomains = urls + .map((url) => { + try { + return new URL(url).hostname; + } catch { + return null; + } + }) + .filter(Boolean); }) .catch(() => {}); From 890baf82d2e4cbc9675d8fac53a14cc58be74b7b Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Wed, 18 Feb 2026 05:51:15 +0000 Subject: [PATCH 09/12] Fix ESLint errors in generateAllowedUrls.ts - Remove unnecessary escape character in regex lookbehind (no-useless-escape) - Move assignment out of while condition (no-cond-assign) - Replace .forEach() with for...of loop (unicorn/no-array-for-each) Co-authored-by: Rushat Gabhane --- .github/scripts/generateAllowedUrls.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/scripts/generateAllowedUrls.ts b/.github/scripts/generateAllowedUrls.ts index 08da048e499e..34174556ae77 100644 --- a/.github/scripts/generateAllowedUrls.ts +++ b/.github/scripts/generateAllowedUrls.ts @@ -16,7 +16,7 @@ const OUTPUT_FILE = path.join(DOCS_DIR, 'assets', 'js', 'allowedExternalUrls.jso const URL_PATTERNS = [ /\[.*?\]\((https?:\/\/[^)\s]+)\)/g, // Markdown links [text](url) /<(https?:\/\/[^>\s]+)>/g, // Angle bracket URLs - /(?"']+)/g, // Bare URLs + /(?"']+)/g, // Bare URLs ]; function findMarkdownFiles(dir: string): string[] { @@ -39,8 +39,8 @@ function extractUrls(content: string): Set { for (const pattern of URL_PATTERNS) { const regex = new RegExp(pattern.source, pattern.flags); - let match; - while ((match = regex.exec(content)) !== null) { + let match = regex.exec(content); + while (match !== null) { // Get the captured group (URL) or the full match const url = match[1] || match[0]; // Clean up trailing punctuation that might be captured @@ -48,6 +48,7 @@ function extractUrls(content: string): Set { if (cleanUrl.startsWith('http')) { urls.add(cleanUrl); } + match = regex.exec(content); } } return urls; @@ -64,7 +65,9 @@ function main() { for (const file of markdownFiles) { const content = fs.readFileSync(file, 'utf-8'); const urls = extractUrls(content); - urls.forEach((url) => allUrls.add(url)); + for (const url of urls) { + allUrls.add(url); + } } // Filter out Expensify URLs (check domain properly) and sort From 274a61320b04b3ef49b99ea8cec6ef947b713468 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 18 Feb 2026 13:38:39 +0100 Subject: [PATCH 10/12] Update .github/scripts/generateAllowedUrls.ts Co-authored-by: Issa Nimaga --- .github/scripts/generateAllowedUrls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/generateAllowedUrls.ts b/.github/scripts/generateAllowedUrls.ts index 34174556ae77..7ddf10828731 100644 --- a/.github/scripts/generateAllowedUrls.ts +++ b/.github/scripts/generateAllowedUrls.ts @@ -5,7 +5,7 @@ import path from 'path'; * Script to extract all URLs from help articles and generate a whitelist. * Run this at build time to update the allowed URLs list. * - * Usage: npx ts-node docs/scripts/generateAllowedUrls.ts + * Usage: npx ts-node .github/scripts/generateAllowedUrls.ts */ const DOCS_DIR = path.join(__dirname, '..', '..', 'docs'); From 3fcc64dfcc17c466fdfbe637946f61d499c5b5ad Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 18 Feb 2026 13:52:22 +0100 Subject: [PATCH 11/12] style: switch to double quotes and tidy formatting --- docs/assets/js/main.js | 676 ++++++++++++++++++++++------------------- 1 file changed, 366 insertions(+), 310 deletions(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index aa562b53ccc1..6604a985932a 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -1,24 +1,24 @@ /* eslint-disable no-unused-vars */ function toggleHeaderMenu() { - const lhn = document.getElementById('lhn'); - const lhnContent = document.getElementById('lhn-content'); - const angleUpIcon = document.getElementById('angle-up-icon'); - const barsIcon = document.getElementById('bars-icon'); - if (lhnContent.className === 'expanded') { - // Collapse the LHN in mobile - lhn.className = ''; - lhnContent.className = ''; - barsIcon.classList.remove('hide'); - angleUpIcon.classList.add('hide'); - document.body.classList.remove('disable-scrollbar'); - } else { - // Expand the LHN in mobile - lhn.className = 'expanded'; - lhnContent.className = 'expanded'; - barsIcon.classList.add('hide'); - angleUpIcon.classList.remove('hide'); - document.body.classList.add('disable-scrollbar'); - } + const lhn = document.getElementById("lhn"); + const lhnContent = document.getElementById("lhn-content"); + const angleUpIcon = document.getElementById("angle-up-icon"); + const barsIcon = document.getElementById("bars-icon"); + if (lhnContent.className === "expanded") { + // Collapse the LHN in mobile + lhn.className = ""; + lhnContent.className = ""; + barsIcon.classList.remove("hide"); + angleUpIcon.classList.add("hide"); + document.body.classList.remove("disable-scrollbar"); + } else { + // Expand the LHN in mobile + lhn.className = "expanded"; + lhnContent.className = "expanded"; + barsIcon.classList.add("hide"); + angleUpIcon.classList.remove("hide"); + document.body.classList.add("disable-scrollbar"); + } } /** @@ -30,7 +30,7 @@ function toggleHeaderMenu() { * @returns {Number} */ function clamp(num, min, max) { - return Math.min(Math.max(num, min), max); + return Math.min(Math.max(num, min), max); } /** @@ -42,7 +42,7 @@ function clamp(num, min, max) { * @returns {Boolean} */ function isInRange(num, min, max) { - return num >= min && num <= max; + return num >= min && num <= max; } /** * Checks if the user has navigated within the docs using internal links and uses browser history to navigate back. @@ -50,67 +50,72 @@ function isInRange(num, min, max) { * back to the relevant hub page of that article. */ function navigateBack() { - const currentHost = window.location.host; - const referrer = document.referrer; - - if (referrer.includes(currentHost) && window.history.length > 1) { - window.history.back(); - return; - } - - // Path name is of the form /articles/[platform]/[hub]/[resource] - const path = window.location.pathname.split('/'); - if (path[2] && path[3]) { - window.location.href = `/${path[2]}/hubs/${path[3]}`; - } else { - window.location.href = '/'; - } - - // Add a little delay to avoid showing the previous content in a fraction of a time - setTimeout(toggleHeaderMenu, 250); + const currentHost = window.location.host; + const referrer = document.referrer; + + if (referrer.includes(currentHost) && window.history.length > 1) { + window.history.back(); + return; + } + + // Path name is of the form /articles/[platform]/[hub]/[resource] + const path = window.location.pathname.split("/"); + if (path[2] && path[3]) { + window.location.href = `/${path[2]}/hubs/${path[3]}`; + } else { + window.location.href = "/"; + } + + // Add a little delay to avoid showing the previous content in a fraction of a time + setTimeout(toggleHeaderMenu, 250); } function injectFooterCopyright() { - const footer = document.getElementById('footer-copyright-date'); - footer.innerHTML = `©2008-${new Date().getFullYear()} Expensify, Inc.`; + const footer = document.getElementById("footer-copyright-date"); + footer.innerHTML = `©2008-${new Date().getFullYear()} Expensify, Inc.`; } -const SEARCH_API_URL = 'https://www.expensify.com/api/SearchHelpsite'; -const ASK_AI_API_URL = 'https://www.expensify.com/api/AskHelpsiteAI'; +const SEARCH_API_URL = "https://www.expensify.com/api/SearchHelpsite"; +const ASK_AI_API_URL = "https://www.expensify.com/api/AskHelpsiteAI"; let allowedDomains = []; -fetch('/assets/js/allowedExternalUrls.json') - .then((response) => response.json()) - .then((urls) => { - allowedDomains = urls - .map((url) => { - try { - return new URL(url).hostname; - } catch { - return null; - } - }) - .filter(Boolean); - }) - .catch(() => {}); - -DOMPurify.addHook('afterSanitizeAttributes', (node) => { - if (node.tagName === 'A' && node.hasAttribute('href')) { - const href = node.getAttribute('href'); +fetch("/assets/js/allowedExternalUrls.json") + .then((response) => response.json()) + .then((urls) => { + allowedDomains = urls + .map((url) => { try { - const hostname = new URL(href).hostname; - const isExpensifyLink = hostname === 'expensify.com' || hostname.endsWith('.expensify.com'); - if (!isExpensifyLink && !allowedDomains.includes(hostname)) { - node.remove(); - } + return new URL(url).hostname; } catch { - node.remove(); + return null; } + }) + .filter(Boolean); + }) + .catch(() => {}); + +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A" && node.hasAttribute("href")) { + const href = node.getAttribute("href"); + try { + const hostname = new URL(href).hostname; + const isExpensifyLink = + hostname === "expensify.com" || hostname.endsWith(".expensify.com"); + if ( + !isExpensifyLink && + allowedDomains.length > 0 && + !allowedDomains.includes(hostname) + ) { + node.remove(); + } + } catch { + node.remove(); } + } }); function getTitleFromURL(url) { - return url.split('/').pop().replace(/-/g, ' '); + return url.split("/").pop().replace(/-/g, " "); } /** @@ -120,301 +125,352 @@ function getTitleFromURL(url) { * @returns {DocumentFragment} */ function cloneTemplate(templateId) { - return document.getElementById(templateId).content.cloneNode(true); + return document.getElementById(templateId).content.cloneNode(true); } function searchPageQuery(query) { - const resultsContainer = document.getElementById('search-page-results'); - if (!query.trim()) { - resultsContainer.innerHTML = ''; - return; - } + const resultsContainer = document.getElementById("search-page-results"); + if (!query.trim()) { + resultsContainer.innerHTML = ""; + return; + } - resultsContainer.innerHTML = ''; - resultsContainer.appendChild(cloneTemplate('search-loading-template')); + resultsContainer.innerHTML = ""; + resultsContainer.appendChild(cloneTemplate("search-loading-template")); - askHelpsiteAI(query); + askHelpsiteAI(query); - const formData = new FormData(); - formData.append('command', 'SearchHelpsite'); - formData.append('query', query.trim()); + const formData = new FormData(); + formData.append("command", "SearchHelpsite"); + formData.append("query", query.trim()); - const platform = new URLSearchParams(window.location.search).get('platform'); - if (platform) { - formData.append('platform', platform); - } + const platform = new URLSearchParams(window.location.search).get("platform"); + if (platform) { + formData.append("platform", platform); + } - fetch(SEARCH_API_URL, {method: 'POST', body: formData}) - .then((response) => response.json()) - .then((data) => { - const results = (data.searchResults || []).filter((result) => !result.url.includes('/Unlisted/')); - resultsContainer.innerHTML = ''; - if (results.length === 0) { - resultsContainer.appendChild(cloneTemplate('search-no-results-template')); - return; - } - results.forEach((result) => { - const item = cloneTemplate('search-result-item-template'); - const link = item.querySelector('.search-result-item'); - link.href = result.url; - link.querySelector('.search-result-title').textContent = getTitleFromURL(result.url); - const description = link.querySelector('.search-result-description'); - if (result.description) { - description.textContent = result.description; - } else { - description.remove(); - } - resultsContainer.appendChild(item); - }); - }) - .catch(() => { - resultsContainer.innerHTML = ''; - resultsContainer.appendChild(cloneTemplate('search-error-template')); - }); + fetch(SEARCH_API_URL, { method: "POST", body: formData }) + .then((response) => response.json()) + .then((data) => { + const results = (data.searchResults || []).filter( + (result) => !result.url.includes("/Unlisted/"), + ); + resultsContainer.innerHTML = ""; + if (results.length === 0) { + resultsContainer.appendChild( + cloneTemplate("search-no-results-template"), + ); + return; + } + results.forEach((result) => { + const item = cloneTemplate("search-result-item-template"); + const link = item.querySelector(".search-result-item"); + link.href = result.url; + link.querySelector(".search-result-title").textContent = + getTitleFromURL(result.url); + const description = link.querySelector(".search-result-description"); + if (result.description) { + description.textContent = result.description; + } else { + description.remove(); + } + resultsContainer.appendChild(item); + }); + }) + .catch(() => { + resultsContainer.innerHTML = ""; + resultsContainer.appendChild(cloneTemplate("search-error-template")); + }); } function clearSearchInput() { - const input = document.getElementById('search-page-input'); - input.value = ''; - input.focus(); + const input = document.getElementById("search-page-input"); + input.value = ""; + input.focus(); } let aiAbortController = null; function askHelpsiteAI(query) { - const aiContainer = document.getElementById('ai-answer-container'); - if (!aiContainer) { + const aiContainer = document.getElementById("ai-answer-container"); + if (!aiContainer) { + return; + } + + if (aiAbortController) { + aiAbortController.abort(); + } + aiAbortController = new AbortController(); + + aiContainer.innerHTML = ""; + aiContainer.appendChild(cloneTemplate("ai-thinking-template")); + + const formData = new FormData(); + formData.append("command", "AskHelpsiteAI"); + formData.append("query", query.trim()); + + const platform = new URLSearchParams(window.location.search).get("platform"); + if (platform) { + formData.append("platform", platform); + } + + fetch(ASK_AI_API_URL, { + method: "POST", + body: formData, + signal: aiAbortController.signal, + }) + .then((response) => response.json()) + .then((data) => { + const answer = data.answer || ""; + if (!answer) { + aiContainer.innerHTML = ""; return; - } - - if (aiAbortController) { - aiAbortController.abort(); - } - aiAbortController = new AbortController(); - - aiContainer.innerHTML = ''; - aiContainer.appendChild(cloneTemplate('ai-thinking-template')); - - const formData = new FormData(); - formData.append('command', 'AskHelpsiteAI'); - formData.append('query', query.trim()); - - const platform = new URLSearchParams(window.location.search).get('platform'); - if (platform) { - formData.append('platform', platform); - } - - fetch(ASK_AI_API_URL, {method: 'POST', body: formData, signal: aiAbortController.signal}) - .then((response) => response.json()) - .then((data) => { - const answer = data.answer || ''; - if (!answer) { - aiContainer.innerHTML = ''; - return; - } - - const template = cloneTemplate('ai-response-template'); - const content = template.querySelector('.ai-content'); - content.innerHTML = DOMPurify.sanitize(answer, { - ALLOWED_TAGS: ['p', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'a', 'code', 'pre'], - ALLOWED_ATTR: ['href', 'target', 'rel'], - }); - - const showMoreButton = template.querySelector('.ai-show-more'); - aiContainer.innerHTML = ''; - aiContainer.appendChild(template); - - // Show "Show more" button if content overflows - const renderedContent = aiContainer.querySelector('.ai-content'); - if (renderedContent.scrollHeight > renderedContent.clientHeight) { - renderedContent.classList.add('has-overflow'); - showMoreButton.classList.remove('hidden'); - showMoreButton.addEventListener('click', () => { - renderedContent.classList.toggle('expanded'); - showMoreButton.firstChild.textContent = renderedContent.classList.contains('expanded') ? 'Show less ' : 'Show more '; - }); - } - }) - .catch((error) => { - if (error.name === 'AbortError') { - return; - } - aiContainer.innerHTML = ''; - aiContainer.appendChild(cloneTemplate('ai-error-template')); + } + + const template = cloneTemplate("ai-response-template"); + const content = template.querySelector(".ai-content"); + content.innerHTML = DOMPurify.sanitize(answer, { + ALLOWED_TAGS: [ + "p", + "br", + "strong", + "b", + "em", + "i", + "ul", + "ol", + "li", + "a", + "code", + "pre", + ], + ALLOWED_ATTR: ["href", "target", "rel"], + }); + + const showMoreButton = template.querySelector(".ai-show-more"); + aiContainer.innerHTML = ""; + aiContainer.appendChild(template); + + // Show "Show more" button if content overflows + const renderedContent = aiContainer.querySelector(".ai-content"); + if (renderedContent.scrollHeight > renderedContent.clientHeight) { + renderedContent.classList.add("has-overflow"); + showMoreButton.classList.remove("hidden"); + showMoreButton.addEventListener("click", () => { + renderedContent.classList.toggle("expanded"); + showMoreButton.firstChild.textContent = + renderedContent.classList.contains("expanded") + ? "Show less " + : "Show more "; }); + } + }) + .catch((error) => { + if (error.name === "AbortError") { + return; + } + aiContainer.innerHTML = ""; + aiContainer.appendChild(cloneTemplate("ai-error-template")); + }); } function initSearchPage() { - const searchForm = document.getElementById('search-page-form'); - if (!searchForm) { - return; - } - - const input = document.getElementById('search-page-input'); - const params = new URLSearchParams(window.location.search); - const query = params.get('q') || ''; - const platform = params.get('platform') || ''; - - const title = document.getElementById('search-page-title'); + const searchForm = document.getElementById("search-page-form"); + if (!searchForm) { + return; + } + + const input = document.getElementById("search-page-input"); + const params = new URLSearchParams(window.location.search); + const query = params.get("q") || ""; + const platform = params.get("platform") || ""; + + const title = document.getElementById("search-page-title"); + if (query) { + input.value = query; + title.textContent = "Search results"; + searchPageQuery(query); + } + + input.focus(); + + searchForm.addEventListener("submit", (e) => { + e.preventDefault(); + const query = input.value.trim(); if (query) { - input.value = query; - title.textContent = 'Search results'; - searchPageQuery(query); + const url = + "/search?q=" + + encodeURIComponent(query) + + (platform ? "&platform=" + encodeURIComponent(platform) : ""); + history.replaceState(null, "", url); + title.textContent = "Search results"; + searchPageQuery(query); } + }); - input.focus(); - - searchForm.addEventListener('submit', (e) => { - e.preventDefault(); - const query = input.value.trim(); - if (query) { - const url = '/search?q=' + encodeURIComponent(query) + (platform ? '&platform=' + encodeURIComponent(platform) : ''); - history.replaceState(null, '', url); - title.textContent = 'Search results'; - searchPageQuery(query); - } - }); - - document.getElementById('search-page-clear').addEventListener('click', clearSearchInput); + document + .getElementById("search-page-clear") + .addEventListener("click", clearSearchInput); } const FIXED_HEADER_HEIGHT = 80; const tocbotOptions = { - // Where to render the table of contents. - tocSelector: '.article-toc', - - // Where to grab the headings to build the table of contents. - contentSelector: '', - - // Disable the collapsible functionality of the library by - // setting the maximum number of heading levels (6) - collapseDepth: 6, - headingSelector: 'h1, h2, h3, summary', - - // Main class to add to lists. - listClass: 'lhn-items', - - // Main class to add to links. - linkClass: 'link', - - // Class to add to active links, - // the link corresponding to the top most heading on the page. - activeLinkClass: 'selected-article', - - // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) - headingsOffset: FIXED_HEADER_HEIGHT, - scrollSmoothOffset: -FIXED_HEADER_HEIGHT, - scrollSmooth: true, - - // If there is a fixed article scroll container, set to calculate titles' offset - scrollContainer: 'content-area', - - onClick: (e) => { - e.preventDefault(); - const hashText = e.target.href.split('#').pop(); - // Append hashText to the current URL without saving to history - const newUrl = `${window.location.pathname}#${hashText}`; - history.replaceState(null, '', newUrl); - }, + // Where to render the table of contents. + tocSelector: ".article-toc", + + // Where to grab the headings to build the table of contents. + contentSelector: "", + + // Disable the collapsible functionality of the library by + // setting the maximum number of heading levels (6) + collapseDepth: 6, + headingSelector: "h1, h2, h3, summary", + + // Main class to add to lists. + listClass: "lhn-items", + + // Main class to add to links. + linkClass: "link", + + // Class to add to active links, + // the link corresponding to the top most heading on the page. + activeLinkClass: "selected-article", + + // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) + headingsOffset: FIXED_HEADER_HEIGHT, + scrollSmoothOffset: -FIXED_HEADER_HEIGHT, + scrollSmooth: true, + + // If there is a fixed article scroll container, set to calculate titles' offset + scrollContainer: "content-area", + + onClick: (e) => { + e.preventDefault(); + const hashText = e.target.href.split("#").pop(); + // Append hashText to the current URL without saving to history + const newUrl = `${window.location.pathname}#${hashText}`; + history.replaceState(null, "", newUrl); + }, }; // Define the media query string for the mobile breakpoint -const mobileBreakpoint = window.matchMedia('(max-width: 799px)'); +const mobileBreakpoint = window.matchMedia("(max-width: 799px)"); // Function to update tocbot options and refresh function updateTocbotOptions(headingsOffset, scrollSmoothOffset) { - tocbotOptions.headingsOffset = headingsOffset; - tocbotOptions.scrollSmoothOffset = scrollSmoothOffset; - window.tocbot.refresh({ - ...tocbotOptions, - }); + tocbotOptions.headingsOffset = headingsOffset; + tocbotOptions.scrollSmoothOffset = scrollSmoothOffset; + window.tocbot.refresh({ + ...tocbotOptions, + }); } function handleBreakpointChange() { - const isMobile = mobileBreakpoint.matches; - const headingsOffset = isMobile ? FIXED_HEADER_HEIGHT : 0; - const scrollSmoothOffset = isMobile ? -FIXED_HEADER_HEIGHT : 0; - - // Update tocbot options only if there is a change in offsets - if (tocbotOptions.headingsOffset !== headingsOffset || tocbotOptions.scrollSmoothOffset !== scrollSmoothOffset) { - updateTocbotOptions(headingsOffset, scrollSmoothOffset); - } + const isMobile = mobileBreakpoint.matches; + const headingsOffset = isMobile ? FIXED_HEADER_HEIGHT : 0; + const scrollSmoothOffset = isMobile ? -FIXED_HEADER_HEIGHT : 0; + + // Update tocbot options only if there is a change in offsets + if ( + tocbotOptions.headingsOffset !== headingsOffset || + tocbotOptions.scrollSmoothOffset !== scrollSmoothOffset + ) { + updateTocbotOptions(headingsOffset, scrollSmoothOffset); + } } // Add listener for changes to the media query status using addEventListener -mobileBreakpoint.addEventListener('change', handleBreakpointChange); +mobileBreakpoint.addEventListener("change", handleBreakpointChange); // Initial check handleBreakpointChange(); -window.addEventListener('DOMContentLoaded', () => { - injectFooterCopyright(); +window.addEventListener("DOMContentLoaded", () => { + injectFooterCopyright(); - if (window.tocbot) { - window.tocbot.init({ - ...tocbotOptions, - contentSelector: '.article-toc-content', - }); + if (window.tocbot) { + window.tocbot.init({ + ...tocbotOptions, + contentSelector: ".article-toc-content", + }); + } + + initSearchPage(); + + document + .getElementById("header-button") + .addEventListener("click", toggleHeaderMenu); + + // Back button doesn't exist on all the pages + const backButton = document.getElementById("back-button"); + if (backButton) { + backButton.addEventListener("click", navigateBack); + } + + const articleContent = document.getElementById("article-content"); + const lhnContent = document.getElementById("lhn-content"); + + // This event listener checks if a link clicked in the LHN points to some section of the same page and toggles + // the LHN menu in responsive view. + lhnContent.addEventListener("click", (event) => { + const clickedLink = event.target; + if (clickedLink) { + const href = clickedLink.getAttribute("href"); + if ( + href && + href.startsWith("#") && + !!document.getElementById(href.slice(1)) + ) { + toggleHeaderMenu(); + } } - - initSearchPage(); - - document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); - - // Back button doesn't exist on all the pages - const backButton = document.getElementById('back-button'); - if (backButton) { - backButton.addEventListener('click', navigateBack); + }); + lhnContent.addEventListener("wheel", (e) => { + const scrollTop = lhnContent.scrollTop; + const isScrollingPastLHNTop = e.deltaY < 0 && scrollTop === 0; + const isScrollingPastLHNBottom = + e.deltaY > 0 && + isInRange( + lhnContent.scrollHeight - lhnContent.offsetHeight, + scrollTop - 1, + scrollTop + 1, + ); + if (isScrollingPastLHNTop || isScrollingPastLHNBottom) { + e.preventDefault(); } - - const articleContent = document.getElementById('article-content'); - const lhnContent = document.getElementById('lhn-content'); - - // This event listener checks if a link clicked in the LHN points to some section of the same page and toggles - // the LHN menu in responsive view. - lhnContent.addEventListener('click', (event) => { - const clickedLink = event.target; - if (clickedLink) { - const href = clickedLink.getAttribute('href'); - if (href && href.startsWith('#') && !!document.getElementById(href.slice(1))) { - toggleHeaderMenu(); - } - } - }); - lhnContent.addEventListener('wheel', (e) => { - const scrollTop = lhnContent.scrollTop; - const isScrollingPastLHNTop = e.deltaY < 0 && scrollTop === 0; - const isScrollingPastLHNBottom = e.deltaY > 0 && isInRange(lhnContent.scrollHeight - lhnContent.offsetHeight, scrollTop - 1, scrollTop + 1); - if (isScrollingPastLHNTop || isScrollingPastLHNBottom) { - e.preventDefault(); - } - }); - window.addEventListener('scroll', (e) => { - const scrollingElement = e.target.scrollingElement; - const scrollPercentageInArticleContent = clamp(scrollingElement.scrollTop - articleContent.offsetTop, 0, articleContent.scrollHeight) / articleContent.scrollHeight; - lhnContent.scrollTop = scrollPercentageInArticleContent * lhnContent.scrollHeight; - }); + }); + window.addEventListener("scroll", (e) => { + const scrollingElement = e.target.scrollingElement; + const scrollPercentageInArticleContent = + clamp( + scrollingElement.scrollTop - articleContent.offsetTop, + 0, + articleContent.scrollHeight, + ) / articleContent.scrollHeight; + lhnContent.scrollTop = + scrollPercentageInArticleContent * lhnContent.scrollHeight; + }); }); if (window.location.hash) { - const lowerCaseHash = window.location.hash.toLowerCase(); - const element = document.getElementById(lowerCaseHash.slice(1)); + const lowerCaseHash = window.location.hash.toLowerCase(); + const element = document.getElementById(lowerCaseHash.slice(1)); - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - }); - } + if (element) { + element.scrollIntoView({ + behavior: "smooth", + }); + } } // Handle hash changes (like back/forward navigation) -window.addEventListener('hashchange', () => { - if (!window.location.hash) { - return; - } - const lowerCaseHash = window.location.hash.toLowerCase(); - document.getElementById(lowerCaseHash.slice(1))?.scrollIntoView({ - behavior: 'smooth', - }); +window.addEventListener("hashchange", () => { + if (!window.location.hash) { + return; + } + const lowerCaseHash = window.location.hash.toLowerCase(); + document.getElementById(lowerCaseHash.slice(1))?.scrollIntoView({ + behavior: "smooth", + }); }); From 337004ceed04e69b0a4ac4c845d01857c3e3a1fd Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 18 Feb 2026 14:01:19 +0100 Subject: [PATCH 12/12] style: switch to single quotes and tidy --- docs/assets/js/main.js | 680 +++++++++++++++++++---------------------- 1 file changed, 314 insertions(+), 366 deletions(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 6604a985932a..01f823fe2e41 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -1,24 +1,24 @@ /* eslint-disable no-unused-vars */ function toggleHeaderMenu() { - const lhn = document.getElementById("lhn"); - const lhnContent = document.getElementById("lhn-content"); - const angleUpIcon = document.getElementById("angle-up-icon"); - const barsIcon = document.getElementById("bars-icon"); - if (lhnContent.className === "expanded") { - // Collapse the LHN in mobile - lhn.className = ""; - lhnContent.className = ""; - barsIcon.classList.remove("hide"); - angleUpIcon.classList.add("hide"); - document.body.classList.remove("disable-scrollbar"); - } else { - // Expand the LHN in mobile - lhn.className = "expanded"; - lhnContent.className = "expanded"; - barsIcon.classList.add("hide"); - angleUpIcon.classList.remove("hide"); - document.body.classList.add("disable-scrollbar"); - } + const lhn = document.getElementById('lhn'); + const lhnContent = document.getElementById('lhn-content'); + const angleUpIcon = document.getElementById('angle-up-icon'); + const barsIcon = document.getElementById('bars-icon'); + if (lhnContent.className === 'expanded') { + // Collapse the LHN in mobile + lhn.className = ''; + lhnContent.className = ''; + barsIcon.classList.remove('hide'); + angleUpIcon.classList.add('hide'); + document.body.classList.remove('disable-scrollbar'); + } else { + // Expand the LHN in mobile + lhn.className = 'expanded'; + lhnContent.className = 'expanded'; + barsIcon.classList.add('hide'); + angleUpIcon.classList.remove('hide'); + document.body.classList.add('disable-scrollbar'); + } } /** @@ -30,7 +30,7 @@ function toggleHeaderMenu() { * @returns {Number} */ function clamp(num, min, max) { - return Math.min(Math.max(num, min), max); + return Math.min(Math.max(num, min), max); } /** @@ -42,7 +42,7 @@ function clamp(num, min, max) { * @returns {Boolean} */ function isInRange(num, min, max) { - return num >= min && num <= max; + return num >= min && num <= max; } /** * Checks if the user has navigated within the docs using internal links and uses browser history to navigate back. @@ -50,72 +50,67 @@ function isInRange(num, min, max) { * back to the relevant hub page of that article. */ function navigateBack() { - const currentHost = window.location.host; - const referrer = document.referrer; - - if (referrer.includes(currentHost) && window.history.length > 1) { - window.history.back(); - return; - } - - // Path name is of the form /articles/[platform]/[hub]/[resource] - const path = window.location.pathname.split("/"); - if (path[2] && path[3]) { - window.location.href = `/${path[2]}/hubs/${path[3]}`; - } else { - window.location.href = "/"; - } - - // Add a little delay to avoid showing the previous content in a fraction of a time - setTimeout(toggleHeaderMenu, 250); + const currentHost = window.location.host; + const referrer = document.referrer; + + if (referrer.includes(currentHost) && window.history.length > 1) { + window.history.back(); + return; + } + + // Path name is of the form /articles/[platform]/[hub]/[resource] + const path = window.location.pathname.split('/'); + if (path[2] && path[3]) { + window.location.href = `/${path[2]}/hubs/${path[3]}`; + } else { + window.location.href = '/'; + } + + // Add a little delay to avoid showing the previous content in a fraction of a time + setTimeout(toggleHeaderMenu, 250); } function injectFooterCopyright() { - const footer = document.getElementById("footer-copyright-date"); - footer.innerHTML = `©2008-${new Date().getFullYear()} Expensify, Inc.`; + const footer = document.getElementById('footer-copyright-date'); + footer.innerHTML = `©2008-${new Date().getFullYear()} Expensify, Inc.`; } -const SEARCH_API_URL = "https://www.expensify.com/api/SearchHelpsite"; -const ASK_AI_API_URL = "https://www.expensify.com/api/AskHelpsiteAI"; +const SEARCH_API_URL = 'https://www.expensify.com/api/SearchHelpsite'; +const ASK_AI_API_URL = 'https://www.expensify.com/api/AskHelpsiteAI'; let allowedDomains = []; -fetch("/assets/js/allowedExternalUrls.json") - .then((response) => response.json()) - .then((urls) => { - allowedDomains = urls - .map((url) => { +fetch('/assets/js/allowedExternalUrls.json') + .then((response) => response.json()) + .then((urls) => { + allowedDomains = urls + .map((url) => { + try { + return new URL(url).hostname; + } catch { + return null; + } + }) + .filter(Boolean); + }) + .catch(() => {}); + +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute('href')) { + const href = node.getAttribute('href'); try { - return new URL(url).hostname; + const hostname = new URL(href).hostname; + const isExpensifyLink = hostname === 'expensify.com' || hostname.endsWith('.expensify.com'); + if (!isExpensifyLink && allowedDomains.length > 0 && !allowedDomains.includes(hostname)) { + node.remove(); + } } catch { - return null; + node.remove(); } - }) - .filter(Boolean); - }) - .catch(() => {}); - -DOMPurify.addHook("afterSanitizeAttributes", (node) => { - if (node.tagName === "A" && node.hasAttribute("href")) { - const href = node.getAttribute("href"); - try { - const hostname = new URL(href).hostname; - const isExpensifyLink = - hostname === "expensify.com" || hostname.endsWith(".expensify.com"); - if ( - !isExpensifyLink && - allowedDomains.length > 0 && - !allowedDomains.includes(hostname) - ) { - node.remove(); - } - } catch { - node.remove(); } - } }); function getTitleFromURL(url) { - return url.split("/").pop().replace(/-/g, " "); + return url.split('/').pop().replace(/-/g, ' '); } /** @@ -125,352 +120,305 @@ function getTitleFromURL(url) { * @returns {DocumentFragment} */ function cloneTemplate(templateId) { - return document.getElementById(templateId).content.cloneNode(true); + return document.getElementById(templateId).content.cloneNode(true); } function searchPageQuery(query) { - const resultsContainer = document.getElementById("search-page-results"); - if (!query.trim()) { - resultsContainer.innerHTML = ""; - return; - } + const resultsContainer = document.getElementById('search-page-results'); + if (!query.trim()) { + resultsContainer.innerHTML = ''; + return; + } - resultsContainer.innerHTML = ""; - resultsContainer.appendChild(cloneTemplate("search-loading-template")); + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(cloneTemplate('search-loading-template')); - askHelpsiteAI(query); + askHelpsiteAI(query); - const formData = new FormData(); - formData.append("command", "SearchHelpsite"); - formData.append("query", query.trim()); + const formData = new FormData(); + formData.append('command', 'SearchHelpsite'); + formData.append('query', query.trim()); - const platform = new URLSearchParams(window.location.search).get("platform"); - if (platform) { - formData.append("platform", platform); - } + const platform = new URLSearchParams(window.location.search).get('platform'); + if (platform) { + formData.append('platform', platform); + } - fetch(SEARCH_API_URL, { method: "POST", body: formData }) - .then((response) => response.json()) - .then((data) => { - const results = (data.searchResults || []).filter( - (result) => !result.url.includes("/Unlisted/"), - ); - resultsContainer.innerHTML = ""; - if (results.length === 0) { - resultsContainer.appendChild( - cloneTemplate("search-no-results-template"), - ); - return; - } - results.forEach((result) => { - const item = cloneTemplate("search-result-item-template"); - const link = item.querySelector(".search-result-item"); - link.href = result.url; - link.querySelector(".search-result-title").textContent = - getTitleFromURL(result.url); - const description = link.querySelector(".search-result-description"); - if (result.description) { - description.textContent = result.description; - } else { - description.remove(); - } - resultsContainer.appendChild(item); - }); - }) - .catch(() => { - resultsContainer.innerHTML = ""; - resultsContainer.appendChild(cloneTemplate("search-error-template")); - }); + fetch(SEARCH_API_URL, {method: 'POST', body: formData}) + .then((response) => response.json()) + .then((data) => { + const results = (data.searchResults || []).filter((result) => !result.url.includes('/Unlisted/')); + resultsContainer.innerHTML = ''; + if (results.length === 0) { + resultsContainer.appendChild(cloneTemplate('search-no-results-template')); + return; + } + results.forEach((result) => { + const item = cloneTemplate('search-result-item-template'); + const link = item.querySelector('.search-result-item'); + link.href = result.url; + link.querySelector('.search-result-title').textContent = getTitleFromURL(result.url); + const description = link.querySelector('.search-result-description'); + if (result.description) { + description.textContent = result.description; + } else { + description.remove(); + } + resultsContainer.appendChild(item); + }); + }) + .catch(() => { + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(cloneTemplate('search-error-template')); + }); } function clearSearchInput() { - const input = document.getElementById("search-page-input"); - input.value = ""; - input.focus(); + const input = document.getElementById('search-page-input'); + input.value = ''; + input.focus(); } let aiAbortController = null; function askHelpsiteAI(query) { - const aiContainer = document.getElementById("ai-answer-container"); - if (!aiContainer) { - return; - } - - if (aiAbortController) { - aiAbortController.abort(); - } - aiAbortController = new AbortController(); - - aiContainer.innerHTML = ""; - aiContainer.appendChild(cloneTemplate("ai-thinking-template")); - - const formData = new FormData(); - formData.append("command", "AskHelpsiteAI"); - formData.append("query", query.trim()); - - const platform = new URLSearchParams(window.location.search).get("platform"); - if (platform) { - formData.append("platform", platform); - } - - fetch(ASK_AI_API_URL, { - method: "POST", - body: formData, - signal: aiAbortController.signal, - }) - .then((response) => response.json()) - .then((data) => { - const answer = data.answer || ""; - if (!answer) { - aiContainer.innerHTML = ""; + const aiContainer = document.getElementById('ai-answer-container'); + if (!aiContainer) { return; - } - - const template = cloneTemplate("ai-response-template"); - const content = template.querySelector(".ai-content"); - content.innerHTML = DOMPurify.sanitize(answer, { - ALLOWED_TAGS: [ - "p", - "br", - "strong", - "b", - "em", - "i", - "ul", - "ol", - "li", - "a", - "code", - "pre", - ], - ALLOWED_ATTR: ["href", "target", "rel"], - }); - - const showMoreButton = template.querySelector(".ai-show-more"); - aiContainer.innerHTML = ""; - aiContainer.appendChild(template); - - // Show "Show more" button if content overflows - const renderedContent = aiContainer.querySelector(".ai-content"); - if (renderedContent.scrollHeight > renderedContent.clientHeight) { - renderedContent.classList.add("has-overflow"); - showMoreButton.classList.remove("hidden"); - showMoreButton.addEventListener("click", () => { - renderedContent.classList.toggle("expanded"); - showMoreButton.firstChild.textContent = - renderedContent.classList.contains("expanded") - ? "Show less " - : "Show more "; - }); - } + } + + if (aiAbortController) { + aiAbortController.abort(); + } + aiAbortController = new AbortController(); + + aiContainer.innerHTML = ''; + aiContainer.appendChild(cloneTemplate('ai-thinking-template')); + + const formData = new FormData(); + formData.append('command', 'AskHelpsiteAI'); + formData.append('query', query.trim()); + + const platform = new URLSearchParams(window.location.search).get('platform'); + if (platform) { + formData.append('platform', platform); + } + + fetch(ASK_AI_API_URL, { + method: 'POST', + body: formData, + signal: aiAbortController.signal, }) - .catch((error) => { - if (error.name === "AbortError") { - return; - } - aiContainer.innerHTML = ""; - aiContainer.appendChild(cloneTemplate("ai-error-template")); - }); + .then((response) => response.json()) + .then((data) => { + const answer = data.answer || ''; + if (!answer) { + aiContainer.innerHTML = ''; + return; + } + + const template = cloneTemplate('ai-response-template'); + const content = template.querySelector('.ai-content'); + content.innerHTML = DOMPurify.sanitize(answer, { + ALLOWED_TAGS: ['p', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'a', 'code', 'pre'], + ALLOWED_ATTR: ['href', 'target', 'rel'], + }); + + const showMoreButton = template.querySelector('.ai-show-more'); + aiContainer.innerHTML = ''; + aiContainer.appendChild(template); + + // Show "Show more" button if content overflows + const renderedContent = aiContainer.querySelector('.ai-content'); + if (renderedContent.scrollHeight > renderedContent.clientHeight) { + renderedContent.classList.add('has-overflow'); + showMoreButton.classList.remove('hidden'); + showMoreButton.addEventListener('click', () => { + renderedContent.classList.toggle('expanded'); + showMoreButton.firstChild.textContent = renderedContent.classList.contains('expanded') ? 'Show less ' : 'Show more '; + }); + } + }) + .catch((error) => { + if (error.name === 'AbortError') { + return; + } + aiContainer.innerHTML = ''; + aiContainer.appendChild(cloneTemplate('ai-error-template')); + }); } function initSearchPage() { - const searchForm = document.getElementById("search-page-form"); - if (!searchForm) { - return; - } - - const input = document.getElementById("search-page-input"); - const params = new URLSearchParams(window.location.search); - const query = params.get("q") || ""; - const platform = params.get("platform") || ""; - - const title = document.getElementById("search-page-title"); - if (query) { - input.value = query; - title.textContent = "Search results"; - searchPageQuery(query); - } - - input.focus(); - - searchForm.addEventListener("submit", (e) => { - e.preventDefault(); - const query = input.value.trim(); + const searchForm = document.getElementById('search-page-form'); + if (!searchForm) { + return; + } + + const input = document.getElementById('search-page-input'); + const params = new URLSearchParams(window.location.search); + const query = params.get('q') || ''; + const platform = params.get('platform') || ''; + + const title = document.getElementById('search-page-title'); if (query) { - const url = - "/search?q=" + - encodeURIComponent(query) + - (platform ? "&platform=" + encodeURIComponent(platform) : ""); - history.replaceState(null, "", url); - title.textContent = "Search results"; - searchPageQuery(query); + input.value = query; + title.textContent = 'Search results'; + searchPageQuery(query); } - }); - document - .getElementById("search-page-clear") - .addEventListener("click", clearSearchInput); + input.focus(); + + searchForm.addEventListener('submit', (e) => { + e.preventDefault(); + const query = input.value.trim(); + if (query) { + const url = '/search?q=' + encodeURIComponent(query) + (platform ? '&platform=' + encodeURIComponent(platform) : ''); + history.replaceState(null, '', url); + title.textContent = 'Search results'; + searchPageQuery(query); + } + }); + + document.getElementById('search-page-clear').addEventListener('click', clearSearchInput); } const FIXED_HEADER_HEIGHT = 80; const tocbotOptions = { - // Where to render the table of contents. - tocSelector: ".article-toc", - - // Where to grab the headings to build the table of contents. - contentSelector: "", - - // Disable the collapsible functionality of the library by - // setting the maximum number of heading levels (6) - collapseDepth: 6, - headingSelector: "h1, h2, h3, summary", - - // Main class to add to lists. - listClass: "lhn-items", - - // Main class to add to links. - linkClass: "link", - - // Class to add to active links, - // the link corresponding to the top most heading on the page. - activeLinkClass: "selected-article", - - // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) - headingsOffset: FIXED_HEADER_HEIGHT, - scrollSmoothOffset: -FIXED_HEADER_HEIGHT, - scrollSmooth: true, - - // If there is a fixed article scroll container, set to calculate titles' offset - scrollContainer: "content-area", - - onClick: (e) => { - e.preventDefault(); - const hashText = e.target.href.split("#").pop(); - // Append hashText to the current URL without saving to history - const newUrl = `${window.location.pathname}#${hashText}`; - history.replaceState(null, "", newUrl); - }, + // Where to render the table of contents. + tocSelector: '.article-toc', + + // Where to grab the headings to build the table of contents. + contentSelector: '', + + // Disable the collapsible functionality of the library by + // setting the maximum number of heading levels (6) + collapseDepth: 6, + headingSelector: 'h1, h2, h3, summary', + + // Main class to add to lists. + listClass: 'lhn-items', + + // Main class to add to links. + linkClass: 'link', + + // Class to add to active links, + // the link corresponding to the top most heading on the page. + activeLinkClass: 'selected-article', + + // Headings offset between the headings and the top of the document (requires scrollSmooth enabled) + headingsOffset: FIXED_HEADER_HEIGHT, + scrollSmoothOffset: -FIXED_HEADER_HEIGHT, + scrollSmooth: true, + + // If there is a fixed article scroll container, set to calculate titles' offset + scrollContainer: 'content-area', + + onClick: (e) => { + e.preventDefault(); + const hashText = e.target.href.split('#').pop(); + // Append hashText to the current URL without saving to history + const newUrl = `${window.location.pathname}#${hashText}`; + history.replaceState(null, '', newUrl); + }, }; // Define the media query string for the mobile breakpoint -const mobileBreakpoint = window.matchMedia("(max-width: 799px)"); +const mobileBreakpoint = window.matchMedia('(max-width: 799px)'); // Function to update tocbot options and refresh function updateTocbotOptions(headingsOffset, scrollSmoothOffset) { - tocbotOptions.headingsOffset = headingsOffset; - tocbotOptions.scrollSmoothOffset = scrollSmoothOffset; - window.tocbot.refresh({ - ...tocbotOptions, - }); + tocbotOptions.headingsOffset = headingsOffset; + tocbotOptions.scrollSmoothOffset = scrollSmoothOffset; + window.tocbot.refresh({ + ...tocbotOptions, + }); } function handleBreakpointChange() { - const isMobile = mobileBreakpoint.matches; - const headingsOffset = isMobile ? FIXED_HEADER_HEIGHT : 0; - const scrollSmoothOffset = isMobile ? -FIXED_HEADER_HEIGHT : 0; - - // Update tocbot options only if there is a change in offsets - if ( - tocbotOptions.headingsOffset !== headingsOffset || - tocbotOptions.scrollSmoothOffset !== scrollSmoothOffset - ) { - updateTocbotOptions(headingsOffset, scrollSmoothOffset); - } + const isMobile = mobileBreakpoint.matches; + const headingsOffset = isMobile ? FIXED_HEADER_HEIGHT : 0; + const scrollSmoothOffset = isMobile ? -FIXED_HEADER_HEIGHT : 0; + + // Update tocbot options only if there is a change in offsets + if (tocbotOptions.headingsOffset !== headingsOffset || tocbotOptions.scrollSmoothOffset !== scrollSmoothOffset) { + updateTocbotOptions(headingsOffset, scrollSmoothOffset); + } } // Add listener for changes to the media query status using addEventListener -mobileBreakpoint.addEventListener("change", handleBreakpointChange); +mobileBreakpoint.addEventListener('change', handleBreakpointChange); // Initial check handleBreakpointChange(); -window.addEventListener("DOMContentLoaded", () => { - injectFooterCopyright(); +window.addEventListener('DOMContentLoaded', () => { + injectFooterCopyright(); - if (window.tocbot) { - window.tocbot.init({ - ...tocbotOptions, - contentSelector: ".article-toc-content", - }); - } - - initSearchPage(); - - document - .getElementById("header-button") - .addEventListener("click", toggleHeaderMenu); - - // Back button doesn't exist on all the pages - const backButton = document.getElementById("back-button"); - if (backButton) { - backButton.addEventListener("click", navigateBack); - } - - const articleContent = document.getElementById("article-content"); - const lhnContent = document.getElementById("lhn-content"); - - // This event listener checks if a link clicked in the LHN points to some section of the same page and toggles - // the LHN menu in responsive view. - lhnContent.addEventListener("click", (event) => { - const clickedLink = event.target; - if (clickedLink) { - const href = clickedLink.getAttribute("href"); - if ( - href && - href.startsWith("#") && - !!document.getElementById(href.slice(1)) - ) { - toggleHeaderMenu(); - } + if (window.tocbot) { + window.tocbot.init({ + ...tocbotOptions, + contentSelector: '.article-toc-content', + }); } - }); - lhnContent.addEventListener("wheel", (e) => { - const scrollTop = lhnContent.scrollTop; - const isScrollingPastLHNTop = e.deltaY < 0 && scrollTop === 0; - const isScrollingPastLHNBottom = - e.deltaY > 0 && - isInRange( - lhnContent.scrollHeight - lhnContent.offsetHeight, - scrollTop - 1, - scrollTop + 1, - ); - if (isScrollingPastLHNTop || isScrollingPastLHNBottom) { - e.preventDefault(); + + initSearchPage(); + + document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); + + // Back button doesn't exist on all the pages + const backButton = document.getElementById('back-button'); + if (backButton) { + backButton.addEventListener('click', navigateBack); } - }); - window.addEventListener("scroll", (e) => { - const scrollingElement = e.target.scrollingElement; - const scrollPercentageInArticleContent = - clamp( - scrollingElement.scrollTop - articleContent.offsetTop, - 0, - articleContent.scrollHeight, - ) / articleContent.scrollHeight; - lhnContent.scrollTop = - scrollPercentageInArticleContent * lhnContent.scrollHeight; - }); + + const articleContent = document.getElementById('article-content'); + const lhnContent = document.getElementById('lhn-content'); + + // This event listener checks if a link clicked in the LHN points to some section of the same page and toggles + // the LHN menu in responsive view. + lhnContent.addEventListener('click', (event) => { + const clickedLink = event.target; + if (clickedLink) { + const href = clickedLink.getAttribute('href'); + if (href && href.startsWith('#') && !!document.getElementById(href.slice(1))) { + toggleHeaderMenu(); + } + } + }); + lhnContent.addEventListener('wheel', (e) => { + const scrollTop = lhnContent.scrollTop; + const isScrollingPastLHNTop = e.deltaY < 0 && scrollTop === 0; + const isScrollingPastLHNBottom = e.deltaY > 0 && isInRange(lhnContent.scrollHeight - lhnContent.offsetHeight, scrollTop - 1, scrollTop + 1); + if (isScrollingPastLHNTop || isScrollingPastLHNBottom) { + e.preventDefault(); + } + }); + window.addEventListener('scroll', (e) => { + const scrollingElement = e.target.scrollingElement; + const scrollPercentageInArticleContent = clamp(scrollingElement.scrollTop - articleContent.offsetTop, 0, articleContent.scrollHeight) / articleContent.scrollHeight; + lhnContent.scrollTop = scrollPercentageInArticleContent * lhnContent.scrollHeight; + }); }); if (window.location.hash) { - const lowerCaseHash = window.location.hash.toLowerCase(); - const element = document.getElementById(lowerCaseHash.slice(1)); + const lowerCaseHash = window.location.hash.toLowerCase(); + const element = document.getElementById(lowerCaseHash.slice(1)); - if (element) { - element.scrollIntoView({ - behavior: "smooth", - }); - } + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + }); + } } // Handle hash changes (like back/forward navigation) -window.addEventListener("hashchange", () => { - if (!window.location.hash) { - return; - } - const lowerCaseHash = window.location.hash.toLowerCase(); - document.getElementById(lowerCaseHash.slice(1))?.scrollIntoView({ - behavior: "smooth", - }); +window.addEventListener('hashchange', () => { + if (!window.location.hash) { + return; + } + const lowerCaseHash = window.location.hash.toLowerCase(); + document.getElementById(lowerCaseHash.slice(1))?.scrollIntoView({ + behavior: 'smooth', + }); });