From a8ceeba429fd3c0b5034245a16f7b923130deb6e Mon Sep 17 00:00:00 2001 From: George Hartley Date: Wed, 22 Jul 2026 16:08:23 +1000 Subject: [PATCH 1/2] feat(docs): community Q&A widget injected into every built page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-build injector (the only repo-controlled every-page injection point — Sourcey templates live in node_modules): inject-qa.js appends a marker- idempotent, self-contained vanilla-JS widget before on every dist HTML file and fails loudly on a missing or empty dist. The widget reads the normalized pathname as page_key (matrix mirrors the API: decode once, lowercase, collapse slashes, strip trailing slash and .html, / -> /introduction), fetches GET /v1/public/questions, renders in the docs theme (.dark-aware), URL-encodes the Ask deep link into the app, and removes only its own container on any fetch/render failure. Every user/LLM-originated string is inserted via createTextNode only — no innerHTML or attribute sinks (the DOM test's innerHTML setter throws). verify-qa-injection.mjs gates the Vercel build: every generated HTML file must carry exactly one marker (zero and 2+ both fail), with an explicit allowlist for any future -less files. inject-qa.test.mjs covers the injector, the verifier, and the widget XSS/normalization contract with node stdlib only. MERGE NOTE (binding release order): deploy only after the api release is verified live returning 200-empty on GET /v1/public/questions — Cloudflare negative-caches 404s for a year. --- scripts/inject-qa.js | 259 ++++++++++++++++++++++ scripts/inject-qa.test.mjs | 371 ++++++++++++++++++++++++++++++++ scripts/verify-qa-injection.mjs | 91 ++++++++ vercel.json | 2 +- 4 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 scripts/inject-qa.js create mode 100644 scripts/inject-qa.test.mjs create mode 100644 scripts/verify-qa-injection.mjs diff --git a/scripts/inject-qa.js b/scripts/inject-qa.js new file mode 100644 index 0000000..deb8dcf --- /dev/null +++ b/scripts/inject-qa.js @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/* + * inject-qa.js — appends the community Q&A widget to EVERY built docs page. + * + * MAINTENANCE NOTES + * ----------------- + * Sourcey's page templates live in node_modules with no repo-controlled + * hook, so this post-build injector is the only every-page injection point + * we own. It runs from vercel.json's buildCommand: + * + * npx sourcey build && node scripts/inject-qa.js && node scripts/verify-qa-injection.mjs + * + * Behaviour: + * - Idempotent: pages already carrying the MARKER are skipped, so a double + * run never double-injects. + * - Inserts the snippet immediately before the LAST in each file. + * - Fails loudly (non-zero exit) when dist/ is missing or holds no HTML — + * a silent no-op here would ship docs without the widget. + * - If a Sourcey upgrade ever emits an HTML file without , this + * script warns per file and leaves it untouched; the build then FAILS in + * verify-qa-injection.mjs unless that file is explicitly allowlisted + * there. That is deliberate: injection coverage regressions must be loud. + * + * WIDGET INVARIANTS (binding, mirrored by inject-qa.test.mjs) + * - XSS: every user/LLM-originated string (question bodies, answer bodies, + * first names) is inserted via document.createTextNode ONLY. No + * innerHTML, no attribute sinks, no CSS interpolation for untrusted + * values. The only dynamic attribute is the Ask link's href, built from + * the page_key AFTER charset validation (^\/[a-z0-9\-\/]{0,120}$) and + * URL-encoding — question/answer/user strings never reach attributes. + * - page_key normalization mirrors the API: decode percent-encoding once + * (invalid encoding -> render nothing), lowercase, collapse repeated + * slashes, strip trailing slash then a .html suffix, "/" or "" -> + * "/introduction". Keys that still fail validation render nothing. + * - Fetch/render failure removes only the widget container (the style tag + * lives inside the container, so removal is atomic). Docs pages never + * break because the Q&A API is down. + * - Reading is public; asking always deep-links into the signed-in app. + * - Theme-aware via Sourcey's `.dark` class on (CSS variants only). + */ + +const fs = require('fs'); +const path = require('path'); + +const MARKER = 'data-nitro-qa="1"'; + +// NOTE: WIDGET_SOURCE sits inside a JS template literal — keep the widget +// code free of backticks and "${", and never include a literal +// "" sequence in its strings. +const WIDGET_SOURCE = `(function (w, d) { + 'use strict'; + + var API_BASE = 'https://api.nitrosend.com'; + var ASK_BASE = 'https://app.nitrosend.com/learning/community/ask'; + var PAGE_KEY_RE = /^\\/[a-z0-9\\-\\/]{0,120}$/; + + var CSS = '' + + '.nitro-qa{max-width:48rem;margin:3rem auto 2rem;padding:1.5rem 1rem 0;border-top:1px solid #e7e5e4;font-size:.9375rem;line-height:1.6;color:#44403c}' + + '.dark .nitro-qa{border-top-color:#292524;color:#d6d3d1}' + + '.nitro-qa-title{font-size:1.125rem;font-weight:600;margin:0 0 1rem;color:#1c1917}' + + '.dark .nitro-qa-title{color:#fafaf9}' + + '.nitro-qa-item{margin:0 0 1.5rem}' + + '.nitro-qa-meta{font-size:.8125rem;color:#78716c;margin:0 0 .25rem}' + + '.dark .nitro-qa-meta{color:#a8a29e}' + + '.nitro-qa-q{font-weight:600;margin:0 0 .5rem;white-space:pre-wrap;color:#1c1917}' + + '.dark .nitro-qa-q{color:#fafaf9}' + + '.nitro-qa-answer{margin:0 0 .75rem;padding-left:1rem;border-left:2px solid #e7e5e4}' + + '.dark .nitro-qa-answer{border-left-color:#292524}' + + '.nitro-qa-answer-label{font-size:.8125rem;color:#78716c;margin:0 0 .25rem}' + + '.dark .nitro-qa-answer-label{color:#a8a29e}' + + '.nitro-qa-answer-body{margin:0;white-space:pre-wrap}' + + '.nitro-qa-unanswered{font-size:.8125rem;font-style:italic;color:#78716c;margin:0}' + + '.dark .nitro-qa-unanswered{color:#a8a29e}' + + '.nitro-qa-empty,.nitro-qa-foot{margin:.5rem 0 0}' + + '.nitro-qa-ask{color:#ea580c;text-decoration:none;font-weight:500}' + + '.nitro-qa-ask:hover{text-decoration:underline}'; + + function normalizePageKey(pathname) { + var key = String(pathname == null ? '' : pathname); + if (key === '') return null; + try { + key = decodeURIComponent(key); + } catch (e) { + return null; + } + key = key.toLowerCase(); + key = key.replace(/\\/+/g, '/'); + key = key.replace(/\\/$/, ''); + key = key.replace(/\\.html$/, ''); + if (key === '' || key === '/') key = '/introduction'; + if (!PAGE_KEY_RE.test(key)) return null; + return key; + } + + // The ONLY insertion helpers. Untrusted strings pass exclusively through + // createTextNode here — never innerHTML, never attributes. + function el(tag, cls, text) { + var node = d.createElement(tag); + if (cls) node.className = cls; + if (text !== undefined && text !== null) { + node.appendChild(d.createTextNode(String(text))); + } + return node; + } + + function askLink(pageKey, label) { + var a = el('a', 'nitro-qa-ask', label); + // pageKey is charset-validated above and URL-encoded here; no other + // dynamic value ever reaches an attribute. + a.href = ASK_BASE + '?page_key=' + encodeURIComponent(pageKey); + return a; + } + + function answerLabel(answer) { + if (answer.source === 'ai') { + return answer.verified_at + ? 'Answered by NitroLLM \\u00b7 verified by the team' + : 'Answered by NitroLLM \\u00b7 not yet verified'; + } + var name = answer.author && answer.author.first_name ? answer.author.first_name : 'a community member'; + return 'Answered by ' + name; + } + + function renderAnswer(answer) { + var box = el('div', 'nitro-qa-answer'); + box.appendChild(el('p', 'nitro-qa-answer-label', answerLabel(answer))); + box.appendChild(el('p', 'nitro-qa-answer-body', answer.body)); + return box; + } + + function renderQuestion(question) { + var item = el('div', 'nitro-qa-item'); + var name = question.user && question.user.first_name ? question.user.first_name : 'Someone'; + var when = ''; + try { + when = new Date(question.created_at).toLocaleDateString(); + } catch (e) { + when = ''; + } + item.appendChild(el('p', 'nitro-qa-meta', name + (when ? ' \\u00b7 ' + when : ''))); + item.appendChild(el('p', 'nitro-qa-q', question.body)); + var answers = question.answers || []; + for (var i = 0; i < answers.length; i++) { + item.appendChild(renderAnswer(answers[i])); + } + if (!question.answered) { + item.appendChild(el('p', 'nitro-qa-unanswered', 'Not answered yet.')); + } + return item; + } + + function render(container, pageKey, questions) { + container.appendChild(el('h2', 'nitro-qa-title', 'Community Q&A')); + if (!questions.length) { + var empty = el('p', 'nitro-qa-empty'); + empty.appendChild(d.createTextNode('No questions about this page yet. ')); + empty.appendChild(askLink(pageKey, 'Ask the first question')); + container.appendChild(empty); + return; + } + for (var i = 0; i < questions.length; i++) { + container.appendChild(renderQuestion(questions[i])); + } + var foot = el('p', 'nitro-qa-foot'); + foot.appendChild(askLink(pageKey, 'Ask a question about this page')); + container.appendChild(foot); + } + + function main() { + if (typeof w.fetch !== 'function') return Promise.resolve(); + var pageKey = normalizePageKey(w.location && w.location.pathname); + if (!pageKey) return Promise.resolve(); + var host = d.querySelector('main') || d.body; + if (!host) return Promise.resolve(); + + var container = el('section', 'nitro-qa'); + var style = d.createElement('style'); + style.appendChild(d.createTextNode(CSS)); + container.appendChild(style); + host.appendChild(container); + + return w.fetch(API_BASE + '/v1/public/questions?page_key=' + encodeURIComponent(pageKey)) + .then(function (res) { + if (!res || !res.ok) throw new Error('questions request failed'); + return res.json(); + }) + .then(function (payload) { + render(container, pageKey, (payload && payload.questions) || []); + }) + .catch(function () { + // Never break a docs page over the widget: remove it wholesale. + if (container && typeof container.remove === 'function') container.remove(); + }); + } + + var done = main(); + if (typeof w.__nitroQaOnDone === 'function') w.__nitroQaOnDone(done); +})(window, document);`; + +const SNIPPET = '\n