From 3019df0e4729736198e221ca3996d329bf77801d Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Tue, 29 Sep 2026 18:56:07 +1000 Subject: [PATCH 1/2] Polish reading UI and add cache-busting asset URLs - Breadcrumb navigation with section links replaces plain context text - Search results anchor to the search control; multi-term highlight and counts - Mobile nav gains a landmark; inline outline auto-opens on active section - Print hides sidebar and search, keeps footer in ink, fixes glass titles - Tables, admonitions, hero, cards, heading anchors and back-to-top polish - Generated site.css/site.js/math.js carry stable content-hash ?v= URLs - New z-index/sticky design tokens and pre-color-mix fallbacks --- src/docsprout/assets.py | 121 ++++++++++++++++++++++------- src/docsprout/build.py | 51 +++++++++--- tests/test_build.py | 41 +++++++++- tests/test_custom_css.py | 2 +- tests/test_qualification_shapes.py | 12 ++- tests/test_reading_layout.py | 4 +- tests/test_responsive_images.py | 2 +- 7 files changed, 185 insertions(+), 48 deletions(-) diff --git a/src/docsprout/assets.py b/src/docsprout/assets.py index 8ad993f..af44c37 100644 --- a/src/docsprout/assets.py +++ b/src/docsprout/assets.py @@ -1,5 +1,7 @@ """Shared DocSprout browser assets.""" +from hashlib import sha1 + SITE_CSS = r''' :root{ color-scheme:light dark; @@ -30,6 +32,12 @@ --dk-shadow:0 .75rem 2rem color-mix(in srgb,#1e293b 12%,transparent); --dk-focus-offset:.2rem; --dk-control-height:2.5rem; + --dk-z-header:2; + --dk-z-progress:3; + --dk-z-results:4; + --dk-z-skip:5; + --dk-sticky-top:6rem; + --dk-sticky-offset:7rem; --dk-font-ui:system-ui,-apple-system,"Segoe UI",Inter,"Aptos",sans-serif; --dk-font-display:var(--dk-font-ui); --dk-font-body:var(--dk-font-ui); @@ -49,14 +57,16 @@ @media(prefers-color-scheme:dark){html[data-visual-theme="paper"]:not([data-theme]){color-scheme:dark;--dk-bg:#1c1a17;--dk-surface:#29251f;--dk-text:#f7f0e5;--dk-muted:#c9bcaa;--dk-border:#554b3d;--dk-code-bg:#11100e;--dk-raised:#24211c;--dk-focus-ring:#fdba74;--dk-interactive:color-mix(in srgb,var(--dk-accent) 45%,#fff);--dk-shadow:0 .75rem 2rem color-mix(in srgb,#000 55%,transparent),0 1px 0 color-mix(in srgb,#f7f0e5 7%,transparent)}} @media(prefers-color-scheme:dark){html[data-visual-theme="e-ink"]:not([data-theme]){color-scheme:dark;--dk-bg:#0d0d0d;--dk-surface:#181818;--dk-text:#dadada;--dk-muted:#9b9b9b;--dk-border:#303030;--dk-code-bg:#050505;--dk-code-text:#d6d6d6;--dk-raised:#1e1e1e;--dk-focus-ring:#f5f5f5;--dk-interactive:color-mix(in srgb,var(--dk-accent) 45%,#fff)}} @media(prefers-color-scheme:dark){html[data-visual-theme="glassmorphic"]:not([data-theme]){color-scheme:dark;--dk-bg:#070b16;--dk-surface:#111b2e;--dk-text:#e9eeff;--dk-muted:#98abd0;--dk-border:#2b3a58;--dk-code-bg:#050810;--dk-raised:#1a2540;--dk-focus-ring:#67e8f9;--dk-interactive:color-mix(in srgb,var(--dk-accent) 45%,#fff);--dk-shadow:0 .75rem 2rem color-mix(in srgb,#000 55%,transparent),0 1px 0 color-mix(in srgb,#fff 6%,transparent);--dk-mesh-1:15%;--dk-mesh-2:14%;--dk-mesh-3:11%;--dk-mesh-4:9%}} +/* Base: stacking order (--dk-z-*) and sticky offsets (--dk-sticky-*) are defined in :root. */ *{box-sizing:border-box} +html{scroll-padding-top:6.5rem} .visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} -.skip-link{position:fixed;top:.5rem;left:.5rem;z-index:5;transform:translateY(-200%);padding:.5rem .75rem;border:2px solid var(--dk-focus-ring);border-radius:var(--dk-radius);background:var(--dk-bg);color:var(--dk-text);font-family:var(--dk-font-ui);font-weight:700} +.skip-link{position:fixed;top:.5rem;left:.5rem;z-index:var(--dk-z-skip);transform:translateY(-200%);padding:.5rem .75rem;border:2px solid var(--dk-focus-ring);border-radius:var(--dk-radius);background:var(--dk-bg);color:var(--dk-text);font-family:var(--dk-font-ui);font-weight:700} .skip-link:focus{transform:none} body{margin:0;background:var(--dk-bg);color:var(--dk-text);font-family:var(--dk-font-body);font-size:17px;line-height:1.7;font-synthesis:none;font-kerning:normal;font-optical-sizing:auto;font-variant-ligatures:common-ligatures contextual;text-rendering:optimizeLegibility} ::selection{background:color-mix(in srgb,var(--dk-accent) 24%,transparent)} -.reading-progress{position:fixed;inset:0 0 auto;z-index:3;height:3px;background:var(--dk-border);pointer-events:none}.reading-progress span{display:block;width:100%;height:100%;background:var(--dk-accent);transform:scaleX(0);transform-origin:left} -.site-header{position:sticky;top:0;z-index:2;border-bottom:1px solid var(--dk-border);background:var(--dk-bg)} +.reading-progress{position:fixed;inset:0 0 auto;z-index:var(--dk-z-progress);height:3px;background:var(--dk-border);pointer-events:none}.reading-progress span{display:block;width:100%;height:100%;background:var(--dk-accent);transform:scaleX(0);transform-origin:left} +.site-header{position:sticky;top:0;z-index:var(--dk-z-header);border-bottom:1px solid var(--dk-border);background:var(--dk-bg)} :is(.site-header,.sidebar,.toc,.mobile-nav,.capability-strip,.hero-actions,.release-context){font-family:var(--dk-font-ui)} .brand>span{color:var(--dk-text)}.brand em{color:var(--dk-interactive);font-style:normal} .brand-mark{display:block;width:1.35rem;height:1.35rem;flex:0 0 1.35rem} @@ -67,7 +77,7 @@ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid var(--dk-focus-ring);outline-offset:3px} .shell{max-width:var(--dk-shell-width);margin:auto;display:grid;grid-template-columns:minmax(12rem,15rem) minmax(0,var(--dk-content-width)) minmax(10rem,13rem);gap:clamp(1.75rem,3.5vw,3.5rem);padding:clamp(2rem,5vw,4rem) clamp(1rem,3vw,2rem) 3rem} .shell-no-toc{grid-template-columns:minmax(12rem,15rem) minmax(0,var(--dk-content-width));justify-content:center} -.sidebar{position:sticky;top:6rem;align-self:start;max-height:calc(100vh - 7rem);overflow:auto;padding-right:.35rem;font-size:.86rem;line-height:1.45;scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} +.sidebar{position:sticky;top:var(--dk-sticky-top);align-self:start;max-height:calc(100vh - var(--dk-sticky-offset));overflow:auto;padding-right:.35rem;font-size:.86rem;line-height:1.45;scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} .sidebar a{display:block;padding:.42rem .7rem;border-left:2px solid transparent;border-radius:0 .25rem .25rem 0;color:var(--dk-text);font-size:.88rem;font-weight:500;text-decoration:none;overflow-wrap:anywhere} .sidebar a:hover{background:var(--dk-surface);color:var(--dk-text);text-decoration:none} .sidebar a.active{border-left:2px solid var(--dk-accent);background:color-mix(in srgb,var(--dk-accent) 9%,transparent);color:var(--dk-interactive);font-weight:700;text-decoration:none} @@ -82,7 +92,7 @@ .nav-group-links{display:flex;flex-direction:column;margin:.15rem 0 .35rem .7rem;padding-left:.55rem;border-left:1px solid var(--dk-border)} .nav-group-links a{padding:.34rem .6rem;font-size:.84rem} .prose{min-width:0;max-width:var(--dk-content-width);padding-bottom:var(--dk-space-2);scroll-margin-top:6.5rem} -.page-context{margin:0 0 .85rem;color:var(--dk-muted);font-family:var(--dk-font-ui);font-size:.72rem;font-weight:700;letter-spacing:.08em;line-height:1.4;text-transform:uppercase;overflow-wrap:anywhere} +.page-context{margin:0 0 .85rem;color:var(--dk-muted);font-family:var(--dk-font-ui);font-size:.72rem;font-weight:700;letter-spacing:.08em;line-height:1.4;text-transform:uppercase;overflow-wrap:anywhere}.page-context ol{list-style:none;display:flex;flex-wrap:wrap;align-items:baseline;gap:.35rem 0;margin:0;padding:0}.page-context li{display:inline}.page-context li+li::before{content:"/";margin:0 .6rem;color:var(--dk-muted);opacity:.55;letter-spacing:0}.page-context a{color:var(--dk-muted);text-decoration:none}.page-context a:hover{color:var(--dk-interactive);text-decoration:underline} .prose h1,.prose h2,.prose h3{font-family:var(--dk-font-display);font-weight:720;letter-spacing:-.032em;text-wrap:balance;scroll-margin-top:6.5rem} .prose h1{font-size:clamp(2.3rem,4vw,3rem);line-height:1.12;max-width:none;margin:0 0 1.25rem} .prose h2{margin:3.5rem 0 .8rem;padding-top:.25rem;font-size:1.7rem;line-height:1.2} @@ -101,7 +111,7 @@ html[data-visual-theme="paper"] .prose blockquote{border-left-color:color-mix(in srgb,var(--dk-accent) 38%,var(--dk-border));background:color-mix(in srgb,var(--dk-surface) 62%,transparent)} html[data-visual-theme="paper"] .prose hr{border-top-style:dashed;border-top-color:color-mix(in srgb,var(--dk-muted) 45%,transparent)} /* E-ink keeps structural rules and removes decorative depth and motion. */ -html[data-visual-theme="e-ink"]{--dk-font-ui:var(--dk-font-mono);--dk-font-body:system-ui,sans-serif;--dk-font-display:var(--dk-font-body)} +html[data-visual-theme="e-ink"]{--dk-font-body:system-ui,sans-serif;--dk-font-display:var(--dk-font-body)} html[data-visual-theme="e-ink"] :is(.prose pre,.capability-strip li,.search-results,.hero){box-shadow:none;border-color:var(--dk-border)} html[data-visual-theme="e-ink"] .hero{border-color:var(--dk-text)} html:not([data-visual-theme="glassmorphic"]) .hero::before,html:not([data-visual-theme="glassmorphic"]) .hero::after{display:none} @@ -118,9 +128,9 @@ html[data-visual-theme="e-ink"] ::selection,html[data-visual-theme="e-ink"]::selection{background:color-mix(in srgb,var(--dk-text) 18%,transparent)} .task-list{display:inline-block;width:1.25em;color:var(--dk-interactive);font-weight:700} .prose .capability-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));align-items:stretch;grid-template-rows:minmax(0,1fr);gap:var(--dk-space-3);margin:0 0 2.75rem;padding:0;list-style:none} -.capability-strip li{display:flex;flex-direction:column;align-self:stretch;min-height:0;padding:1.1rem 1.15rem;border:1px solid var(--dk-border);border-radius:var(--dk-radius);max-width:none;background:var(--dk-raised);line-height:1.5;transition:border-color .18s ease,box-shadow .18s ease} -.capability-strip strong{display:block;font-family:var(--dk-font-display);font-size:.8rem;font-weight:750;letter-spacing:.01em;line-height:1.35;color:var(--dk-text)} -.capability-strip span{display:block;margin-top:.5rem;color:var(--dk-muted);font-size:.8rem;line-height:1.6} +.capability-strip li{display:flex;flex-direction:column;align-self:stretch;min-height:0;padding:1.15rem 1.2rem;border:1px solid var(--dk-border);border-radius:var(--dk-radius);max-width:none;background:var(--dk-raised);line-height:1.5;transition:border-color .18s ease,box-shadow .18s ease,transform .18s ease} +.capability-strip strong{display:block;font-family:var(--dk-font-display);font-size:.88rem;font-weight:750;letter-spacing:.01em;line-height:1.35;color:var(--dk-text)} +.capability-strip span{display:block;margin-top:.5rem;color:var(--dk-muted);font-size:.84rem;line-height:1.6} .release-context{display:flex;gap:.65rem;align-items:baseline;margin:1.5rem 0;padding:.75rem 1rem;border-left:3px solid var(--dk-accent);border-radius:0 .3rem .3rem 0;background:var(--dk-surface);font-size:.82rem}.release-context strong{font-family:var(--dk-font-display);font-size:.76rem;letter-spacing:.08em;text-transform:uppercase}.release-context span{color:var(--dk-muted);font-family:var(--dk-font-mono);font-size:.8rem} .prose a{color:var(--dk-interactive);font-weight:650;text-decoration-thickness:.08em;text-underline-offset:.16em} .prose a:hover{text-decoration-thickness:.13em} @@ -128,18 +138,19 @@ .prose code{font-family:var(--dk-font-mono);font-size:.88em} .prose :not(pre)>code{background:var(--dk-surface);border:1px solid var(--dk-border);padding:.08rem .28rem;border-radius:.2rem;color:var(--dk-text);font-weight:650;line-height:1.4;hyphens:none;overflow-wrap:break-word} .syntax-highlight .tok-property{color:#93c5fd}.syntax-highlight .tok-keyword{color:#c4b5fd}.syntax-highlight .tok-function{color:#67e8f9}.syntax-highlight .tok-string{color:#86efac}.syntax-highlight .tok-number{color:#fcd34d}.syntax-highlight .tok-boolean{color:#f9a8d4}.syntax-highlight .tok-constant{color:#fda4af}.syntax-highlight .tok-comment{color:#94a3b8;font-style:italic}.syntax-highlight .tok-heading{color:#93c5fd;font-weight:700}.syntax-highlight .tok-code{color:#fcd34d} -.copy-code{position:absolute;top:.65rem;right:.65rem;min-height:2rem;padding:.3rem .55rem;border-color:#ffffff3d;border-radius:.25rem;background:#ffffff12;color:inherit;font-family:var(--dk-font-mono);font-size:.7rem;line-height:1.3;letter-spacing:.01em;opacity:.78;transition:opacity .15s ease}.prose pre:hover .copy-code,.copy-code:focus-visible{opacity:1} +.copy-code{position:absolute;top:.65rem;right:.65rem;min-height:2rem;padding:.3rem .55rem;border-color:#ffffff59;border-color:color-mix(in srgb,#fff 35%,transparent);border-radius:.25rem;background:#ffffff12;background:color-mix(in srgb,#fff 7%,transparent);color:inherit;font-family:var(--dk-font-mono);font-size:.7rem;line-height:1.3;letter-spacing:.01em;opacity:.78;transition:opacity .15s ease}.prose pre:hover .copy-code,.copy-code:focus-visible{opacity:1} .table-scroll{overflow:auto;margin:1.5rem 0;border:1px solid var(--dk-border);border-radius:var(--dk-radius);-webkit-overflow-scrolling:touch;scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} -.prose table{border-collapse:collapse;min-width:32rem;width:100%;font-size:.9rem;line-height:1.5;font-variant-numeric:tabular-nums} +.prose table{border-collapse:collapse;min-width:min(32rem,100%);width:100%;font-size:.9rem;line-height:1.5;font-variant-numeric:tabular-nums} .prose th,.prose td{border:0;border-bottom:1px solid var(--dk-border);padding:.75rem .9rem;text-align:left;vertical-align:top} .prose tr:last-child td{border-bottom:0}.prose tbody tr:nth-child(even){background:color-mix(in srgb,var(--dk-surface) 62%,transparent)} .prose th{font-family:var(--dk-font-display);font-weight:700;background:var(--dk-surface);font-size:.88rem;letter-spacing:.01em} .admonition{--admonition-accent:var(--dk-accent);display:grid;grid-template-columns:auto minmax(0,1fr);gap:.15rem .75rem;max-width:var(--dk-reading-width);margin:1.6rem 0;padding:1rem 1.1rem;border:1px solid var(--dk-border);border-left:4px solid var(--admonition-accent);border-radius:0 .4rem .4rem 0;background:var(--dk-surface)} .admonition.note{--admonition-accent:#0284c7}.admonition.important{--admonition-accent:#7c3aed}.admonition.tip{--admonition-accent:#0f766e}.admonition.warning{--admonition-accent:#b45309} .banner{display:block;width:100%;max-width:100%;height:auto;max-height:16rem;margin:0 0 1.75rem;border:1px solid var(--dk-border);border-radius:var(--dk-radius);object-fit:cover} -.search-results{position:absolute;top:100%;left:max(1rem,calc((100% - var(--dk-shell-width))/2 + 16rem));width:min(34rem,calc(100% - 2rem));max-height:min(60vh,30rem);overflow:auto;padding:.45rem;background:var(--dk-raised);border:1px solid var(--dk-border);border-radius:var(--dk-radius);box-shadow:var(--dk-shadow)} -.search-results a{display:block;padding:.7rem .75rem;border-radius:.25rem;color:var(--dk-text);text-decoration:none}.search-results small{display:block;margin-top:.2rem;color:var(--dk-muted);font-size:.76rem;line-height:1.45}.search-results mark{padding:0 .1em;background:color-mix(in srgb,var(--dk-accent) 22%,transparent);border-radius:.1rem;color:var(--dk-text);font-weight:700}.search-empty{margin:.25rem;padding:.7rem .75rem;color:var(--dk-muted);font-size:.82rem} -.mobile-nav{display:none}.toc{position:sticky;top:6rem;align-self:start;max-height:calc(100vh - 7rem);overflow:auto;padding-left:1rem;border-left:1px solid var(--dk-border);font-size:.8rem;line-height:1.4;color:var(--dk-muted);scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} +/* Search popover: anchored to .search-control, floats above content at --dk-z-results. */ +.search-results{position:absolute;top:calc(100% + .4rem);left:0;right:0;width:auto;max-width:34rem;max-height:min(60vh,30rem);overflow:auto;padding:.45rem;background:var(--dk-raised);border:1px solid var(--dk-border);border-radius:var(--dk-radius);box-shadow:var(--dk-shadow);z-index:var(--dk-z-results)} +.search-results a{display:block;padding:.7rem .75rem;border-radius:.25rem;color:var(--dk-text);text-decoration:none}.search-results small{display:block;margin-top:.2rem;color:var(--dk-muted);font-size:.76rem;line-height:1.45}.search-results mark{padding:0 .1em;background:color-mix(in srgb,var(--dk-accent) 22%,transparent);border-radius:.1rem;color:var(--dk-text);font-weight:700}.search-empty{margin:.25rem;padding:.7rem .75rem;color:var(--dk-muted);font-size:.82rem}.search-count{margin:.25rem .75rem .35rem;color:var(--dk-muted);font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase} +.mobile-nav{display:none}.toc{position:sticky;top:var(--dk-sticky-top);align-self:start;max-height:calc(100vh - var(--dk-sticky-offset));overflow:auto;padding-left:1rem;border-left:1px solid var(--dk-border);font-size:.8rem;line-height:1.4;color:var(--dk-muted);scrollbar-color:var(--dk-border) transparent;scrollbar-width:thin} .inline-toc{display:none} .inline-toc summary{padding:.65rem 0;font-family:var(--dk-font-ui);font-size:.86rem;font-weight:700} .inline-toc nav{margin-top:.75rem;padding-top:.65rem;border-top:1px solid var(--dk-border)} @@ -168,8 +179,8 @@ /* Shared surfaces and type; each style changes only its material and signature. */ .hero{position:relative;isolation:isolate;margin:0 0 2rem;padding:clamp(1.5rem,3vw,2.5rem);border:1px solid color-mix(in srgb,var(--dk-accent) 20%,var(--dk-border));border-radius:calc(var(--dk-radius)*2);background:var(--dk-surface);overflow:hidden} .hero::before,.hero::after{content:"";position:absolute;inset:0;z-index:-1} -.hero::after{background-image:radial-gradient(color-mix(in srgb,var(--dk-accent) 26%,transparent) 1px,transparent 1px);background-size:1.15rem 1.15rem;-webkit-mask-image:linear-gradient(155deg,#000 8%,transparent 58%);mask-image:linear-gradient(155deg,#000 8%,transparent 58%)} -.hero h1{font-size:clamp(2.4rem,5vw,3.4rem);letter-spacing:-.04em;margin-bottom:1rem;overflow-wrap:break-word} +.hero::after{background-image:radial-gradient(color-mix(in srgb,var(--dk-accent) 14%,transparent) 1px,transparent 1px);background-size:1.15rem 1.15rem;-webkit-mask-image:linear-gradient(155deg,#000 8%,transparent 58%);mask-image:linear-gradient(155deg,#000 8%,transparent 58%)} +.hero h1{font-size:clamp(2.3rem,4vw,3.1rem);letter-spacing:-.04em;margin-bottom:1rem;overflow-wrap:break-word} .hero-copy{min-width:0} .hero .hero-copy>p{max-width:42rem;color:var(--dk-muted);font-size:1.16rem;line-height:1.62} .hero .release-context{display:inline-flex;align-self:flex-start;margin:0 0 1.15rem} @@ -204,8 +215,8 @@ .syntax-highlight .tok-keyword{font-weight:650} .syntax-highlight .tok-function{font-weight:600} .prose tbody tr:hover{background:color-mix(in srgb,var(--dk-surface) 82%,transparent)} -.admonition>strong{grid-row:1;color:var(--admonition-accent);font-size:.76rem;letter-spacing:.07em;line-height:1.6;text-transform:uppercase} -.admonition>p{grid-column:2;margin:0;line-height:1.6} +.admonition>strong{grid-row:1;color:var(--admonition-accent);font-size:.76rem;letter-spacing:.07em;line-height:1.6;text-transform:uppercase}.admonition>strong::before{content:"";display:inline-block;width:.55em;height:.55em;margin-right:.45em;border-radius:50%;background:var(--admonition-accent);vertical-align:baseline} +.admonition>:not(strong){grid-column:2;min-width:0;margin:0;line-height:1.6}.admonition>:not(strong)+:not(strong){margin-top:.6rem} .search-results a+a{margin-top:.15rem} .search-results a:hover,.search-results a:focus-visible{background:var(--dk-surface);text-decoration:none} .search-results a strong{display:block;font-family:var(--dk-font-display);font-size:.9rem;line-height:1.35} @@ -213,7 +224,9 @@ .toc .toc-level-4{padding-left:1.5rem;font-size:.96em} .toc .toc-level-5{padding-left:1.85rem;font-size:.92em} .toc .toc-level-6{padding-left:2.2rem;font-size:.92em} -@media(hover:hover){.capability-strip li:hover{border-color:var(--dk-interactive)}} +@media(hover:hover){.capability-strip li:hover{border-color:var(--dk-interactive)}html:not([data-visual-theme="e-ink"]) .capability-strip li:hover{transform:translateY(-2px);box-shadow:var(--dk-shadow)}} +.prose :is(h2,h3)[id]{position:relative}.heading-anchor{margin-left:.5rem;color:var(--dk-muted);font-size:.75em;font-weight:400;text-decoration:none;opacity:0}.prose :is(h2,h3):hover .heading-anchor,.prose :is(h2,h3):focus-within .heading-anchor,.heading-anchor:focus-visible{opacity:1}.heading-anchor:hover{color:var(--dk-interactive)} +.site-footer .back-to-top{margin-left:auto;color:var(--dk-muted);font-weight:600;text-decoration:none}.site-footer .back-to-top:hover{color:var(--dk-interactive);text-decoration:underline} /* Glass composites stay within palette.py contrast bounds; blur is optional. */ html[data-visual-theme="glassmorphic"] body{background:radial-gradient(46rem 32rem at 10% 12%,color-mix(in srgb,var(--dk-accent) var(--dk-mesh-1),transparent),transparent 58%),radial-gradient(38rem 30rem at 94% 8%,color-mix(in srgb,var(--dk-accent-secondary) var(--dk-mesh-2),transparent),transparent 55%),radial-gradient(42rem 34rem at 52% 118%,color-mix(in srgb,var(--dk-accent) var(--dk-mesh-3),transparent),transparent 60%),radial-gradient(28rem 24rem at 76% 74%,color-mix(in srgb,var(--dk-accent-secondary) var(--dk-mesh-4),transparent),transparent 55%),var(--dk-bg);background-attachment:fixed} html[data-visual-theme="glassmorphic"] :is(.site-header,.hero,.capability-strip li,.release-context,.search-results,.sidebar,.toc,.mobile-nav){background:var(--dk-glass);border-color:color-mix(in srgb,var(--dk-raised) 30%,var(--dk-border));box-shadow:inset 0 1px 0 color-mix(in srgb,var(--dk-raised) 70%,transparent),0 1.25rem 2.8rem -.55rem color-mix(in srgb,var(--dk-bg) 65%,transparent)} @@ -230,7 +243,7 @@ html[data-visual-theme="paper"] .hero{background:var(--dk-raised);border-width:0 0 1px;border-radius:0} html[data-visual-theme="paper"] .hero .hero-copy{padding:clamp(1rem,2vw,1.5rem) 0} html[data-visual-theme="paper"] .hero .hero-copy>p{font-style:italic} -html[data-visual-theme="paper"] .prose>p:first-of-type::first-letter{float:left;margin:.06em .12em 0 0;font-family:var(--dk-font-display);font-size:3.1em;font-weight:700;line-height:1;color:var(--dk-interactive)} +html[data-visual-theme="paper"] .prose[data-homepage="true"]>p:first-of-type::first-letter{float:left;margin:.06em .12em 0 0;font-family:var(--dk-font-display);font-size:3.1em;font-weight:700;line-height:1;color:var(--dk-interactive)} html[data-visual-theme="e-ink"] .prose h1{border-bottom:3px double var(--dk-text);padding-bottom:.5rem} @supports ((background-clip:text) or (-webkit-background-clip:text)){html[data-visual-theme="glassmorphic"] .hero h1{background:linear-gradient(135deg,var(--dk-interactive),var(--dk-text));-webkit-background-clip:text;background-clip:text;color:transparent}} html[data-visual-theme="glassmorphic"] .hero-emoji h1{background:none;color:var(--dk-text)} @@ -255,6 +268,7 @@ .mobile-nav a.active{color:var(--dk-interactive);font-weight:700} .toc{display:none} .prose h1{font-size:2.15rem} + html{scroll-padding-top:9rem} .prose{scroll-margin-top:9rem} .prose[data-homepage="false"] :is(h1,h2,h3,h4,h5,h6){scroll-margin-top:9rem} .copy-code{opacity:1} @@ -269,6 +283,7 @@ .header-control{flex:1;min-width:0} .header-control>span{font-size:.6rem} .topbar select{width:100%;min-width:0} + html{scroll-padding-top:12rem} .prose{scroll-margin-top:12rem} .prose[data-homepage="false"] :is(h1,h2,h3,h4,h5,h6){scroll-margin-top:12rem} .prose .capability-strip,.prose[data-homepage="true"] .capability-strip{grid-template-columns:1fr} @@ -281,10 +296,24 @@ .header-control select{font-size:.75rem} .prose pre{margin-inline:-.25rem;padding-inline:1rem} } +/* Pre-color-mix fallback: solid surfaces where mixing is unsupported; decoration simply drops. */ +@supports not (color:color-mix(in srgb,red 50%,blue)){ + ::selection{background:#bcd} + .sidebar a.active{background:var(--dk-surface)} + .prose .inline-toc a.is-active{background:var(--dk-surface)} + .search-results mark{background:#fef08a} + .prose tbody tr:nth-child(even){background:var(--dk-surface)} + .prose tbody tr:hover{background:var(--dk-surface)} + .hero{border-color:var(--dk-border)} + .prose pre{border-color:var(--dk-border)} +} +/* Print: navigation chrome off, article and footer in ink. */ @media print{ - .skip-link,.reading-progress,.site-header,.mobile-nav,.toc,.inline-toc,.page-navigation,.hero-actions{display:none} + .skip-link,.reading-progress,.site-header,.sidebar,.mobile-nav,.toc,.inline-toc,.page-navigation,.hero-actions,.search-results,.copy-code{display:none} + .site-footer,.site-footer a{color:#000} .hero::before,.hero::after{display:none} .hero{border-color:#999;background:none} + html[data-visual-theme="glassmorphic"] .hero h1{background:none!important;color:#000!important;-webkit-text-fill-color:#000!important} body{background:#fff;color:#000;font-size:12pt;line-height:1.5} html[data-visual-theme="glassmorphic"] body{background:#fff} html[data-visual-theme="paper"] .prose{padding:0;border:0;background:none;box-shadow:none} @@ -322,6 +351,18 @@ while(index!==-1){element.append(text.slice(offset,index));const mark=document.createElement('mark');mark.textContent=text.slice(index,index+query.length);element.append(mark);offset=index+query.length;index=haystack.indexOf(query,offset)} element.append(text.slice(offset)); } + function appendHighlights(element,text,terms){ + const lower=text.toLowerCase(),unique=[...new Set(terms.filter(Boolean))].sort((a,b)=>b.length-a.length); + let offset=0; + while(offsetbestTerm.length))){best=idx;bestTerm=term}} + if(best===-1){element.append(text.slice(offset));break} + if(best>offset)element.append(text.slice(offset,best)); + const mark=document.createElement('mark');mark.textContent=text.slice(best,best+bestTerm.length);element.append(mark); + offset=best+bestTerm.length; + } + } function excerpt(text,query){const index=text.toLowerCase().indexOf(query);if(index<0)return text.slice(0,90);const start=Math.max(0,index-36),end=Math.min(text.length,index+query.length+54);return `${start?'…':''}${text.slice(start,end)}${end({entry,score:rank(entry,terms)})).filter(match=>Number.isFinite(match.score)).sort((left,right)=>left.score-right.score||left.entry.title.localeCompare(right.entry.title)).slice(0,8); if(!matches.length){const empty=document.createElement('p');empty.className='search-empty';empty.textContent=`No pages match “${input.value.trim()}”.`;results.replaceChildren(empty)} - else results.replaceChildren(...matches.map(({entry})=>{ - const link=document.createElement('a');link.href=entry.url; - const title=document.createElement('strong');appendHighlight(title,entry.title,terms[0]); - const summary=document.createElement('small');summary.textContent=`${entry.section} — `;appendHighlight(summary,excerpt(entry.text,terms[0]),terms[0]); - link.append(title,summary);return link; - })); + else{ + const count=document.createElement('p');count.className='search-count';count.textContent=`${matches.length} result${matches.length===1?'':'s'}`; + results.replaceChildren(count,...matches.map(({entry})=>{ + const link=document.createElement('a');link.href=entry.url; + const title=document.createElement('strong');appendHighlights(title,entry.title,terms); + const summary=document.createElement('small');summary.textContent=`${entry.section} — `;const excerptText=excerpt(entry.text,terms[0]);const span=document.createElement('span');appendHighlights(span,excerptText,terms);summary.append(span); + link.append(title,summary);return link; + })); + } results.hidden=false;input.setAttribute('aria-expanded','true'); } if(input&&results){ @@ -387,13 +431,34 @@ const tocLinks=[...document.querySelectorAll('.toc a[href^="#"],.inline-toc a[href^="#"]')]; if(tocLinks.length&&'IntersectionObserver'in globalThis){ const headingIds=new Set(tocLinks.map(link=>link.getAttribute('href').slice(1))); - const activate=id=>tocLinks.forEach(link=>{const current=link.getAttribute('href').slice(1)===id;link.classList.toggle('is-active',current);if(current)link.setAttribute('aria-current','location');else link.removeAttribute('aria-current')}); + const activate=id=>tocLinks.forEach(link=>{const current=link.getAttribute('href').slice(1)===id;link.classList.toggle('is-active',current);if(current){link.setAttribute('aria-current','location');const inline=link.closest('.inline-toc');if(inline&&!inline.open)inline.open=true}else link.removeAttribute('aria-current')}); const observer=new IntersectionObserver(entries=>{const visible=entries.find(entry=>entry.isIntersecting);if(visible)activate(visible.target.id)},{rootMargin:'0px 0px -70% 0px'}); headingIds.forEach(id=>{const heading=document.getElementById(id);if(heading)observer.observe(heading)}); } + document.querySelectorAll('main.prose :is(h2,h3)[id]').forEach(heading=>{ + if(heading.querySelector('.heading-anchor'))return; + const anchor=document.createElement('a');anchor.className='heading-anchor';anchor.href=`#${heading.id}`; + anchor.setAttribute('aria-label',`Link to ${heading.textContent.trim()}`);anchor.textContent='#'; + heading.append(anchor); + }); })(); ''' MATH_JS = r''' (()=>{function render(node,displayMode){if(!globalThis.katex){node.textContent=node.dataset.tex||'';return}try{globalThis.katex.render(node.dataset.tex||'',node,{displayMode,throwOnError:false,strict:'ignore'})}catch(_){node.textContent=node.dataset.tex||''}}document.querySelectorAll('.math-inline').forEach(node=>render(node,false));document.querySelectorAll('.math-display').forEach(node=>render(node,true))})(); ''' + + +def _content_hash(value: str) -> str: + """Short content hash for cache-busting generated asset URLs. + + Identical content keeps an identical URL (long-lived caches stay valid); + any style or script change mints a new URL, so browsers and proxies can + never serve a stale stylesheet or script after a rebuild. + """ + return sha1(value.encode("utf-8")).hexdigest()[:12] + + +SITE_CSS_HASH = _content_hash(SITE_CSS) +SITE_JS_HASH = _content_hash(SITE_JS) +MATH_JS_HASH = _content_hash(MATH_JS) diff --git a/src/docsprout/build.py b/src/docsprout/build.py index de0329b..d15a57d 100644 --- a/src/docsprout/build.py +++ b/src/docsprout/build.py @@ -12,7 +12,7 @@ import unicodedata from urllib.parse import quote, urlsplit -from .assets import MATH_JS, SITE_CSS, SITE_JS +from .assets import MATH_JS, MATH_JS_HASH, SITE_CSS, SITE_CSS_HASH, SITE_JS, SITE_JS_HASH from .config import load_config, page_source_path, page_source_reference from .errors import DocSproutError from .markdown import render_markdown @@ -117,6 +117,32 @@ def _page_context(page: Page) -> str: return html.escape(location) +def _page_context_nav(*, page: Page, config, current_route: str) -> str: + crumbs: list[str] = [] + if page.section: + target = next((item for item in config.pages if item.section == page.section and item.subsection is None), None) + if target is None: + target = next((item for item in config.pages if item.section == page.section), None) + if target is not None and target.path != page.path: + href = html.escape(_relative(current_route, _route(target.path, config.home_document)), quote=True) + crumbs.append(f'
  • {html.escape(page.section)}
  • ') + else: + crumbs.append(f"
  • {html.escape(page.section)}
  • ") + if page.subsection: + target = next( + (item for item in config.pages if item.section == page.section and item.subsection == page.subsection), + None, + ) + if target is not None and target.path != page.path: + href = html.escape(_relative(current_route, _route(target.path, config.home_document)), quote=True) + crumbs.append(f'
  • {html.escape(page.subsection)}
  • ') + else: + crumbs.append(f"
  • {html.escape(page.subsection)}
  • ") + if not crumbs: + return "" + return f'' + + def _hero_title_has_emoji(body: str) -> bool: """Return True when the hero heading contains a colour emoji glyph. @@ -233,7 +259,7 @@ def _shell(*, body: str, headings: tuple[tuple[int, str, str], ...], page: Page, ) toc = f'

    On this page

    {toc_links}' if toc_links else '

    Documentation

    Browse the sections in the navigation.

    ' if not homepage: - context = f'
    {_page_context(page)}
    ' + context = _page_context_nav(page=page, config=config, current_route=current_route) if toc_links: inline_toc = f'
    On this page
    ' before_title, title_end, after_title = body.partition("") @@ -241,18 +267,22 @@ def _shell(*, body: str, headings: tuple[tuple[int, str, str], ...], page: Page, body = context + body page_navigation = _page_navigation(page=page, config=config, current_route=current_route, include_context=not homepage) footer_links = "".join(f'{html.escape(label)}' for label, url in config.project_links) - footer = f'
    {html.escape(config.footer or config.name)}{footer_links}
    ' if config.footer or footer_links else "" + footer = ( + f'
    {html.escape(config.footer or config.name)}' + f"{footer_links}" + 'Back to top
    ' + ) header_controls = f'''
    ''' main_context = ' data-homepage="true"' if homepage else ' data-homepage="false"' theme_bootstrap = """""" custom_css_link = f'' if custom_css else "" home_route = html.escape(_relative(current_route, "index.html"), quote=True) search_index_route = html.escape(_relative(current_route, "search-index.json"), quote=True) - site_css_route = html.escape(_relative(current_route, "assets/site.css"), quote=True) + site_css_route = html.escape(f"{_relative(current_route, 'assets/site.css')}?v={SITE_CSS_HASH}", quote=True) katex_css_route = html.escape(_relative(current_route, "assets/katex/katex.min.css"), quote=True) katex_js_route = html.escape(_relative(current_route, "assets/katex/katex.min.js"), quote=True) - math_js_route = html.escape(_relative(current_route, "assets/math.js"), quote=True) - site_js_route = html.escape(_relative(current_route, "assets/site.js"), quote=True) + math_js_route = html.escape(f"{_relative(current_route, 'assets/math.js')}?v={MATH_JS_HASH}", quote=True) + site_js_route = html.escape(f"{_relative(current_route, 'assets/site.js')}?v={SITE_JS_HASH}", quote=True) favicon = "data:image/svg+xml," + quote(BRAND_SVG, safe="") document_head = ( f'' '' 'Type to search. Use the arrow keys to move' - " through results, Enter to open, and Escape to close." + ' through results, Enter to open, and Escape to close.' + '' f"{header_controls}" - '' + '' ) - mobile_navigation = f'
    Browse documentation{nav}
    ' + mobile_navigation = f'
    Browse documentation
    ' shell_class = "shell" if homepage or toc_links else "shell shell-no-toc" toc_aside = f'' if homepage or toc_links else "" document_body = ( diff --git a/tests/test_build.py b/tests/test_build.py index 1f890af..2b4c296 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,8 +1,10 @@ import json from pathlib import Path +import re import tempfile import unittest +from docsprout.assets import MATH_JS_HASH, SITE_CSS_HASH, SITE_JS_HASH from docsprout.build import build_site from docsprout.errors import DocSproutError @@ -27,6 +29,38 @@ def test_publishes_local_markdown_images_with_their_page(self) -> None: self.assertIn('Architecture', page) self.assertEqual("", (root / "site" / "assets" / "content" / "images" / "architecture.svg").read_text(encoding="utf-8")) + def test_generated_asset_urls_carry_a_stable_content_hash(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "docs" + (docs / "guides").mkdir(parents=True) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "guides" / "advanced.md").write_text("# Advanced\n", encoding="utf-8") + (docs / "docsprout.json").write_text(json.dumps({"schema_version": 1, "project": {"name": "Demo"}}), encoding="utf-8") + (docs / "layout.json").write_text(json.dumps({ + "schema_version": 1, + "navigation": [{"title": "Docs", "pages": [ + {"title": "Home", "path": "index.md"}, + {"title": "Advanced", "path": "guides/advanced.md"}, + ]}], + }), encoding="utf-8") + + build_site(root=root, output=root / "site", release="dev") + + home = (root / "site" / "index.html").read_text(encoding="utf-8") + self.assertIn(f'href="assets/site.css?v={SITE_CSS_HASH}"', home) + self.assertIn(f'src="assets/site.js?v={SITE_JS_HASH}"', home) + self.assertIn(f'src="assets/math.js?v={MATH_JS_HASH}"', home) + nested = (root / "site" / "guides" / "advanced.html").read_text(encoding="utf-8") + self.assertIn(f'href="../assets/site.css?v={SITE_CSS_HASH}"', nested) + for digest in (SITE_CSS_HASH, SITE_JS_HASH, MATH_JS_HASH): + self.assertRegex(digest, r"^[0-9a-f]{12}$") + self.assertEqual(1, home.count(f"?v={digest}")) + rebuild = re.sub(r"\?v=[0-9a-f]{12}", "?v=", home) + build_site(root=root, output=root / "second", release="dev") + twice = (root / "second" / "index.html").read_text(encoding="utf-8") + self.assertEqual(rebuild, re.sub(r"\?v=[0-9a-f]{12}", "?v=", twice)) + def test_publishes_nested_page_assets_under_a_non_colliding_content_path(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -105,7 +139,7 @@ def test_builds_the_maintained_visual_fixture(self) -> None: self.assertIn('href="assets/custom.css"', home) self.assertGreater( home.index('href="assets/custom.css"'), - home.index('href="assets/site.css"'), + home.index("assets/site.css?v="), ) def test_copies_an_identity_logo_into_the_header(self) -> None: @@ -179,8 +213,9 @@ def test_builds_modern_navigation_search_and_theme_tokens(self) -> None: self.assertIn("