diff --git a/CHANGELOG.md b/CHANGELOG.md index 1384774..fc3811f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,11 @@ methodology, followed by a fix wave that touched every chapter. layer (the former multi-format and schema pages, merged) plus five behavioral surfaces; the command-line page rebuilt around the actual decode workflow (UTF-16LE, abbreviated flags, the 4688 command-line gap). +- The linear reading arc flows section-to-section: the Alert chapter's + thirteen subsection reference pages step out of the previous/next pager + (they are reached from their section pages and render no pager of their + own), so reading straight through no longer detours into deep reference + material. - Altitude honesty pass: Tier 3 redefined as senior technical escalation with incident command as a separate function; program-level content (telemetry principles, the intel pipeline, dark-web monitoring, insider @@ -171,6 +176,11 @@ methodology, followed by a fix wave that touched every chapter. The five guide pages (Fast path, AI triage, Cognitive traps, Quick reference, Where ASSURED sits) now fold into a Guides dropdown, restoring the eleven-item row the 1350px breakpoint was calibrated for. +- Laptops get the desktop navigation instead of the phone menu: the + eleven-link header now fits from 1240px up, using tighter spacing and an + icon-only search trigger at laptop widths (1240 to 1449px), with the full + search label returning at 1450px. The hamburger menu is reserved for + phones and tablets. - The progress strip on a quiz's own page ticks the moment the chapter quiz completes instead of after a reload; quiz-completion storage now lives in one shared module read by both the quiz and the strip. @@ -186,7 +196,8 @@ methodology, followed by a fix wave that touched every chapter. themselves current for every child page. - The Alert dropdown lists its three sections like every other chapter instead of every subsection page; subsection navigation stays on the - section pages themselves. + section pages themselves. The mobile menu now matches, and its section + rows stay highlighted while reading a subsection. - The CDE and PFI hover tooltips on the regulatory-boundaries page show their definitions for the first time; the term text passed as slot children had always replaced the definition body. The DefineTerm doc diff --git a/astro.config.ts b/astro.config.ts index 9bfece4..c41e7c5 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -2,20 +2,19 @@ import { defineConfig } from "astro/config"; import tailwindcss from "@tailwindcss/vite"; import mdx from "@astrojs/mdx"; -import remarkGfm from 'remark-gfm'; -import rehypeSlug from 'rehype-slug'; -import rehypeAutolinkHeadings from 'rehype-autolink-headings'; +import remarkGfm from "remark-gfm"; +import rehypeSlug from "rehype-slug"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; import sitemap from "@astrojs/sitemap"; // https://astro.build/config export default defineConfig({ - - site: 'https://assured-methodology.vercel.app', - base: '/', + site: "https://assured-methodology.vercel.app", + base: "/", // Vercel serves the trailing-slash form (vercel.json "trailingSlash": true) // and 308-redirects the bare form. "always" makes dev 404 the bare form so // a link written without the slash fails loudly before it ships. - trailingSlash: 'always', + trailingSlash: "always", // Legacy-URL redirects live in vercel.json (server-side 308s), not here: // Astro's static redirects emit meta-refresh stub pages without an // element, and inbound legacy links are a production concern anyway. @@ -34,9 +33,9 @@ export default defineConfig({ // The site is dark-only; a single dark theme keeps code blocks from // rendering on a white background (the old dual-theme config defaulted // to github-light and nothing ever switched it). - theme: 'github-dark', - wrap: true - } + theme: "github-dark", + wrap: true, + }, }, vite: { plugins: [tailwindcss()], @@ -46,4 +45,4 @@ export default defineConfig({ }, }, }, -}); \ No newline at end of file +}); diff --git a/scripts/check-csp.mjs b/scripts/check-csp.mjs index 4745573..5a06731 100644 --- a/scripts/check-csp.mjs +++ b/scripts/check-csp.mjs @@ -63,7 +63,9 @@ function main() { .flatMap((h) => h.headers ?? []) .find((h) => h.key === "Content-Security-Policy"); if (!rule) { - console.error("check-csp: no Content-Security-Policy header in vercel.json"); + console.error( + "check-csp: no Content-Security-Policy header in vercel.json", + ); process.exit(1); } @@ -86,7 +88,11 @@ function main() { .split(/\s+/) .slice(1) .filter((src) => !src.startsWith("'sha256-")); - directives[scriptIdx] = ["script-src", ...keep, ...[...built.keys()].sort()].join(" "); + directives[scriptIdx] = [ + "script-src", + ...keep, + ...[...built.keys()].sort(), + ].join(" "); rule.value = directives.join("; "); writeFileSync(VERCEL_JSON, `${JSON.stringify(config, null, 2)}\n`); console.log(`check-csp: wrote ${built.size} script hashes to vercel.json`); diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index 19495a9..569e3e9 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -63,7 +63,9 @@ function idsIn(html) { function hrefsIn(html) { const hrefs = []; - for (const m of html.matchAll(/]*\bhref\s*=\s*("([^"]*)"|'([^']*)')/gi)) { + for (const m of html.matchAll( + /]*\bhref\s*=\s*("([^"]*)"|'([^']*)')/gi, + )) { hrefs.push(decodeEntities(m[2] ?? m[3])); } return hrefs; @@ -78,7 +80,8 @@ function main() { const files = htmlFiles(DIST); const idCache = new Map(); // html file -> Set of ids const idsOf = (file) => { - if (!idCache.has(file)) idCache.set(file, idsIn(readFileSync(file, "utf8"))); + if (!idCache.has(file)) + idCache.set(file, idsIn(readFileSync(file, "utf8"))); return idCache.get(file); }; @@ -97,13 +100,17 @@ function main() { // Same-page fragment ("#risk-matrix") or bare "#" (top of page). if (pathname === "") { if (fragment && !idsOf(file).has(decodeURIComponent(fragment))) { - broken.push(`${pagePath} -> ${raw} (no id "${fragment}" on this page)`); + broken.push( + `${pagePath} -> ${raw} (no id "${fragment}" on this page)`, + ); } continue; } if (!pathname.startsWith("/")) { - broken.push(`${pagePath} -> ${raw} (relative link; use a root-relative path)`); + broken.push( + `${pagePath} -> ${raw} (relative link; use a root-relative path)`, + ); continue; } @@ -124,13 +131,17 @@ function main() { continue; } if (fragment && !idsOf(target).has(decodeURIComponent(fragment))) { - broken.push(`${pagePath} -> ${raw} (no id "${fragment}" on target page)`); + broken.push( + `${pagePath} -> ${raw} (no id "${fragment}" on target page)`, + ); } } } if (broken.length === 0) { - console.log(`check-links: OK (${checked} internal links across ${files.length} pages)`); + console.log( + `check-links: OK (${checked} internal links across ${files.length} pages)`, + ); return; } for (const b of broken.sort()) console.error(`check-links: BROKEN ${b}`); diff --git a/scripts/check-reading-order.mjs b/scripts/check-reading-order.mjs index 0d392ee..fd17035 100644 --- a/scripts/check-reading-order.mjs +++ b/scripts/check-reading-order.mjs @@ -39,6 +39,21 @@ const EXEMPT_ROUTES = new Set([ "/docs/glossaries/bad/", "/docs/glossaries/cat/", "/docs/glossaries/clear/", + // Alert subsection reference pages: reached from their section hubs, not + // part of the section-to-section reading arc. + "/docs/alert/detection-mechanisms/signature/", + "/docs/alert/detection-mechanisms/anomaly/", + "/docs/alert/detection-mechanisms/rule/", + "/docs/alert/detection-mechanisms/behavioral/", + "/docs/alert/validation/baseline-comparison/", + "/docs/alert/validation/attack-chains/", + "/docs/alert/validation/criticality/", + "/docs/alert/parsing/multi-format/", + "/docs/alert/parsing/command-line/", + "/docs/alert/parsing/process-relationships/", + "/docs/alert/parsing/network/", + "/docs/alert/parsing/file-system/", + "/docs/alert/parsing/cloud-identity/", ]); function* mdxFiles(dir) { diff --git a/scripts/extract-glossary.mjs b/scripts/extract-glossary.mjs index 05dfe21..5da37bf 100644 --- a/scripts/extract-glossary.mjs +++ b/scripts/extract-glossary.mjs @@ -53,7 +53,7 @@ const put = (term, definition, source, file) => { definition = clean(definition); if (!term || !definition) { problems.push( - `${file}: ${source} entry has an empty ${term ? `definition (term "${term}")` : "term"}` + `${file}: ${source} entry has an empty ${term ? `definition (term "${term}")` : "term"}`, ); return; } @@ -74,7 +74,7 @@ const expectCount = (file, what, src, openRe, parsed) => { if (open !== parsed) { problems.push( `${file}: ${open} ${what} opening tag(s) but only ${parsed} parsed as entries; ` + - "an entry's inner markup no longer matches the extractor" + "an entry's inner markup no longer matches the extractor", ); } }; @@ -83,7 +83,7 @@ const expectCount = (file, what, src, openRe, parsed) => { const cat = readFileSync(paths.cat, "utf8"); let catParsed = 0; for (const m of cat.matchAll( - /]*class="cat-tool[^"]*"[^>]*>\s*

([\s\S]*?)<\/h3>\s*

([\s\S]*?)<\/p>/g + /]*class="cat-tool[^"]*"[^>]*>\s*

([\s\S]*?)<\/h3>\s*

([\s\S]*?)<\/p>/g, )) { put(m[1], m[2], "cat-tool", "cat.mdx"); catParsed++; @@ -93,7 +93,7 @@ expectCount( "cat-tool", cat, /]*class="[^"]*\bcat-tool\b[^"]*"[^>]*>/g, - catParsed + catParsed, ); // --- B.A.D.: terms, actors, campaigns, families, featured animations ----- @@ -102,7 +102,7 @@ const bad = readFileSync(paths.bad, "utf8"); // feature missing its heading would silently borrow the next card's. let featParsed = 0; for (const m of bad.matchAll( - /

(?:(?!<\/div>)[\s\S])*?

([\s\S]*?)<\/h3>\s*

([\s\S]*?)<\/p>/g + /

(?:(?!<\/div>)[\s\S])*?

([\s\S]*?)<\/h3>\s*

([\s\S]*?)<\/p>/g, )) { put(m[1], m[2], "bad-feature", "bad.mdx"); featParsed++; @@ -112,7 +112,7 @@ expectCount( "bad-feature", bad, /]*class="[^"]*\bbad-feature__text\b[^"]*"[^>]*>/g, - featParsed + featParsed, ); const badKinds = [ ["bad-term", "bad-term"], @@ -123,7 +123,7 @@ const badKinds = [ for (const [cls, source] of badKinds) { const re = new RegExp( `]*class="${cls}[^"]*"[^>]*>\\s*

([\\s\\S]*?)<\\/h3>\\s*

([\\s\\S]*?)<\\/p>`, - "g" + "g", ); let parsed = 0; for (const m of bad.matchAll(re)) { @@ -135,14 +135,16 @@ for (const [cls, source] of badKinds) { cls, bad, new RegExp(`]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "g"), - parsed + parsed, ); } // --- C.L.E.A.R.: definition lists (wins on collisions) -------------------- const clear = readFileSync(paths.clear, "utf8"); let clearParsed = 0; -for (const m of clear.matchAll(/

([\s\S]*?)<\/dt>\s*
([\s\S]*?)<\/dd>/g)) { +for (const m of clear.matchAll( + /
([\s\S]*?)<\/dt>\s*
([\s\S]*?)<\/dd>/g, +)) { put(m[1], m[2], "clear", "clear.mdx"); clearParsed++; } @@ -150,13 +152,13 @@ const dtCount = countMatches(clear, /]/g); const ddCount = countMatches(clear, /]/g); if (dtCount !== ddCount) { problems.push( - `clear.mdx: ${dtCount}
tag(s) but ${ddCount}
tag(s); every term needs exactly one definition` + `clear.mdx: ${dtCount}
tag(s) but ${ddCount}
tag(s); every term needs exactly one definition`, ); } if (clearParsed !== dtCount) { problems.push( `clear.mdx: ${dtCount}
tag(s) but only ${clearParsed} dt/dd pair(s) parsed; ` + - "a term is missing its definition or has stray markup between
and
" + "a term is missing its definition or has stray markup between
and
", ); } @@ -166,53 +168,59 @@ if (clearParsed !== dtCount) { // for digit-initial terms (2FA, 802.1X), which no A-Z letter can file. const letterSections = [ ...clear.matchAll( - /]*\bid="([A-Z]|0-9)")(?=[^>]*\bclass="[^"]*\bclear-section\b[^"]*")[^>]*>([\s\S]*?)<\/section>/g + /]*\bid="([A-Z]|0-9)")(?=[^>]*\bclass="[^"]*\bclear-section\b[^"]*")[^>]*>([\s\S]*?)<\/section>/g, ), ]; const sectionLetters = letterSections.map((m) => m[1]); for (let i = 1; i < sectionLetters.length; i++) { if (sectionLetters[i] <= sectionLetters[i - 1]) { problems.push( - `clear.mdx: letter section "${sectionLetters[i]}" is duplicated or out of order (0-9 first, then A-Z)` + `clear.mdx: letter section "${sectionLetters[i]}" is duplicated or out of order (0-9 first, then A-Z)`, ); } } const navLetters = [ ...clear.matchAll( - /]*\bhref="#([A-Z]|0-9)")(?=[^>]*\bclass="[^"]*\bclear-nav__letter\b[^"]*")[^>]*>/g + /]*\bhref="#([A-Z]|0-9)")(?=[^>]*\bclass="[^"]*\bclear-nav__letter\b[^"]*")[^>]*>/g, ), ].map((m) => m[1]); if (navLetters.join("") !== sectionLetters.join("")) { problems.push( - `clear.mdx: letter nav [${navLetters.join("")}] does not match the letter sections [${sectionLetters.join("")}]` + `clear.mdx: letter nav [${navLetters.join("")}] does not match the letter sections [${sectionLetters.join("")}]`, ); } // Mirrors normalizeKey() in glossary.ts so "Pass-the-Hash" sorts as words. -const sortKey = (t) => t.toLowerCase().replace(/-/g, " ").replace(/\s+/g, " ").trim(); +const sortKey = (t) => + t.toLowerCase().replace(/-/g, " ").replace(/\s+/g, " ").trim(); let sectionedTerms = 0; for (const [, letter, body] of letterSections) { let prev = null; for (const dt of body.matchAll(/
([\s\S]*?)<\/dt>/g)) { sectionedTerms++; const term = clean(dt[1]); - const initial = term.replace(/^[^0-9A-Za-z]+/, "").charAt(0).toUpperCase(); + const initial = term + .replace(/^[^0-9A-Za-z]+/, "") + .charAt(0) + .toUpperCase(); const home = /[0-9]/.test(initial) ? "0-9" : initial; if (home !== letter) { problems.push( - `clear.mdx: "${term}" is filed under ${letter} but belongs under ${home || '"?"'}` + `clear.mdx: "${term}" is filed under ${letter} but belongs under ${home || '"?"'}`, ); } const key = sortKey(term); if (prev && key < prev.key) { problems.push( - `clear.mdx: section ${letter} is out of alphabetical order: "${term}" follows "${prev.term}"` + `clear.mdx: section ${letter} is out of alphabetical order: "${term}" follows "${prev.term}"`, ); } prev = { key, term }; } } if (sectionedTerms !== dtCount) { - problems.push(`clear.mdx: ${dtCount - sectionedTerms} term(s) sit outside the letter sections`); + problems.push( + `clear.mdx: ${dtCount - sectionedTerms} term(s) sit outside the letter sections`, + ); } // --- Duplicate terms -------------------------------------------------------- @@ -233,10 +241,15 @@ for (const [file, fileTerms] of perFileTerms) { for (const { spellings, sources } of byKey.values()) { if (sources.length < 2) continue; const cards = sources.filter((s) => s !== "bad-feature"); - if (file === "bad.mdx" && sources.length === 2 && cards.length === 1 && spellings.length === 1) + if ( + file === "bad.mdx" && + sources.length === 2 && + cards.length === 1 && + spellings.length === 1 + ) continue; problems.push( - `${file}: "${spellings.join('" / "')}" is defined ${sources.length} times (${sources.join(", ")})` + `${file}: "${spellings.join('" / "')}" is defined ${sources.length} times (${sources.join(", ")})`, ); } } @@ -261,7 +274,11 @@ for (const bySpelling of spellingsByKey.values()) { // --- Anchor ids: unique per page ------------------------------------------- // Duplicate ids break the letter/section anchors and any aria references. -for (const [file, src] of [["cat.mdx", cat], ["bad.mdx", bad], ["clear.mdx", clear]]) { +for (const [file, src] of [ + ["cat.mdx", cat], + ["bad.mdx", bad], + ["clear.mdx", clear], +]) { const idCounts = new Map(); for (const m of src.matchAll(/\bid="([^"]+)"/g)) { idCounts.set(m[1], (idCounts.get(m[1]) ?? 0) + 1); @@ -272,7 +289,9 @@ for (const [file, src] of [["cat.mdx", cat], ["bad.mdx", bad], ["clear.mdx", cle } if (problems.length > 0) { - console.error(`extract-glossary: ${problems.length} structural problem(s) in the glossary MDX:`); + console.error( + `extract-glossary: ${problems.length} structural problem(s) in the glossary MDX:`, + ); for (const p of problems) console.error(` - ${p}`); process.exit(1); } @@ -283,18 +302,21 @@ const body = sorted .map((k) => { const e = entries.get(k); return ` ${JSON.stringify(k)}: {\n "term": ${JSON.stringify( - e.term + e.term, )},\n "definition": ${JSON.stringify( - e.definition + e.definition, )},\n "source": ${JSON.stringify(e.source)}\n }`; }) .join(",\n"); const record = `export const glossary: Record = {\n${body}\n};`; const current = readFileSync(paths.out, "utf8"); -const marker = /export const glossary: Record = \{[\s\S]*?\n\};/; +const marker = + /export const glossary: Record = \{[\s\S]*?\n\};/; if (!marker.test(current)) { - console.error("extract-glossary: could not find the glossary record in glossary.ts"); + console.error( + "extract-glossary: could not find the glossary record in glossary.ts", + ); process.exit(2); } const next = current.replace(marker, record); @@ -303,12 +325,14 @@ if (process.argv.includes("--check")) { if (next !== current) { console.error( "extract-glossary: src/data/glossary.ts is out of sync with the glossary MDX files.\n" + - "Run `node scripts/extract-glossary.mjs` and commit the result." + "Run `node scripts/extract-glossary.mjs` and commit the result.", ); process.exit(1); } console.log(`extract-glossary: in sync (${sorted.length} entries).`); } else { writeFileSync(paths.out, next); - console.log(`extract-glossary: wrote ${sorted.length} entries to src/data/glossary.ts`); + console.log( + `extract-glossary: wrote ${sorted.length} entries to src/data/glossary.ts`, + ); } diff --git a/src/components/Header.astro b/src/components/Header.astro index 4a638f1..d84dbb9 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -171,27 +171,17 @@ const isActiveSection = (item: NavItem): boolean =>
{item.children!.map((child) => { if (child.status === "soon") return null; - const childActive = currentPath === child.href; + // Sections only, like the desktop dropdown: Alert's leaf pages + // are reached from their section page. Prefix-match so a + // section row stays lit while reading one of its subsections. + const childActive = isActivePage(child.href, currentPath); return ( - <> - - {child.title} - - {child.children?.map((leaf) => { - const leafActive = currentPath === leaf.href; - return ( - - {leaf.title} - - ); - })} - + + {child.title} + ); })}
@@ -244,10 +234,12 @@ const isActiveSection = (item: NavItem): boolean => } /* Eleven top-level items (the five guide pages fold into the Guides - dropdown) fit only from ~1350px up, even with compact spacing; below - that the hamburger menu takes over (the nav used to paint over the - search and GitHub controls when it was shown any narrower). */ - @media (min-width: 1350px) { + dropdown) need about 1200px of content width even at the tightest + spacing with an icon-only search, so the desktop nav starts at 1240px + (laptops keep it; Windows scrollbars eat ~17px of the media-query + width). Below that the hamburger menu takes over, since the nav used + to paint over the search and GitHub controls when shown any narrower. */ + @media (min-width: 1240px) { .header__nav { display: flex; } } @@ -266,8 +258,17 @@ const isActiveSection = (item: NavItem): boolean => white-space: nowrap; } + /* Tightest tier, for laptop widths: minimal spacing and a slightly + smaller label size; SiteSearch drops to icon-only over the same range. */ + @media (min-width: 1240px) and (max-width: 1449px) { + .header__inner { padding: 0 1.5rem; gap: 1rem; } + .header__left { gap: 0.85rem; } + .header__link { padding: 0.45rem 0.3rem; font-size: 0.86rem; } + .header__nav { gap: 0.1rem; } + } + /* Compact spacing until the viewport can hold the roomier layout. */ - @media (min-width: 1350px) and (max-width: 1549px) { + @media (min-width: 1450px) and (max-width: 1549px) { .header__left { gap: 1.25rem; } .header__link { padding: 0.45rem 0.35rem; } .header__nav { gap: 0.15rem; } @@ -468,7 +469,7 @@ const isActiveSection = (item: NavItem): boolean => cursor: pointer; } - @media (min-width: 1350px) { + @media (min-width: 1240px) { .header__menu-btn { display: none; } } @@ -479,7 +480,7 @@ const isActiveSection = (item: NavItem): boolean => /* 44px touch-target floor for the header's icon controls on touch devices and narrow screens (WCAG 2.5.5; the audit measured ~32px). */ - @media (max-width: 1349px), (pointer: coarse) { + @media (max-width: 1239px), (pointer: coarse) { .header__icon, .header__menu-btn { min-width: 2.75rem; @@ -572,11 +573,6 @@ const isActiveSection = (item: NavItem): boolean => color: var(--assured-accent-soft); background: rgba(20, 184, 166, 0.12); } - - .header__mobile-sublink--sub { - margin-left: 1rem; - font-size: 0.82rem; - }