diff --git a/LLM_RULES.md b/LLM_RULES.md index 663a383..7328b2f 100644 --- a/LLM_RULES.md +++ b/LLM_RULES.md @@ -52,7 +52,7 @@ selectors: data-\*-mount ONLY for JS mount points. no generic IDs except well-kn responsive: use static/js/core/responsive.js helpers. no ad-hoc breakpoints in JS. daisyUI: data-tip XOR title. never both. typography: HTML class="not-prose". NO CSS @apply not-prose. -access-keys: SEMANTIC_RULES in access-keys.js = single source of truth for keyboard hints. no data-hint in templates except override cases. elements inside [data-toc-sidebar] are excluded from semantic rules (internal anchors). +access-keys: SEMANTIC_RULES in access-keys.js = single source of truth for keyboard hints. no data-hint in templates except override cases. elements inside [data-toc-sidebar] are excluded from semantic rules (internal anchors). if a force-rail page needs a shortcut for a rail item like Archive, set an explicit `data-hint` on that template link. TOC: single initToc() from toc.js covers ALL pages (blog, archive, taxonomy). getTocLink uses href$=#ID for full-URL zola children AND data-toc-ID for summary parents AND bare #ID for archive anchors. notify-banner: path is js/ui/notify-banner.js (not root-level js/). @@ -62,7 +62,7 @@ macros: import separate files. no deep same-file chaining. 404 routing: templates/404.html ONLY. no content/404.md. offline page: templates/offline.html (no content/offline.md). 404 + offline layout: \_force_rail=true in base.html → use_fit_shell=true + has_layout_rail=true + empty #toc-sidebar nav (aria-label="Page tools") for full structural parity. Toggle button label is "Hide/Show tools rail" (not "table of contents"). offline page must have force_rail=true in [extra]. -CSP: no inline style="" attributes. use CSS classes. JS DOM manipulation toggles classes only. +CSP: no inline style="" attributes. use CSS classes. JS DOM manipulation toggles classes only. deferred style sheets use `data-defer-css="true"` and get activated by `static/js/core/boot.js`; avoid inline event-handler attributes on style sheet links. SEO / SCHEMA article JSON-LD: author Person must include both name AND URL (→ /about/). diff --git a/scripts/clean-pagination-redirects.js b/scripts/clean-pagination-redirects.js index 230919c..0cf2b63 100644 --- a/scripts/clean-pagination-redirects.js +++ b/scripts/clean-pagination-redirects.js @@ -66,7 +66,12 @@ if (fs.existsSync(sitemapPath) && removedPaths.length > 0) { } } if (purged > 0) { - fs.writeFileSync(sitemapPath, sitemap, "utf8"); + // Write atomically to avoid race conditions: write to a temp file + // and rename into place. This prevents other processes from reading + // a partially-written file and addresses file-system race warnings. + const tmpPath = sitemapPath + ".tmp"; + fs.writeFileSync(tmpPath, sitemap, "utf8"); + fs.renameSync(tmpPath, sitemapPath); console.log(`Purged ${purged} stale page/1 URL(s) from sitemap.xml.`); } } diff --git a/scripts/sync-project-data.js b/scripts/sync-project-data.js index 0512b60..d45ea32 100644 --- a/scripts/sync-project-data.js +++ b/scripts/sync-project-data.js @@ -28,10 +28,16 @@ function parseRepoFromUrl(url) { const match = value.match(/github\.com\/([^/]+)\/([^/#?]+)/i); if (!match) return null; - return { - owner: match[1], - repo: match[2].replace(/\.git$/i, ""), - }; + const owner = match[1]; + const repo = match[2].replace(/\.git$/i, ""); + + // Validate owner and repo against a strict whitelist of characters to + // prevent crafted file data from being used to build unexpected URLs. + // GitHub owners: alphanumeric and hyphens; repos: allow dot/underscore/hyphen. + if (!/^[A-Za-z0-9-]+$/.test(owner)) return null; + if (!/^[A-Za-z0-9._-]+$/.test(repo)) return null; + + return { owner, repo }; } function getGitHubToken() { @@ -60,8 +66,22 @@ function githubRequest(endpoint, token) { if (token) headers.Authorization = `Bearer ${token}`; // lgtm[js/file-access-to-http] token sourced from env or gh CLI return new Promise((resolve, reject) => { + // Construct the full URL via the URL API and validate the hostname so + // injected or unexpected endpoint values cannot cause requests to other + // origins. This ensures outbound requests are constrained to GitHub. + let urlObj; + try { + urlObj = new URL(endpoint, API_BASE); + } catch { + return reject(new Error("Invalid GitHub API endpoint")); + } + + if (urlObj.hostname !== new URL(API_BASE).hostname) { + return reject(new Error("Disallowed GitHub API host")); + } + const req = https.request( - `${API_BASE}${endpoint}`, + urlObj, { method: "GET", headers, diff --git a/static/js/core/boot.js b/static/js/core/boot.js index b16a274..d4909de 100644 --- a/static/js/core/boot.js +++ b/static/js/core/boot.js @@ -24,10 +24,36 @@ : raw; d.setAttribute("data-theme", mode); + // Also set a theme class immediately so CSS prepaint rules using + // `html.dark` / `html.light` apply before the JS modules load. This + // prevents a flash/flicker of the wrong logo variant during navigation. + try { + d.classList.remove("light", "dark"); + d.classList.add(mode); + } catch { + // Ignore failures (e.g. if classList isn't supported). The data-theme + // attribute will still be set correctly. + } d.setAttribute("data-ui-init", "0"); d.setAttribute( "data-sidebar-collapsed", getL("sidebar-collapsed") ? "1" : "0", ); d.setAttribute("data-toc-collapsed", getL("toc-collapsed") ? "1" : "0"); + + function activateDeferredCss() { + var links = document.querySelectorAll('link[data-defer-css="true"]'); + for (var i = 0; i < links.length; i += 1) { + links[i].media = "all"; + links[i].removeAttribute("data-defer-css"); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", activateDeferredCss, { + once: true, + }); + } else { + activateDeferredCss(); + } })(); diff --git a/static/js/features/access-keys.js b/static/js/features/access-keys.js index d84523c..3cf50fa 100644 --- a/static/js/features/access-keys.js +++ b/static/js/features/access-keys.js @@ -371,8 +371,9 @@ function showHints() { for (const el of targets) { let explicitHint = null; - // Semantic rules — skip TOC sidebar: those are page-internal anchors - // (#skills, #section-heading) that must not inherit site-nav shortcuts. + // Semantic rules — skip TOC sidebar: those are page-internal anchors that + // must not inherit site-nav shortcuts. If a TOC entry needs a shortcut, + // it should opt in explicitly with data-hint in the template. if (!el.closest("[data-toc-sidebar]")) { for (const rule of SEMANTIC_RULES) { if ( diff --git a/static/js/system/offline-reload.js b/static/js/system/offline-reload.js index 8466f75..5cb0f1f 100644 --- a/static/js/system/offline-reload.js +++ b/static/js/system/offline-reload.js @@ -6,7 +6,10 @@ if (!el) return; var ATTEMPT_KEY = "offlineReloadLastAttempt"; var ATTEMPT_WINDOW_MS = 6000; + var POLL_INTERVAL_MS = 5000; + var PING_TIMEOUT_MS = 2500; var isChecking = false; + var pollInterval = null; el.textContent = "Waiting for connection"; el.classList.add("status-pill", "status-pill--pending"); @@ -16,6 +19,8 @@ el.classList.remove("status-pill--pending"); el.classList.add("status-pill--online"); + if (pollInterval) clearInterval(pollInterval); + setTimeout(function () { var path = window.location.pathname || "/"; if (path === "/offline" || path === "/offline/") { @@ -45,16 +50,25 @@ function probeNetwork() { // Requests under /__runtime/ are excluded from SW handling in static/sw.js. + var controller = new AbortController(); + var timeoutId = window.setTimeout(function () { + controller.abort(); + }, PING_TIMEOUT_MS); + return fetch("/__runtime/ping?ts=" + Date.now(), { method: "GET", cache: "no-store", credentials: "same-origin", + signal: controller.signal, }) .then(function () { return true; }) .catch(function () { return false; + }) + .finally(function () { + window.clearTimeout(timeoutId); }); } @@ -84,4 +98,22 @@ } window.addEventListener("online", maybeRecover); + + function stopPolling() { + if (pollInterval) { + window.clearInterval(pollInterval); + pollInterval = null; + } + } + + // Periodic polling: keep checking network status every POLL_INTERVAL_MS + // even if 'online' event doesn't fire. This ensures recovery on reconnect. + pollInterval = setInterval(function () { + if (navigator.onLine) { + maybeRecover(); + } + }, POLL_INTERVAL_MS); + + window.addEventListener("pagehide", stopPolling, { once: true }); + window.addEventListener("beforeunload", stopPolling, { once: true }); })(); diff --git a/templates/base.html b/templates/base.html index d4504e4..dca20a0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -255,7 +255,7 @@
You appear to be offline.
+Your browser has lost connection to the internet. Check your network and try reloading the page.
+Waiting for connection
+{% endblock content %} + {% block prev_link %} {{ macros_nav::render_button(url="/", title="Home", subtitle="Back to", direction="prev") }} {% endblock prev_link %} -{% block next_link %} -{% endblock next_link %} +{% block next_link %}{% endblock next_link %} {% block js_body %} {{ super() }}