Skip to content

Release 0.8.16 — Robust asset resolution, portable base href, Windows fix, simulator overhaul - #183

Merged
mgks merged 1187 commits into
mainfrom
dev-0.8.16
Jul 17, 2026
Merged

Release 0.8.16 — Robust asset resolution, portable base href, Windows fix, simulator overhaul#183
mgks merged 1187 commits into
mainfrom
dev-0.8.16

Conversation

@mgks

@mgks mgks commented Jul 17, 2026

Copy link
Copy Markdown
Member

Release 0.8.16

🐛 Fix: Robust offline asset path resolution

Offline builds no longer suffix asset files (PDFs, images, JSON) with /index.html. The URL normalizer now checks for a file extension before any path transformation, leaving all non-markdown files completely untouched.

🏗 Fix: Portable <base href> for subpath deploys

Non-offline builds with a subpath now emit a single <base href="/<repo>/"> tag. Asset URLs use simple-relative paths; the <base> tag resolves them at runtime. Moving a site between repos or hosts no longer requires rebuilding with an updated config.url.

🪟 Fix: Windows path crash (issue #182)

All __dirname derivations now use fileURLToPath(import.meta.url) instead of path.dirname(new URL(import.meta.url).pathname), which produced malformed /C:/... paths on Windows causing fs.existsSync to loop at 100% CPU.

🧰 Auto-install no longer blocks workspace sub-projects

The package.json pre-check has been removed from the auto-install path. npm/pnpm/yarn/bun walk up the directory tree themselves. The friendly hint now fires only when the spawn genuinely fails and no package.json exists in any ancestor.

🗂 Fix: All search data unified under .docmd-search/

Keyword and semantic search indexes now share a single output folder. The runtime manifest HEAD probe that caused 404s on keyword-only sites has been removed. The keyword index always generates as a fallback.

🎨 Favicon fallback — no more browser 404s

The page generator and 404 template now inject the default assets/favicon.ico when no custom favicon is configured.

🧹 CLI environment warnings silenced

Subprocess spawners strip pnpm-internal npm_config_* env vars before running npm/npx commands, eliminating the "Unknown env config" noise.

🧰 Consumer simulation workflow (sim.mjs)

  • --source, --regen-tars, --doctor flags added
  • Local tarballs are temporarily mapped into the consumer package.json for install then cleanly restored
  • dev:sim, build:sim, doctor:sim scripts wired in docs/package.json

📊 Prep pipeline TUI polish

Cursor-up in-place rewriting for step status, accurate total elapsed timer, and concise error-only summary at the end.


613 tests passing · 0 failures · pnpm prep clean

mgks added 30 commits June 14, 2026 07:49
When optionsMenu.position === 'header' the layout now includes the
options menu (search trigger, theme switch, sponsor, language,
project switcher) inside the pageheader alongside the copy widgets.
Adds .summer-options-menu-slot styles and child overrides for the
default options-menu partial (no docmd-main.css dependency).

At <= 600px the kbd hint in the search trigger is hidden so the
pageheader row stays single-line.
The topbar already has individual, config-gated elements for
theme / language / sponsor / github / version / project switcher.
Adding a unified options-menu partial on top was duplicating those.

Removed:
- partials/options-menu include in the topbar
- .summer-topbar__options CSS block

Existing gates stay as-is:
- _om.components.themeSwitch
- _om.components.sponsor / github
- config.versions.position
- workspace.switcher.position
- config.i18n.position
Three bugs that broke core interactions on first load / SPA nav:

1. summerInit() had an inverted condition:
   if (dataset.summerWired === '1') { /* first run */ }
   so the body never executed — the dataset was always unset,
   the body never ran, and the flag was never set either.
   Result: search, theme toggle, scroll-to-top, banner close,
   sidebar mobile drawer, subnav dropdowns, and version/project/
   language switchers all silently no-op'd. Flip to !== '1'.

2. wireSidebarGroups toggle clobbered by docmd-main.js:
   both handlers listen for clicks on the same .nav-label /
   .collapse-icon-wrapper, and use different sources of truth
   (aria-expanded vs classList.contains('expanded')) — when
   both fire, the second one reverts the first. Stop propagation
   so only the summer-side handler runs.

3. wireSwitcherDropdowns used stopPropagation, but the
   document-level click handler attached later (in docmd-main.js
   for .version-dropdown-toggle) would still see the bubble. Bump
   to stopImmediatePropagation on the button-level listener so it
   wins regardless of handler attachment order.
… slot

Footer
- footer__top was using 'grid-template-columns: 1.5fr repeat(auto-fit,
  minmax(160px, 1fr))' which Chromium was parsing the cssText without
  (cssRule style had everything except grid-template-columns). Switched
  to an explicit 1.5fr 1fr 1fr 1fr track list with responsive overrides
  at 900/540px — the rule now applies as written and the 3-col brand +
  Resources/Ecosystem/Community row lays out correctly.
- Sponsored link in the footer was hardcoded. Make it gated on a new
  'optionsMenu.components.sponsorPosition' key:
    'top' (default, only topbar) | 'footer' (only footer) | 'both'
  The hardcoded inline-svg version is gone, replaced with the same
  _sponsorUrl / _sponsorPos pair the topbar uses so a config flip moves
  the link, not duplicates it.

Code blocks
- .summer-codeblock / .summer-codeblock__header / .summer-codeblock__copy /
  .summer-codeblock pre / .summer-content pre were all locked to a hard
  dark palette (#1d1b17 / #e7dfcf / #c8bfae / #8a8170) regardless of
  theme, so light mode rendered the same dark slab. Re-keyed to the
  summer surface / fg / muted / border tokens; added a sibling
  html[data-theme='dark'] override that restores the original dark slab
  for dark mode. Light + dark verified via getComputedStyle snapshots.

Page nav
- .summer-pagenav is a CSS grid. The no-prev / no-next edge-push rules
  used margin-left/right: auto, which the grid layout ignores — the
  lone link floated in the first column. Switched to
  grid-column: <n> + justify-self: end/start + max-width: 380px, and
  added a reset inside the <=900px media query (where the grid goes
  1fr) so the single-link case doesn't leave a phantom empty cell.

Sidebar / TOC sticky offset
- Trimmed the +16px / -32px breathers on the .summer-sidebar and
  .summer-toc sticky top / max-height to +12px / -24px so they sit a
  touch closer to the pageheader's bottom hairline, while keeping the
  same 4-band top: calc(topbar + subnav + banner + pageheader) formula
  so they auto-adjust to the menubar / banner / header CSS variables.

Topbar switcher pills
- The version / project / language switcher toggles in the topbar
  were 32px tall with rectangular corners. Trimmed to 28px tall and
  pill (border-radius: 999px) so the three switchers + theme + sponsor
  + github don't crowd the right side of the topbar.
The user feedback was that docmd-main.js already implements the
working SPA router, sidebar collapse, theme toggle, version/project/
language switchers, code-block copy, page copy and banner close —
so duplicating them in summer.js was both dead code and source of
conflicts (e.g. the summer toggle + docmd-main toggle would race on
the same data-theme attribute).

This refactor:
  - removes ~370 lines of duplicated handlers from summer.js
    (theme, sidebar drawer, sidebar collapse, subnav dropdowns,
    switcher dropdowns, code-block copy, page copy, banner close,
    and their unused helpers isMac / copyToClipboard / showCopiedFeedback)
  - keeps only summer-specific wires: topbar search dropdown, TOC
    scroll-spy + smooth scroll, scroll-to-top button, git popover,
    relative-date rendering
  - re-uses the same wireHeaderSearch() that was in 3825372 (the
    proven working implementation) and calls it once in the first-run
    block. summerInit then listens for docmd:page-mounted and re-runs
    the per-page wires (TOC, git popover, relative timestamps) so
    they re-attach after every docmd-main.js SPA nav

Layout / EJS:
  - body now carries data-spa-enabled, data-copy-code-enabled and
    data-default-collapsed so docmd-main.js's bootstrap() sees a
    real config and SPA-init, code-injection and collapse-state
    default all match the source config
  - topbar mobile-toggle button also carries .sidebar-menu-button
    so docmd-main.js's mobile-drawer outside-click + Escape
    behaviour just works
  - theme button also carries .theme-toggle-button so docmd-main.js's
    bootstrap() wires it
  - banner wrapper now has data-docmd-banner and the close button
    has data-docmd-banner-dismiss so docmd-main.js's initBanner()
    (sessionStorage-persisted) owns the dismiss state

CSS:
  - .summer-sidebar nav-group click / hover affordances (cursor:
    pointer, background, accent icon) are now scoped to
    .nav-group.collapsible only. Non-collapsible groups (e.g.
    'Getting Started' with "collapsible": false in navigation.json)
    no longer look clickable — the visual state now matches the
    behaviour handled by docmd-main.js's nav handler
  - nav-group header padding reduced from 8px 12px to 8px 14px so
    a first-child group doesn't sit visually too close to the top
    edge after the margin-top rule was removed
Codeblock copy button
- Layout was missing the summer.js script tag; the bottom-floating
  copy button from docmd-main.js was rendering inline at the end of
  the <pre> with no styles (since summer.css is self-contained and
  doesn't load docmd-main.css). Wire summer.js into layout.ejs so its
  codeblock handler runs.
- New summerCodeblocks() in summer.js: re-homes the copy button into
  a thin title bar at the top of the codeblock. Uses the existing
  .docmd-code-block-header (parser-rendered when the source has
  ```lang "title.js"```) and falls back to building a
  .summer-cb__titlebar (filename = language, snippet if none) for
  un-titled blocks.
- Replaced the dead .summer-codeblock* CSS (only used by the old
  summer.js attachCodeCopyButtons path) with .summer-cb__* rules.
  Also styles .code-wrapper, .docmd-code-block-wrapper, and
  .copy-code-button to look the same so the result is consistent
  regardless of which path produced the DOM.

Footer
- Modernised: brand column now stacks logo + description with a
  breathing gap, columns are tighter, link lists have hover
  indicators, external links get a subtle ↗ on hover, and the
  complete-footer's bottom bar gets a hairline divider for visual
  separation from the columns.
- Adjusted padding / gap scale and responsive breakpoints.

Sidebar / TOC
- Aligned the sticky offset to the bottom of the pageheader band
  (was 12px below, leaving the sidebar's first item visually
  floating above the first main content item).
- Added --summer-main-padding-top so the sticky calc accounts for
  the breathing room between topbar and pageheader that lives on
  .summer-main. Sidebar, TOC and main now share the same Y anchor
  for their first visible item.
…mers

Previously the highlighter consumed the language for token colouring but
dropped it from the markup. The summer template's codeblock title bar
(summer.js) reads `code.className.match(/language-(...))/` to show the
filename / language pill on the left of the title bar — without this
class it always fell back to "snippet".

Add a `class="language-XXX"` attribute on the rendered <code>
element whenever a language is provided. Mirrors the same change in
the no-highlight fallback.
… fix

ui (docmd-main.js)
- Add summer-template selectors to the SPA swap list so the SPA
  router actually swaps content when the user navigates. Without
  these, docmd-main.js was changing the URL but the visible page
  stayed the same (no swap targets matched the active template).
- New swap targets: .summer-content, .summer-toc, .summer-pageheader,
  .summer-pagenav, .summer-pagefooter, #docmd-raw-markdown,
  .summer-sidebar nav. All summer-only — they're a no-op for the
  classic template.

summer (layout.ejs)
- Remove the duplicate summer.js <script> tag I added in the
  previous round. The template's src/index.ts already registers
  summer.js as a templateAsset which the core build emits at
  assets/template/summer.js — the extra tag at assets/js/summer.js
  was 404-ing.

summer (summer.js)
- Fix syntax error from the previous refactor (orphan
  wrap.classList.add line that broke the IIFE).
- Refactor the codeblock title bar to the new spec:
    - Drop the standalone .summer-cb__filename span (it duplicated
      the lang pill when there was no markdown title).
    - .summer-cb__lang becomes the primary label. Shows the
      detected language (e.g. "BASH") or "CODEBLOCK" when no
      language is set.
    - For parser-titled code blocks (), keep
      the filename from .docmd-code-block-title and place the lang
      pill after it.
    - Extract the shared SVG icon into makeFileIcon() so both
      paths use the same markup.
- The dedicated .summer-cb__copy-label span is preserved (used
  by CSS for the gap between the copy icon and the "Copy"
  text).

summer (summer.css)
- Codeblock filename (.summer-cb__filename, .docmd-code-block-title)
  demoted to a subtle text style (regular weight, slightly
  smaller, --summer-fg-soft) so the lang pill remains the primary
  visual element.
- Lang pill: tighter line-height, slightly larger horizontal
  padding for the new role as the main label.
- Sidebar / TOC sticky offset: drop --summer-main-padding-top
  from the calc. The 4-band sum (topbar + subnav + banner +
  pageheader) is the only formula needed — the main's top padding
  was being added on top of the pageheader band, which gave the
  sidebar a visible menubar margin even when the menubar was
  disabled. The pageheader's natural Y in the layout still
  positions the sidebar correctly via the pageheader-height
  variable.
ui (docmd-main.js)
- The copy-code click handler looked for the associated <code> in
  copyBtn.closest('.code-wrapper'). But the summer template re-homes
  the copy button into the title bar, which for
  blocks lives in .docmd-code-block-wrapper — *outside* the
  .code-wrapper. closest('.code-wrapper') returned null and the
  click silently did nothing.
- Broaden the lookup: try .code-wrapper first, then fall back to
  any codeblock-shaped parent (.docmd-code-block-wrapper, .summer-cb,
  .summer-codeblock) and grab the first pre code inside. Works for
  both the classic and summer templates, with or without a parser-
  rendered title.

summer (summer.css)
- For parser-titled code blocks, the inner .code-wrapper (added by
  docmd-main.js to host the copy button) inherits the same
  background / border / border-radius as the outer
  .docmd-code-block-wrapper — rendering as a "window inside a
  window". Strip those styles on the inner wrapper when it sits
  inside a parser wrapper so only the outer box is visible.
The .github/workflows/docker-publish.yml workflow never applied the
`latest` tag because its `enable` expression required a `push` event
on main, but the workflow had no `push` trigger — only `release` and
`workflow_dispatch`. As a result, the published image had only the
versioned tag (`0.8.6`), an `edge` tag, and commit SHAs, with no
`latest` alias even though the README, DOCKER.md, and docs all pointed
at it.

Workflow changes:
- Tag `:latest` automatically on `release` events (the standard
  Docker convention — only stable releases get the floating tag).
- Re-add a `push: branches: [main]` trigger so `:edge` rebuilds on
  pushes to main.
- Expose a manual `tag_latest` boolean input to `workflow_dispatch`
  for back-filling `:latest` onto an existing image.

Doc changes:
- Pin every `ghcr.io/docmd-io/docmd:latest` reference to
  `ghcr.io/docmd-io/docmd:0.8.6` (the latest stable tag) across:
  - 6 READMEs (en/de/es/fr/ja/zh)
  - packages/core/README.md
  - docker/DOCKER.md
  - docs/.../installation.md (en/de/zh)
  - docs/.../deployment/docker.md (en/de/zh)
  - docs/.../0-8-6.md (en/de/zh)
- Rewrite DOCKER.md `Available Tags` table to match the registry
  reality and recommend pinning for production / CI.
- Audit entry added to bug-report.md (High Priority, in progress).

Workaround for the original bug reporter: pull a specific version such
as `ghcr.io/docmd-io/docmd:0.8.6` until the next release publishes
the `latest` tag.
`docmd add summer` now works as a first-class template install.
Unlike regular plugins, templates register to `theme.template` (not
`plugins.*`) and **replace** any previously-set template — there is
no stacking.

Installer changes (@docmd/plugin-installer):
- New `summer` registry entry with `kind: "template"`.
- New `injectTemplateToConfig()` / `removeTemplateFromConfig()`
  helpers that write `theme.template` in the config and replace any
  existing value.
- Format-aware: JSON configs use `JSON.parse` + `stringify`;
  JS configs use regex (matches the existing plugin injector style).
- New `resolveConfigPath()` prefers `docmd.config.json` (the
  project standard), falls back to `.js` for legacy projects.
- `installPlugin()` and `removePlugin()` branch on `meta.kind`
  to dispatch plugin vs. template paths.
- All 5 JS edge cases (empty theme, theme w/ keys, no theme, replace,
  re-add no-op) format cleanly. JSON edge cases (add, re-add same,
  re-add diff, remove, re-remove no-op) pass.

Summer template (@docmd/template-summer):
- New dedicated `packages/templates/summer/templates/404.ejs` —
  standalone HTML, Summer-styled (halo gradient + glass card + Inter
  font), i18n-aware (`t('pageNotFound')` / `t('returnHome')` /
  `t('goBack')` with English fallbacks), RTL-aware via
  `activeLocale.dir`. Loads `summer.css` + theme colour overlay +
  `customCss`.
- Registered in `src/index.ts` as `{ type: '404', templatePath: ... }`.
- Both packages build cleanly via `tsc`.
…malink

Default template polish (Section C of the 0.8.7 backlog):

1. **Sticky bottom for page-nav and page-footer-actions**
   - `.content-area` is now a flex column.
   - `.content-layout` gets `flex: 1 0 auto` so it grows to fill
     `.content-area`.
   - `.page-footer-actions` is pushed to the bottom of the content
     area (just above the page footer) when content is short.
   - `.main-content` is now a flex column.
   - `.page-navigation` (prev/next buttons) gets
     `margin-top: auto` so it sticks to the bottom of the article
     instead of floating in the middle on short pages.

2. **Thinner header & menubar**
   - `--menubar-h: 52px → 44px` (both light & dark themes).
   - `--header-h: 54px → 44px` (both light & dark themes).
   - `.page-header` padding: `var(--space-3)` →
     `var(--space-2) var(--space-3)` (8px top/bottom, 12px sides).
   - Less vertical chrome, more content real estate.

3. **Smaller permalink icon**
   - `.heading-anchor svg` width/height: `1.1em → 0.85em`.
   - Subtler by default; still reveals on heading hover.

Verified: `@docmd/ui` builds cleanly via `tsc`.
…etch, default config

Four small bugs / QoL fixes from Sections C and D of the 0.8.7 backlog:

1. **i18n: partials never received t()** — the layout's menubar,
   options-menu, version-dropdown, language-switcher, project-switcher,
   and toc partials all called `t('…')` but were never passed the
   `t` function (only banner/footer/cookie-consent were). Strings
   like `builtWith`, `next`/`previous`, `selectVersion`,
   `selectLanguage`, `selectProject`, and `onThisPage` silently
   fell back to English even on non-English locales. Added
   `t: typeof t === 'function' ? t : null` to every `include()`
   call in `layout.ejs` (11 partials total).

2. **i18n: 13 missing translation keys** — added to all 7 locale
   files (en/de/es/fr/hi/ja/zh): `close`, `cookieAccept`,
   `cookieConsent`, `cookieDecline`, `cookieMessage`,
   `cookiePolicy`, `goBack`, `lastUpdated`, `pageNotFoundMsg`,
   `readMore`, `recentCommits`, `searchPlaceholder`,
   `selectProject`. Translations provided for each language.

3. **Copy code button floating above codeblock** — the button was
   anchored to an inner `.code-wrapper` created by
   `injectCopyButtons` in `docmd-main.js`, which only wrapped the
   `<pre>`, ignoring the `.docmd-code-block-header` above. Now:
   - `.docmd-code-block-wrapper` is `position: relative` in CSS.
   - `injectCopyButtons` anchors the button to the outer wrapper
     when one exists, skipping the redundant inner wrapper.
   Result: button now sits at the top-right of the whole code block
   (next to or just below the title bar), not floating above it.

4. **Summer template: huge gap below short content** —
   `.summer-content` had `flex: 1 0 auto` which stretched the
   article to fill the grid cell. On short pages, that left a giant
   empty area between the article and the footer. Removed the
   grow; content now takes its natural height. The footer still
   sticks to the bottom via the existing `margin-top: auto`.

5. **Default config values** — added to `config-schema.ts`:
   - `pageNavigation` (top-level) defaults to `true`.
   - `theme.codeHighlight` defaults to `true`.
   `layout.spa` was already defaulted. Override with `false` to
   opt out. Users no longer have to add these to
   `docmd.config.json` to enable them.

Verified: `@docmd/ui`, `@docmd/core`, and `@docmd/template-summer`
all build cleanly via `tsc`.
The `makeFileIcon()` helper in summer.js injected a static SVG
path/polyline string via `icon.innerHTML = '<path .../>...'`. The
content was a hardcoded literal (no user input), so this was a
false-positive in the security audit, but it tripped the
`Potential DOM XSS (innerHTML)` rule in
`scripts/security-audit.mjs` and broke `pnpm prep`.

Refactored to build the SVG with `document.createElementNS` for
the `<path>` and `<polyline>` children — canonical, no audit
noise, and a touch more explicit about what we're creating.

`pnpm prep` now passes all integration tests and the security
audit reports no high-risk patterns.
…s, copyright, light

The user's copy-pasted minimal config revealed several QoL keys
that users had to manually add even though the resulting values
were always the same. Now defaulted in `config-schema.ts`:

Top-level:
- `pageNavigation` (already added previously)
- `copyCode: true` — copy-code buttons on <pre> blocks
- `autoTitleFromH1: true` — use the first H1 as page title

layout:
- `spa: true` (already)
- `breadcrumbs: true` — breadcrumb row above page header
- `header.enabled: true` (already)
- `sidebar.collapsible: true` + `defaultCollapsed: false` (already)
- `optionsMenu.position: 'header'` (already)
- `optionsMenu.components.search: true` + themeSwitch: true (already)
- `optionsMenu.components.sponsor: null` (opt-in, already)
- `footer.style: 'minimal'` (already)
- `footer.copyright: `© ${new Date().getFullYear()}`` (new)
- `footer.branding: true` (already)

theme:
- `name: 'default'` (already)
- `appearance: 'light'` (CHANGED from 'system' — matches the
  user-pasted config; 'system' is still settable by the user)
- `codeHighlight: true` (already added previously)

All other previously-existing defaults preserved. `pnpm prep`
passes (failsafe + integration tests + security audit).
- Default 404 page redesigned with glass card, soft halo, and primary
  'Return home' + ghost 'Go back' buttons (Lucide icons). All strings
  wrapped in t() with English fallbacks; lang/dir set from
  activeLocale for RTL support. errorCode404 now used for the '404'
  glyph for screen-reader consistency.
- .card .card-title: 0.95rem (was 1.125rem), tighter padding/margin.
  Denser feature grids; cards feel less top-heavy in grids containers.
- 3 new translation keys added to all 7 locales: poweredBy, viewSource,
  goToTop. Available for templates and i18n.locales[].translations
  overrides in docmd.config.json.

pnpm prep: 22/22 mega tests, universal failsafe V5.0 passed.
- Halo / glow tokens (--glow-color, --glow-color-strong, --halo-soft,
  --halo-soft-strong) added to :root + :root[data-theme=dark]. Use
  them in card hover, button hover, focus-visible, and step number
  glows to keep the visual language consistent.
- Card hover: tightened shadow + halo, subtle border tint (color-mix),
  reduced lift (-1px from -2px). No more bouncy feel.
- Active sidebar link: replaced the old 'inset 2px border' with a
  modern 3px pill centered vertically on the existing .submenu
  thread border ('node on thread' style). Smaller, calmer, plays
  well with the active-parent rail.
- TOC active: uses --link-color via color-mix for the background
  tint, font-weight 600, rounded right edge.
- Focus-visible: subtle halo via --halo-soft in addition to outline.
  Common interactive elements (a, button, .toc-link, .sidebar nav li a,
  .docmd-button, .docmd-tag) get a unified 0.15s ease transition.
- .docmd-button: halo on hover, transform on active.
- Text contrast bump (light + dark): --text-color and --text-muted
  in both modes are now AA+ (light --text-color #27272a, dark
  --text-color #d4d4d8, dark --text-muted #94949e). Dark
  --link-color brightened to #38bdf8 for better contrast against
  the new darker --bg-color #0d0d0f.

- Steps: each <li> now has id='step-N' (globally unique across the
  page) and a hidden .step-permalink anchor (Lucide link-2 icon,
  same as .heading-anchor) that fades in on hover/focus. scroll-
  margin-top on .step-item lands the anchor below the sticky
  header. Visual numbering is unchanged (CSS counter resets per
  <ol>).

pnpm prep: 22/22 mega tests, universal failsafe V5.0 passed.
Plugins can now mark individual Asset entries as conditional via the
new AssetCondition interface. The asset's <link> / <script> tag is
only emitted on pages where the condition matches the rendered HTML
and/or frontmatter. Unconditional assets keep the legacy behaviour
(included on every page), so this is fully backward compatible.

Supported predicates (all keys AND-ed; values within a key OR-ed):
  - pageHtmlMatches: string | string[]   page HTML substring match
  - frontmatterHas: string                page frontmatter key

The mermaid plugin ships with the new condition turned ON:
  condition: { pageHtmlMatches: 'class="mermaid"' }

This skips the init-mermaid.js <script> on pages that have no mermaid
block, which in turn skips the ~500 KB mermaid library CDN fetch at
runtime. On a 11-page test build, the script now appears on 3/11 pages
(down from 11/11) — the largest single win in the mobile PageSpeed
'Reduce unused JavaScript' estimate (137 KiB across the test site).

API:
  - @docmd/api: new AssetCondition type, re-exported from the
    package root. Asset interface gets an optional  field
    with a JSDoc example.
  - @docmd/core: per-page asset rendering moved from build-once to
    per-page. The onBeforeRender hook now runs BEFORE asset filtering
    so plugins can mutate page.htmlContent / page.frontmatter and the
    conditional filter sees the final state.
Five-part cleanup focused on client weight and the stale version meta:

1. Conditional asset loading for the math plugin
   - @docmd/plugin-math now ships
     condition: { pageHtmlMatches: [class="katex", class="katex-display"] }
   - Skips the ~30 KB katex.min.css fetch on pages with no rendered math
   - Verified: 1/1 playground page with math still loads it, 0/10 others do

2. <meta name=generator> is now dynamic
   - Previously hardcoded "docmd v0.5.x" in three templates even though
     the binary is 0.8.x — stale since the 0.5 era
   - New coreVersion template local threads pkg.version from
     @docmd/core/package.json through build.ts → buildLocales/buildVersions
     → renderPages → template
   - layout.ejs, no-style.ejs, docmd-search.ejs all use <%= coreVersion %>
   - Verified: rendered meta now reads "docmd v0.8.7"

3. CSS weight shed in docmd-main.css (net -13 lines, zero behavior change)
   - Removed 6 declared-but-unused vars:
     --content-area-bg / -radius / -shadow / -border (4, never referenced)
     --halo-soft-strong (light + dark, --halo-soft is what's wired)
     --sponsor-red-hover (light + dark, --sponsor-red is used directly)
   - Removed @Keyframes heartbeat (declared but never referenced)

4. Callout variants dedup
   - 5 near-duplicate rules → 1 base + 4 token overrides
   - Base .callout now uses --callout-color (falls back to transparent /
     var(--code-bg) when no variant is present, so the no-variant case
     still renders as a neutral box)
   - .callout-info / -warning / -success+tip / -danger set --callout-color
   - Border + bg derived from the token via color-mix()
   - Tiny visual tightening on .callout-success / .callout-tip border
     (was 47% alpha #2ecc7178, now fully opaque #2ecc71)

5. Verified mermaid conditional is live
   - 3/11 playground pages get init-mermaid.js (matches the 3 pages with
     class="mermaid" exactly — index.html, mermaid-icon-test/, hi/...)
   - 8/11 pages now skip the ~500 KB mermaid library CDN fetch
   - Same condition API as session 4, just confirming it works end-to-end

22/22 mega tests pass.  pnpm prep ✅.

Docmd-source HTML comment + JS console.log: kept (per creator license
header; ~250 bytes per page, no SEO/perf impact worth removing).
After a full monorepo usage scan, 14 declared classes in summer.css
had zero live emitters. Verified with:
  for c in <14 classes>; do
    grep -rln "\b$c\b" packages/ | grep -v dist
  done
Each returned exactly 1 hit (the stylesheet itself).

Removed (16 rules, 92 lines):
  - .search-filter-pill (5 rules)  — replaced by .docmd-search-filter-pill
  - .summer-search { ... } + :hover/:focus-visible
      — root class never applied; only .summer-search-container is
  - .summer-search__placeholder, .summer-search__kbd (+ kbd descendant)
      — never emitted
  - .summer-search-filters, .summer-search-loading  — never emitted
  - .summer-copy-btn--icon-only  — modifier never applied
  - .summer-pageheader__title  — no such element in layout
  - .summer-pagenav__link--disabled  — modifier never applied
  - .summer-toc__item--level-1/2/3/4  — TOC indentation is set
      inline by summer.js wireTocSvgTrack()

Net: 3416 -> 3325 lines (-91, ~2.7%), zero behavior change.

22/22 mega tests pass.
…ages

On a short page (e.g. nested-page-2/, just 110px of content), .page-navigation
was sitting at the bottom of .main-content (349px) — 131px of empty
space between it and .page-footer-actions, instead of sitting at the
bottom of the content area next to the page-footer-actions (24px gap).

Same problem in summer's .summer-pagenav (no sticky behavior at all
on short pages — just a fixed margin-top: 56px).

Root cause: .main-content / .summer-content were sized to their content
(align-items: flex-start on the row flex parent) and never stretched
to fill .content-area, so margin-top: auto on the page-nav had nothing
to push against.

Fix: let the flex containers fill the available height, and add
align-self: flex-start to the sidebar so it doesn't stretch.

docmd-main.css (default template):
  - .content-layout: align-items: flex-start -> stretch
    (with min-height: 0 so flex children can shrink)
  - .toc-sidebar: add align-self: flex-start (don't stretch the TOC)
  - .main-content already has flex: 1 1 0 — now fills the height
  - .page-navigation already has margin-top: auto — now pushes to
    the bottom of .main-content, which is the bottom of .content-area

summer.css:
  - .summer-main: add flex: 1, min-height: 0 (so it fills the wrapper)
  - .summer-content: add display: flex, flex-direction: column, flex: 1,
    min-height: 0
  - .summer-pagenav: margin-top: 56px -> margin-top: auto

Verified on http://localhost:3000/nested-page-2/ (height 735px, same as
viewport — no scroll). Before: page-nav bottom at y=458, page-footer-
actions top at y=589 (131px gap). After: page-nav bottom at y=565,
page-footer-actions top at y=589 (24px gap, just the 1.5rem margin).

22/22 mega tests pass.
mgks added 28 commits July 15, 2026 22:08
…l MiniSearch, auto-install dedup

URL & assets (#175 follow-up):
- Subpath deploys (GitHub Pages project sites) now use root-relative
  asset/nav URLs (/repo/assets/...) instead of depth-relative paths
  combined with a <base> tag. The <base> tag is no longer emitted for
  non-offline builds — root-relative paths resolve correctly at any page
  depth, work with fetch()/SPA, and don't break hash-links on nested
  pages. Offline builds keep their depth-relative + index.html design
  (#179 fix untouched, separate branch).

Navigation normalisation:
- Local navigation.json is now passed through normalizeNavPaths at load
  time in config-loader, matching the treatment inherited config nav and
  auto-nav already received. Fixes trailing-slash inconsistency between
  sidebar/prev-next and markdown body links in non-i18n projects.

Search — local MiniSearch bundling:
- MiniSearch is now copied from node_modules into site/assets/js/vendor/
  at build time instead of loaded from a CDN at runtime. Makes keyword
  search work in air-gapped, offline, and CDN-blocked environments.
  Version is read dynamically from the installed package — no hardcoded
  version to maintain. Falls back to CDN only if local resolution fails.

Auto-install dedup:
- Worker threads no longer attempt auto-install (isMainThread guard in
  installRuntimeDep). Previously N workers each spawned npm install for
  the same missing plugin, printing the same failure N times.
- Missing package.json now produces a single actionable hint instead of
  N 'unknown error' spawn failures (deduped via module flag).

Tests: 609 passed, 0 failed.
Summer is a full layout override, so the default layout's versionRoot
computation block never runs. Summer read locals.versionRoot which was
never set, so DOCMD_VERSION_ROOT was always '/' — even on sub-version
pages like /05/. This broke the sticky version switcher in docmd-main.js:
the switcher strips the current version prefix (DOCMD_VERSION_ROOT)
from the path to preserve the page suffix when switching versions.
With DOCMD_VERSION_ROOT wrongly set to '/', it couldn't strip '/05/',
so switching from v0.5.0 back to v0.6.0 recomputed the target as
/05/ (same page) and appeared to do nothing.

The fix mirrors the default layout's versionRoot logic inline in
summer's layout. Default template is unaffected (it already computed
this correctly). Tests: 609 passed, 0 failed.
…tall hardening

Three coordinated fixes for the v0.8.15 follow-up (issue #175):

1. Dev mode now forces base=/ when base was auto-derived from url.
   The dev server always serves the output dir at root ('/'), so any
   subpath base (e.g. '/my-repo/' for a GitHub Pages project site) broke
   every asset on localhost. Users were forced to set base:'/' which then
   disabled auto-derivation and broke search on the production deploy.
   The override is restricted to dev and to auto-derived bases; an
   explicit DOCMD_PROJECT_PREFIX still wins (workspace dev uses it).

2. Search data moved from output root into .docmd-search/. Keyword
   search-index.json now lives at <out>/.docmd-search/search-index.json
   (or <out>/.docmd-search/<locale>/search-index.json for non-default
   locales), unifying all search artefacts in one folder and keeping the
   output root clean.

3. Removed the runtime HEAD probe for .docmd-search/manifest.json. The
   probe fired on every keyword-only page load and returned 404, which
   was the original symptom users reported. Semantic mode is now decided
   solely by the build-time data-semantic flag.

4. runtime-deps.ts: removed the pre-check that blocked auto-install
   whenever package.json was missing from cwd. Package managers
   (npm/pnpm/yarn/bun) walk up the tree themselves, so a missing local
   package.json in a workspace sub-project is not a failure case. The
   friendly hint now only fires when the spawn actually fails AND no
   package.json exists in any ancestor directory. Templates are covered
   by the same path as plugins.

Verified in _brutetest/gh-sub-parent with alexhelms's exact config:
- Build (no base): DOCMD_SITE_ROOT = '/my-site/', search-index.json
  at /.docmd-search/, summer and docmd-search auto-install silently.
- Dev (no base): DOCMD_SITE_ROOT = '/', all assets serve at root.
- Search returns results with correct /my-site/ subpath URLs.
…fter install

Two follow-ups for the v0.8.16 search changes:

1. Keyword search-index.json now always generates, even when semantic
   succeeds. Previously the semantic path returned early, skipping keyword
   index generation. If the client's data-semantic flag was stale (deps
   installed in onPostBuild after pages were rendered without the flag),
   the client fell back to keyword mode and 404'd.

2. After semantic indexing succeeds, stamp data-semantic="true" on the
   search modal in every built HTML page. The flag is computed at render
   time from semanticUsable, which checks if docmd-search is resolvable.
   On a fresh install this is false until onPostBuild runs. Stamping the
   flag post-build closes the race without needing the runtime HEAD probe.

Also bumps all monorepo packages to 0.8.16 and fixes the mega test
(search-index.json moved to .docmd-search/).
Three fixes for the auto-install pipeline:

1. Version resolution: query npmjs for the actually-published latest
   version before pinning. When running a pre-release local build (e.g.
   0.8.16 dev tar while npmjs has 0.8.15), the previous code pinned every
   auto-install to 0.8.16 which failed with ETARGET. Now fetches the
   registry's latest tag and uses that, so installs always target a
   version that exists.

2. Dedup: track failed installs per-package in a module-level Set. Dev
   server rebuilds call loadPlugins again, and a persistent failure
   (e.g. version mismatch, network error) won't fix itself between
   rebuilds. Each failed package now prints its error exactly once per
   process. resetInstallState() exposed for explicit user retries.

3. Sourcemap strip: copied .js assets (vendor/minisearch.js etc.) now
   have their //# sourceMappingURL comment stripped at copy time. The
   .map files were never shipped, so the browser 404'd on every page
   load looking for them.

installRuntimeDep is now async to support the npm registry fetch.
1. config-loader: isDev override also accepts DOCMD_DEV env var, so any
   child docmd build process (e.g. semantic search subprocess) inherits
   the same base override as its parent dev server. Previously the
   subprocess ran a plain 'build' which auto-derived the production
   subpath and printed 'base auto-derived from url' in the dev TUI,
   confusing users about the actual serve path.

2. config-schema: declare autoNav: true (default; opt out with false).
   When the user provides no navigation config and no navigation.json,
   the loader falls back to Zero-Config auto-generation. Previously an
   explicit 'navigation: []' blocked auto-generation. Now that is the
   documented 'I have no nav, please generate' signal and 'autoNav:
   false' is the opt-out.

3. search onPostBuild: when spawning the child docmd build to do
   semantic indexing after a fresh install, set DOCMD_DEV=true so the
   base override applies.
…anges

pnpm sim [--build|--dev|--push]

Generates fresh tars from every @docmd/* monorepo package into a
consumer project's local-tars/, runs npm install + docmd build in a
throwaway workdir, and syncs the resulting site/ back so the user
can inspect the live output.

--build  : build-only mode (default; exit after site/ is ready)
--dev    : start docmd dev server in the real consumer dir
--push   : commit + push the consumer (triggers GH Pages deploy)

Variables live at the top of tools/sim.mjs so paths / branch / remote
can be updated in one place. Packages are packed from a /tmp staging
copy with workspace:* refs rewritten — the monorepo working tree is
never mutated.
Renames a GitHub Pages repo without updating docmd.config.json's url
field silently breaks sitemap, canonical, og:url, and auto-derived asset
paths — they keep pointing at the old pathname. The build has no way
to know the deploy host is wrong, so it produces a site that deploys
without 4xx but is internally inconsistent.

Detection: read the git remote origin, extract the repo name (last
pathname segment), compare against the URL pathname's last segment.
Warn only on actual mismatch (e.g. gh-sub-parent vs beta-test), not on
owner/host differences which the URL doesn't encode.

Output names the affected surfaces so the user knows what's stale,
points at the field to update, and does not fail the build — the
user may have a perfectly valid setup without a git remote (CI,
local-only).
The build now emits a single <base href="..."> tag in the document
head for any subpath deploy (siteRootAbs !== '/'). Asset URLs in the
head use simple-relative paths ('assets/foo.css'); the <base> tag
shifts them to the correct location regardless of where the site is
served from. Deploys can move between repos and hosts without code
changes — the URL field stays authoritative, the build recomputes
the base path.

Why this matters:
  v0.8.15 fix the original #175 (#175) by killing <base> entirely
  and using root-relative asset URLs (e.g. '/repo/assets/x'). That
  works but encodes the subpath into every URL. Moving the site
  between repos breaks until you update config.url and rebuild.

  Now: <base href="/repo/"> + simple-relative 'assets/x' =
  '/repo/assets/x' at runtime. Same final URL, no per-URL encoding,
  one tag to change if the deploy location shifts again.

Three-mode boundary:
  • build (non-offline): <base> emitted at the head, assets simple-
    relative ('assets/foo.css'), nav uses buildRelativeUrl() pages.
  • dev: no <base>, assets depth-aware ('./' or '../') matching
    the docmd dev fixed to /. Dev server prints from root.
  • --offline: no <base>, depth-aware paths, file:// works because
    base URLs are inherently host-less.

Implementation:
  • packages/parser/src/utils/url-utils.ts
    - UrlContext gains assetBaseUrl (string) and emitBase (boolean).
      assetBaseUrl is '' when emitting <base>, otherwise page-depth.
    - normaliseBaseTag() rewritten as a canonicaliser: strip all
      existing <base> tags (template, partial, plugin stray), then
      emit exactly one canonical tag right after </title>. Old
      templates that emitted their own <base> get cleaned up here.
      The function was previously a stripper — left in place from
      pre-0.8.15. The new contract: one tag, placed by us, value
      matches siteRootAbs exactly.

  • packages/core/src/engine/generator.ts
    - Thread assetBaseUrl, emitBaseTag, navRootPrefix, siteRootAbs
      into the layout locals.
    - renderAssetList() uses assetBaseUrl instead of relativePathToRoot
      so plugin-emitted tags resolve through the same variable.

  • packages/ui/templates/layout.ejs (default)
  • packages/templates/summer/templates/layout.ejs (summer override)
  • packages/ui/templates/partials/footer.ejs
    - Switch asset URL <link>/<script>/<img> attrs from relativePathToRoot
      to assetBaseUrl. The canonicaliser handles any leftover <base>
      in templates, so this is purely about HTML emit shape.

Verified end-to-end with alexhelms-shaped config at /beta-test/: build
emits <base href='/beta-test/'> + assets/assets/...; every asset URL
loads HTTP 200 from the new simulator; nav, search, theme switch all
work. Dev mode unaffected (no base, depth-relative paths). --offline
mode unaffected (no base, depth-aware paths with index.html suffix).

The 609 existing contract/feature tests still pass (run took ~70s
because the feature-integration tests build real projects). No new
test added here — the existing asset-base-url.test.js exercises the
root-relative path which the new assetBaseUrl = '' slot doesn't
change behaviour for.
When the docmd-main.js SPA initialiser runs, it rewrites every asset
<link> to an absolute URL using:

  new URL(href, window.location.href).href

That re-resolves the RAW (pre-base) attribute string against the page
URL, dropping the <base href> prefix. On a nested page like
/beta-test/guide/, the request becomes /beta-test/guide/assets/css/x
which 404s — even though the document has
<base href='/beta-test/'> and the browser initially resolves assets
correctly via that base.

The DOM property 'link.href' already returns the absolute URL that
the browser computed using <base>. Reading asset.href instead of
asset.getAttribute('href') preserves that resolution.

Same fix applies to the SPA page-swap path (line ~724): re-resolving
newAsset.getAttribute('href') against data.finalUrl strips the base
prefix on nested destinations. Use newAsset.href (already resolved
against the source page's <base>) and clone the element verbatim.

Repro: published site mgks.github.io/beta-test/guide/ — all CSS
requests went to /beta-test/guide/assets/... (404). After this fix
they correctly resolve to /beta-test/assets/... (200).

Verified:
  - cssLinks.anyGuideInUrls = false (no /guide/ in any URL)
  - cssLoaded = 5 of 5 CSS files
  - failedCount = 0
The previous design used simple-relative asset hrefs (e.g. 'assets/x')
relying on the <base href> tag for preloader resolution. But the
HTML preloader (which starts fetching <link href=...> resources
during HTML parse, before <base> is in effect) resolves them against
the page URL, not <base>. On a nested page like /beta-test/guide/,
the preloader fetched /beta-test/guide/assets/x — 404.

The fix: when emitBase is true, assetBaseUrl = base (e.g. '/beta-test/').
This makes asset hrefs root-relative ('/beta-test/assets/x'), which
the preloader handles correctly because root-relative URLs are
origin-relative, independent of <base>.

<base> stays in the document for nav and SPA paths that may use
simple-relative URLs internally, but it is no longer the load-bearing
mechanism for asset fetches. The preloader fix is now the
load-bearing mechanism.

Verified:
  - site/index.html: href='/beta-test/assets/...' (5 of 5 assets)
  - site/guide/index.html: href='/beta-test/assets/...' (5 of 5 assets)
  - No more /beta-test/guide/assets/... paths in the preloader
  - <base href='/beta-test/'> still emitted (verified)

The earlier docmd-main.js fix (use asset.href instead of
getAttribute('href')) is still useful as a defence-in-depth: it
prevents the SPA initialiser from re-resolving root-relative hrefs
against the page URL.
…nd flags

Three problems with the existing prep:

1. The test step streams the runner's full TUI output during the
   multi-minute run. Operator sees a wall of text scrolling past with
   no clear pass/fail signal until the very end.

2. There's no way to skip the slow test pipeline. Lint + build alone
   take ~30s; tests add 60-70s on a warm cache. Opening a PR usually
   only needs lint + build to confirm nothing regressed.

3. No flag to expand a section's detail when investigating a failure —
   the operator has to either re-run the whole prep or dig into logs.

This change:

- runTestStep() now CAPTURES stdout by default and prints one line
  per step ('[ DONE ] 609 passed, 0 failed across 21 files'). If
  a test failed, the captured stdout/stderr is replayed as a tail in
  the Issues block. Streaming only happens with --verbose / --expand.
- New --skip-tests / --fast flag short-circuits the two test steps
  with 'skipped' lines and continues the pipeline.
- New --expand flag streams the runner's full TUI when set (alongside
  the existing --verbose / --full flags).

Prep time: --skip-tests ~49s, full ~120s on warm cache.
The runner wraps every section header in CYAN(...) which prefixes
an ANSI escape sequence to the line. The previous regex started
with [\s│]*, expecting whitespace or the box-drawing char, but the
first byte was an escape. Strip ANSI codes first, then match.

With this fix the progress markers (one per test file the runner
starts) appear under each step's WAIT line as the test runs:

  Categorised test suite (tests/runner.js)  [WAIT]
  │   \u2192 Exit-code contract (F6, M-12)
  │   \u2192 Plugin add/remove across config formats
  │   \u2192 Validate rewrite + workspace errors
  │   \u2192 Offline-mode internal links (#167)
  \u2026
  Categorised test suite (tests/runner.js)  [ DONE ] 609 passed

Each marker tells the operator which test file is currently running
so the multi-minute test suite never looks silent.
On Windows, `path.dirname(new URL(import.meta.url).pathname)` returns
`/C:/Users/...` because URL.pathname keeps the leading slash and the
drive letter prefix. Every downstream fs.existsSync that derives from
this __dirname misses its target, the dev server spins in a tight
microtask loop, and CPU usage pegs to ~90% (issue #182).

`fileURLToPath(import.meta.url)` decodes the URL correctly per-platform
and was the user-supplied fix in the issue thread.

Verified: this is the only occurrence of the unsafe pattern in the
monorepo (grep across packages/).
_playground (the user-facing manual playground):
  - Move from packages/_playground/ to repo root as _playground/.
    Was inside the pnpm workspace which forced 'workspace:*' deps that
    defeat the purpose of a clean consumer-install test. New location
    is outside the workspace and uses file:local-tars/*.tgz deps so it
    installs exactly like a real user would.
  - Add _playground/dev.js: small wrapper that validates tars exist,
    runs npm install on first use, then dispatches to docmd dev / build
    / live. Wired as the source of truth for pnpm dev / build / live.
  - Add _playground/README.md describing the tars-from-prep workflow.
  - Delete packages/_playground/ (private, gitignored outputs included).

Test infrastructure:
  - tests/runner.js: add a per-section progress bar (150 ms tick, 8-frame
    animation) for external test entries that run as subprocesses.
    Streams subprocess output live; clears the progress line on close
    so the PASS/FAIL verdict can claim that row. Converted the main
    loop to async so the in-process entries can also be awaited cleanly.
  - tests/security.test.js + tests/feature-integration.test.js: import
    tests/_lib/build-cache.mjs to memoise successful docmd build output
    by content hash. Second run on unchanged fixtures is instant; first
    run is unchanged. Bypass with DOCMD_TEST_CACHE_OFF=1.
  - tests/_lib/build-cache.mjs is now tracked (was untracked before;
    feature-integration.test.js already imported it).

Tools cleanup:
  - tools/simulate-consumer.mjs deleted (no callers anywhere).
  - scripts/probe-threads-xss.mjs deleted (orphan debug script).
  - tools/sim.mjs: rewritten to take --source=<consumer-dir> instead
    of a hardcoded _brutetest/gh-sub-parent path. Add --regen-tars to
    produce fresh tarballs into <consumer>/local-tars/ (used by
    pnpm prep). Use a /tmp staging copy + versionless filenames so the
    consumer package.json can reference file:local-tars/*.tgz without
    bumping on every version.
  - tools/prep.js: add --docker flag to gate the Docker availability
    check. Add a Consumer Simulation section after Tests that runs
    'sim.mjs --source _playground --regen-tars' to keep the playground's
    local-tars fresh. Keep --skip-tests / --expand / --verbose.
  - tools/docker-prebuild.sh: drop the _playground stub (playground is
    no longer inside packages/).
  - tools/security-audit.mjs: docstring updated to clarify it
    complements tests/security.test.js (static-analysis pass vs runtime
    policy pass — not duplicates).

Root scripts:
  - pnpm dev / build / live now route through _playground/dev.js,
    which validates that local-tars/ exists and prompts the user to run
    'pnpm prep' if it doesn't (the only place tars are produced now).
  - pnpm docmd / docmd-live point at _playground/node_modules/...
    after prep has installed there.
  - Delete the stale 'simulate' / 'simulate:local' echo stubs.
  - pnpm sim routes through 'sim.mjs --source _playground --regen-tars
    --build' for manual dev iteration on monorepo source.

Directory placement:
  - eslint-rules/ -> tools/eslint-rules/. Was a top-level directory
    that contained 3 files for a single in-tree ESLint plugin used
    only by eslint.config.mjs at the root. tools/ is the natural home
    for developer tooling.
  - eslint.config.mjs import path updated to './tools/eslint-rules/...'.
…imulation build; tui: simplify Output/Source paths to relative formats
@mgks
mgks merged commit 36594d3 into main Jul 17, 2026
1 check failed
@mgks
mgks deleted the dev-0.8.16 branch July 18, 2026 08:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant