Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
557 changes: 557 additions & 0 deletions .github/scripts/package-lock.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .github/scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "docs-search-excerpt-smoke",
"private": true,
"description": "Pinned dependency set for search_excerpt_smoke.mjs (run via npm ci --prefix .github/scripts)",
"devDependencies": {
"jsdom": "29.1.1"
}
}
202 changes: 202 additions & 0 deletions .github/scripts/search_excerpt_smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/* Behavioral smoke test for the search-excerpt anchor fix.
*
* Loads the BUILT Sphinx search machinery (doctools.js + searchtools.js) and
* the shipped shims (searchtools-css-escape.js, legacy-fragment-redirect.js)
* into a jsdom window, then drives the exact code path search results use -
* Search.htmlToText(pageHtml, anchor) - against built tutorial HTML for every
* CSS-special anchor class (apostrophe, parentheses+comma, colon, and a
* synthetic legacy digit anchor). Without the CSS.escape wrapper each of
* these throws SyntaxError and the excerpt is dropped; the test asserts a
* non-empty, section-scoped excerpt instead. Also asserts the
* legacy-fragment redirect rewrites a pre-rename numbered hash.
*
* Run from the repo root after a docs build (jsdom is pinned by the
* committed package.json + package-lock.json next to this script):
* npm ci --prefix .github/scripts --ignore-scripts
* node .github/scripts/search_excerpt_smoke.mjs
*/
import { readFileSync, readdirSync } from "node:fs";
import { JSDOM } from "jsdom";

const ROOT = "docs/_build/html";
let failures = 0;

const check = (label, ok, detail) => {
console.log(`${ok ? "PASS" : "FAIL"}: ${label}${detail ? ` - ${detail}` : ""}`);
if (!ok) failures += 1;
};

// jsdom quirks vs browsers: window.CSS is absent (Part 1's env stub inlines
// a minimal spec-subset escaper so the wrapper's guard passes and its logic
// runs against jsdom's real selector engine; "\3<digit> " is the correct
// CSS hex escape for ASCII digits since '0'-'9' are 0x30-0x39), and
// document.readyState stays "loading" until jsdom finishes its async load
// (so shims register DOMContentLoaded listeners - await before asserting).
const domLoaded = (window) =>
new Promise((resolve) => {
if (window.document.readyState !== "loading") resolve();
else window.document.addEventListener("DOMContentLoaded", () => resolve());
});

// --- Part 0: production emit order on the built search page --------------
// The shim relies on being emitted BEFORE searchtools.js (it wraps at
// DOMContentLoaded, and listener order follows script order). Pin that
// order here so a theme/template change that flips it fails CI instead of
// silently racing the wrapper against the search runner.
{
const searchPage = readFileSync(`${ROOT}/search.html`, "utf8");
const shimAt = searchPage.indexOf("searchtools-css-escape.js");
const toolsAt = searchPage.indexOf('src="_static/searchtools.js"');
check(
"search.html emits the shim before searchtools.js",
shimAt !== -1 && toolsAt !== -1 && shimAt < toolsAt,
`shim@${shimAt} searchtools@${toolsAt}`,
);
}

// --- Part 1: Search.htmlToText excerpt rendering (production order) ------
// Reproduces the real page lifecycle instead of a settled-DOM eval: the
// scripts run as inline classic <script> tags in the PRODUCTION order -
// doctools, shim (Search undefined at its parse time), then searchtools -
// while document.readyState is "loading", so the shim and searchtools race
// through DOMContentLoaded exactly as on the live search page. If the
// wrapper loses that race, the special-anchor cases below throw.
// (The full query pipeline is not driven: performSearch defers without a
// loaded searchindex and fetches result pages; Search.htmlToText is the
// excerpt entry point either way.)
{
const inline = (relPath) => {
const src = readFileSync(`${ROOT}/${relPath}`, "utf8");
if (src.includes("</script")) {
throw new Error(`${relPath} contains "</script" - cannot inline safely`);
}
return `<script>${src}</script>`;
};
const envStub =
"<script>window.DOCUMENTATION_OPTIONS = {}; window.CSS = { escape: " +
"(value) => String(value).replace(/[\\0-,./:-@[-^`{-~]|^\\d/g, " +
"(ch) => (ch >= '0' && ch <= '9' ? `\\\\3${ch} ` : `\\\\${ch}`)) };" +
"</script>";
const harnessHtml = [
"<!doctype html><html><head>",
envStub,
inline("_static/doctools.js"),
inline("_static/searchtools-css-escape.js"),
inline("_static/searchtools.js"),
"</head><body></body></html>",
].join("\n");
const dom = new JSDOM(harnessHtml, {
url: "http://localhost/search.html",
runScripts: "dangerously",
});
const { window } = dom;
await domLoaded(window);
const Search = window.eval("Search");

const page = readFileSync(`${ROOT}/tutorials/02_staggered_did.html`, "utf8");
// Expected prefixes are the section heading TEXT (note the typographic
// apostrophe in rendered prose vs the straight one in the id): asserting
// startsWith proves the excerpt is section-scoped, not the whole-page
// fallback searchtools uses when the anchor lookup finds nothing.
const cases = [
["#Callaway-Sant'Anna-Estimator", "Callaway-Sant’Anna Estimator"],
["#Group-Time-Effects-ATT(g,t)", "Group-Time Effects ATT(g,t)"],
[
"#Understanding-Why-TWFE-Fails:-Goodman-Bacon-Decomposition",
"Understanding Why TWFE Fails: Goodman-Bacon Decomposition",
],
["#Aggregating-Effects", "Aggregating Effects"],
];
for (const [anchor, expectedPrefix] of cases) {
try {
const text = Search.htmlToText(page, anchor);
check(
`section-scoped excerpt for ${anchor}`,
typeof text === "string" && text.trim().startsWith(expectedPrefix),
text ? `got ${JSON.stringify(text.trim().slice(0, 60))}` : "empty result",
);
} catch (err) {
check(`section-scoped excerpt for ${anchor}`, false, `threw ${err}`);
}
}

// Legacy digit anchors no longer exist in built pages (headings were
// renumbered) but still arrive via old inbound search/deep links; the
// wrapper must keep them from throwing. Synthetic page keeps this case
// covered independently of the notebook content.
const legacyPage =
'<div role="main"><section id="3.-Legacy-Digit-Heading"><h2>Legacy Digit Heading</h2>' +
"<p>legacy digit section body</p></section></div>";
try {
const text = Search.htmlToText(legacyPage, "#3.-Legacy-Digit-Heading");
check(
"excerpt for synthetic legacy digit anchor",
typeof text === "string" && text.includes("legacy digit section body"),
text ? undefined : "empty result",
);
} catch (err) {
check("excerpt for synthetic legacy digit anchor", false, `threw ${err}`);
}
}

// --- Part 2: legacy fragment redirect ------------------------------------
{
const page = readFileSync(`${ROOT}/tutorials/02_staggered_did.html`, "utf8");
const legacyHash = "#3.-Callaway-Sant'Anna-Estimator";
const dom = new JSDOM(page, {
url: `http://localhost/tutorials/02_staggered_did.html${legacyHash}`,
runScripts: "outside-only",
});
const { window } = dom;
window.eval(readFileSync(`${ROOT}/_static/legacy-fragment-redirect.js`, "utf8"));
await domLoaded(window);
check(
"legacy numbered fragment redirects to renamed target",
window.location.hash === "#Callaway-Sant'Anna-Estimator",
`hash is ${JSON.stringify(window.location.hash)}`,
);

// A hash that still resolves (or matches nothing) must be left alone.
const dom2 = new JSDOM(page, {
url: "http://localhost/tutorials/02_staggered_did.html#Aggregating-Effects",
runScripts: "outside-only",
});
dom2.window.eval(
readFileSync(`${ROOT}/_static/legacy-fragment-redirect.js`, "utf8"),
);
await domLoaded(dom2.window);
check(
"valid fragment left untouched",
dom2.window.location.hash === "#Aggregating-Effects",
`hash is ${JSON.stringify(dom2.window.location.hash)}`,
);
}

// --- Part 3: redirect targets are unambiguous ----------------------------
// The legacy redirect resolves a stripped fragment with getElementById, so
// its correctness rests on ids being unique per page. nbsphinx guarantees
// this today (repeated heading titles - "Background", "Summary" - get an id
// only on their FIRST occurrence, in the pre-rename state too, so no legacy
// numbered fragment ever pointed at a later duplicate). Pin the uniqueness
// so a toolchain change that starts emitting duplicate ids fails here
// instead of silently making redirects ambiguous.
{
const tutorialsDir = `${ROOT}/tutorials`;
let dupPages = 0;
for (const file of readdirSync(tutorialsDir).filter((f) => f.endsWith(".html"))) {
const ids = [...readFileSync(`${tutorialsDir}/${file}`, "utf8").matchAll(/\sid="([^"]+)"/g)]
.map((m) => m[1]);
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) {
dupPages += 1;
console.log(`FAIL: duplicate ids in ${file}: ${[...new Set(dupes)].join(", ")}`);
}
}
check("built tutorial pages have unique ids (unambiguous redirect targets)", dupPages === 0);
}

if (failures) {
console.error(`\n${failures} check(s) failed`);
process.exit(1);
}
console.log("\nall search-excerpt smoke checks passed");
40 changes: 40 additions & 0 deletions .github/workflows/docs-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ on:
# (Python version, apt packages, post_install docs deps).
- '.readthedocs.yaml'
- '.github/workflows/docs-tests.yml'
# Search smoke test: script + pinned jsdom manifest/lockfile.
- '.github/scripts/**'
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
Expand All @@ -36,6 +38,8 @@ on:
# (Python version, apt packages, post_install docs deps).
- '.readthedocs.yaml'
- '.github/workflows/docs-tests.yml'
# Search smoke test: script + pinned jsdom manifest/lockfile.
- '.github/scripts/**'
schedule:
# Weekly Sunday 6am UTC - smoke test that snippets still execute
# against current upstream deps (mirrors notebooks.yml schedule).
Expand Down Expand Up @@ -136,6 +140,42 @@ jobs:
# blocking any new warning from sneaking in.
run: make -C docs html SPHINXOPTS="-W"

- name: Guard search-anchor invariants in built HTML
# Tripwires for the search-excerpt fix (searchtools-css-escape.js):
# 1. No digit-leading section ids may reappear in tutorial pages -
# heading numbers were stripped because searchtools.js crashed
# on them before the shim existed, and numberless headings are
# the documented convention.
# 2. The CSS.escape shim must be emitted on the built search page.
# 3. The shim wraps Search.htmlToText; if a Sphinx upgrade renames
# that hook the shim's feature-guard silently no-ops, so fail
# loudly here and revisit the shim instead.
run: |
test -d docs/_build/html/tutorials
! grep -rE 'id="[0-9]' docs/_build/html/tutorials/
grep -q 'searchtools-css-escape.js' docs/_build/html/search.html
grep -q 'htmlToText' docs/_build/html/_static/searchtools.js

- name: Set up Node for the search smoke test
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
# Current LTS; jsdom's floor is far below this. Pinned so the
# smoke test doesn't float with the runner image default.
node-version: '24'

- name: Behavioral smoke test - search excerpts + legacy fragments
# Runs the BUILT searchtools.js + shims in jsdom and drives the
# exact search-excerpt path (Search.htmlToText) with apostrophe,
# parens, colon, and legacy digit anchors, asserting section-scoped
# excerpts render without SyntaxError; also asserts the
# legacy-fragment redirect rewrites pre-rename numbered hashes.
# Catches a Sphinx/theme upgrade changing the hook's signature or
# invocation semantics, which the greps above cannot. jsdom is
# pinned by the committed .github/scripts/package{,-lock}.json.
run: |
npm ci --prefix .github/scripts --ignore-scripts
node .github/scripts/search_excerpt_smoke.mjs

docs-deps-py39-smoke:
name: Validate docs deps install on Python 3.9 (floor compat)
# Skip unrelated label churn: a non-ready-for-ci label add/remove won't run this job.
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ target/
# Local scripts (not part of package)
scripts/
!.claude/scripts/
!.github/scripts/

# Launch directories (local only)
launch/
Expand Down Expand Up @@ -125,3 +126,9 @@ benchmarks/refresh_2026_07/results/refresh_results_smoke.json
# the golden JSON each generator produces IS committed (benchmarks/data/*.json).
# Root-anchored to the generators' own logs so it doesn't hide logs elsewhere.
/generate_*_golden.log

# Node install tree for the docs search-excerpt smoke test. jsdom is pinned
# by .github/scripts/package.json + package-lock.json (both COMMITTED - do
# not ignore manifests repo-wide); `npm ci --prefix .github/scripts` puts
# node_modules next to them.
node_modules/
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Docs: search-result excerpts fixed and search-as-you-type enabled.** Notebook
section anchors containing CSS-special characters (digit-leading `#3.-Fit-Event-Study`,
apostrophes, parentheses, colons) crashed Sphinx's search-excerpt renderer
(`querySelector` SyntaxError), silently dropping result summaries. A `CSS.escape`
shim (`docs/_static/searchtools-css-escape.js`) now makes every anchor a valid
selector, numeric prefixes are stripped from 185 tutorial headings across 22
notebooks (ordering is conveyed by the grouped tutorials index; prose "Section N"
cross-references reworded to heading names, paper-section citations kept verbatim),
and the theme's `search_as_you_type` live-filtering overlay is enabled. Page paths
are unchanged; the renamed section fragments are kept backward-compatible by a
client-side redirect (`docs/_static/legacy-fragment-redirect.js`) that rewrites old
`#3.-Fit-Event-Study`-style deep links to their unnumbered targets.
- **ImputationDiD leave-one-out SE now anchored against Stata `did_imputation, leaveout`
(no library behavior change).** The Borusyak-Jaravel-Spiess (2024) App. A.9 LOO variance
(`leave_one_out=True`) has no runnable R reference (R `didimputation` omits LOO), so it
Expand Down
45 changes: 45 additions & 0 deletions docs/_static/legacy-fragment-redirect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* Redirect pre-2026-07 numbered section fragments to their renamed targets.
*
* Tutorial headings used to carry numeric prefixes ("## 3. Fit Event Study"),
* giving nbsphinx anchors like "#3.-Fit-Event-Study". The prefixes were
* stripped (the anchors crashed search-excerpt rendering and the numbering
* duplicated the grouped tutorials index), which renamed those fragments to
* "#Fit-Event-Study". Page paths are unchanged; this shim keeps old deep
* links working: when the requested fragment does not exist but the same id
* without a leading "N.-" / "N.M.-" prefix does, it replaces the hash (no
* extra history entry) so the browser scrolls to the renamed section.
*
* Repeated heading titles ("Background", "Summary") cannot make this
* ambiguous: those duplicates were already unnumbered BEFORE the rename
* (no "#N.-Background" fragment ever existed to redirect), and nbsphinx
* emits an id only for the first occurrence, so getElementById has exactly
* one possible target. The search smoke test pins per-page id uniqueness.
*/
(function () {
function migrateLegacyFragment() {
var hash = window.location.hash;
if (!hash || hash.length < 2) return;
var id;
try {
id = decodeURIComponent(hash.slice(1));
} catch (err) {
return;
}
if (document.getElementById(id)) return; // fragment still valid
var m = id.match(/^\d+(?:\.\d+)*\.-(.+)$/);
if (!m) return;
var target = document.getElementById(m[1]);
if (!target) return;
if (window.history && window.history.replaceState) {
window.history.replaceState(null, "", "#" + m[1]);
if (typeof target.scrollIntoView === "function") target.scrollIntoView();
} else {
window.location.replace("#" + m[1]);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", migrateLegacyFragment);
} else {
migrateLegacyFragment();
}
})();
Loading
Loading