Skip to content

Instantly share code, notes, and snippets.

@natea
Created May 11, 2026 13:27
Show Gist options
  • Select an option

  • Save natea/07e449857db62be5df0f268aae31aa83 to your computer and use it in GitHub Desktop.

Select an option

Save natea/07e449857db62be5df0f268aae31aa83 to your computer and use it in GitHub Desktop.
Repository Wiki — generated by GitNexus
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>scta-app — Wiki</title>
<script src="https://cdn.jsdelivr.net/npm/marked@11.0.0/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#ffffff;--sidebar-bg:#f8f9fb;--border:#e5e7eb;
--text:#1e293b;--text-muted:#64748b;--primary:#2563eb;
--primary-soft:#eff6ff;--hover:#f1f5f9;--code-bg:#f1f5f9;
--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08);
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
line-height:1.65;color:var(--text);background:var(--bg)}
.layout{display:flex;min-height:100vh}
.sidebar{width:280px;background:var(--sidebar-bg);border-right:1px solid var(--border);
position:fixed;top:0;left:0;bottom:0;overflow-y:auto;padding:24px 16px;
display:flex;flex-direction:column;z-index:10}
.content{margin-left:280px;flex:1;padding:48px 64px;max-width:960px}
.sidebar-header{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--border)}
.sidebar-title{font-size:16px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px}
.sidebar-title svg{flex-shrink:0}
.sidebar-meta{font-size:11px;color:var(--text-muted);margin-top:6px}
.nav-section{margin-bottom:2px}
.nav-item{display:block;padding:7px 12px;border-radius:var(--radius);cursor:pointer;
font-size:13px;color:var(--text);text-decoration:none;transition:all .15s;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.nav-item:hover{background:var(--hover)}
.nav-item.active{background:var(--primary-soft);color:var(--primary);font-weight:600}
.nav-item.overview{font-weight:600;margin-bottom:4px}
.nav-children{padding-left:14px;border-left:1px solid var(--border);margin-left:12px}
.nav-group-label{font-size:11px;font-weight:600;color:var(--text-muted);
text-transform:uppercase;letter-spacing:.5px;padding:12px 12px 4px;user-select:none}
.sidebar-footer{margin-top:auto;padding-top:16px;border-top:1px solid var(--border);
font-size:11px;color:var(--text-muted);text-align:center}
.content h1{font-size:28px;font-weight:700;margin-bottom:8px;line-height:1.3}
.content h2{font-size:22px;font-weight:600;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
.content h3{font-size:17px;font-weight:600;margin:24px 0 8px}
.content h4{font-size:15px;font-weight:600;margin:20px 0 6px}
.content p{margin:12px 0}
.content ul,.content ol{margin:12px 0 12px 24px}
.content li{margin:4px 0}
.content a{color:var(--primary);text-decoration:none}
.content a:hover{text-decoration:underline}
.content blockquote{border-left:3px solid var(--primary);padding:8px 16px;margin:16px 0;
background:var(--primary-soft);border-radius:0 var(--radius) var(--radius) 0;
color:var(--text-muted);font-size:14px}
.content code{font-family:'SF Mono',Consolas,'Courier New',monospace;font-size:13px;
background:var(--code-bg);padding:2px 6px;border-radius:4px}
.content pre{background:#1e293b;color:#e2e8f0;border-radius:var(--radius);padding:16px;
overflow-x:auto;margin:16px 0}
.content pre code{background:none;padding:0;font-size:13px;line-height:1.6;color:inherit}
.content table{border-collapse:collapse;width:100%;margin:16px 0}
.content th,.content td{border:1px solid var(--border);padding:8px 12px;text-align:left;font-size:14px}
.content th{background:var(--sidebar-bg);font-weight:600}
.content img{max-width:100%;border-radius:var(--radius)}
.content hr{border:none;border-top:1px solid var(--border);margin:32px 0}
.content .mermaid{margin:20px 0;text-align:center}
.menu-toggle{display:none;position:fixed;top:12px;left:12px;z-index:20;
background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
padding:8px 12px;cursor:pointer;font-size:18px;box-shadow:var(--shadow)}
@media(max-width:768px){
.sidebar{transform:translateX(-100%);transition:transform .2s}
.sidebar.open{transform:translateX(0);box-shadow:2px 0 12px rgba(0,0,0,.1)}
.content{margin-left:0;padding:24px 20px;padding-top:56px}
.menu-toggle{display:block}
}
.empty-state{text-align:center;padding:80px 20px;color:var(--text-muted)}
.empty-state h2{font-size:20px;margin-bottom:8px;border:none}
</style>
</head>
<body>
<button class="menu-toggle" id="menu-toggle" aria-label="Toggle menu">&#9776;</button>
<div class="layout">
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
scta-app
</div>
<div class="sidebar-meta" id="meta-info"></div>
</div>
<div id="nav-tree"></div>
<div class="sidebar-footer">Generated by GitNexus</div>
</nav>
<main class="content" id="content">
<div class="empty-state"><h2>Loading…</h2></div>
</main>
</div>
<script>
var PAGES = {"application-core":"# Application Core\n\n# Application Core\n\n## Overview\n\nThe Application Core defines the packaging, deployment, and HTTP routing infrastructure for the SCTA (Scholastic Commentaries and Texts Archive) eXist-db application. It consists of four files that together describe what the application is, how it gets built and installed, and how incoming HTTP requests are dispatched to the appropriate XQuery handlers.\n\n---\n\n## Package Metadata\n\nThe application is packaged as an [EXPath package](http://expath.org/ns/pkg), a standard format for distributing eXist-db applications.\n\n**`expath-pkg.xml`** declares the package identity:\n\n- **Name URI**: `http://scta.info/apps/scta-app`\n- **Abbreviation**: `scta-app`\n- **Version**: `0.2`\n- **Dependency**: `http://exist-db.org/apps/shared` (eXist-db shared resources)\n\n**`repo.xml`** provides eXist-db repository metadata, including:\n\n- **Install target**: `/db/apps/scta-app`\n- **Pre-install hook**: `pre-install.xql` (runs before deployment)\n- **Permissions**: `rw-rw-r--`, owned by `scta-user:scta-group`\n- **Status**: `alpha`\n- **License**: GNU-LGPL\n\n---\n\n## Build\n\n**`build.xml`** is an Ant build file with a single target, `xar`, that packages the application into a deployable `.xar` archive:\n\n```xml\n<zip basedir=\".\" destfile=\"build/scta-app-{version}.xar\" excludes=\"build/*\"/>\n```\n\nThe version is read from `expath-pkg.xml` at build time via `<xmlproperty>`. Running `ant` (or `ant xar`) in the project root produces `build/scta-app-0.2.xar`, which can be uploaded directly to eXist-db's Package Manager.\n\n---\n\n## Request Routing\n\n**`controller.xql`** is the eXist-db URL rewriter. It inspects every incoming request path and either forwards it to the appropriate XQuery handler or passes it through unchanged. The controller receives three key variables from the eXist-db runtime:\n\n| Variable | Description |\n|---|---|\n| `$exist:path` | The request path after the app's mount point |\n| `$exist:controller` | Absolute path to the controller's directory |\n| `$exist:resource` | The final path segment |\n| `$exist:prefix` | The URL prefix for the mounted app |\n\n### Route Table\n\n| Path Pattern | Handler | Key Parameter |\n|---|---|---|\n| `/iiif/{manifestationid}/search` | `iiif/iiifsearch-with-paging.xq` | `manifestationid` |\n| `/iiif2/{codexid}/search` | `iiifsearch-with-paging-line-level-from-simpleXmlCoordinates2.xq` | `codex` |\n| `/document/{transcriptionid}` | `doc/constructor.xq` | `transcriptionid` |\n| `/text/{filepath}` | `../scta-data/{filepath}` (passthrough) | — |\n| `/csv/{transcriptionid}` | `doc/csv.xq` or `doc/csv-json.xq` | `transcriptionid`, `format` |\n| `/search/author/{authorid}` | `search/search-text-by-author.xq` | `authorid`, `query` |\n| `/search/expressiontype/{id}` | `search/search-text-by-expressiontype.xq` | `expression_type_id`, `query` |\n| `/search/workgroup/{id}` | `search/search-text-by-workgroupid.xq` | `workGroupId`, `query` |\n| `/search/expression/{id}` | `search/search-text-by-expressionid.xq` | `expressionid`, `query` |\n| *(all others)* | `<ignore>` (no-cache passthrough) | — |\n\n### Routing Logic\n\n```mermaid\ngraph TD\n R[Incoming Request] --> A{/iiif/?}\n A -->|yes| B[iiifsearch-with-paging.xq]\n A -->|no| C{/iiif2/?}\n C -->|yes| D[iiifsearch-with-paging-line-level.xq]\n C -->|no| E{/document/?}\n E -->|yes| F[doc/constructor.xq]\n E -->|no| G{/text/ /csv/ /search/*}\n G -->|matched| H[Respective handler]\n G -->|no match| I[Ignore / passthrough]\n```\n\n### IIIF vs IIIF2\n\nTwo separate IIIF search endpoints coexist with different coordinate models:\n\n- `/iiif/` routes use **manifestation-level** identifiers and delegate to `iiifsearch-with-paging.xq`\n- `/iiif2/` routes use **codex-level** identifiers and delegate to a handler using \"simple XML coordinates v2\" — a newer coordinate scheme for line-level results\n\n### `/text/` Passthrough\n\nThe `/text/` route is the only one that crosses application boundaries. It sets a CORS header (`Access-Control-Allow-Origin: *`) and forwards directly into the sibling `scta-data` collection:\n\n```xquery\n<forward url=\"{$exist:controller}/../scta-data/{$filepath}\"/>\n```\n\nThis means raw text files stored in `scta-data` are served directly under the `/text/` namespace without additional processing.\n\n### Search Routes\n\nAll search routes extract a resource identifier from the URL path and a `query` parameter from the request (defaulting to `'quod'`). The identifier is passed as an `add-parameter` element on the forward dispatch; the query is passed as a `set-attribute`. This distinction matters: `add-parameter` appends to the HTTP request parameters visible to the handler, while `set-attribute` sets a request-scoped attribute.\n\n---\n\n## Adding a New Route\n\n1. Add a new `else if (starts-with($exist:path, '/your-prefix/'))` branch in `controller.xql` before the final `else` clause.\n2. Extract any path segments with `substring-after` or `tokenize`.\n3. Return a `<dispatch>` element pointing to the handler XQuery, forwarding required parameters via `<add-parameter>`.\n4. Create the handler XQuery at the path referenced in the `<forward url=\"...\">`.","csv-export":"# CSV Export\n\n# CSV Export Module\n\nThese XQuery scripts extract plain text from SCTA TEI-XML transcriptions and serve it in formats suitable for NLP pipelines, corpus analysis, and data export. Each script is a self-contained eXist-db endpoint. They share the same core text-extraction logic but differ in their input parameters, SPARQL query shape, and output format.\n\n## Scripts at a Glance\n\n| Script | Input parameter | Output | Notes |\n|---|---|---|---|\n| `csv.xq` | `transcriptionid` | CSV or plain text | Main production endpoint |\n| `csv-canonical.xq` | `eid` (expression ID) | CSV or plain text | Works from expression layer, resolves canonical transcription |\n| `csv-json.xq` | `transcriptionid` | JSON array of `{block, text}` | Structured output for API consumers |\n| `csv-pct.xq` | `resourceid` (single resource) | Plain text | Single-paragraph scope, punctuation always preserved |\n| `csv-pct-noquote.xq` | `resourceid` (single resource) | Plain text | Like `csv-pct.xq` but with controllable quote inclusion |\n| `doc/csv-old.xq` | `transcriptionid` | CSV | Legacy; no `show_quote`/`show_pct` params |\n\n`csv-pct.xq` and `csv-pct-noquote.xq` live at the top level and operate on a single SCTA resource URI. The `doc/` variants operate on a transcription or expression identifier and walk all structure items beneath it.\n\n## Architecture\n\n```mermaid\nflowchart LR\n Client -->|HTTP params| Script\n Script -->|SPARQL| Triplestore[(SPARQL Endpoint)]\n Triplestore -->|item URIs| Script\n Script -->|doc()| eXist[(eXist-db XML)]\n eXist -->|TEI XML| Script\n Script -->|plain text / CSV / JSON| Client\n```\n\nEvery script follows the same three-phase execution:\n\n1. **SPARQL lookup** — resolve the input identifier to one or more `structureItem` transcription URIs and determine the corpus root (`topLevelTranscription` or `cid`).\n2. **Document load** — construct a filesystem path of the form `/db/apps/scta-data/{cid}/{itemid}/{fileid}.xml` and open the TEI document with `doc()`.\n3. **Render and serialize** — walk `tei:p` elements through `local:render` / `local:removePunctation`, then emit each paragraph as a CSV row, a plain text line, or a JSON map.\n\n## Core Functions\n\n### `local:render($node, $show_quote, $show_pct)`\n\nA recursive `typeswitch` dispatcher. It walks any TEI node and produces a normalized intermediate XML fragment. The critical decisions:\n\n- **`tei:quote`** — included only when `$show_quote eq 'true'`; when both `$show_quote` and `$show_pct` are true, the quote text is wrapped in typographic quotation marks.\n- **`tei:corr`** — included only when its parent element is `lem` (a critical apparatus lemma). Standalone corrections are suppressed.\n- **Suppressed entirely**: `tei:rdg`, `tei:bibl`, `tei:note`, `tei:orig`, `tei:del`, `tei:lb`, `tei:head`. These carry editorial or structural metadata that should not appear in a plain-text corpus.\n- **Passed through as inline content**: `tei:ref`, `tei:name`, `tei:div`, `tei:p`.\n\nThe older `csv-old.xq` applies `local:removePunctation` inside `render` at the text-node level (per character), whereas all current scripts call it after the full render pass on the serialized string. Do not replicate the old approach.\n\n### `local:recurse($node, $show_quote, $show_pct)`\n\nCalls `local:render` on each child node. Exists to break the mutual recursion cleanly. There is no meaningful logic here.\n\n### `local:removePunctation($string, $show_pct)`\n\nNormalizes the serialized text string before output.\n\n- When `$show_pct eq 'true'`: collapses runs of non-letter/non-digit/non-punctuation characters that follow whitespace — preserves punctuation characters but strips stray glyphs.\n- When `$show_pct eq 'false'`: replaces any non-letter/non-digit sequence with a single space — produces a bag-of-words-friendly string.\n- A second pass with `replace($clean, '\\s+', ' ')` collapses all remaining whitespace.\n\n`csv-pct.xq` has an older single-mode version of this function that also strips `¶` and `/` explicitly. That variant is only used in that one file.\n\n### `local:getSparqlQuery($id)`\n\nConstructs a SPARQL `SELECT` query as a string. The query resolves the given identifier — which may be at any level of the SCTA structure hierarchy (collection, item, division, block, element) — to the `structureItem`-level transcription URIs needed to open XML files. It uses `UNION`/`OPTIONAL` branches to handle each structural level.\n\n`csv-canonical.xq` uses an expression-layer variant that follows `hasCanonicalManifestation` → `hasCanonicalTranscription` to land on the canonical transcription rather than any specific witness. `csv-json.xq` also includes `?order` for sorted results.\n\n## Request Parameters\n\n### `csv.xq` and `csv-json.xq`\n\n| Parameter | Default | Description |\n|---|---|---|\n| `transcriptionid` | `lectio1/critical/transcription` | Short transcription path (e.g. `workid/critical/transcription`) |\n| `quote` | `true` | Include `tei:quote` content |\n| `pct` | `false` | Preserve punctuation characters |\n| `format` | `json` | `json` for CSV rows; `text` for plain text only |\n\n### `csv-canonical.xq`\n\n| Parameter | Default | Description |\n|---|---|---|\n| `eid` | `plaoulreportatio` | SCTA expression short ID |\n| `quote` | `true` | Include `tei:quote` content |\n| `pct` | `false` | Preserve punctuation characters |\n| `format` | `json` | `json` for CSV rows; `text` for plain text only |\n\n### `csv-pct.xq` and `csv-pct-noquote.xq`\n\n| Parameter | Default | Description |\n|---|---|---|\n| `resourceid` | `http://scta.info/resource/l1-cpspfs/reims/transcription` | Full SCTA resource URI |\n| `quote` | `true` (`csv-pct-noquote.xq` only) | Include `tei:quote` content |\n| `pct` | `true` (`csv-pct-noquote.xq` only) | Preserve punctuation characters |\n\n`csv-pct.xq` does not expose `quote` or `pct` parameters — it always preserves punctuation and always includes quotes. `csv-pct-noquote.xq` makes both configurable.\n\nThe single-resource scripts parse the `resourceid` URI to extract the `pid` (paragraph `xml:id`), then query for the parent `structureItem` and load the document directly. They emit only the text of the matching paragraph element rather than iterating all `tei:p` in a document.\n\n## Output Formats\n\n**CSV** (`format=json`, despite the parameter name):\n```\nparagraphId,paragraph text content, corpusId\\n\n```\nEach row is one `tei:p`. The `cid` is the top-level corpus identifier (e.g. `plaoulreportatio`).\n\n**Plain text** (`format=text`):\nEach paragraph's cleaned text on its own, no wrapping. Used when piping directly into NLP tooling.\n\n**JSON** (`csv-json.xq`):\n```json\n{\"block\": \"http://scta.info/resource/paragraphId\", \"text\": \"cleaned text\"}\n```\nEach paragraph becomes a map with a fully-qualified SCTA URI as the block identifier.\n\n## File Path Resolution\n\nThe eXist-db document path is built from two components derived from the SPARQL result:\n\n- `$cid` — the corpus root, taken from the `topLevelTranscription` URI after `/resource/`\n- `$itemid` / `$fileid` — derived from the `item` URI\n\n```\n/db/apps/scta-data/{cid}/{itemid}/{fileid}.xml\n```\n\n`$fileid` is either `critical` (for critical transcriptions stored flat) or `{witnessSlug}_{itemid}` for witness-specific files. The `if ($url-array = \"critical\")` check distinguishes these cases.\n\nFor top-level collections (`$level eq '1'`), `$eid` or `$expression_short_id` is used as the path root instead of `$cid`, and is also substituted back as `$cid` for the output row — ensuring the CSV corpus column correctly names the collection rather than an empty string.\n\n## Relationship Between Scripts\n\n`csv-pct.xq` → `csv-pct-noquote.xq` adds `$show_quote` and `$show_pct` parameters and passes them through `local:render` and `local:removePunctation`. If you need to change text normalization behavior, the parameterized versions in `csv.xq` / `csv-canonical.xq` / `csv-json.xq` / `csv-pct-noquote.xq` are the ones to modify. `csv-old.xq` and `csv-pct.xq` are effectively frozen.\n\n`csv.xq` and `csv-canonical.xq` share identical `local:render` and `local:removePunctation` implementations. The only difference is that `csv-canonical.xq` accepts an expression ID and resolves through `hasCanonicalManifestation` in SPARQL, while `csv.xq` accepts a transcription ID directly.","document-construction":"# Document Construction\n\n# Document Construction Module\n\n## Overview\n\nThis module consists of three XQuery scripts that assemble multi-part scholarly texts from distributed TEI XML sources stored in eXist-db. The core problem each script solves is the same: a scholastic text in the SCTA data model is not stored as a single file — it is broken into many small XML documents organized by `structureItem` (typically a single question, distinction, or lecture). These scripts query the SCTA SPARQL endpoint to discover the correct set of files, then pull them from eXist-db and stitch them into one coherent document.\n\nThe three scripts represent three output variants of the same underlying assembly pattern:\n\n| Script | Input parameter | Output |\n|---|---|---|\n| `constructor.xq` | `transcriptionid` | Bare TEI `<body>` fragment |\n| `constructor-canonical.xq` | `eid` (expression ID) | Full TEI document with rich `<teiHeader>` |\n| `tei-to-json.xq` | `transcriptionid` | JSON representation of the assembled TEI |\n\n---\n\n## The SCTA Data Model (necessary context)\n\nBefore reading this code, you need to understand how the SCTA organizes texts. Every resource lives at one of these structural levels:\n\n- **structureCollection** — a collection of works (e.g. an author's complete commentary)\n- **structureItem** — a single discrete text unit (a distinction, question, or lecture)\n- **structureBlock** — a paragraph or block within an item\n- **structureElement** — a fine-grained element within a block\n\nEach expression has a **manifestation** (a particular version of the text) and each manifestation has a **transcription** (the actual XML file). The canonical constructor works at the expression level; the other two work at the transcription level.\n\nFile storage in eXist-db follows the pattern:\n\n```\n/db/apps/scta-data/{collection-id}/{item-id}/{file-id}.xml\n```\n\nwhere `file-id` is typically `{transcription-slug}_{item-id}` (e.g. `vat_lectio1`), except for critical editions where it is just `critical`.\n\n---\n\n## How Assembly Works\n\nAll three scripts share the same pipeline:\n\n```mermaid\ngraph LR\n A[HTTP request\\nwith ID param] --> B[Build SPARQL query]\n B --> C[Query SPARQL endpoint]\n C --> D[For each result row\\nresolve file path]\n D --> E[doc() from eXist-db]\n E --> F[Extract div by type]\n F --> G[Emit output]\n```\n\n### Step 1: SPARQL Discovery via `local:getSparqlQuery`\n\nEach script defines a `local:getSparqlQuery` function that builds a SPARQL SELECT query parameterized on the resource ID. The query uses four `OPTIONAL` clauses — one for each structural level the input resource might occupy:\n\n- **Top-level collection** — binds `?level` and walks `hasStructureItem` to find child items\n- **Non-top-level collection** — same `hasStructureItem` walk plus `isPartOfTopLevelTranscription/Expression`\n- **Division or block** — uses `isPartOfStructureItem` to find the containing item\n- **Element** — uses `isPartOfStructureBlock` → `isPartOfStructureItem` to walk up two levels\n\nBecause all four are `OPTIONAL`, exactly one will bind for any valid input. The query always returns `?type`, `?item`, and `?topLevelTranscription` (or `?topLevel` in the canonical variant). The canonical query additionally returns author metadata (`?author`, `?authorTitle`, `?wikiDataId`, `?title`) to populate the `<teiHeader>`.\n\nThe `ORDER BY ?totalOrder` clause in the canonical query ensures that when a collection-level ID is given, items appear in reading order.\n\n### Step 2: File Path Resolution\n\nFor each SPARQL result row, the script derives the eXist-db file path through a series of string manipulations on the returned URIs:\n\n```xquery\n(: Strip the /resource/ prefix and split on \"/\" :)\nlet $url-array := fn:tokenize(\n substring-after($result/sparql:binding[@name=\"item\"]/sparql:uri/text(), \"/resource/\"), \n \"/\"\n)\nlet $itemid := $url-array[1]\nlet $fileid := if ($url-array = \"critical\") then $url-array[1] \n else concat($url-array[2], \"_\", $url-array[1])\n```\n\nWhen the resource is itself a `structureItem`, there is no separate `?item` binding — the script falls back to splitting the input ID directly.\n\nThe collection ID (`$cid`) is extracted from `?topLevelTranscription` / `?topLevel` the same way. A special case handles top-level collections (`$level eq '1'`), where the expression short ID is used as the collection root instead of `$cid`.\n\n### Step 3: Content Extraction\n\nOnce the document is loaded with `doc()`, the relevant XML is selected based on `$type`:\n\n```xquery\nlet $div :=\n if ($type eq \"structureCollection\") then\n $doc/tei:TEI/tei:text/tei:body/tei:div\n else if ($type eq \"structureItem\") then\n $doc/tei:TEI/tei:text/tei:body/tei:div\n else\n $doc/tei:TEI/tei:text/tei:body//*[@xml:id=$expression_short_id]\n```\n\nFor collections and items, the entire `<div>` is taken. For finer-grained resources (blocks, elements), the script searches the document for an element whose `@xml:id` matches the short ID extracted from the input parameter.\n\n---\n\n## Script Differences\n\n### `constructor.xq`\n\nThe baseline script. Outputs only the assembled `<body>` content wrapped in a minimal TEI skeleton. The `<teiHeader>` contains placeholder `<p/>` elements. Use this when you need the raw content and will handle metadata separately.\n\n### `constructor-canonical.xq`\n\nThe production variant. Adds a fully populated `<teiHeader>` with:\n- `<title>` and `<author>` drawn from SPARQL, with `@ref` pointing to the SCTA resource URI and the author's WikiData URI respectively\n- `<publicationStmt>` declaring CC BY-NC-SA 4.0\n- `<seriesStmt>` identifying the SCTA\n- `<editionStmt>` explaining that this is an auto-compiled canonical edition\n\nEach assembled section is also wrapped in a `<div xml:id=\"{itemid}-wrapper\">` with a `<head>` containing the item's `longTitle`, giving the output a navigable structure.\n\nThe canonical script takes an expression ID (`eid`) rather than a transcription ID, and traverses the expression → manifestation → transcription chain in SPARQL (`hasCanonicalManifestation` / `hasCanonicalTranscription`) to find the best available text at the time of compilation.\n\n### `tei-to-json.xq`\n\nExtends `constructor.xq` with two additional functions:\n\n**`local:remove-lb($node)`** — Walks the XML tree recursively and strips `<lb>`, `<cb>`, and `<pb>` elements before conversion. These milestone elements interrupt text nodes mid-word, which corrupts word-range calculations in downstream consumers. The function uses `typeswitch` to dispatch on node type, preserving all attributes and non-milestone children.\n\n**`local:jsonify($nodes)`** — Converts the cleaned XML tree to a JSON array of maps using XQuery 3.1 arrays and maps. Each node becomes a map with `\"node-type\"` as the discriminator:\n- `document-node` → `{ \"node-type\", \"child-nodes\" }`\n- `element` → `{ \"node-type\", \"node-name\", \"attributes\": [{name, value}], \"child-nodes\" }`\n- `text` → `{ \"node-type\", \"value\" }`\n\nThe output is JSON (declared via `output:method \"json\"`), making it suitable for JavaScript clients that need to traverse the document tree or compute word offsets.\n\nThe script also accepts two additional query parameters not present in the XML variants:\n- `quote` (default `true`) — intended for controlling quote display (not yet implemented in the visible code)\n- `pct` (default `false`) — likely for percentage-based positioning\n\n---\n\n## Query Parameters\n\n| Parameter | Script(s) | Default | Description |\n|---|---|---|---|\n| `transcriptionid` | `constructor.xq`, `tei-to-json.xq` | `plaoulcommentary/vat/transcription` | SCTA transcription resource path |\n| `eid` | `constructor-canonical.xq` | `plaoulreportatio` | SCTA expression resource ID |\n| `quote` | `tei-to-json.xq` | `true` | Quote display control (reserved) |\n| `pct` | `tei-to-json.xq` | `false` | Percentage positioning (reserved) |\n| `format` | `tei-to-json.xq` | `json` | Output format selector (reserved) |\n\nAll scripts set `Access-Control-Allow-Origin: *`, making them callable directly from browser clients.\n\n---\n\n## Known Issues and Fragility\n\n**Redundant `$doc` branches.** All three scripts contain a `let $doc := if...` chain where the `structureCollection`, `structureItem`, and default branches all resolve to the same path (`/db/apps/scta-data/{cid}/{itemid}/{fileid}.xml`). This is dead code — the `$level eq '1'` branch is the only meaningfully distinct path. Cleaning this up would require verifying that no top-level collection uses a different path scheme.\n\n**`local:remove-lb` comment in source.** The function body in `tei-to-json.xq` contains a commented-out alternative approach for text-node concatenation after `<lb>` removal. The current approach simply drops milestone elements; the intended long-term solution may need to preserve spacing at break points.\n\n**SPARQL endpoint is hardcoded.** The production endpoint `http://sparql-docker.scta.info/ds/query` is hardcoded with a commented-out localhost alternative. There is no environment-based configuration.\n\n**`$cid` shadowing in `tei-to-json.xq`.** The variable `$cid` is assigned twice in the main for-loop body — once from the SPARQL result and once with a level-1 override. The second assignment shadows the first, which is the intended behavior, but it is easy to misread.","folio-annotation":"# Folio Annotation\n\n# Folio Annotation Module\n\nThis module is a collection of XQuery scripts running on eXist-db that serve IIIF Annotation Lists for manuscript folio surfaces. Each script is a distinct endpoint or experiment in the same problem space: given a manuscript surface identifier, retrieve the transcription text for that folio and return it as a IIIF-compliant JSON (or XML) annotation list that IIIF viewers can consume.\n\n## Background\n\nThe [SCTA](http://scta.info) (Scholastic Commentaries and Texts Archive) encodes medieval manuscript transcriptions in TEI XML and exposes them through IIIF. A viewer like Universal Viewer or Mirador requests an Annotation List for a given canvas, and these endpoints fulfill that request — linking transcribed text segments back to regions on the manuscript image.\n\n## Scripts\n\n### `folio-annotation-list.xq` (primary endpoint)\n\nThe canonical production endpoint. It:\n\n1. Accepts a `surface_id` query parameter (default: `http://scta.info/resource/sorb/20r`)\n2. Queries the SCTA SPARQL endpoint (`http://sparql-docker.scta.info/ds/query`) to find all manifestation items on that surface, ordered by `totalOrderNumber`\n3. For each SPARQL result, constructs a path into the eXist-db TEI corpus at `/db/apps/scta-data/{dir}/{fs}/{prefix}_{fs}.xml`\n4. Extracts the folio fragment using `util:get-fragment-between`, with boundary nodes determined by the document's LBP schema version\n5. Renders the fragment via `local:render` and emits one `oa:Annotation` per manifestation item\n\n**Request parameters**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `surface_id` | `http://scta.info/resource/sorb/20r` | Full SCTA URI for the surface |\n\n**Response shape** (IIIF Presentation API 2.x):\n\n```json\n{\n \"@context\": \"http://iiif.io/api/presentation/2/context.jsonld\",\n \"@type\": \"sc:AnnotationList\",\n \"within\": { \"@type\": \"sc:Layer\", \"label\": \"Diplomatic Transcription\" },\n \"resources\": [\n {\n \"@type\": \"oa:Annotation\",\n \"motivation\": \"sc:painting\",\n \"resource\": { \"@type\": \"dctypes:Text\", \"chars\": \"...\" },\n \"on\": \"{canvasId}#xywh=0,{offset},300,200\"\n }\n ]\n}\n```\n\nNote: coordinates in this version are synthetic — `xywh=0,{count*200+10},300,200` — not real image coordinates.\n\n---\n\n### `folio-annotation-list-with-coordinates.xq`\n\nIdentical SPARQL query and fragment extraction logic, but targets a **local Fuseki instance** at `http://localhost:3030/ds/query` rather than the production SPARQL server. Intended for development. Coordinates remain synthetic. The `local:render` function handles `tei:lb` and `tei:cb` as human-readable HTML strings (`<br/> * `, `<br/> [column break]`) rather than stripping them.\n\n---\n\n### `folio-annotation-list-with-coordinates2.xq`\n\nThe most structurally ambitious of the set. Rather than emitting one annotation per manifestation item, it **splits the transcription into individual lines** and emits one annotation per physical line, with real image coordinates pulled from a coordinate document.\n\nKey additions over the base script:\n\n- `local:getLineArray($textstring)` — splits a rendered text string on the `///` sentinel that `local:render` emits for `tei:lb` elements\n- For each line, opens a coordinate document at `/db/apps/scta-data/simpleXmlCoordinates/{prefix}/L{surface_title}.xml` and reads the `<iiif>` element for the line id (`r1l{position}`)\n- The `on` value becomes `{canvasId}#xywh={real_coordinates}` instead of a synthetic offset\n\nKnown issues documented in the source:\n- `tokenize` produces a leading empty string, so `$pos != 1` guards are required\n- Coordinate file naming is hardcoded for gracilis; other texts require generalization\n- The local Fuseki instance is required (HTTPS SPARQL calls fail)\n\n---\n\n### `folio-annotaiton-list-from-simpleXmlCoordinates.xq`\n\nA separate approach that **bypasses SPARQL entirely**. It reads directly from pre-processed coordinate XML files in `/db/apps/simpleXmlCoordinates/` (different from the path used by `coordinates2`).\n\n**Request parameters**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `surfaceid` | `lon/12v` | Slash-delimited path within `simpleXmlCoordinates` |\n| `coords` | `tight` | `tight` reads `<new:iiif>`, anything else reads `<new:iiifAdjusted>` |\n\nEach `<new:line>` element in the coordinate document is mapped to one annotation. The `on` value is constructed from `<new:canvasId>` and `<new:iiif>` (or `<new:iiifAdjusted>`). This approach produces real per-line coordinates without any SPARQL dependency, but requires the coordinate files to already exist and carry embedded text in `<new:text>`.\n\nThe response follows IIIF Search API 0 context (`http://iiif.io/api/search/0/context.json`) rather than the Presentation API 2.x context used by the other scripts.\n\n---\n\n### `folio-annotation-list-old.xq`\n\nAn incomplete historical artifact. The `local:getSparqlQuery` function has a syntax error (missing closing quote on `$query`), and the main body is almost entirely commented out. Keep it only as a reference for the original design intent; do not deploy it.\n\n---\n\n### `folio-lines-xml.xq`\n\nReturns **XML** instead of JSON. Rather than an IIIF Annotation List, it produces a `<div>` in the `http://scta.info/ns/xml-lines` namespace containing `<line n=\"{lineNumber}\" fs=\"{fs}\">` elements with `<word n=\"{position}\">` children.\n\nNotable differences from the JSON scripts:\n- `local:render` emits `/// {lb/@n}-{lb/@type} ` for `tei:lb`, so line numbers are preserved in the rendered string and can be parsed back out with `tokenize($line, \" \")[2]`\n- Uses `fn:parse-xml` instead of `util:parse` and clips malformed closing-tag duplication with a regex replace before parsing\n- Surface and next-surface identifiers are derived from the surface URI's last path segment rather than the SPARQL `surface_title` literal, which is more robust when the RDF label doesn't match TEI `@n` values\n- Uses the production SPARQL endpoint (`https://sparql-docker.scta.info`)\n\n---\n\n## Shared Logic\n\nAll scripts that deal with fragment extraction share the same pattern:\n\n```mermaid\nflowchart LR\n A[surface_id] --> B[SPARQL query]\n B --> C[short_id / topLevelExpression_short_id]\n C --> D[construct eXist-db doc path]\n D --> E[detect LBP schema version]\n E --> F[resolve boundary nodes]\n F --> G[util:get-fragment-between]\n G --> H[local:render]\n H --> I[annotation output]\n```\n\n### LBP Schema branching\n\nEvery script that extracts TEI fragments checks `tei:teiHeader/tei:encodingDesc/tei:schemaRef/@n` and falls back to `lbp-diplomatic-0.0.0` if absent. The schema version determines:\n\n- **0.0.0**: boundaries are `tei:cb` elements; surface labels get an `a` suffix appended (e.g., `107va`)\n- **1.x**: boundaries are `tei:pb` elements; surface labels are rewritten from `107r` to `107-r`\n\nThis branching is duplicated verbatim across four files. Any change to the boundary resolution logic needs to be applied in all four places.\n\n### `local:render` / `local:recurse`\n\nEach script has its own `local:render` implementation with slightly different suppression rules. The pattern is a typeswitch that strips `tei:corr`, `tei:reg`, `tei:note`, `tei:head`, and either serializes or strips structural breaks (`tei:lb`, `tei:cb`, `tei:pb`). The `coordinates2` and `folio-lines-xml` variants use `///` as a line-break sentinel instead of HTML strings.\n\n---\n\n## Data Dependencies\n\n| Path | Used by |\n|------|---------|\n| `/db/apps/scta-data/{dir}/{fs}/{prefix}_{fs}.xml` | All SPARQL-based scripts |\n| `/db/apps/scta-data/simpleXmlCoordinates/{prefix}/L{surface_title}.xml` | `coordinates2` |\n| `/db/apps/simpleXmlCoordinates/{surfaceid}.xml` | `from-simpleXmlCoordinates` |\n| `http://sparql-docker.scta.info/ds/query` | `folio-annotation-list.xq`, `folio-lines-xml.xq` |\n| `http://localhost:3030/ds/query` | `with-coordinates.xq`, `with-coordinates2.xq` |\n\n---\n\n## Known Fragility\n\nThe `short_id` binding from SPARQL is a slash-delimited string (`{fs}/{prefix}`) that is split positionally. If the SCTA data model ever changes the `shortId` format, the `tokenize(..., \"/\")[1]` and `[2]` calls in every script will silently produce wrong paths. The SPARQL query ideally should return the eXist-db storage path directly rather than forcing clients to reconstruct it.","iiif-integration":"# IIIF Integration\n\n# IIIF Search Integration\n\n## Overview\n\nThis module implements the [IIIF Content Search API 0.9/1.0](https://iiif.io/api/search/0.9/) for the SCTA (Scholastic Commentaries and Texts Archive) platform. It exposes full-text search over TEI-encoded manuscript transcriptions and returns results as IIIF `AnnotationList` resources — enabling IIIF viewers to highlight matching text on manuscript images.\n\nAll endpoints run as XQuery scripts inside an eXist-db instance and return JSON.\n\n---\n\n## The Four Endpoints\n\nThe module contains four XQuery scripts at different stages of development. They share a common core but differ in how they identify canvases and extract snippet text.\n\n### `iiifsearch.xq` — Legacy, no pagination\n\nThe original implementation. Uses a `full-msslug` parameter (e.g., `pp-sorb`) and a hardcoded slug-to-commentary-id mapping table to resolve which eXist collection to query. Returns all hits in a single response with no pagination. The slug mapping is explicitly marked temporary and should be replaced by a SPARQL query or a mapping document stored in eXist.\n\n**Parameters:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `q` | `\"\"` | Full-text search query |\n| `full-msslug` | `pp-sorb` | Combined commentary + manuscript slug (e.g. `pp-sorb`) |\n\n### `iiif/iiifsearch-with-paging.xq` — TEI-based, paginated\n\nAdds pagination support and replaces `full-msslug` with `manifestationid` (e.g., `plaoulcommentary/sorb`). The commentary ID is parsed from the first path segment; the manuscript slug from the last. Each result annotation's `chars` field renders the entire matching `tei:p` element.\n\n### `iiif/iiifsearch-with-paging-line-level.xq` — TEI-based, line-level snippets\n\nIdentical to the paging variant except the annotation `chars` field uses `util:get-fragment-between()` to extract only the text between the nearest `tei:lb` elements surrounding the match — giving a line-level rather than paragraph-level excerpt.\n\n### `iiifsearch-with-paging-line-level-from-simpleXmlCoordinates.xq` / `…2.xq` — simpleXmlCoordinates format\n\nThese two scripts abandon TEI source documents entirely. Instead they query a separate `/db/apps/simpleXmlCoordinates/{codex}` collection where each line is stored as a `new:text` element alongside pre-computed IIIF fields (`new:canvasId`, `new:iiifAdjusted`, `new:imageUrl`, `new:lineNumber`, etc.). This eliminates the complex canvas-resolution logic needed in the TEI scripts.\n\nThe `…2.xq` variant additionally queries a SPARQL endpoint (`https://sparql-docker.scta.info/ds/query`) to enumerate codices by type, institution, or date filter, then runs search across each matching codex in a single response — returning one `AnnotationList` map per codex where results exist.\n\n---\n\n## Shared Request Parameters (paging scripts)\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `q` | `fides` / `potest` | Full-text query term |\n| `manifestationid` / `codex` | `plaoulcommentary/sorb` | Identifies the manuscript collection |\n| `page` | `1` | Page number; use `all` to return every hit |\n| `records` | `10` / `5` | Results per page |\n| `start` | `1` | Offset (overridden when `page` is numeric) |\n\n---\n\n## Canvas Resolution (TEI scripts)\n\nThe most complex part of the TEI-based scripts is resolving the IIIF canvas ID for each search hit. The logic walks the XML tree in priority order:\n\n```mermaid\nflowchart TD\n A[Search hit tei:p] --> B{Preceding tei:zone\\nwith @start=#pid?}\n B -- yes --> C[Use surface/@n\\nto build canvas URI]\n B -- no --> D{Preceding tei:cb\\nfor this witness?}\n D -- yes, @select --> E[witness/@xml:base + @select]\n D -- yes, no @select --> F[sctacanvasbase + @n strip last char]\n D -- no --> G{Preceding tei:pb\\nfor this witness?}\n G -- yes, @select --> H[witness/@xml:base + @select]\n G -- yes, no @select --> I[sctacanvasbase + @ed + @n]\n G -- no --> J[Fallback: following tei:cb/pb\\n⚠ known off-by-one bug]\n```\n\nWhen a zone with coordinates is found, the `on` value gains a spatial fragment:\n```\nhttp://scta.info/iiif/{msslug}/canvas/{surface-n}#xywh={ulx},{uly},{width},{height}\n```\n\nOtherwise `on` is just the canvas URI without a region.\n\nThe comments in the source acknowledge this logic is \"a real mess\" and flag that a `startsOn` front-matter convention is being adopted incrementally, but is not yet widespread enough to rely on.\n\n---\n\n## HTML Rendering: `local:render` / `local:recurse`\n\nBoth functions appear identically across all four scripts. They walk a node tree and produce HTML fragments for the annotation `chars` field:\n\n| Input node | Output |\n|------------|--------|\n| `text()` | Text content + trailing space |\n| `tei:p` | `<p>` wrapper |\n| `tei:title` | `<em>` wrapper |\n| `tei:name` | `<span style=\"font-variant: small-caps\">` |\n| `exist:match` | `<span style=\"background-color: yellow;\">` (highlights the matched term) |\n| `tei:rdg`, `tei:bibl`, `tei:note` | suppressed |\n| everything else | recurse transparently |\n\nThe `simpleXmlCoordinates` variants strip out most of these cases since their source format doesn't use full TEI markup.\n\n---\n\n## Response Shape\n\nAll scripts emit a IIIF Search API `sc:AnnotationList`:\n\n```json\n{\n \"@context\": \"http://iiif.io/api/search/0/context.json\",\n \"@id\": \"http://exist.scta.info/search?q=fides&page=1\",\n \"@type\": \"sc:AnnotationList\",\n \"within\": {\n \"@type\": \"sc:Layer\",\n \"total\": 42,\n \"first\": \"...?q=fides&page=1\",\n \"last\": \"...?q=fides&page=5\"\n },\n \"next\": \"...?q=fides&page=2\",\n \"prev\": \"...?q=fides&page=1\",\n \"startIndex\": 0,\n \"resources\": [\n {\n \"@id\": \"http://scta.info/iiif/{manifestationid}/search/annotations/{pid}\",\n \"@type\": \"oa:Annotation\",\n \"motivation\": \"sc:painting\",\n \"resource\": { \"@type\": \"cnt:ContentAsText\", \"chars\": \"<p>...matched <span style=\\\"background-color: yellow;\\\">fides<\/span>...<\/p>\" },\n \"on\": \"http://scta.info/iiif/{msslug}/canvas/{folio}#xywh=100,200,300,40\"\n }\n ]\n}\n```\n\nThe `simpleXmlCoordinates2` variant adds `\"label\"` at the top level (codex title + date) and `\"imageUrl\"` / `\"surfaceId\"` on each annotation.\n\n---\n\n## Known Issues and Limitations\n\n**Hardcoded slug mapping in `iiifsearch.xq`.** The commentary slug → collection ID table is hardcoded and covers only eight commentaries. Any new text requires a code change.\n\n**Canvas resolution fallback is broken.** When no preceding `tei:cb` or `tei:pb` is found, the scripts fall through to `following::` axes but still reference `preceding::` axes in the string construction — producing an off-by-one folio. The source comments acknowledge this and mark those branches as needing work.\n\n**`within` pagination object is always emitted.** The scripts note they cannot conditionally suppress the `within` block when `page=all`; it appears in all responses regardless.\n\n**`simpleXmlCoordinates2.xq` hardcodes the production SPARQL endpoint.** The `localhost` URL is commented out. Switching environments requires a code edit.\n\n**`local:render` / `local:recurse` are duplicated** verbatim across all four files. They should be extracted into a shared library module.\n\n---\n\n## Data Dependencies\n\n| Script | Source collection | Coordinate data |\n|--------|------------------|-----------------|\n| `iiifsearch.xq` | `/db/apps/scta/{commentaryid}` | Derived from TEI `tei:zone`, `tei:cb`, `tei:pb` |\n| `iiifsearch-with-paging*.xq` | `/db/apps/scta-data/{commentaryid}` | Same |\n| `…simpleXmlCoordinates.xq` | `/db/apps/simpleXmlCoordinates/{codex}` | Pre-computed in `new:*` elements |\n| `…simpleXmlCoordinates2.xq` | Same + SPARQL at `sparql-docker.scta.info` | Pre-computed + codex metadata |","json-search":"# JSON Search\n\n# JSON Search Module\n\nThe JSON Search module provides full-text search over the SCTA (Scholastic Commentaries and Texts Archive) corpus of medieval TEI-encoded texts. It runs inside eXist-db and exposes several XQuery endpoints that accept HTTP query parameters and return JSON.\n\n## Architecture Overview\n\nAll search endpoints share the same two-phase pattern: resolve a set of documents from either the local filesystem or a SPARQL knowledge graph, then run eXist-db's Lucene full-text engine (`ft:query`) against those documents and format the hits as JSON using the `jssearchutils` shared library.\n\n```mermaid\nflowchart LR\n Client -->|HTTP params| Endpoint\n Endpoint -->|SPARQL| KG[(\"SCTA Knowledge Graph\\nsparql-docker\")]\n Endpoint -->|doc()| DB[(\"/db/apps/scta-data\")]\n Endpoint --> Utils[\"json-search-utils.xq\\n(render + token position)\"]\n Utils --> KWIC[\"eXist kwic module\"]\n Endpoint -->|JSON| Client\n```\n\n## Endpoints\n\n### `json-search-text.xq` — Primary unified search\n\nThe most capable endpoint. Accepts any combination of four filter parameters to dynamically build a SPARQL query, fetches matching documents from eXist-db, then searches within them.\n\n**Query parameters:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `wgid` | `''` | Work Group ID — filters to expressions belonging to a work group |\n| `eid` | `''` | Expression ID — filters to a specific top-level expression or its structure items |\n| `aid` | `''` | Author ID — filters to items authored by a given person |\n| `etid` | `''` | Expression Type ID — filters by expression type (e.g. prologus, lectio) |\n| `query` | `''` | Lucene query string; supports phrase queries like `\"potentia absoluta\"~5` |\n| `searchType` | `'text'` | Set to `'figure'` to search `tei:figure` elements instead of `tei:p` |\n| `offset` | `1` | Pagination offset; results are returned in pages of 20 |\n\nThe `local:getSparqlQuery($wgid, $etid, $aid, $eid)` function builds a SPARQL `SELECT` using `UNION` blocks for each non-empty parameter. Each block contributes constraints on `?item` and `?topLevelExpression`. The result bindings are then used to construct paths into `/db/apps/scta-data/<cid>/<itemid>/`.\n\nPagination is handled by running `subsequence()` twice — once for the current page and once for the next — and setting `\"moreResults\": \"true\"` if the lookahead is non-empty.\n\n### `json-search-text-by-expressionid.xq` — Search within a single commentary\n\nScoped to a single `expressionid` (default: `plaoulcommentary`). When `expressionid = \"all\"`, it walks every collection in `/db/apps/scta-data/` and searches them all. Does not use SPARQL; enumerates child collections directly with `xmldb:get-child-collections`.\n\n**Query parameters:** `expressionid`, `query`\n\n### `json-search-text-by-authorid.xq` — Search by author\n\nUses SPARQL to find all structure items (`?item`) authored by the given `authorid`, then searches within those documents.\n\n```sparql\nSELECT ?item ?topLevelExpression WHERE {\n ?topLevelExpression <.../AUT> <http://scta.info/resource/{authorid}> .\n ?topLevelExpression <.../level> \"1\" .\n ?topLevelExpression <.../hasStructureItem> ?item .\n}\n```\n\n**Query parameters:** `authorid`, `query`\n\n### `json-search-text-by-workGroupId.xq` — Search within a work group\n\nSimilar to `json-search-text-by-authorid.xq` but scoped to a work group rather than an author. The SPARQL query finds all expressions belonging to the work group and their structure items.\n\n**Query parameters:** `workGroupId`, `query`\n\n### `json-search-text-by-figure.xq` — Figure search\n\nSearches `tei:figure` elements instead of `tei:p`. When `query = 'all'`, returns every figure without a Lucene filter. Results include `\"imgurl\"` from `tei:graphic/@url`.\n\n**Query parameters:** `expressionid`, `query` (`'all'` or a Lucene string)\n\n### `json-search-text-by-expressionid-old.xq` — Legacy (do not use)\n\nAn older version of `json-search-text-by-expressionid.xq` that uses an inline `local:render()` function instead of `jssearchutils`. Produces simpler output (full rendered paragraph text, no token positions). Superseded by the current endpoint.\n\n## Document Resolution\n\nAll endpoints use the same fallback logic to locate the actual TEI XML file for a given `(cid, itemid)` pair:\n\n```xquery\nlet $transcription :=\n doc(concat('/db/apps/scta-data/', $cid, '/', $itemid, '/transcriptions.xml'))\n /transcriptions/transcription[1]\n\nlet $doc := if ($transcription) then\n concat('/db/apps/scta-data/', $cid, '/', $itemid, '/', $transcription)\nelse\n concat('/db/apps/scta-data/', $cid, '/', $itemid, '/', $itemid, '.xml')\n```\n\nIf `transcriptions.xml` exists and names a preferred transcription, that file is used. Otherwise the endpoint falls back to the canonical `<itemid>.xml`.\n\n## Search Result Shape\n\nAll text-search endpoints (except the legacy one) return results in the same structure, populated by the KWIC module and `jssearchutils`:\n\n```json\n{\n \"pid\": \"paragraph-xml-id\",\n \"index\": 1,\n \"start\": 42,\n \"end\": 44,\n \"hit\": \"potentia absoluta\",\n \"previous\": \"text before the match\",\n \"next\": \"text after the match\"\n}\n```\n\n- `start` / `end` are character-token offsets computed by `jssearchutils:getTokenPosition()`\n- `hit`, `previous`, `next` come from KWIC's `span[@class='hi']`, `span[@class='previous']`, and `span[@class='following']`\n- `index` is the ordinal of this KWIC summary item within the parent hit (a single `tei:p` can produce multiple summary spans if the query matches multiple times)\n\nFigure search results replace the text fields with `\"imgurl\"` and omit token offsets.\n\n## `json-search-utils.xq`\n\nShared utility module at namespace `http://xquery.scta.info/json-search-utils`. All endpoints except the legacy one import it.\n\n**`_:render($node)`**\n\nA typeswitch traversal that converts a TEI node tree to clean HTML-like output, stripping apparatus readings (`tei:rdg`), bibliographic references (`tei:bibl`), and footnotes (`tei:note`). It also handles `tei:lb[@break='no']` by emitting a `<span class=\"noBreak\"/>` marker, which participates in token counting.\n\n**`_:removePunctuation($string)`**\n\nApplied to every text node during rendering. Strips `¶`, `/`, `.`, `,`, `;`, `:` and normalizes whitespace. This ensures the token counts in `_:getTokenPosition()` align with what the client sees.\n\n**`_:getTokenPosition($node, $index)`**\n\nComputes the `start` and `end` token offsets of the Nth highlighted match within a rendered node. It counts total tokens, tokens following the Nth highlight, and adjusts for `noBreak` spans that represent line-break continuations rather than word boundaries. Returns an XML `<result><start/><end/><\/result>` element.\n\n**`_:filter($node, $mode)`**\n\nA filter function intended for use as a third argument to `kwic:summarize()` to exclude `rdg`, `bibl`, and `note` nodes from KWIC expansion. There is a comment in `json-search-text-by-expressionid.xq` noting this is currently non-functional and the parameter is not passed.\n\n## `xslt-lbpwebjs-mdx.xsl`\n\nAn XSLT 3.0 stylesheet that transforms a TEI document into MDX (Markdown with JSX components). It is not part of the search pipeline but lives alongside the search files. It emits custom JSX elements — `<Paragraph>`, `<Head>`, `<Div>`, `<Quote>`, `<Ref>`, `<Name>`, `<Title>` — with structural attributes like `level`, `sectionNumber`, and `totalNumber`. Apparatus readings, notes, and bibl elements are suppressed, matching the same filtering convention used in `_:render()`.\n\n## Known Limitations and Notes\n\n- **SPARQL endpoint is hardcoded** to `http://sparql-docker.scta.info/ds/query`. There is no configuration abstraction.\n- **`etid` filtering in `json-search-text.xq`** uses `UNION` blocks to catch expression types at multiple structural levels (item level, part-of-item, and has-item). The same parameter in `json-search-text2-toDelete.xq` (marked for deletion) uses a simpler single triple, which is why the `-toDelete` variant does not handle all cases correctly.\n- **No result count in SPARQL-backed endpoints.** The `json-search-text.xq` endpoint returns `\"totalCount\"` for the current page only (20 items max). Callers must use `\"moreResults\"` to drive pagination rather than computing total hits upfront.\n- **`kwic:filter` workaround** — the `_:filter` function in `json-search-utils.xq` exists but cannot currently be passed to `kwic:summarize()`. As a result, KWIC context windows may include text from `tei:rdg`, `tei:bibl`, and `tei:note` even though those nodes are stripped in the final `_:render()` call.","overview":"# scta-app — Wiki\n\n# SCTA App\n\nThe SCTA App is the server-side application powering the [Scholastic Commentaries and Texts Archive](http://scta.info) — a digital humanities platform for discovering, reading, and analyzing medieval scholarly texts encoded in TEI XML. It runs as an [EXPath package](http://expath.org/ns/pkg) inside an [eXist-db](https://exist-db.org) instance, exposing a collection of XQuery endpoints that handle everything from full-text search to manuscript image annotation.\n\n## What It Does\n\nThe archive stores thousands of TEI-encoded medieval texts — commentaries, distinctions, questions, lectures — distributed across many small XML documents in eXist-db collections. This application ties them together: it resolves identifiers through a SPARQL knowledge graph, assembles fragmented texts into coherent documents, indexes them for full-text search, serves them to IIIF-compatible manuscript viewers, and keeps the database in sync as source repositories evolve on GitHub.\n\n## Architecture\n\n```mermaid\ngraph TD\n GH[GitHub Push] -->|webhook| Sync[Webhook & Repo Sync]\n Sync --> DB[(eXist-db / TEI XML)]\n SPARQL[SCTA SPARQL Endpoint] --> DC[Document Construction]\n DC --> DB\n DB --> Search[Full-Text Search]\n DB --> IIIF[IIIF Integration]\n DB --> XSLT[XSLT Transformation]\n DB --> CSV[CSV Export]\n Search --> Client[Browser / IIIF Viewer]\n XSLT --> Client\n IIIF --> Client\n```\n\n## How Data Flows\n\n**Ingestion** starts at [Webhook & Repository Sync](webhook-and-repository-sync.md). When a contributor pushes to a SCTA text repository on GitHub, the webhook endpoint receives the push event, downloads the changed archive, and writes the updated XML files into the appropriate eXist-db collections — no manual intervention required.\n\n**Assembly** is handled by [Document Construction](document-construction.md). Because SCTA texts are stored as many small per-question or per-distinction XML files, this module queries the SPARQL endpoint to discover the right set of documents for a given expression or structure item, then stitches them together into a single coherent TEI document on the fly.\n\n**Reading and rendering** passes through [XSLT Transformation](xslt-transformation.md), which applies server-side stylesheets to TEI documents and returns rendered HTML fragments to the browser. It sits at the boundary between the database and the web frontend.\n\n**Search** is served by two complementary modules. [Search](search.md) returns HTML fragments scoped to an author, expression, transcription, or structural item. [JSON Search](json-search.md) covers the same ground but returns JSON — used by JavaScript frontends and API consumers. Both rely on eXist-db's Lucene full-text index.\n\n**Manuscript viewer integration** goes through [IIIF Integration](iiif-integration.md), which implements the IIIF Content Search API. Given a query against a manuscript, it returns an `AnnotationList` that IIIF viewers use to highlight matching words on manuscript images. The related [Folio Annotation](folio-annotation.md) module serves per-folio annotation lists — the transcription text for a single manuscript surface, wrapped in the IIIF annotation format.\n\n**Supporting modules** round out the stack: [Word and Line Lookup](word-and-line-lookup.md) resolves word tokens to their page and line coordinates (used by the IIIF search layer); [CSV Export](csv-export.md) extracts plain text for NLP pipelines and corpus analysis; [Reports](reports.md) surfaces word-frequency statistics from the Lucene index as sortable HTML tables.\n\nThe [Application Core](application-core.md) defines the EXPath package metadata, build descriptors, and the URL router (`controller.xql`) that dispatches every incoming HTTP request to the right XQuery handler. Start here if you are setting up or deploying the application.\n\nThe [Sandbox and Experiments](sandbox-and-experiments.md) directory holds one-off exploratory scripts that are not part of the production request lifecycle — useful as reference when building new endpoints.\n\n## Setup\n\nThe application is packaged as a standard EXPath `.xar` file for eXist-db. To get started:\n\n1. Install and start [eXist-db](https://exist-db.org/exist/apps/homepage/index.html).\n2. Build the `.xar` package using the descriptor in `build.xml` (Ant) or deploy directly from the eXist-db Package Manager.\n3. Point the webhook endpoint at your GitHub repositories so the [sync module](webhook-and-repository-sync.md) can populate the database.\n4. The SPARQL endpoint URL and collection paths are configured in the application's controller and individual XQuery scripts — review `controller.xql` in [Application Core](application-core.md) for the routing map.","reports":"# Reports\n\n# Reports Module\n\nThe Reports module provides word-frequency analysis for TEI-encoded SCTA texts stored in an eXist-db instance. It queries the Lucene full-text index to extract term statistics from `<tei:p>` elements and renders the results as a sortable HTML table.\n\nThere are two scripts in this module. They share the same logic and output structure but differ in scope: one operates on a single manuscript witness of a specific item, and the other aggregates across an entire commentary collection.\n\n---\n\n## Scripts\n\n### `wordcount-lucene.xq` — Collection-level frequency report\n\nAccepts a single query parameter:\n\n| Parameter | Default | Description |\n|---|---|---|\n| `commentaryid` | `liberextra` | The commentary collection directory under `scta-data/` |\n\nBuilds a collection path of the form `scta-data/{commentaryid}` and queries all documents in that subtree via `collection(...)`.\n\n### `wordcount-lucene-item.xq` — Item/witness-level frequency report\n\nAccepts three query parameters:\n\n| Parameter | Default | Description |\n|---|---|---|\n| `commentaryid` | `graciliscommentary` | The commentary collection directory |\n| `itemid` | `pg-b1q2` | The specific item identifier (e.g., a question) |\n| `mid` | `lon` | The manuscript/witness identifier |\n\nResolves a single document at the path `scta-data/{commentaryid}/{itemid}/{mid}_{itemid}.xml`.\n\n---\n\n## How It Works\n\nBoth scripts follow the same pipeline:\n\n```mermaid\ngraph LR\n A[Request params] --> B[Build path]\n B --> C[util:index-keys on tei:p]\n C --> D[Aggregate term counts]\n D --> E[Sort descending by frequency]\n E --> F[Render HTML table]\n```\n\n**1. Path construction** — `concat(...)` builds either a collection path or a single-document path depending on the script.\n\n**2. Lucene index query** — `util:index-keys(..., \"lucene-index\")` is called against all `tei:p` elements in scope. The callback function materializes each term as a `<term name=\"...\" count=\"...\" docs=\"...\"/>` element, where `count` is the total occurrence count and `docs` is the number of documents containing the term.\n\n**3. Aggregation** — `sum($terms//@count)` produces the total token count across all terms, used both for display and percentage calculation.\n\n**4. Rendering** — Terms are sorted by frequency descending. Each row shows the term, its raw count, and its percentage share of total tokens formatted to two decimal places via `format-number($term/@count div $sum, \"%.00\")`.\n\n---\n\n## Output\n\nBoth scripts serialize to `text/html` (declared via `exist:serialize`). The page structure is:\n\n- A header bar (light gray) showing the collection path and total word count\n- A plain HTML table: **Term | Frequency | Percentage**\n\nThe total word count is passed through `xs:decimal(xs:double($sum))` to normalize the numeric type before display.\n\n---\n\n## Assumptions and Constraints\n\n- Both scripts depend on a Lucene index being configured for `tei:p` content in the eXist-db instance. If the index is absent or stale, `util:index-keys` will return no terms.\n- The file naming convention `{mid}_{itemid}.xml` (used in `wordcount-lucene-item.xq`) must be followed exactly for the document lookup to succeed.\n- There is no error handling for missing collections, missing documents, or a zero-sum (which would cause a division-by-zero when calculating percentages).\n- These are read-only reporting scripts with no side effects on the database.\n\n---\n\n## Extending These Scripts\n\nTo add a new scope level (e.g., per-chapter or per-folio), follow the same pattern: parameterize the path, call `util:index-keys` on the appropriate TEI element set, and reuse the aggregation and rendering block. The only meaningful variation between the two existing scripts is the path-building expression and whether `doc(...)` or `collection(...)` is used to select the node set.","sandbox-and-experiments":"# Sandbox and Experiments\n\n# Sandbox and Experiments\n\nThis directory contains standalone XQuery scripts used for exploratory development and one-off queries against the SCTA eXist-db database. None of these scripts are part of the application's production request lifecycle — they exist as isolated proofs-of-concept and debugging aids.\n\n---\n\n## Scripts\n\n### `quoteSearch.xq` — TEI Quote Extraction\n\nSearches a commentary's TEI transcriptions for all `<tei:quote>` elements and renders them as an HTML result list.\n\n**Request parameter:** `commentaryid` (default: `lombardsententia`)\n\n**How it works:**\n\n1. Iterates over child collections under `/db/apps/scta/{commentaryid}/`\n2. For each collection, checks for a `transcriptions.xml` manifest. If found, it reads the transcription filename from that manifest. If not, it falls back to the canonical document named `{collection}.xml`.\n3. Collects all `<tei:quote>` elements across the resolved documents.\n4. Renders each hit with its containing paragraph ID (`@xml:id`), item ID (the enclosing `<tei:div>` in `<tei:body>`), item title (from the preceding `<tei:titleStmt>`), and any sibling `<tei:bibl>` citation.\n\n**Note:** The script contains a dead `let $docs := ...` binding before the main `for` loop that is never used — a leftover from a refactor. It does not affect execution.\n\n---\n\n### `sparqlsearch.xq` — SPARQL Federated Query via HTTP\n\nSends a SPARQL SELECT query to a remote SPARQL endpoint and renders the results as an HTML list.\n\n**Endpoint:** `http://sparql-docker.scta.info/ds/query`\n\n**How it works:**\n\n1. `local:getSparqlQuery()` returns a hardcoded SPARQL query that retrieves all items with the predicate `scta:mentions` pointing to `scta:person/Holcot`.\n2. The query is URI-encoded and appended to the endpoint URL.\n3. `http:send-request` (EXPath HTTP Client) sends a GET request with `Accept: application/xml`, which requests SPARQL results in XML format.\n4. `local:getHtml($sparql-result)` parses the XML response using the `sparql:` namespace and renders each `<sparql:result>` as an `<li>`. The item ID is extracted from the last path segment of the URI; the commentary ID (`$cid`) is taken from index `[5]`.\n\n**Hardcoded assumptions:**\n- The SPARQL endpoint URL is not parameterized.\n- The Holcot person URI is not parameterized.\n- The commentary ID is assumed to live at position 5 of the slash-split URI path (e.g. `http://scta.info/resource/{type}/{cid}/{...}`).\n\nThe commented-out `return $sparql-result` at the bottom is a common debug escape hatch — uncomment to inspect the raw HTTP response instead of the rendered HTML.\n\n---\n\n### `wordsearch-old.xq` — Full-Text Word Search (Legacy)\n\nAn older implementation of full-text search across a commentary's TEI paragraphs, preserved for reference. The current production word search presumably supersedes this.\n\n**Request parameters:**\n- `query` — the search string\n- `commentaryid` — restricts the search to a specific commentary collection\n\n**How it works:**\n\n1. Loads all documents in `/db/apps/scta/{commentaryid}/` whose filenames do not contain an underscore (`_`). This filter excludes auxiliary files (apparatus, variant readings, etc.) that follow a `base_variant.xml` naming convention.\n2. Runs an eXist-db full-text query (`ft:query`) against all `<tei:p>` elements.\n3. Reports the result count and wall-clock duration (minutes + seconds) of the query.\n4. Renders each hit using `local:render` / `local:recurse`, a mutual-recursion typeswitch renderer that converts TEI nodes to HTML:\n\n| TEI element | HTML output |\n|---|---|\n| `tei:p` | `<p>` |\n| `tei:title` | `<em>` |\n| `tei:name` | `<span style=\"font-variant: small-caps\">` |\n| `exist:match` | `<span style=\"background-color: yellow\">` (highlight) |\n| `tei:rdg`, `tei:bibl`, `tei:note` | suppressed |\n| text nodes | node value + trailing space |\n\n`util:expand($hit)` is called on each hit before rendering — this is an eXist-db function that inlines `exist:match` wrappers around the matched terms, which the renderer then highlights in yellow.\n\nEach result links to `/text/{itemid}#{pid}` using the item's div ID and paragraph ID as the anchor.\n\n---\n\n## Patterns Common Across Scripts\n\n**Transcription resolution** (`quoteSearch.xq`) follows a two-level lookup: a manifest file first, then a conventional filename fallback. This same pattern likely appears in the production query modules.\n\n**TEI path conventions** used throughout:\n- `tei:body/tei:div/@xml:id` → item identifier\n- `preceding::tei:titleStmt/tei:title` → item title\n- `@xml:id` on `tei:p` → paragraph identifier\n\n**eXist-db specifics** these scripts depend on:\n- `xmldb:get-child-collections()` for collection traversal\n- `ft:query()` for full-text search\n- `util:expand()` to materialize match highlights\n- `console:log()` for server-side debug logging\n- EXPath HTTP Client (`http:send-request`) for federated SPARQL queries\n\n---\n\n## Contributing\n\nThese scripts are intentionally outside the main application module namespace. To experiment:\n\n- Add new `.xq` files to `sandbox/` freely — they are not loaded by any import chain.\n- When a sandbox script matures into production use, move it into the appropriate application module directory and replace `local:` function prefixes with a proper module namespace declaration.\n- Dead bindings (like the unused `$docs` in `quoteSearch.xq`) and commented-out debug returns (like `sparqlsearch.xq`) are expected in this directory — clean them up before promoting to production.","search":"# Search\n\n# Search Module\n\nThe Search module provides full-text search across the SCTA (Scholastic Commentaries and Texts Archive) corpus. It consists of four independent XQuery scripts, each scoping a search query to a different slice of the text collection. All scripts run inside eXist-db and produce HTML5 fragment output suitable for embedding in a page.\n\n---\n\n## The Four Search Endpoints\n\n| Script | Request Parameter | Scope |\n|---|---|---|\n| `search-text-by-author.xq` | `authorid` | All texts attributed to a given author |\n| `search-text-by-expressionid.xq` | `expressionid` | A single commentary, or `\"all\"` for the entire corpus |\n| `search-text-by-expressiontype.xq` | `expression_type_id` | All expressions sharing a structural type (e.g. a prologue) |\n| `search-text-by-workgroupid.xq` | `workGroupId` | All expressions belonging to a work group |\n\nEvery script also accepts a `query` parameter containing the full-text search string.\n\n---\n\n## How a Search Executes\n\nThree of the four scripts share a common two-phase pattern: resolve the corpus scope via SPARQL, then run a full-text query against the resolved documents.\n\n```mermaid\nflowchart LR\n A[HTTP request] --> B[Read request params]\n B --> C{Needs SPARQL?}\n C -- yes --> D[Build SPARQL query]\n D --> E[sparql-docker.scta.info]\n E --> F[Extract item IDs]\n F --> G[Load XML from eXist-db]\n C -- no\\nexpression-by-id --> G\n G --> H[ft:query on tei:p]\n H --> I[Render HTML hits]\n```\n\n### Phase 1 — Scope resolution\n\nScripts that use SPARQL (`by-author`, `by-expressiontype`, `by-workgroupid`) call `local:getSparqlQuery()` to build a SPARQL SELECT, URL-encode it, and POST it to `http://sparql-docker.scta.info/ds/query` via the EXPath HTTP client. The response is XML (`application/xml`). Each `sparql:result` node yields two bindings:\n\n- `item` — URI of an item-level expression (e.g. `http://scta.info/resource/pg-b1q1`)\n- `topLevelExpression` — URI of the containing commentary\n\nThe script extracts the final path segment from each URI using `fn:tokenize(..., \"/\")[last()]`, then constructs the eXist-db document path:\n\n```\n/db/apps/scta-data/{cid}/{itemid}/{itemid}.xml\n```\n\nThe `by-expressionid` script skips SPARQL entirely. It reads all child collections under `/db/apps/scta-data/{commentaryid}/` directly via `xmldb:get-child-collections()`. For each child collection, it checks for a `transcriptions.xml` manifest; if one exists, its first listed transcription file is preferred over the canonical `{itemid}.xml`. This lets the script transparently serve diplomatic transcriptions when available. Passing `expressionid=all` iterates every top-level collection in the data store.\n\n### Phase 2 — Full-text search\n\nAll scripts use eXist-db's Lucene integration to query the resolved document set:\n\n```xquery\n$docs//tei:p[ft:query(., $query)]\n```\n\nFor the SPARQL-backed scripts, this runs per-document (one `doc()` call per SPARQL result row). For `by-expressionid`, documents are collected into a sequence first and the predicate runs across all of them at once, which also enables the aggregate result count and timing display.\n\n---\n\n## Result Rendering\n\nAll four scripts share identical `local:render()` and `local:recurse()` functions. These traverse a TEI node tree and produce HTML:\n\n| TEI element | HTML output |\n|---|---|\n| `tei:p` | `<p>` |\n| `tei:title` | `<em>` |\n| `tei:name` | `<span style=\"font-variant: small-caps\">` |\n| `exist:match` | `<span style=\"background-color: yellow;\">` (search highlight) |\n| `tei:rdg`, `tei:bibl`, `tei:note` | suppressed |\n\nThe `exist:match` case is the key one: eXist-db's `util:expand()` wraps Lucene match fragments in `<exist:match>` elements before the node is passed to `local:render()`, so matched terms automatically appear highlighted in yellow.\n\nEach hit generates a result block linking to the paragraph's canonical URL:\n\n```xquery\n<a href=\"/text/{$itemid}#{$pid}\">{$itemtitle}, paragraph {$pid}<\/a>\n```\n\nThe `by-expressionid` script additionally wraps all results in a `<div class=\"searchresults\">` with a summary line showing result count and query duration (measured with `util:system-time()`).\n\n---\n\n## SPARQL Query Shapes\n\nEach SPARQL-backed script uses a different graph pattern to find items:\n\n**By author** — finds all level-1 expressions authored by the given person, then their structure items:\n```sparql\n?topLevelExpression <loc.gov/relators/AUT> <scta.info/resource/{authorid}> .\n?topLevelExpression <scta.info/property/level> \"1\" .\n?topLevelExpression <scta.info/property/hasStructureItem> ?item .\n```\n\n**By expression type** — finds expressions sharing a structural type:\n```sparql\n?expression <scta.info/property/expressionType> <scta.info/resource/{expression_type_id}> .\n?expression <scta.info/property/hasStructureItem> ?item .\n?item <scta.info/property/isPartOfTopLevelExpression> ?topLevelExpression .\n```\n\n**By work group** — finds all expressions belonging to a work group:\n```sparql\n<scta.info/resource/{workGroupId}> <scta.info/property/hasExpression> ?topLevelExpression .\n?topLevelExpression <scta.info/property/hasStructureItem> ?item .\n```\n\n---\n\n## Known Limitations and Development Notes\n\n- **`by-author` hard-codes level `\"1\"`** — the SPARQL query filters to level-1 top-level expressions. The comment in `local:getSparqlQuery()` notes that `$expression_type_id` cannot currently be passed into the query string, so it is not configurable.\n\n- **`by-expressiontype` has the same parameter note** — the function signature accepts `$expression_type_id` but the comment flags this as a known gap from a previous iteration of the code.\n\n- **No pagination** — all matching paragraphs are returned in a single response. For large corpora (especially `expressionid=all`), this can be slow; the `by-expressionid` script surfaces the query duration as a diagnostic.\n\n- **`local:render` and `local:recurse` are duplicated** across all four files. If rendering behavior needs to change (e.g., adding support for a new TEI element), it must be updated in each script independently. Extracting these into a shared library module would be the obvious refactor.\n\n- **`by-expressionid` has commented-out `console:log` calls** — these were used during development to trace which documents were being loaded and can be re-enabled for debugging document resolution issues.","webhook-and-repository-sync":"# Webhook and Repository Sync\n\n# Webhook and Repository Sync\n\nThis module receives GitHub push events and syncs the changed XML source files into an eXist-db instance. It is the primary ingestion path for SCTA text data: when a contributor pushes to a text repository on GitHub, these scripts update the corresponding eXist-db collections without manual intervention.\n\nThe module spans several XQuery files with distinct responsibilities — webhook entry points, archive download, HTTP utilities, and administrative scripts.\n\n---\n\n## Architecture Overview\n\n```mermaid\ngraph TD\n GH[GitHub Push Event] --> WR[github-webhook-receiver-new.xq]\n WR --> RC[local:replaceCollection]\n WR --> LOG[local:log]\n RC --> TLC[local:topLevelCollectionQuery]\n RC --> GA[gitarc:get-github-archive]\n GA --> DL[hd:download]\n GA --> UZ[unzip:unzip]\n DL --> EX[(eXist-db)]\n UZ --> EX\n LOG --> EX\n```\n\n---\n\n## Entry Points\n\n### `github-webhook-receiver-new.xq`\n\nThe active webhook handler. It expects a GitHub push event payload as the HTTP request body and dispatches only on pushes to the `master` branch. Pushes to other branches are acknowledged but produce no side effects.\n\n**Payload extraction:**\n\n```xquery\nlet $post_data := request:get-data()\nlet $parsed_data := parse-json(util:binary-to-string($post_data))\nlet $before := $parsed_data?before\nlet $after := $parsed_data?after\nlet $repo := $parsed_data?repository?name\nlet $owner := $parsed_data?repository?owner?name\nlet $branch := $parsed_data?ref\nlet $access_token := environment-variable(\"GH_ACCESS_TOKEN\")\n```\n\nThe GitHub access token is never embedded in the payload — it is read from the `GH_ACCESS_TOKEN` environment variable.\n\n**Branch guard:**\n\n```xquery\nif ($branch = \"refs/heads/master\") then\n <div>\n {local:replaceCollection($owner, $repo, $access_token)}\n {local:log(...)}\n <\/div>\nelse\n <div><p>Push Event Not on Master Branch, No Action Taken<\/p><\/div>\n```\n\n---\n\n### `github-webhook-receiver.xq` (legacy)\n\nAn earlier version of the webhook receiver. It calls `local:files()` instead of `local:replaceCollection()`, meaning it performs incremental file-by-file sync using the GitHub compare API rather than a full archive replacement. The `local:replaceCollection` call is commented out in this file. It remains in the codebase but `github-webhook-receiver-new.xq` is the current handler.\n\n---\n\n## Core Functions\n\n### `local:topLevelCollectionQuery($shortid)`\n\nDetermines where in the eXist-db collection hierarchy a repository's data belongs. It queries the SPARQL endpoint at `http://sparql-docker.scta.info/ds/query` to resolve a repository short ID to its top-level expression.\n\n```sparql\nSELECT ?top_level_expression_short_id WHERE {\n ?expression_item <http://scta.info/property/shortId> '<shortid>' .\n ?expression_item <http://scta.info/property/isPartOfTopLevelExpression> ?topLevelExpression .\n ?topLevelExpression <http://scta.info/property/shortId> ?top_level_expression_short_id .\n}\n```\n\nIf the SPARQL query returns no results, the function assumes the short ID is itself a top-level expression and returns it unchanged. This drives the path resolution logic:\n\n| Condition | eXist-db path |\n|-----------|--------------|\n| Repo is top-level | `/db/apps/scta-data/<repo>/` |\n| Repo is nested | `/db/apps/scta-data/<top-level>/<repo>/` |\n\n### `local:replaceCollection($owner, $repo, $access_token)`\n\nThe strategy used by the new webhook receiver. Rather than applying a diff, it replaces the entire collection with a fresh archive download. Steps:\n\n1. Call `gitarc:getArchiveUrl()` to resolve the GitHub API archive URL for the repository.\n2. Call `local:topLevelCollectionQuery()` to determine the parent eXist-db collection.\n3. Call `gitarc:get-github-archive()` to download, unzip, and rename the archive into the correct location.\n\n### `local:files($before, $after, $owner, $repo, $access_token)`\n\nThe incremental sync strategy (used by the legacy receiver, present but unused in the new one). It calls the GitHub compare API:\n\n```\nGET https://api.github.com/repos/<owner>/<repo>/compare/<before>...<after>\n```\n\nFor each changed file it handles three cases:\n\n- **added / modified** — fetches the file content via `contents_url`, base64-decodes it, and stores it with `xmldb:store()`. If the target collection does not exist, it logs a \"Collection not available\" message rather than creating it.\n- **removed** — removes the file with `xmldb:remove()`.\n- **other** — emits a no-op message.\n\n### `local:log($before, $after, $owner, $repo, $pushed-at, ...)`\n\nWrites a minimal push event record to `/db/apps/logs/` as `pushEvent-<pushed_at>.xml`. The log entry records the commit SHA and the owner/repo. The full raw payload (`$new_data`) is available to this function but is not currently stored — the format is hard-coded XML.\n\n---\n\n## Supporting Modules\n\n### `get-github-zip-archive.xq` — `gitarc` namespace\n\nProvides the archive download and install logic.\n\n**`gitarc:getArchiveUrl($owner, $repo, $access_token)`**\n\nCalls the GitHub repos API and extracts the `archive_url` field, replacing the `{archive_format}{/ref}` template with `zipball` to produce a direct zipball URL.\n\n**`gitarc:get-github-archive($archive-url, $parent-collection, $destination-collection-name)`**\n\nOrchestrates the full download-unzip-rename sequence by delegating to `local:download-and-unpack-zip-archive()`:\n\n1. Ensures `/db/apps/scta-data/http-download-temp` exists (creates it if not).\n2. Downloads the zip via `hd:download()`, storing it in the temp collection.\n3. Unzips via `unzip:unzip()`.\n4. Renames the extracted GitHub-generated directory (e.g., `scta-texts-summahalensis-abc1234`) to the plain repo name via `xmldb:rename()`.\n5. Deletes the temp collection.\n\n### `http-download.xqm` — `hd` namespace\n\nSingle public function: **`hd:download($file-url, $collection)`**\n\nDownloads a remote file via HTTP GET and stores it in a given eXist-db collection. Handles two edge cases automatically:\n\n- If the `content-disposition` header contains a `filename=` value, that name is used; otherwise the last path segment of the URL is used.\n- If the URL ends in `.xml` but the server reports `text/plain`, the MIME type is overridden to `application/xml` so eXist-db indexes it correctly.\n\nReturns the stored path on success, or an `<error>` element wrapping the response head on failure.\n\n### `unzip.xqm` — `unzip` namespace\n\nWraps eXist-db's `compression:unzip()` with collection-creation logic. The two public functions are:\n\n- **`unzip:list($resource)`** — returns a manifest of zip entries without extracting anything.\n- **`unzip:unzip($zip-file)`** / **`unzip:unzip($zip-file, $target-collection)`** — extracts all entries. For each entry, if the parent collection does not yet exist it is created recursively via the private `unzip:mkcol()` helper. Returns an `<entries>` element summarising stored files and any errors.\n\n---\n\n## Administrative Scripts\n\n### `permissionBatchChanges.xq`\n\nA one-shot utility. Uses `dbutil:scan()` to walk the entire `/db/apps/scta-data` tree and reassign ownership and permissions:\n\n- Resources: `chown` → `scta-user`, `chgrp` → `scta-group`\n- Collections: same ownership changes, plus `chmod` → `rwxrwxr-x`\n\nRun this after a bulk import or after the webhook receiver has created collections under a different user context.\n\n### `cloneItemRepos.xq`\n\nA separate ingestion path for Bitbucket-hosted repositories, now likely superseded. It fetches RDF metadata for a commentary from `http://scta.info/text/`, extracts item IDs, and calls `git:pull()` from the `http://exist-db.org/git` module to pull each item repository directly into eXist-db. Credentials are hardcoded — treat this as a one-time migration script, not operational code.\n\n---\n\n## Configuration and Dependencies\n\n| Concern | Value |\n|---------|-------|\n| GitHub access token | `GH_ACCESS_TOKEN` environment variable |\n| SPARQL endpoint | `http://sparql-docker.scta.info/ds/query` |\n| Data root | `/db/apps/scta-data/` |\n| Log root | `/db/apps/logs/` |\n| Temp download dir | `/db/apps/scta-data/http-download-temp` |\n\nThe SPARQL endpoint is an internal Docker service. If it is unreachable, `local:topLevelCollectionQuery()` will not return results, and the fallback behavior (treating the repo as top-level) will place data in the wrong collection path.\n\n---\n\n## Incremental vs. Full-Replace Sync\n\nThe two webhook receivers represent two different sync strategies. The new receiver always performs a full collection replacement. This is simpler and eliminates the risk of stale files, but it is more expensive — every push downloads the entire repository. The legacy `local:files()` function performs a cheaper incremental sync but requires that target collections already exist; it will silently skip files if the target collection is missing rather than creating it. If incremental sync is needed in the future, collection pre-creation logic would need to be added before calling `xmldb:store()`.","word-and-line-lookup":"# Word and Line Lookup\n\n# Word and Line Lookup Module\n\n## Overview\n\nThis module resolves a transcription identifier (`tid`) to a sequence of word tokens, each annotated with its line number, page reference, and position within that line. It is the core mechanism for word-level coordinate lookup in the SCTA (Scholastic Commentaries and Texts Archive) platform — enabling consumers to answer questions like \"which line is word N on, and where does it fall within that line?\"\n\nThe module exists in two versions: `word-line-lookup-old.xq` (legacy) and `word-line-lookup.xq` (current). A diagnostic helper `test-line-output.xq` aids manual inspection of line fragments. `lineCount.xql` is an unrelated utility that counts `<new:line>` elements in the `simpleXmlCoordinates` collection.\n\n---\n\n## How It Works\n\nThe pipeline has three stages:\n\n```mermaid\ngraph LR\n A[tid parameter] --> B[SPARQL lookup\\npath + passageType]\n B --> C[Load TEI doc\\nfrom eXist-db]\n C --> D[render / renderQuote]\n D --> E[getTokenPosition]\n E --> F[JSON word array]\n```\n\n### Stage 1 — Resolve the transcription via SPARQL\n\n`local:getSparqlQuery($tid)` builds a SPARQL SELECT against `http://sparql-docker.scta.info`. Given a transcription URI it returns:\n\n- `?path` — the Git path to the source XML file\n- `?topLevelShortId` — the top-level work identifier (used to locate the file in eXist-db)\n- `?itemShortId` — the structural item containing this passage\n- `?passageType` — (current version only) whether the passage is a `structureElement` or a paragraph-level node\n\nThe old version hardcodes a `tei:p` lookup; the current version uses a UNION to handle passages nested inside a `structureBlock` (one extra hop through `?block`), and also fetches `?passageType` so the renderer can be selected at runtime.\n\n### Stage 2 — Load and render the TEI passage\n\nThe path returned by SPARQL is parsed to extract the eXist-db document path and fragment identifier:\n\n```xquery\nlet $docpath := \"/db/apps/scta-data/\" || $cid || \"/\" || $pathFragments[4] || \"/\" || $fileName\nlet $transcription := doc($docpath)//node()[@xml:id=$pxmlid]\n```\n\nThe passage is then passed through a rendering function that converts TEI XML into a flat intermediate structure suitable for tokenization.\n\n**`local:render($node)`** — used for paragraph-level (`tei:p`) passages:\n\n| TEI element | Output |\n|---|---|\n| `text()` | cleaned text node + trailing space |\n| `tei:p` | `<p>` wrapper, recurse children |\n| `tei:title`, `tei:name` | recurse (text only, no wrapper) |\n| `tei:rdg`, `tei:orig`, `tei:bibl`, `tei:note` | suppressed entirely |\n| `tei:lb` | `<span n=\"{n}\" type=\"line\" break=\"{break}\" page=\"{preceding pb/@n}\"/>` |\n| `tei:pb` | `<span n=\"{n}\" type=\"page\"/>` |\n| default | recurse |\n\n**`local:renderQuote($node)`** — identical but replaces `tei:p` with `tei:quote` as the wrapper trigger. Used when `?passageType` is `http://scta.info/resource/structureElement`.\n\nThe dispatch in the current version:\n\n```xquery\nif ($passageType eq \"http://scta.info/resource/structureElement\") then\n local:getTokenPosition(local:renderQuote($transcription), $precedingpb)\nelse\n local:getTokenPosition(local:render($transcription), $precedingpb)\n```\n\n**`local:removePunctation($string)`** normalizes text nodes before tokenization. It strips whitespace-adjacent non-letter/non-digit/non-punctuation characters and collapses pilcrow (`¶`, `&#182;`) and slash sequences into a single space. This prevents inflated word counts caused by stray symbols.\n\n### Stage 3 — Tokenize and assign coordinates\n\n`local:getTokenPosition($node, $precedingpb)` walks every node in the rendered intermediate tree. For each text node (identified by the absence of an `@n` attribute), it:\n\n1. Tokenizes the text into words using `tokenize()`\n2. Looks backward one position in the node sequence for a `<span type=\"line\">` to get the current line number (`@n`) and page (`@page`)\n3. Falls back to `$precedingpb` if no page span precedes the token (current version also looks forward to `$position+1/@page` as a secondary fallback)\n4. Skips the first word of a line when the preceding `<span>` has `break=\"no\"` — that word is the continuation of a hyphenated word from the prior line, not an independent token\n\nEach surviving word is emitted as a map:\n\n```xquery\nmap {\n \"line\": number($lbNum),\n \"precedingpb\": $pbNum,\n \"word\": $word,\n \"wordLinePosition\": $position2,\n \"wordLineTotal\": count($lineTextTokens)\n}\n```\n\nThe full response is a JSON array of these maps (serialized via `output:method \"json\"`).\n\n---\n\n## Key Differences: Old vs. Current Version\n\n| Concern | `word-line-lookup-old.xq` | `word-line-lookup.xq` |\n|---|---|---|\n| SPARQL query | 4 bound vars; no `passageType` | 5 vars; fetches `passageType` |\n| Structure traversal | `isPartOfStructureItem` only | UNION adds `isPartOfStructureBlock` path |\n| Node lookup | `//tei:p[@xml:id=$pxmlid]` | `//node()[@xml:id=$pxmlid]` (any element type) |\n| Page fallback | Falls back to `starts-on` div only | Also tries `$totalNodes[$position+1]/@page` |\n| Quote passages | Not supported | `local:renderQuote` dispatched on `structureElement` type |\n\n---\n\n## `test-line-output.xq`\n\nA standalone diagnostic script. Rather than taking a `tid` parameter, it hardcodes a document path and page range (`pg-b1q2`, pages `15-v` to `16-r` in the `graciliscommentary` corpus) and uses eXist-db's `util:get-fragment-between` to slice out a page fragment. It then iterates `tei:lb` elements within that fragment.\n\nMost of the result-returning logic is commented out — this file is a workbench for experimenting with fragment extraction, not production code. The active `return` yields `$ending-node` references to verify positional indexing of line elements.\n\n---\n\n## `lineCount.xql`\n\nA trivial administrative query. Counts all `<new:line>` elements across the `/db/apps/simpleXmlCoordinates` collection and returns `<div><count>N<\/count><\/div>`. Unrelated to the word/line coordinate pipeline.\n\n---\n\n## Gotchas for Contributors\n\n**Page number resolution is fragile.** The `$precedingpb` fallback chain exists because eXist-db's XPath axis cannot always reach a `tei:pb` that appears before the passage's containing element in document order. If a passage starts on a new page, the fallback to `doc(...)//tei:div[@xml:id='starts-on']/tei:pb/@n` may still return the wrong page if the `starts-on` div is absent or misplaced. The current version's additional forward-look (`$position+1/@page`) partially compensates but is documented in-code as imperfect for first-line page breaks.\n\n**`local:removePunctation` is intentionally lossy.** The function name typo (`Punctation`) is preserved in both versions. It normalizes pilcrows and slashes to spaces, which means word-count fidelity depends on consistent encoding practice in the source TEI. Adding new cleanup patterns requires updating this function in both render paths.\n\n**The SPARQL endpoint is not abstracted.** The URL `http://sparql-docker.scta.info/ds/query` is hardcoded. Any environment change (staging, local dev) requires editing the query string construction directly.\n\n**`$totalTokens` is computed but never used** in `local:getTokenPosition`. It appears to be a leftover from an earlier approach to total-count tracking.","xslt-transformation":"# XSLT Transformation\n\n# XSLT Transformation Module\n\n## Overview\n\nThis module handles server-side transformation of TEI-encoded medieval scholarly texts into HTML for browser display. It consists of two XQuery endpoint scripts that invoke eXist-db's XSLT processor, and four XSLT stylesheets that define the actual transformation logic.\n\nThe module sits at the boundary between the eXist-db XML database (which stores TEI documents at `/db/apps/scta-data/`) and the web frontend. Clients request an endpoint, pass parameters selecting which document and stylesheet to use, and receive rendered HTML.\n\n```mermaid\ngraph LR\n Client -->|xmlurl + xslurl| xslt-conversion.xq\n Client -->|xslurl + filter| grac-xslt-conversion.xq\n xslt-conversion.xq -->|stream-transform| lbpwebjs-main.xsl\n grac-xslt-conversion.xq -->|stream-transform| modern-citations-2.xsl\n grac-xslt-conversion.xq -->|stream-transform| historical-refs.xsl\n lbpwebjs-main.xsl -->|fetches| TEI-XML[(TEI XML in eXist)]\n modern-citations-2.xsl -->|doc()| gracilis-docs[(Gracilis TEI docs)]\n```\n\n---\n\n## Endpoints\n\n### `xslt-conversion.xq`\n\nGeneral-purpose transformation endpoint. Fetches an arbitrary TEI document from `scta-data` and applies a caller-specified stylesheet.\n\n**Parameters:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `xmlurl` | `https://exist.scta.info/exist/apps/scta-app/text/wodehamordinatio/b1-d3-qun/b1-d3-qun.xml` | Full URL of the source XML document |\n| `xslurl` | `xslt-lbpwebjs-main.xsl` | Path fragment relative to `/db/apps/scta-app/` |\n\nThe endpoint strips the `https://exist.scta.info/exist/apps/scta-app/text/` prefix from `xmlurl` to produce a path fragment, then resolves the document internally at `/db/apps/scta-data/{fragment}`. This means `xmlurl` must always be an `exist.scta.info` URL — the path rewriting is hardcoded.\n\nIf the input document cannot be found, `error(\"file not found\")` is thrown. No parameters are forwarded to the stylesheet.\n\n### `grac-xslt-conversion.xq`\n\nGracilis-specific endpoint. Unlike `xslt-conversion.xq`, there is no input XML document — the stylesheet itself is both the source and the stylesheet passed to `transform:stream-transform`. The stylesheets invoked here (`xslt-modern-citations-2.xsl`, `xslt-historical-refs.xsl`) contain a hardcoded list of document IDs and fetch those documents internally via `doc()` calls.\n\n**Parameters:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `xslurl` | `xslt-modern-citations-2.xsl` | Path fragment relative to `/db/apps/scta-app/` |\n| `filter` | `false` | Author name filter passed to the stylesheet as `$nameFilter` |\n\nBoth endpoints set `Access-Control-Allow-Origin: *`.\n\n---\n\n## Stylesheets\n\n### `xslt-lbpwebjs-main.xsl` — Full Critical Edition Renderer\n\nThe primary stylesheet for rendering a complete TEI critical edition document. It targets TEI documents encoded following the [Lombard Press schema](https://lombardpress.org/) and produces HTML suitable for the LombardPress web JavaScript frontend.\n\n**Key parameters:**\n\n| Parameter | Description |\n|-----------|-------------|\n| `show-images` | Whether to render manuscript folio links (default: `true`) |\n| `default-ms-image` | Fallback manuscript slug for image display |\n| `show-line-breaks` / `isDiplomatic` | Controls diplomatic vs. critical rendering mode. Derived from `$schema-type`: critical editions suppress line breaks by default |\n| `default-msslug` | Pulled from the first `tei:witness/@n` in the TEI header |\n| `file-path` | Passed through to a review display widget |\n| `by_phrase`, `edited_by_phrase` | Localizable UI strings |\n\n**Root template structure:**\n\n1. Named template `teiHeaderInfo` — renders publication info block (`#lbp-pub-info`): title, author, editors, contributors, edition number, authority, license, and witness/source links\n2. `xsl:apply-templates` — processes the document body\n3. Named template `footnotes` — builds the *Apparatus Fontium* (citation footnotes) as a `<ul>`\n4. Named template `variants` — builds the *Apparatus Criticus* (textual variants) as a `<ul>`\n\n**TEI element coverage:**\n\n| TEI element | Output | Notes |\n|-------------|--------|-------|\n| `tei:teiHeader` | suppressed | Rendered separately by `teiHeaderInfo` |\n| `tei:div` | `<div id=\"{@xml:id}\" class=\"plaoulparagraph\">` | |\n| `tei:head` | `<h{depth}>` | Heading level derived from ancestor `div` count; adds info-sign button unless `@type='question-title'` |\n| `tei:p` | `<div class=\"para_wrap\">` wrapping `<p>` | Includes paragraph number span |\n| `tei:name` | `<span class=\"lbp-name\" data-name=\"{$refID}\">` | Strips `#` prefix from `@ref` |\n| `tei:title` | `<span class=\"lbp-title\" data-title=\"{$refID}\">` | |\n| `tei:quote` | `<span class=\"lbp-quote ...\">` | Behavior differs by diplomatic mode and whether `@source` is a SCTA resource URI |\n| `tei:ref` | `<span class=\"lbp-ref ...\">` | Same diplomatic/SCTA branching as `tei:quote` |\n| `tei:cb` | `<span class=\"lbp-folionumber\">` | Complex folio/side calculation from preceding `pb` and `lb` |\n| `tei:pb` | `<span class=\"lbp-folionumber\">` | Only rendered when no `tei:cb` shares the same `@ed` |\n| `tei:lb` (in body, not in `reg`) | `<br/><span class=\"lbp-line-number\">` | Only when `$show-line-breaks = 'true'`; calculates line number from position relative to preceding `pb` |\n| `tei:app` | `<span class=\"lemma\">` + variant superscript | Lemma rendered; readings suppressed inline (listed in apparatus) |\n| `tei:cit` | applies `quote`/`ref` children + footnote superscript | Footnote only created if `quote/@source`, `ref/@target`, or `bibl` child is present |\n| `tei:figure` | `<img>` | Only if `tei:graphic/@url` exists |\n| `tei:unclear`, `tei:add`, `tei:del`, `tei:supplied`, `tei:mentioned` | Respective `lbp-*` spans | |\n| `tei:choice/tei:corr`, `tei:choice/tei:reg` | Parenthesized `lbp-corr`/`lbp-reg` spans | |\n| `tei:rdg`, `tei:note`, `tei:desc` | suppressed | |\n| `tei:div[@xml:id='starts-on']`, `tei:div[@xml:id='include-list']`, `tei:div[@xml:id='includeList']` | suppressed | |\n\n**`tei:quote` and `tei:ref` — diplomatic mode branching:**\n\nThe `@source` / `@target` attributes on these elements use an `@` delimiter to encode both a resource URI and an optional word range. The stylesheet tokenizes on `@` to separate them:\n\n```\nsource=\"http://scta.info/resource/someId@word-range-start-word-range-end\"\n```\n\nWhen `$isDiplomatic = 'true'` and the source/target is a SCTA resource URI, the rendered span gets `js-show-reference-paragraph` to enable the frontend's side-panel paragraph viewer. In critical mode (non-diplomatic), quotes are wrapped in curly quotation marks and refs render as plain spans — no interactive behavior.\n\n**Apparatus generation:**\n\n`footnotes` iterates over all `tei:cit` elements, assigning alphabetic identifiers (format `a`, `b`, …). Each footnote links back to the inline reference via `js-show-info`. `quote-bibl` and `ref-bibl` are named templates that resolve the footnote link: if the source/target is a SCTA resource URI, they render a `js-show-reference-paragraph` anchor; otherwise they fall back to `tei:bibl` content.\n\n`variants` iterates over `tei:app` elements with numeric identifiers, listing each `tei:rdg` with its variation type (`variation-absent`, `variation-present`, `correction-addition`, `correction-deletion`, `correction-substitution`). The named template `sigla` renders witness sigla, optionally as `show-line-witness` spans if a `@facs` attribute is present.\n\n**Folio/line number calculation:**\n\nBoth `tei:cb` and `tei:lb` contain significant logic to determine the correct folio reference. The approach handles two complications: (1) `fixed` line breaks (`@type='fixed=value'`) that carry explicit folio position rather than relying on preceding `pb`, and (2) the case where a `pb` falls between a fixed `lb` and the context node. The condition:\n\n```xslt\ntokenize(./preceding::tei:pb[1]/preceding::tei:lb[contains(./@type, 'fixed')][1]/@type, '=')[2]\neq tokenize($precedingFixedLb/@type, '=')[2]\n```\n\ndetects this interposition by checking whether the fixed `lb` before the preceding `pb` is the same as the fixed `lb` before the context node. If equal, the `pb/@n` value is used instead.\n\n---\n\n### `xslt-modern-citations-2.xsl` — Batch Citation Renderer\n\nIterates over a hardcoded list of 20 Gracilis commentary section IDs (`pg-b1q1` through `pg-b1q20`) and renders all `tei:cit` elements from each as a flat HTML list.\n\n**Parameter:** `$nameFilter` (default `false`) — when set to anything other than `false`, restricts output to `cit` elements whose `bibl` child contains the filter string.\n\nEach section is fetched live via `doc()` from `https://exist.scta.info/exist/apps/scta-app/text/graciliscommentary/{id}/{id}.xml`. The document list is embedded in the stylesheet as an XSL variable rather than being passed in or queried from the database.\n\n`quote` and `ref` elements render as clickable links to `https://scta.lombardpress.org/#/res?resourceid=http://scta.info/resource/{@xml:id}`.\n\n---\n\n### `xslt-historical-refs.xsl` — Historical Reference Viewer\n\nStructurally identical to `xslt-modern-citations-2.xsl` but with different template rules for `cit/ref`. Where the modern citations stylesheet links `ref` by `@xml:id`, this stylesheet renders the full `ref` text content with a preceding labeled link:\n\n```html\n<p>\n <a href=\"https://scta.lombardpress.org/#/res?resourceid=http://scta.info/resource/{@xml:id}\" target=\"new\">Ref:<\/a>\n [ref text content]\n<\/p>\n```\n\n`cit/quote` and `bibl` are suppressed. Only refs surface in output.\n\n---\n\n### `xslt-modern-citations.xsl` — Single-Document Citation Renderer\n\nA simpler single-document version: takes whatever document is passed as input (via `xslt-conversion.xq`) and renders all `tei:cit` elements. No hardcoded document list, no `$nameFilter` parameter. Functionally equivalent to `xslt-modern-citations-2.xsl` for a single document.\n\n---\n\n## Adding or Modifying Stylesheets\n\nNew stylesheets placed in `/db/apps/scta-app/` are immediately available to both endpoints via the `xslurl` parameter — no code changes required. The `xslurl` value is a bare path fragment (`xslt-my-new.xsl`), not a full URL.\n\nWhen adding a stylesheet invoked through `grac-xslt-conversion.xq`, note that the stylesheet itself is passed as both input and stylesheet to `transform:stream-transform`. The root template must therefore be self-contained — it cannot rely on an external input document. Use `doc()` calls inside the stylesheet to pull TEI content.\n\nWhen modifying `xslt-lbpwebjs-main.xsl`, be aware that the frontend JavaScript (`lbp-web.js` or equivalent) depends heavily on specific CSS class names and `data-*` attributes emitted by this stylesheet: `js-show-info`, `js-show-folio-image`, `js-show-reference-paragraph`, `lbp-quote-clickable`, `lbp-ref-clickable`, `lbp-line-number`, `lbp-folionumber`, etc. Renaming these will break frontend behavior without corresponding JS changes."};
var TREE = [{"name":"Search","slug":"search","files":["search/search-text-by-author.xq","search/search-text-by-expressionid.xq","search/search-text-by-expressiontype.xq","search/search-text-by-workgroupid.xq"]},{"name":"JSON Search","slug":"json-search","files":["jsonsearch/json-search-text-by-authorid.xq","jsonsearch/json-search-text-by-expressionid-old.xq","jsonsearch/json-search-text-by-expressionid.xq","jsonsearch/json-search-text-by-figure.xq","jsonsearch/json-search-text-by-workGroupId.xq","jsonsearch/json-search-text.xq","jsonsearch/json-search-text2-toDelete.xq","jsonsearch/json-search-utils.xq","jsonsearch/xslt-lbpwebjs-mdx.xsl"]},{"name":"IIIF Integration","slug":"iiif-integration","files":["iiif/iiifsearch-with-paging-line-level.xq","iiif/iiifsearch-with-paging.xq","iiif/iiifsearch.xq","iiifsearch-with-paging-line-level-from-simpleXmlCoordinates.xq","iiifsearch-with-paging-line-level-from-simpleXmlCoordinates2.xq"]},{"name":"Folio Annotation","slug":"folio-annotation","files":["folio-annotaiton-list-from-simpleXmlCoordinates.xq","folio-annotation-list-old.xq","folio-annotation-list-with-coordinates.xq","folio-annotation-list-with-coordinates2.xq","folio-annotation-list.xq","folio-lines-xml.xq"]},{"name":"XSLT Transformation","slug":"xslt-transformation","files":["grac-xslt-conversion.xq","xslt-conversion.xq","xslt-historical-refs.xsl","xslt-lbpwebjs-main.xsl","xslt-modern-citations-2.xsl","xslt-modern-citations.xsl"]},{"name":"CSV Export","slug":"csv-export","files":["csv-pct-noquote.xq","csv-pct.xq","doc/csv-canonical.xq","doc/csv-json.xq","doc/csv-old.xq","doc/csv.xq"]},{"name":"Document Construction","slug":"document-construction","files":["doc/constructor-canonical.xq","doc/constructor.xq","tei-to-json.xq"]},{"name":"Word and Line Lookup","slug":"word-and-line-lookup","files":["word-line-lookup-old.xq","word-line-lookup.xq","test-line-output.xq","lineCount.xql"]},{"name":"Reports","slug":"reports","files":["reports/wordcount-lucene-item.xq","reports/wordcount-lucene.xq"]},{"name":"Webhook and Repository Sync","slug":"webhook-and-repository-sync","files":["github-webhook-receiver.xq","webhookReceiver/get-github-zip-archive.xq","webhookReceiver/github-webhook-receiver-new.xq","webhookReceiver/http-download.xqm","webhookReceiver/unzip.xqm","cloneItemRepos.xq","permissionBatchChanges.xq"]},{"name":"Application Core","slug":"application-core","files":["controller.xql","build.xml","expath-pkg.xml","repo.xml"]},{"name":"Sandbox and Experiments","slug":"sandbox-and-experiments","files":["sandbox/quoteSearch.xq","sandbox/sparqlsearch.xq","sandbox/wordsearch-old.xq"]}];
var META = {"fromCommit":"a9b26f0922cf3405580579742fab966afb54f001","generatedAt":"2026-05-11T00:53:08.758Z","model":"claude-4.6-sonnet-medium","moduleFiles":{"Search":["search/search-text-by-author.xq","search/search-text-by-expressionid.xq","search/search-text-by-expressiontype.xq","search/search-text-by-workgroupid.xq"],"JSON Search":["jsonsearch/json-search-text-by-authorid.xq","jsonsearch/json-search-text-by-expressionid-old.xq","jsonsearch/json-search-text-by-expressionid.xq","jsonsearch/json-search-text-by-figure.xq","jsonsearch/json-search-text-by-workGroupId.xq","jsonsearch/json-search-text.xq","jsonsearch/json-search-text2-toDelete.xq","jsonsearch/json-search-utils.xq","jsonsearch/xslt-lbpwebjs-mdx.xsl"],"IIIF Integration":["iiif/iiifsearch-with-paging-line-level.xq","iiif/iiifsearch-with-paging.xq","iiif/iiifsearch.xq","iiifsearch-with-paging-line-level-from-simpleXmlCoordinates.xq","iiifsearch-with-paging-line-level-from-simpleXmlCoordinates2.xq"],"Folio Annotation":["folio-annotaiton-list-from-simpleXmlCoordinates.xq","folio-annotation-list-old.xq","folio-annotation-list-with-coordinates.xq","folio-annotation-list-with-coordinates2.xq","folio-annotation-list.xq","folio-lines-xml.xq"],"XSLT Transformation":["grac-xslt-conversion.xq","xslt-conversion.xq","xslt-historical-refs.xsl","xslt-lbpwebjs-main.xsl","xslt-modern-citations-2.xsl","xslt-modern-citations.xsl"],"CSV Export":["csv-pct-noquote.xq","csv-pct.xq","doc/csv-canonical.xq","doc/csv-json.xq","doc/csv-old.xq","doc/csv.xq"],"Document Construction":["doc/constructor-canonical.xq","doc/constructor.xq","tei-to-json.xq"],"Word and Line Lookup":["word-line-lookup-old.xq","word-line-lookup.xq","test-line-output.xq","lineCount.xql"],"Reports":["reports/wordcount-lucene-item.xq","reports/wordcount-lucene.xq"],"Webhook and Repository Sync":["github-webhook-receiver.xq","webhookReceiver/get-github-zip-archive.xq","webhookReceiver/github-webhook-receiver-new.xq","webhookReceiver/http-download.xqm","webhookReceiver/unzip.xqm","cloneItemRepos.xq","permissionBatchChanges.xq"],"Application Core":["controller.xql","build.xml","expath-pkg.xml","repo.xml"],"Sandbox and Experiments":["sandbox/quoteSearch.xq","sandbox/sparqlsearch.xq","sandbox/wordsearch-old.xq"]},"moduleTree":[{"name":"Search","slug":"search","files":["search/search-text-by-author.xq","search/search-text-by-expressionid.xq","search/search-text-by-expressiontype.xq","search/search-text-by-workgroupid.xq"]},{"name":"JSON Search","slug":"json-search","files":["jsonsearch/json-search-text-by-authorid.xq","jsonsearch/json-search-text-by-expressionid-old.xq","jsonsearch/json-search-text-by-expressionid.xq","jsonsearch/json-search-text-by-figure.xq","jsonsearch/json-search-text-by-workGroupId.xq","jsonsearch/json-search-text.xq","jsonsearch/json-search-text2-toDelete.xq","jsonsearch/json-search-utils.xq","jsonsearch/xslt-lbpwebjs-mdx.xsl"]},{"name":"IIIF Integration","slug":"iiif-integration","files":["iiif/iiifsearch-with-paging-line-level.xq","iiif/iiifsearch-with-paging.xq","iiif/iiifsearch.xq","iiifsearch-with-paging-line-level-from-simpleXmlCoordinates.xq","iiifsearch-with-paging-line-level-from-simpleXmlCoordinates2.xq"]},{"name":"Folio Annotation","slug":"folio-annotation","files":["folio-annotaiton-list-from-simpleXmlCoordinates.xq","folio-annotation-list-old.xq","folio-annotation-list-with-coordinates.xq","folio-annotation-list-with-coordinates2.xq","folio-annotation-list.xq","folio-lines-xml.xq"]},{"name":"XSLT Transformation","slug":"xslt-transformation","files":["grac-xslt-conversion.xq","xslt-conversion.xq","xslt-historical-refs.xsl","xslt-lbpwebjs-main.xsl","xslt-modern-citations-2.xsl","xslt-modern-citations.xsl"]},{"name":"CSV Export","slug":"csv-export","files":["csv-pct-noquote.xq","csv-pct.xq","doc/csv-canonical.xq","doc/csv-json.xq","doc/csv-old.xq","doc/csv.xq"]},{"name":"Document Construction","slug":"document-construction","files":["doc/constructor-canonical.xq","doc/constructor.xq","tei-to-json.xq"]},{"name":"Word and Line Lookup","slug":"word-and-line-lookup","files":["word-line-lookup-old.xq","word-line-lookup.xq","test-line-output.xq","lineCount.xql"]},{"name":"Reports","slug":"reports","files":["reports/wordcount-lucene-item.xq","reports/wordcount-lucene.xq"]},{"name":"Webhook and Repository Sync","slug":"webhook-and-repository-sync","files":["github-webhook-receiver.xq","webhookReceiver/get-github-zip-archive.xq","webhookReceiver/github-webhook-receiver-new.xq","webhookReceiver/http-download.xqm","webhookReceiver/unzip.xqm","cloneItemRepos.xq","permissionBatchChanges.xq"]},{"name":"Application Core","slug":"application-core","files":["controller.xql","build.xml","expath-pkg.xml","repo.xml"]},{"name":"Sandbox and Experiments","slug":"sandbox-and-experiments","files":["sandbox/quoteSearch.xq","sandbox/sparqlsearch.xq","sandbox/wordsearch-old.xq"]}]};
(function() {
var activePage = 'overview';
document.addEventListener('DOMContentLoaded', function() {
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' });
renderMeta();
renderNav();
document.getElementById('menu-toggle').addEventListener('click', function() {
document.getElementById('sidebar').classList.toggle('open');
});
if (location.hash && location.hash.length > 1) {
activePage = decodeURIComponent(location.hash.slice(1));
}
navigateTo(activePage);
});
function renderMeta() {
if (!META) return;
var el = document.getElementById('meta-info');
var parts = [];
if (META.generatedAt) {
parts.push(new Date(META.generatedAt).toLocaleDateString());
}
if (META.model) parts.push(META.model);
if (META.fromCommit) parts.push(META.fromCommit.slice(0, 8));
el.textContent = parts.join(' \u00b7 ');
}
function renderNav() {
var container = document.getElementById('nav-tree');
var html = '<div class="nav-section">';
html += '<a class="nav-item overview" data-page="overview" href="#overview">Overview</a>';
html += '</div>';
if (TREE.length > 0) {
html += '<div class="nav-group-label">Modules</div>';
html += buildNavTree(TREE);
}
container.innerHTML = html;
container.addEventListener('click', function(e) {
var target = e.target;
while (target && !target.dataset.page) { target = target.parentElement; }
if (target && target.dataset.page) {
e.preventDefault();
navigateTo(target.dataset.page);
}
});
}
function buildNavTree(nodes) {
var html = '';
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
html += '<div class="nav-section">';
html += '<a class="nav-item" data-page="' + escH(node.slug) + '" href="#' + encodeURIComponent(node.slug) + '">' + escH(node.name) + '</a>';
if (node.children && node.children.length > 0) {
html += '<div class="nav-children">' + buildNavTree(node.children) + '</div>';
}
html += '</div>';
}
return html;
}
function escH(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function navigateTo(page) {
activePage = page;
location.hash = encodeURIComponent(page);
var items = document.querySelectorAll('.nav-item');
for (var i = 0; i < items.length; i++) {
if (items[i].dataset.page === page) {
items[i].classList.add('active');
} else {
items[i].classList.remove('active');
}
}
var contentEl = document.getElementById('content');
var md = PAGES[page];
if (!md) {
contentEl.innerHTML = '<div class="empty-state"><h2>Page not found</h2><p>' + escH(page) + '.md does not exist.</p></div>';
return;
}
contentEl.innerHTML = marked.parse(md);
// Rewrite .md links to hash navigation
var links = contentEl.querySelectorAll('a[href]');
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute('href');
if (href && href.endsWith('.md') && href.indexOf('://') === -1) {
var slug = href.replace(/\.md$/, '');
links[i].setAttribute('href', '#' + encodeURIComponent(slug));
(function(s) {
links[i].addEventListener('click', function(e) {
e.preventDefault();
navigateTo(s);
});
})(slug);
}
}
// Convert mermaid code blocks into mermaid divs
var mermaidBlocks = contentEl.querySelectorAll('pre code.language-mermaid');
for (var i = 0; i < mermaidBlocks.length; i++) {
var pre = mermaidBlocks[i].parentElement;
var div = document.createElement('div');
div.className = 'mermaid';
div.textContent = mermaidBlocks[i].textContent;
pre.parentNode.replaceChild(div, pre);
}
try { mermaid.run({ querySelector: '.mermaid' }); } catch(e) {}
window.scrollTo(0, 0);
document.getElementById('sidebar').classList.remove('open');
}
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment