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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
44 changes: 44 additions & 0 deletions .github/workflows/CI-guided-navigation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI-guided-navigation
on:
push:
paths:
- 'guided-navigation/**'
- 'helpers/**'
- 'shared/**'
- '.github/workflows/CI-guided-navigation.yml'
- 'pnpm-lock.yaml'

jobs:
build:
name: Build and test on Node ${{ matrix.node }} and ${{ matrix.os }}

runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: ./guided-navigation
strategy:
matrix:
node: ['24.x', '26.x']
os: [ubuntu-latest, windows-latest, macOS-latest]

steps:
- name: Checkout repo
uses: actions/checkout@v2

- name: Set up pnpm
uses: pnpm/action-setup@v4

- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'pnpm'

- name: Install deps
run: pnpm install

- name: Test
run: pnpm run test --ci --maxWorkers=2

- name: Build
run: pnpm run build
4 changes: 2 additions & 2 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
paths:
- 'shared/**'
- '.github/workflows/CI.yml'
- '.pnpm-lock.yaml'
- 'pnpm-lock.yaml'

jobs:
build:
Expand All @@ -16,7 +16,7 @@ jobs:
working-directory: ./shared
strategy:
matrix:
node: ['22.x', '24.x']
node: ['24.x', '26.x']
os: [ubuntu-latest, windows-latest, macOS-latest]

steps:
Expand Down
6 changes: 6 additions & 0 deletions decorator/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.5] – 2026-10-01

### Changed

- Republished against `@readium/navigator-html-injectables@2.8.5`/`@readium/shared@2.7.0`

## [1.2.4] – 2026-09-29

### Changed
Expand Down
2 changes: 1 addition & 1 deletion decorator/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/decorator",
"version": "1.2.4",
"version": "1.2.5",
"type": "module",
"description": "Standalone decoration controller and renderer for HTML content",
"author": "readium",
Expand Down
1 change: 1 addition & 0 deletions guided-navigation/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fixtures/** text eol=lf
6 changes: 6 additions & 0 deletions guided-navigation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.log
.DS_Store
node_modules
dist
types
coverage
17 changes: 17 additions & 0 deletions guided-navigation/CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] – 2026-10-01

### Added

- `makeGnd` and `parseMarkup` generate Guided Navigation documents and objects from HTML and XHTML, as `@readium/shared` models
- `textrefs` generation option, with CSS selector, DOM range and text fragment references
- `htmlFallback` generation option, to re-parse malformed XHTML as HTML instead of throwing
- `decodeTextref`, `combineDomRangeTextrefs`, and encoders/decoders for `#css(...)` and `#domrange(...)` references
- `isAriaSubstituted` and `substitutedOwnSelector` for text taken from `aria-label`/`aria-labelledby`
- A language-agnostic fixture suite in `fixtures/`
157 changes: 157 additions & 0 deletions guided-navigation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# @readium/guided-navigation

Generates [Guided Navigation](https://github.com/readium/guided-navigation) (GND) documents from HTML and XHTML, as [`@readium/shared`](https://www.npmjs.com/package/@readium/shared) `GuidedNavigationDocument`/`GuidedNavigationObject` instances.

## Installation

```sh
npm install @readium/guided-navigation
```

Needs DOM globals (`DOMParser`, `XMLSerializer`, `Range`, `CSS.escape`…): a browser, or a DOM implementation such as jsdom installed on `globalThis`. No HTML/XML parser is bundled.

## Building a GND document

```typescript
import { makeGnd } from "@readium/guided-navigation";

const gnd = makeGnd(`
<section epub:type="chapter">
<h1>Chapter One</h1>
<p>It was a dark and stormy night.</p>
</section>
`);
// gnd?.guided: GuidedNavigationObject[]
```

```typescript
function makeGnd(input: string | Element, mediaType?: GndMediaType, options?: GndGenerationOptions): GuidedNavigationDocument | undefined;
function parseMarkup(input: string | Element, mediaType?: GndMediaType, options?: GndGenerationOptions): GuidedNavigationObject[];
```

`input` is either raw markup, or a live, already-rendered element to convert in place — see `domRange` below for why that distinction matters. `mediaType` is `"text/html" | "application/xhtml+xml"`. Omit it to sniff from `input` (a string: XML declaration, `xmlns:epub`, XHTML doctype → XHTML, else HTML; an element: its own document's content type).

Malformed XHTML (an undeclared `epub:` prefix, an HTML entity like `&nbsp;`…) throws. Pass `{ htmlFallback: true }` in `options` to re-parse it as HTML instead.

`makeGnd` returns `undefined` when the input has no navigable content, since a document's `guided` can't be empty. `parseMarkup` returns the objects without the document wrapper, and an empty array in that case.

Use `serialize()` on the result to get the JSON.

## Text references (`textrefs`)

Off by default — it costs extra compute to generate. When on, every node with a role gets a `textref`: a link back to the element it came from, either `#id` (if it has one) or `#css(<selector>)`. It's a URI reference, not a Readium `Locator`.

```typescript
interface GndGenerationOptions {
textrefs?: boolean | GndRole[] | TextrefOptions;
}

interface TextrefOptions {
roles?: boolean | GndRole[]; // which roles get a reference; true = every role
domRange?: boolean; // also compute exact textNodeIndex/charOffset
textFragment?: boolean; // also append a WICG Text Fragment directive
}
```

```typescript
makeGnd(html, undefined, { textrefs: true }); // every role
makeGnd(html, undefined, { textrefs: ["heading1", "paragraph"] }); // just these roles
```

Whatever the option, a node with no text, children or other ref of its own (e.g. `<hr>`, or `<span role="img" aria-label="…">`) always gets a `textref` to its element, since the schema requires one of them. So does every `math` node, to locate it.

`roles` also accepts `"leaf-text"`, not a real GND role — it matches a roleless block that owns its own text directly (e.g. a `<div>` standing in for `<p>`, no role, no semantic tag). `true` already includes it; in an array, add it explicitly:

```typescript
makeGnd(html, undefined, { textrefs: ["leaf-text"] }); // only roleless leaf-text blocks
makeGnd(html, undefined, { textrefs: ["leaf-text", "heading1"] }); // that, plus real headings
```

`domRange` makes the reference more precise: `#domrange(...)`, pointing at the exact text node and character offset, not just the element. This only works if you pass a live DOM element as `input` (not an HTML string) — pass a string and `domRange` is silently skipped, because a string gets parsed into a detached copy you can't point back to:

```typescript
makeGnd(document.querySelector("article")!, undefined, {
textrefs: { roles: true, domRange: true },
});
```

`cssSelector` and `domRange` prefer an id, then a unique class or tag, falling back to `tag:nth-child(n)` only when none of those disambiguate a node from its siblings. That fallback (and `domRange`'s text-node index/offset, which is positional regardless) is only accurate against the DOM as it existed at generation time.

`textFragment` adds a [WICG Text Fragment](https://wicg.github.io/scroll-to-text-fragment/) directive on top of whatever reference you already have, e.g. `#css(p.foo):~:text=It%20was...`. It only needs the text, so — unlike `domRange` — it works fine with a plain HTML string:

```typescript
makeGnd(html, undefined, { textrefs: { roles: true, textFragment: true } });
```

The directive is generated by Google's [text-fragments-polyfill](https://github.com/GoogleChromeLabs/text-fragments-polyfill) (via `@readium/helpers`). If the node's text is unique in the document, that text is the whole directive. If it's repeated (e.g. the same heading twice), the polyfill adds a bit of surrounding text so it matches only one spot. If it still can't make it unique, no directive is generated.

If the node's text is too long, the polyfill only keeps the first few words and the last few words (`textStart` and `textEnd`), instead of the whole thing.

Use `decodeTextref({ id, textref })` to read a `textref` back. It returns:

```typescript
{ cssSelector?, domRange?, text?, fragment? }
```

- Exact match (one quote) → `text: { highlight, before?, after? }`
- Range (the `textStart`/`textEnd` case above) → `fragment` (the raw `:~:text=...` string)

It returns `undefined` if the `textref` isn't one of ours (e.g. it's a plain link's `href`). `combineDomRangeTextrefs(first, last)` joins two decoded `domRange` references into one spanning both.

The lower-level pieces are exported too: `encodeCssSelectorFragment(selector)`/`decodeCssSelectorFragment(textref)` for `#css(...)`, and `encodeDomRangeFragment(domRange)`/`decodeDomRangeFragment(textref)` for `#domrange(...)` (a `DomRangeJSON`: `{ start: { cssSelector, textNodeIndex, charOffset? }, end?: {...} }`, the RWPM shape behind `@readium/shared`'s `DomRange`).

## Objects

- **`role`** can have multiple entries: tag name, ARIA `role`, `epub:type` all contribute, in that order (e.g. `<section epub:type="chapter">` → `["section", "chapter"]`). `role="presentation"`/`"none"` overrides everything to `["presentation"]`.
- **`text`** carries `ssml` when the text needs it (inline formatting, a language shift, or an embedded footnote/pagebreak/image mid-sentence). `ssml` marks embedded objects with a `<readium:noteref id="..." />`-style placeholder whose `id` matches a sibling in `children`.
- **`math`** nodes carry MathML as-is in `text.ssml` (`<math xmlns="http://www.w3.org/1998/Math/MathML">…</math>`), or other `role="math"` markup's content in `text.plain`, with `alttext`/`aria-label` as `description`.
- **`imgref`/`audioref`/`videoref`** are a media element's `src`. **`textref`** is an `href` — reused for navigational-list items (`toc`, `index`...), `noteref`/`backlink`/`biblioref`/`glossref`, and plain links.
- **`description`** holds a node's accessible name (`aria-label`, `alt`, `<figcaption>`...) as `description.text` when it differs from its visible text.
- Empty/presentational/`aria-hidden`/`hidden` content and role-less wrapper `<div>`s are dropped from the tree, not kept as empty nodes.
- A block whose only child has no role/id/ref of its own gets that child's text hoisted into it: `<p><em>Cover</em></p>` → `{ role: ["paragraph"], text: { plain: "Cover" } }`, not a nested anonymous child. A child carrying its own ref is never hoisted: `<p><a href="...">Cover</a></p>` → `{ role: ["paragraph"], children: [{ text: { plain: "Cover" }, textref: "..." }] }`.

When `text` comes from `aria-label`/`aria-labelledby` instead of the element's content, it isn't on the page. `isAriaSubstituted(object)` tells such objects apart, and `substitutedOwnSelector(object)` returns a substituted link's own CSS selector, since its `textref` is its `href`. Both only know about objects returned by `parseMarkup`/`makeGnd`, not deserialized ones.

## Footnotes and pagebreaks

Both are read out of narrative order, so the converter handles them specially:

- **`noteref`** (`<a role="doc-noteref" href="#note1">`) resolves its `href` and embeds the target's whole subtree as `children` — no second lookup needed. Unresolvable hrefs get a plain `textref` child instead. The footnote is suppressed from also appearing at its original location — except when that location is inside an `endnotes` section, where it's kept so the section's own list isn't left silent. The kept footnote keeps its id, and the noteref gets a generated one (`noteref1`…) instead.
- **`pagebreak`** (`<span epub:type="pagebreak" title="42">`) carries its label as `text`. Mid-sentence, it's a `<readium:pagebreak id="..." />` placeholder in that sentence's `ssml`, with the pagebreak node as a sibling `children` entry.

## Roles

Three independent sources, mapped in [`src/roles.ts`](src/roles.ts):

1. **Element type** — `<h1>` → `heading1`, `<nav>` → `navigation`, `<blockquote>` → `blockquote`, etc.
2. **ARIA role** — `role="doc-chapter"` → `chapter`, `role="figure"` → `figure`, etc. `role="heading"` reads its level from `aria-level` (default `2`).
3. **`epub:type`** — `epub:type="chapter"` → `chapter`, `epub:type="pagebreak"` → `pagebreak`, etc. (XHTML only, see below).

## `epub:type` requires XHTML

`epub:type` only means anything in namespace-aware XHTML. Pass a complete XHTML document (`xmlns:epub` on the root):

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><meta charset="utf-8"/><title>...</title></head>
<body>
<section epub:type="chapter">...</section>
</body>
</html>
```

ARIA roles and native elements work as plain HTML fragments.

## Fixtures

[`fixtures/`](fixtures/README.md) is a language-agnostic conformance suite for HTML → GND, shipped with the package.

## Development

```sh
pnpm run test # Jest
pnpm run build # outputs dist/ and types/
pnpm run generate-fixtures-manifest
```
Loading
Loading