Commit ecaccb90aa
Verified · cmc
Layout: unified · split
Cargo.lock +1 −1
| @@ -657,7 +657,7 @@ dependencies = [ | |||
| 657 | 657 | ||
| 658 | [[package]] | 658 | [[package]] |
| 659 | name = "org-ssg" | 659 | name = "org-ssg" |
| 660 | version = "0.12.0" | 660 | version = "0.13.0" |
| 661 | dependencies = [ | 661 | dependencies = [ |
| 662 | "anyhow", | 662 | "anyhow", |
| 663 | "blake3", | 663 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "org-ssg" | 2 | name = "org-ssg" |
| 3 | version = "0.12.0" | 3 | version = "0.13.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | license = "MIT" | 6 | license = "MIT" |
README.md +35 −2
| @@ -72,7 +72,7 @@ receive: | |||
| 72 | | Variable | What it is | | 72 | | Variable | What it is | |
| 73 | |---|---| | 73 | |---|---| |
| 74 | | `body` | the rendered page HTML — use `{{ body \| safe }}` | | 74 | | `body` | the rendered page HTML — use `{{ body \| safe }}` | |
| 75 | | `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.keywords` | | 75 | | `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` | |
| 76 | | `site` | `.title`, `.base_url`, `.description`, `.language` | | 76 | | `site` | `.title`, `.base_url`, `.description`, `.language` | |
| 77 | | `nav` | list of `{title, url}`, relative to this page | | 77 | | `nav` | list of `{title, url}`, relative to this page | |
| 78 | | `root` | `../`-prefix back to the site root from this page | | 78 | | `root` | `../`-prefix back to the site root from this page | |
| @@ -231,6 +231,38 @@ The default layout also emits `<link rel="canonical">` when a base URL is set. | |||
| 231 | Listing pages are cached on the entries they list, so adding a post re-renders that | 231 | Listing pages are cached on the entries they list, so adding a post re-renders that |
| 232 | section's index and nothing else. | 232 | section's index and nothing else. |
| 233 | 233 | ||
| 234 | ### Table of contents and `#+OPTIONS:` | ||
| 235 | |||
| 236 | `page.toc` is the page's headings as a **tree** — `{title, anchor, level, children}` — | ||
| 237 | because a table of contents is one, and rebuilding a tree from a flat list of levels | ||
| 238 | inside a template is what Jinja is worst at. Its anchors come from the same function the | ||
| 239 | renderer uses to emit heading `id`s, so a TOC link cannot drift from the heading it | ||
| 240 | points at. | ||
| 241 | |||
| 242 | ```jinja | ||
| 243 | {% macro toc_list(entries) %} | ||
| 244 | <ul>{% for e in entries %} | ||
| 245 | <li><a href="#{{ e.anchor }}">{{ e.title }}</a> | ||
| 246 | {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li> | ||
| 247 | {% endfor %}</ul> | ||
| 248 | {% endmacro %} | ||
| 249 | {% if page.toc %}{{ toc_list(page.toc) }}{% endif %} | ||
| 250 | ``` | ||
| 251 | |||
| 252 | Org's own per-file export switches are honoured, so a document can turn a feature off for | ||
| 253 | itself the way its author already knows: | ||
| 254 | |||
| 255 | | Switch | Effect | Site default | | ||
| 256 | |---|---|---| | ||
| 257 | | `#+OPTIONS: toc:nil` | empties `page.toc` for this page | `[html] toc = true` | | ||
| 258 | | `#+OPTIONS: num:t` | numbers headings `1.`, `1.1.`, … | `[html] section_numbers = false` | | ||
| 259 | |||
| 260 | **Section numbers default to off, which differs from Emacs on purpose.** | ||
| 261 | `org-export-with-section-numbers` is on there, so an org-published site inherits numbered | ||
| 262 | headings whether or not anyone chose them. Most sites do not want them; `num:t` or | ||
| 263 | `section_numbers = true` gets Emacs' behaviour back, with Emacs' own | ||
| 264 | `section-number-N` classes so the output stays diffable against the oracle. | ||
| 265 | |||
| 234 | ### Excerpts and drafts | 266 | ### Excerpts and drafts |
| 235 | 267 | ||
| 236 | `page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph | 268 | `page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph |
| @@ -323,6 +355,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | |||
| 323 | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | | 355 | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | |
| 324 | | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | | 356 | | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | |
| 325 | | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | | 357 | | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | |
| 358 | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | | ||
| 326 | 359 | ||
| 327 | ### v0.2 in / out | 360 | ### v0.2 in / out |
| 328 | 361 | ||
| @@ -610,7 +643,7 @@ PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config) | |||
| 610 | 643 | ||
| 611 | ``` | 644 | ``` |
| 612 | cargo build | 645 | cargo build |
| 613 | cargo test # 135 tests | 646 | cargo test # 142 tests |
| 614 | cargo run -- init my-site # scaffold a new site | 647 | cargo run -- init my-site # scaffold a new site |
| 615 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | 648 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 616 | cargo run -- build fixtures/site -o _site # whole site (incremental) | 649 | cargo run -- build fixtures/site -o _site # whole site (incremental) |
src/config.rs +18 −1
| @@ -153,11 +153,28 @@ pub struct HtmlOutput { | |||
| 153 | /// beneath it. Set to 0 if your template renders no title of its own, so the | 153 | /// beneath it. Set to 0 if your template renders no title of its own, so the |
| 154 | /// document does not start at `<h2>` with nothing above it. | 154 | /// document does not start at `<h2>` with nothing above it. |
| 155 | pub heading_offset: u8, | 155 | pub heading_offset: u8, |
| 156 | /// Make each page's table of contents available to templates as `page.toc`. | ||
| 157 | /// | ||
| 158 | /// On by default: it is *data*, and whether it appears is the template's business. | ||
| 159 | /// A document turns it off for itself with org's own `#+OPTIONS: toc:nil`, which is | ||
| 160 | /// how ~2% of the reference corpus does it. | ||
| 161 | pub toc: bool, | ||
| 162 | /// Number headings, `1.`, `1.1.`, and so on. | ||
| 163 | /// | ||
| 164 | /// Off by default, which differs from Emacs — `org-export-with-section-numbers` is | ||
| 165 | /// on there, and the reference site inherits numbered headings from it. Most sites | ||
| 166 | /// do not want them, so the default is the taste rather than the inheritance; | ||
| 167 | /// `#+OPTIONS: num:t` or `section_numbers = true` gets Emacs' behaviour back. | ||
| 168 | pub section_numbers: bool, | ||
| 156 | } | 169 | } |
| 157 | 170 | ||
| 158 | impl Default for HtmlOutput { | 171 | impl Default for HtmlOutput { |
| 159 | fn default() -> Self { | 172 | fn default() -> Self { |
| 160 | HtmlOutput { heading_offset: 1 } | 173 | HtmlOutput { |
| 174 | heading_offset: 1, | ||
| 175 | toc: true, | ||
| 176 | section_numbers: false, | ||
| 177 | } | ||
| 161 | } | 178 | } |
| 162 | } | 179 | } |
| 163 | 180 | ||
src/index.rs +2 −6
| @@ -7,7 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf}; | |||
| 7 | use serde::{Deserialize, Serialize}; | 7 | use serde::{Deserialize, Serialize}; |
| 8 | 8 | ||
| 9 | use crate::model::{Document, Section}; | 9 | use crate::model::{Document, Section}; |
| 10 | use crate::util::{output_path, plain_text, slugify}; | 10 | use crate::util::{heading_anchor, output_path, plain_text}; |
| 11 | 11 | ||
| 12 | /// Identity of a link target. A target is owned by exactly one file (spec §4.3). | 12 | /// Identity of a link target. A target is owned by exactly one file (spec §4.3). |
| 13 | /// | 13 | /// |
| @@ -119,11 +119,7 @@ fn index_section( | |||
| 119 | targets: &mut HashMap<TargetId, TargetLocation>, | 119 | targets: &mut HashMap<TargetId, TargetLocation>, |
| 120 | ) { | 120 | ) { |
| 121 | if let Some(h) = §ion.heading { | 121 | if let Some(h) = §ion.heading { |
| 122 | let anchor = h | 122 | let anchor = heading_anchor(h); |
| 123 | .custom_id | ||
| 124 | .clone() | ||
| 125 | .or_else(|| h.id.clone()) | ||
| 126 | .unwrap_or_else(|| slugify(&plain_text(&h.title))); | ||
| 127 | let mut record = |id: TargetId, anchor: Option<String>| { | 123 | let mut record = |id: TargetId, anchor: Option<String>| { |
| 128 | targets.insert( | 124 | targets.insert( |
| 129 | id, | 125 | id, |
src/main.rs +1
| @@ -280,6 +280,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | |||
| 280 | word_count: 0, | 280 | word_count: 0, |
| 281 | reading_time: 0, | 281 | reading_time: 0, |
| 282 | keywords: Default::default(), | 282 | keywords: Default::default(), |
| 283 | toc: org_ssg::util::table_of_contents(&resolved.document.root), | ||
| 283 | }; | 284 | }; |
| 284 | let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); | 285 | let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); |
| 285 | ctx.body = &fragment; | 286 | ctx.body = &fragment; |
src/render.rs +34 −7
| @@ -26,7 +26,7 @@ use syntect::util::LinesWithEndings; | |||
| 26 | use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; | 26 | use crate::model::{Checkbox, Element, Link, LinkTarget, ListKind, Object, Section, TableRow}; |
| 27 | use crate::parser::is_image_target; | 27 | use crate::parser::is_image_target; |
| 28 | use crate::resolve::ResolvedDoc; | 28 | use crate::resolve::ResolvedDoc; |
| 29 | use crate::util::{plain_text, slugify}; | 29 | use crate::util::{heading_anchor, plain_text, slugify}; |
| 30 | 30 | ||
| 31 | /// A rendered HTML fragment (content only — no page chrome; spec §2.4). | 31 | /// A rendered HTML fragment (content only — no page chrome; spec §2.4). |
| 32 | #[derive(Debug, Clone)] | 32 | #[derive(Debug, Clone)] |
| @@ -137,6 +137,8 @@ struct Renderer<'a> { | |||
| 137 | inline_defs: HashMap<String, Vec<Object>>, | 137 | inline_defs: HashMap<String, Vec<Object>>, |
| 138 | /// Reference keys in order of first appearance — drives numbering and note order. | 138 | /// Reference keys in order of first appearance — drives numbering and note order. |
| 139 | order: Vec<String>, | 139 | order: Vec<String>, |
| 140 | /// Counter per heading depth, for section numbers. | ||
| 141 | counters: Vec<usize>, | ||
| 140 | } | 142 | } |
| 141 | 143 | ||
| 142 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the | 144 | /// Options affecting how the tree becomes HTML. Presentation choices that belong to the |
| @@ -147,12 +149,17 @@ pub struct RenderOptions { | |||
| 147 | /// beneath a page title supplied by the layout. See | 149 | /// beneath a page title supplied by the layout. See |
| 148 | /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset). | 150 | /// [`HtmlOutput::heading_offset`](crate::config::HtmlOutput::heading_offset). |
| 149 | pub heading_offset: u8, | 151 | pub heading_offset: u8, |
| 152 | /// Prefix headings with `1.`, `1.1.`, … See | ||
| 153 | /// [`HtmlOutput::section_numbers`](crate::config::HtmlOutput::section_numbers). | ||
| 154 | pub section_numbers: bool, | ||
| 150 | } | 155 | } |
| 151 | 156 | ||
| 152 | impl Default for RenderOptions { | 157 | impl Default for RenderOptions { |
| 153 | fn default() -> Self { | 158 | fn default() -> Self { |
| 159 | let html = crate::config::HtmlOutput::default(); | ||
| 154 | RenderOptions { | 160 | RenderOptions { |
| 155 | heading_offset: crate::config::HtmlOutput::default().heading_offset, | 161 | heading_offset: html.heading_offset, |
| 162 | section_numbers: html.section_numbers, | ||
| 156 | } | 163 | } |
| 157 | } | 164 | } |
| 158 | } | 165 | } |
| @@ -170,6 +177,7 @@ pub fn render_with(doc: &ResolvedDoc, highlighter: &dyn Highlighter, opts: &Rend | |||
| 170 | block_defs: HashMap::new(), | 177 | block_defs: HashMap::new(), |
| 171 | inline_defs: HashMap::new(), | 178 | inline_defs: HashMap::new(), |
| 172 | order: Vec::new(), | 179 | order: Vec::new(), |
| 180 | counters: Vec::new(), | ||
| 173 | }; | 181 | }; |
| 174 | r.collect_defs(&doc.document.root); | 182 | r.collect_defs(&doc.document.root); |
| 175 | let mut out = String::new(); | 183 | let mut out = String::new(); |
| @@ -190,11 +198,7 @@ impl Renderer<'_> { | |||
| 190 | fn render_section(&mut self, section: &Section, out: &mut String) { | 198 | fn render_section(&mut self, section: &Section, out: &mut String) { |
| 191 | if let Some(h) = §ion.heading { | 199 | if let Some(h) = §ion.heading { |
| 192 | let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); | 200 | let level = h.level.saturating_add(self.opts.heading_offset).clamp(1, 6); |
| 193 | let anchor = h | 201 | let anchor = heading_anchor(h); |
| 194 | .custom_id | ||
| 195 | .clone() | ||
| 196 | .or_else(|| h.id.clone()) | ||
| 197 | .unwrap_or_else(|| slugify(&plain_text(&h.title))); | ||
| 198 | // A heading with no title text has no meaningful slug; emit no `id` at all | 202 | // A heading with no title text has no meaningful slug; emit no `id` at all |
| 199 | // rather than a run of duplicate empty ones. | 203 | // rather than a run of duplicate empty ones. |
| 200 | if anchor.is_empty() { | 204 | if anchor.is_empty() { |
| @@ -202,6 +206,13 @@ impl Renderer<'_> { | |||
| 202 | } else { | 206 | } else { |
| 203 | out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); | 207 | out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); |
| 204 | } | 208 | } |
| 209 | if self.opts.section_numbers { | ||
| 210 | let number = self.next_section_number(h.level); | ||
| 211 | out.push_str(&format!( | ||
| 212 | "<span class=\"section-number-{}\">{number}</span> ", | ||
| 213 | level | ||
| 214 | )); | ||
| 215 | } | ||
| 205 | // Keyword/priority markup mirrors Emacs' own HTML export classes, so output | 216 | // Keyword/priority markup mirrors Emacs' own HTML export classes, so output |
| 206 | // stays diffable against an `emacs --batch` oracle. | 217 | // stays diffable against an `emacs --batch` oracle. |
| 207 | if let Some(todo) = &h.todo { | 218 | if let Some(todo) = &h.todo { |
| @@ -232,6 +243,22 @@ impl Renderer<'_> { | |||
| 232 | } | 243 | } |
| 233 | } | 244 | } |
| 234 | 245 | ||
| 246 | /// The next section number at `level`, e.g. `1.`, `1.1.`, `2.`. | ||
| 247 | /// | ||
| 248 | /// Deeper levels reset when a shallower one advances, and a document that skips a | ||
| 249 | /// level (a `***` under a `*`) simply starts the missing levels at 1 rather than | ||
| 250 | /// being treated as malformed. | ||
| 251 | fn next_section_number(&mut self, level: u8) -> String { | ||
| 252 | let depth = usize::from(level).max(1); | ||
| 253 | self.counters.truncate(depth); | ||
| 254 | while self.counters.len() < depth { | ||
| 255 | self.counters.push(0); | ||
| 256 | } | ||
| 257 | self.counters[depth - 1] += 1; | ||
| 258 | let parts: Vec<String> = self.counters.iter().map(usize::to_string).collect(); | ||
| 259 | format!("{}.", parts.join(".")) | ||
| 260 | } | ||
| 261 | |||
| 235 | fn render_element(&mut self, element: &Element, out: &mut String) { | 262 | fn render_element(&mut self, element: &Element, out: &mut String) { |
| 236 | match element { | 263 | match element { |
| 237 | Element::Paragraph(objs) => { | 264 | Element::Paragraph(objs) => { |
src/site.rs +21 −11
| @@ -33,8 +33,8 @@ use crate::template::{ | |||
| 33 | Templater, | 33 | Templater, |
| 34 | }; | 34 | }; |
| 35 | use crate::util::{ | 35 | use crate::util::{ |
| 36 | document_text, first_paragraph, is_draft, iso_date, output_path, output_url, relative_root, | 36 | document_text, first_paragraph, is_draft, iso_date, option_enabled, output_path, output_url, |
| 37 | slugify, | 37 | relative_root, slugify, table_of_contents, |
| 38 | }; | 38 | }; |
| 39 | 39 | ||
| 40 | /// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure | 40 | /// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure |
| @@ -481,6 +481,7 @@ fn listing_context(listing: &Listing) -> PageContext { | |||
| 481 | word_count: 0, | 481 | word_count: 0, |
| 482 | reading_time: 0, | 482 | reading_time: 0, |
| 483 | keywords: Default::default(), | 483 | keywords: Default::default(), |
| 484 | toc: Vec::new(), | ||
| 484 | } | 485 | } |
| 485 | } | 486 | } |
| 486 | 487 | ||
| @@ -613,7 +614,7 @@ fn prepare_pages( | |||
| 613 | .collect(); | 614 | .collect(); |
| 614 | 615 | ||
| 615 | PagePrep { | 616 | PagePrep { |
| 616 | context: page_context(doc, &output), | 617 | context: page_context(doc, &output, config), |
| 617 | source: doc.source_path.clone(), | 618 | source: doc.source_path.clone(), |
| 618 | output, | 619 | output, |
| 619 | title: page_title(doc), | 620 | title: page_title(doc), |
| @@ -641,7 +642,6 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |||
| 641 | let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; | 642 | let templater = Templater::load(Some(&src.join(&config.templates.dir)), &config.site.base_url)?; |
| 642 | let site = site_context(&config); | 643 | let site = site_context(&config); |
| 643 | let listing = page_listing(&config, &preps); | 644 | let listing = page_listing(&config, &preps); |
| 644 | let render_opts = render_options(&config); | ||
| 645 | 645 | ||
| 646 | let mut pages = Vec::new(); | 646 | let mut pages = Vec::new(); |
| 647 | let mut broken = Vec::new(); | 647 | let mut broken = Vec::new(); |
| @@ -649,7 +649,7 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |||
| 649 | for t in &p.broken { | 649 | for t in &p.broken { |
| 650 | broken.push((p.source.clone(), t.clone())); | 650 | broken.push((p.source.clone(), t.clone())); |
| 651 | } | 651 | } |
| 652 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?; | 652 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &config, p)?; |
| 653 | pages.push(BuiltPage { | 653 | pages.push(BuiltPage { |
| 654 | source: p.source.clone(), | 654 | source: p.source.clone(), |
| 655 | output: p.output.clone(), | 655 | output: p.output.clone(), |
| @@ -660,9 +660,12 @@ pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |||
| 660 | Ok((pages, broken)) | 660 | Ok((pages, broken)) |
| 661 | } | 661 | } |
| 662 | 662 | ||
| 663 | fn render_options(config: &Config) -> RenderOptions { | 663 | /// Render options for one page: the site's settings, with the document's own |
| 664 | /// `#+OPTIONS:` switches applied on top. | ||
| 665 | fn render_options(config: &Config, keywords: &crate::model::Keywords) -> RenderOptions { | ||
| 664 | RenderOptions { | 666 | RenderOptions { |
| 665 | heading_offset: config.html.heading_offset, | 667 | heading_offset: config.html.heading_offset, |
| 668 | section_numbers: option_enabled(keywords, "num", config.html.section_numbers), | ||
| 666 | } | 669 | } |
| 667 | } | 670 | } |
| 668 | 671 | ||
| @@ -691,10 +694,13 @@ fn render_page( | |||
| 691 | highlighter: &SyntectHighlighter, | 694 | highlighter: &SyntectHighlighter, |
| 692 | site: &SiteContext, | 695 | site: &SiteContext, |
| 693 | pages: Option<&[PageContext]>, | 696 | pages: Option<&[PageContext]>, |
| 694 | render_opts: &RenderOptions, | 697 | config: &Config, |
| 695 | p: &PagePrep, | 698 | p: &PagePrep, |
| 696 | ) -> Result<String> { | 699 | ) -> Result<String> { |
| 697 | let Html(fragment) = render_with(&p.resolved, highlighter, render_opts); | 700 | // Options are resolved per page, because `#+OPTIONS:` is a per-document override of |
| 701 | // the site setting. | ||
| 702 | let opts = render_options(config, &p.resolved.document.keywords); | ||
| 703 | let Html(fragment) = render_with(&p.resolved, highlighter, &opts); | ||
| 698 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. | 704 | // Relative to the *output* path, since `#+SLUG:` can move a page between depths. |
| 699 | let root = relative_root(&p.output); | 705 | let root = relative_root(&p.output); |
| 700 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); | 706 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); |
| @@ -833,7 +839,6 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | |||
| 833 | let highlighter = SyntectHighlighter::new(); | 839 | let highlighter = SyntectHighlighter::new(); |
| 834 | let site = site_context(&cfg); | 840 | let site = site_context(&cfg); |
| 835 | let listing = page_listing(&cfg, &preps); | 841 | let listing = page_listing(&cfg, &preps); |
| 836 | let render_opts = render_options(&cfg); | ||
| 837 | let mut report = SiteReport::default(); | 842 | let mut report = SiteReport::default(); |
| 838 | 843 | ||
| 839 | // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes | 844 | // RENDER + TEMPLATE + EMIT, in parallel. This is where a build's time actually goes |
| @@ -855,7 +860,7 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | |||
| 855 | if let Some(parent) = dest.parent() { | 860 | if let Some(parent) = dest.parent() { |
| 856 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | 861 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; |
| 857 | } | 862 | } |
| 858 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &render_opts, p)?; | 863 | let html = render_page(&templater, &highlighter, &site, listing.as_deref(), &cfg, p)?; |
| 859 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; | 864 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; |
| 860 | Ok(true) | 865 | Ok(true) |
| 861 | }) | 866 | }) |
| @@ -1163,7 +1168,7 @@ fn is_top_level(output: &Utf8Path) -> bool { | |||
| 1163 | /// Everything a template can know about one page. Every `#+KEYWORD:` is passed through | 1168 | /// Everything a template can know about one page. Every `#+KEYWORD:` is passed through |
| 1164 | /// under its lowercased name, so a template can use metadata this crate has never heard | 1169 | /// under its lowercased name, so a template can use metadata this crate has never heard |
| 1165 | /// of without the crate needing a release to support it. | 1170 | /// of without the crate needing a release to support it. |
| 1166 | fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { | 1171 | fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageContext { |
| 1167 | let words = document_text(&doc.root).split_whitespace().count(); | 1172 | let words = document_text(&doc.root).split_whitespace().count(); |
| 1168 | let keyword = |name: &str| { | 1173 | let keyword = |name: &str| { |
| 1169 | doc.keywords | 1174 | doc.keywords |
| @@ -1184,6 +1189,11 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { | |||
| 1184 | .unwrap_or_default(), | 1189 | .unwrap_or_default(), |
| 1185 | word_count: words, | 1190 | word_count: words, |
| 1186 | reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), | 1191 | reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), |
| 1192 | toc: if option_enabled(&doc.keywords, "toc", config.html.toc) { | ||
| 1193 | table_of_contents(&doc.root) | ||
| 1194 | } else { | ||
| 1195 | Vec::new() | ||
| 1196 | }, | ||
| 1187 | tags: keyword("FILETAGS") | 1197 | tags: keyword("FILETAGS") |
| 1188 | .unwrap_or_default() | 1198 | .unwrap_or_default() |
| 1189 | .split(':') | 1199 | .split(':') |
src/template.rs +19 −2
| @@ -63,12 +63,15 @@ pub struct PageContext { | |||
| 63 | /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use | 63 | /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use |
| 64 | /// project-specific metadata this crate has never heard of. | 64 | /// project-specific metadata this crate has never heard of. |
| 65 | pub keywords: BTreeMap<String, String>, | 65 | pub keywords: BTreeMap<String, String>, |
| 66 | /// The page's headings as a tree. Empty when the page has none, when the site turns | ||
| 67 | /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`. | ||
| 68 | pub toc: Vec<crate::util::TocEntry>, | ||
| 66 | } | 69 | } |
| 67 | 70 | ||
| 68 | /// The built-in layout, used when the templates directory has no `base.html`. | 71 | /// The built-in layout, used when the templates directory has no `base.html`. |
| 69 | /// Deliberately plain: it should be a working starting point and an obvious thing to | 72 | /// Deliberately plain: it should be a working starting point and an obvious thing to |
| 70 | /// replace, not a design anyone has to live with. | 73 | /// replace, not a design anyone has to live with. |
| 71 | const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | 74 | const BASE_TEMPLATE: &str = r##"<!DOCTYPE html> |
| 72 | <html lang="{{ site.language }}"> | 75 | <html lang="{{ site.language }}"> |
| 73 | <head> | 76 | <head> |
| 74 | <meta charset="utf-8"> | 77 | <meta charset="utf-8"> |
| @@ -100,10 +103,24 @@ const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | |||
| 100 | {%- if page.date %} | 103 | {%- if page.date %} |
| 101 | <p class="page-date">{{ page.date }}</p> | 104 | <p class="page-date">{{ page.date }}</p> |
| 102 | {%- endif %} | 105 | {%- endif %} |
| 106 | {%- if page.toc | length > 1 %} | ||
| 107 | {%- macro toc_list(entries) %} | ||
| 108 | <ul> | ||
| 109 | {%- for entry in entries %} | ||
| 110 | <li><a href="#{{ entry.anchor }}">{{ entry.title }}</a> | ||
| 111 | {%- if entry.children %}{{ toc_list(entry.children) }}{% endif %}</li> | ||
| 112 | {%- endfor %} | ||
| 113 | </ul> | ||
| 114 | {%- endmacro %} | ||
| 115 | <nav class="toc" aria-label="Table of contents"> | ||
| 116 | <h2>Contents</h2> | ||
| 117 | {{- toc_list(page.toc) }} | ||
| 118 | </nav> | ||
| 119 | {%- endif %} | ||
| 103 | {{ body | safe }}</main> | 120 | {{ body | safe }}</main> |
| 104 | </body> | 121 | </body> |
| 105 | </html> | 122 | </html> |
| 106 | "#; | 123 | "##; |
| 107 | 124 | ||
| 108 | /// The name a template must have to serve as the page layout. | 125 | /// The name a template must have to serve as the page layout. |
| 109 | pub const BASE_TEMPLATE_NAME: &str = "base.html"; | 126 | pub const BASE_TEMPLATE_NAME: &str = "base.html"; |
src/util.rs +76 −1
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | ||
| 5 | use camino::{Utf8Path, Utf8PathBuf}; | 5 | use camino::{Utf8Path, Utf8PathBuf}; |
| 6 | 6 | ||
| 7 | use crate::model::{Element, Keywords, Object, Section, TableRow}; | 7 | use crate::model::{Element, Heading, Keywords, Object, Section, TableRow}; |
| 8 | 8 | ||
| 9 | /// The output path for a document, relative to the site root. | 9 | /// The output path for a document, relative to the site root. |
| 10 | /// | 10 | /// |
| @@ -76,6 +76,81 @@ fn plain_text_into(objs: &[Object], out: &mut String) { | |||
| 76 | } | 76 | } |
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | /// The `id` a heading is emitted with, and therefore the fragment anything linking to it | ||
| 80 | /// must use. | ||
| 81 | /// | ||
| 82 | /// One function because there are three callers — the renderer emitting the `id`, INDEX | ||
| 83 | /// recording link targets, and the table of contents linking into the page. Any drift | ||
| 84 | /// between them is a link that silently goes nowhere. | ||
| 85 | pub fn heading_anchor(heading: &Heading) -> String { | ||
| 86 | heading | ||
| 87 | .custom_id | ||
| 88 | .clone() | ||
| 89 | .or_else(|| heading.id.clone()) | ||
| 90 | .unwrap_or_else(|| slugify(&plain_text(&heading.title))) | ||
| 91 | } | ||
| 92 | |||
| 93 | /// One entry in a page's table of contents. | ||
| 94 | #[derive(Debug, Clone, serde::Serialize)] | ||
| 95 | pub struct TocEntry { | ||
| 96 | pub title: String, | ||
| 97 | /// Fragment identifier, without the `#`. | ||
| 98 | pub anchor: String, | ||
| 99 | /// Org heading level, 1-based, before any `heading_offset` is applied. | ||
| 100 | pub level: u8, | ||
| 101 | pub children: Vec<TocEntry>, | ||
| 102 | } | ||
| 103 | |||
| 104 | /// A page's table of contents, mirroring its heading tree. | ||
| 105 | /// | ||
| 106 | /// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from | ||
| 107 | /// a flat list of levels inside a template is the kind of thing Jinja is bad at. | ||
| 108 | pub fn table_of_contents(root: &Section) -> Vec<TocEntry> { | ||
| 109 | root.children.iter().map(toc_entry).collect() | ||
| 110 | } | ||
| 111 | |||
| 112 | fn toc_entry(section: &Section) -> TocEntry { | ||
| 113 | let heading = section.heading.as_ref(); | ||
| 114 | TocEntry { | ||
| 115 | title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(), | ||
| 116 | anchor: heading.map(heading_anchor).unwrap_or_default(), | ||
| 117 | level: heading.map(|h| h.level).unwrap_or(1), | ||
| 118 | children: section.children.iter().map(toc_entry).collect(), | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | /// Parse `#+OPTIONS:` into its `key:value` switches. | ||
| 123 | /// | ||
| 124 | /// Org's per-file export switches are a space-separated list — `toc:nil num:t` — and | ||
| 125 | /// this is the standard way an author turns a feature off for one document. | ||
| 126 | pub fn export_options(keywords: &Keywords) -> std::collections::BTreeMap<String, String> { | ||
| 127 | let mut out = std::collections::BTreeMap::new(); | ||
| 128 | for (_, value) in keywords | ||
| 129 | .entries | ||
| 130 | .iter() | ||
| 131 | .filter(|(k, _)| k.eq_ignore_ascii_case("OPTIONS")) | ||
| 132 | { | ||
| 133 | for token in value.split_whitespace() { | ||
| 134 | if let Some((key, val)) = token.split_once(':') { | ||
| 135 | if !key.is_empty() { | ||
| 136 | out.insert(key.to_ascii_lowercase(), val.to_ascii_lowercase()); | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | out | ||
| 142 | } | ||
| 143 | |||
| 144 | /// Whether an `#+OPTIONS:` switch is on, falling back to the site default when the | ||
| 145 | /// document says nothing. | ||
| 146 | pub fn option_enabled(keywords: &Keywords, key: &str, default: bool) -> bool { | ||
| 147 | match export_options(keywords).get(key).map(String::as_str) { | ||
| 148 | Some("nil" | "false" | "no" | "0" | "off") => false, | ||
| 149 | Some(_) => true, | ||
| 150 | None => default, | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 79 | /// Is this document marked as a draft? | 154 | /// Is this document marked as a draft? |
| 80 | /// | 155 | /// |
| 81 | /// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the | 156 | /// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the |
tests/config.rs +186
| @@ -1690,3 +1690,189 @@ fn draft_truthiness_is_forgiving_but_respects_an_explicit_negative() { | |||
| 1690 | "no keyword at all means published" | 1690 | "no keyword at all means published" |
| 1691 | ); | 1691 | ); |
| 1692 | } | 1692 | } |
| 1693 | |||
| 1694 | // --------------------------------------------------------------------------- | ||
| 1695 | // Table of contents, section numbers, and #+OPTIONS: | ||
| 1696 | // --------------------------------------------------------------------------- | ||
| 1697 | |||
| 1698 | /// A page with nested headings, one of which sets its own `:CUSTOM_ID:`. | ||
| 1699 | fn write_toc_site(src: &Utf8PathBuf, options: &str, config: &str) { | ||
| 1700 | std::fs::create_dir_all(src.join("templates")).unwrap(); | ||
| 1701 | std::fs::write( | ||
| 1702 | src.join("index.org"), | ||
| 1703 | format!( | ||
| 1704 | "#+TITLE: Contents\n{options}\n\nIntro.\n\n\ | ||
| 1705 | * First\nBody.\n** Nested\nBody.\n\ | ||
| 1706 | * Second\n:PROPERTIES:\n:CUSTOM_ID: chosen-id\n:END:\nBody.\n" | ||
| 1707 | ), | ||
| 1708 | ) | ||
| 1709 | .unwrap(); | ||
| 1710 | std::fs::write( | ||
| 1711 | src.join("templates/base.html"), | ||
| 1712 | "<html><body>{% macro walk(es) %}<ul>{% for e in es %}\ | ||
| 1713 | <li>{{ e.level }}:{{ e.title }}@{{ e.anchor }}{% if e.children %}{{ walk(e.children) }}\ | ||
| 1714 | {% endif %}</li>{% endfor %}</ul>{% endmacro %}\ | ||
| 1715 | <nav>{{ walk(page.toc) }}</nav>{{ body | safe }}</body></html>", | ||
| 1716 | ) | ||
| 1717 | .unwrap(); | ||
| 1718 | std::fs::write(src.join("org-ssg.toml"), config).unwrap(); | ||
| 1719 | } | ||
| 1720 | |||
| 1721 | /// A table of contents is a tree, and reconstructing one from a flat list of levels | ||
| 1722 | /// inside a template is the kind of thing Jinja is bad at. | ||
| 1723 | #[test] | ||
| 1724 | fn the_table_of_contents_mirrors_the_heading_tree() { | ||
| 1725 | let root = tmpdir("toc"); | ||
| 1726 | let src = root.join("src"); | ||
| 1727 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1728 | write_toc_site(&src, "", ""); | ||
| 1729 | let out = root.join("out"); | ||
| 1730 | build(&src, &out); | ||
| 1731 | |||
| 1732 | let html = page(&out, "index.html"); | ||
| 1733 | assert!(html.contains("<li>1:First@first"), "top level:\n{html}"); | ||
| 1734 | assert!( | ||
| 1735 | html.contains("<li>1:First@first<ul><li>2:Nested@nested</li></ul></li>"), | ||
| 1736 | "a child nests inside its parent's item:\n{html}" | ||
| 1737 | ); | ||
| 1738 | } | ||
| 1739 | |||
| 1740 | /// The TOC links into the page, so its anchors must be the ones the headings actually | ||
| 1741 | /// carry — including a heading that chose its own `:CUSTOM_ID:`. | ||
| 1742 | #[test] | ||
| 1743 | fn toc_anchors_match_the_ids_the_headings_are_emitted_with() { | ||
| 1744 | let root = tmpdir("tocanchor"); | ||
| 1745 | let src = root.join("src"); | ||
| 1746 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1747 | write_toc_site(&src, "", ""); | ||
| 1748 | std::fs::write( | ||
| 1749 | src.join("templates/base.html"), | ||
| 1750 | "<html><body>{% for e in page.toc %}<a href=\"#{{ e.anchor }}\">x</a>{% endfor %}\ | ||
| 1751 | {{ body | safe }}</body></html>", | ||
| 1752 | ) | ||
| 1753 | .unwrap(); | ||
| 1754 | let out = root.join("out"); | ||
| 1755 | build(&src, &out); | ||
| 1756 | |||
| 1757 | let html = page(&out, "index.html"); | ||
| 1758 | let links: Vec<&str> = html.matches("href=\"#").map(|_| "").collect(); | ||
| 1759 | assert_eq!(links.len(), 2, "one link per top-level heading"); | ||
| 1760 | assert!(html.contains("href=\"#chosen-id\""), ":CUSTOM_ID: wins:\n{html}"); | ||
| 1761 | assert!( | ||
| 1762 | html.contains("<h2 id=\"chosen-id\">"), | ||
| 1763 | "and the heading carries that same id:\n{html}" | ||
| 1764 | ); | ||
| 1765 | } | ||
| 1766 | |||
| 1767 | /// Org's own per-file switch. 4 of the reference corpus's 179 files use exactly this to | ||
| 1768 | /// turn the table of contents off for one document. | ||
| 1769 | #[test] | ||
| 1770 | fn options_toc_nil_turns_the_toc_off_for_one_document() { | ||
| 1771 | let root = tmpdir("tocnil"); | ||
| 1772 | let src = root.join("src"); | ||
| 1773 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1774 | write_toc_site(&src, "#+OPTIONS: toc:nil", ""); | ||
| 1775 | let out = root.join("out"); | ||
| 1776 | build(&src, &out); | ||
| 1777 | |||
| 1778 | let html = page(&out, "index.html"); | ||
| 1779 | assert!(html.contains("<nav><ul></ul></nav>"), "the toc is empty:\n{html}"); | ||
| 1780 | assert!(html.contains("First"), "the page itself still renders:\n{html}"); | ||
| 1781 | } | ||
| 1782 | |||
| 1783 | /// The site-wide switch, for someone who never wants one. | ||
| 1784 | #[test] | ||
| 1785 | fn the_toc_can_be_disabled_site_wide() { | ||
| 1786 | let root = tmpdir("tocoff"); | ||
| 1787 | let src = root.join("src"); | ||
| 1788 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1789 | write_toc_site(&src, "", "[html]\ntoc = false\n"); | ||
| 1790 | let out = root.join("out"); | ||
| 1791 | build(&src, &out); | ||
| 1792 | |||
| 1793 | assert!(page(&out, "index.html").contains("<nav><ul></ul></nav>")); | ||
| 1794 | } | ||
| 1795 | |||
| 1796 | /// Numbering is off by default — which differs from Emacs deliberately — and | ||
| 1797 | /// `#+OPTIONS: num:t` gets Emacs' behaviour back for a document. | ||
| 1798 | #[test] | ||
| 1799 | fn section_numbers_are_off_by_default_and_enabled_per_document() { | ||
| 1800 | let root = tmpdir("secnum"); | ||
| 1801 | let src = root.join("src"); | ||
| 1802 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1803 | write_toc_site(&src, "", ""); | ||
| 1804 | let out = root.join("out"); | ||
| 1805 | build(&src, &out); | ||
| 1806 | assert!( | ||
| 1807 | !page(&out, "index.html").contains("section-number"), | ||
| 1808 | "no numbers unless asked for" | ||
| 1809 | ); | ||
| 1810 | |||
| 1811 | write_toc_site(&src, "#+OPTIONS: num:t", ""); | ||
| 1812 | let out2 = root.join("out2"); | ||
| 1813 | build(&src, &out2); | ||
| 1814 | let html = page(&out2, "index.html"); | ||
| 1815 | // Emacs' own class names, so output stays diffable against the oracle. | ||
| 1816 | assert!(html.contains("<span class=\"section-number-2\">1.</span> First"), "{html}"); | ||
| 1817 | assert!(html.contains("<span class=\"section-number-3\">1.1.</span> Nested"), "{html}"); | ||
| 1818 | assert!(html.contains("<span class=\"section-number-2\">2.</span> Second"), "{html}"); | ||
| 1819 | } | ||
| 1820 | |||
| 1821 | /// Deeper levels have to reset when a shallower one advances, or the second chapter's | ||
| 1822 | /// first section is numbered 1.3. | ||
| 1823 | #[test] | ||
| 1824 | fn section_numbering_resets_at_each_level() { | ||
| 1825 | let root = tmpdir("secreset"); | ||
| 1826 | let src = root.join("src"); | ||
| 1827 | std::fs::create_dir_all(&src).unwrap(); | ||
| 1828 | std::fs::write( | ||
| 1829 | src.join("index.org"), | ||
| 1830 | "#+TITLE: T\n#+OPTIONS: num:t\n\n\ | ||
| 1831 | * One\n** A\n** B\n* Two\n** C\n*** Deep\n* Three\n", | ||
| 1832 | ) | ||
| 1833 | .unwrap(); | ||
| 1834 | std::fs::write(src.join("org-ssg.toml"), "").unwrap(); | ||
| 1835 | let out = root.join("out"); | ||
| 1836 | build(&src, &out); | ||
| 1837 | |||
| 1838 | let html = page(&out, "index.html"); | ||
| 1839 | for (number, title) in [ | ||
| 1840 | ("1.", "One"), | ||
| 1841 | ("1.1.", "A"), | ||
| 1842 | ("1.2.", "B"), | ||
| 1843 | ("2.", "Two"), | ||
| 1844 | ("2.1.", "C"), | ||
| 1845 | ("2.1.1.", "Deep"), | ||
| 1846 | ("3.", "Three"), | ||
| 1847 | ] { | ||
| 1848 | assert!( | ||
| 1849 | html.contains(&format!("</span> {title}</h")), | ||
| 1850 | "{title} should be numbered:\n{html}" | ||
| 1851 | ); | ||
| 1852 | assert!(html.contains(&format!(">{number}</span> {title}")), "{title} = {number}:\n{html}"); | ||
| 1853 | } | ||
| 1854 | } | ||
| 1855 | |||
| 1856 | /// `#+OPTIONS:` is a space-separated list of switches, and org spells "off" several ways. | ||
| 1857 | #[test] | ||
| 1858 | fn export_options_parse_as_org_writes_them() { | ||
| 1859 | use org_ssg::model::Keywords; | ||
| 1860 | use org_ssg::util::option_enabled; | ||
| 1861 | let keywords = |v: &str| Keywords { | ||
| 1862 | entries: vec![("OPTIONS".to_string(), v.to_string())], | ||
| 1863 | }; | ||
| 1864 | |||
| 1865 | assert!(!option_enabled(&keywords("toc:nil num:t"), "toc", true)); | ||
| 1866 | assert!(option_enabled(&keywords("toc:nil num:t"), "num", false)); | ||
| 1867 | assert!( | ||
| 1868 | option_enabled(&keywords("toc:nil"), "num", true), | ||
| 1869 | "a switch the document does not mention keeps the site default" | ||
| 1870 | ); | ||
| 1871 | assert!( | ||
| 1872 | !option_enabled(&Keywords::default(), "toc", false), | ||
| 1873 | "no #+OPTIONS: at all keeps the site default" | ||
| 1874 | ); | ||
| 1875 | for off in ["nil", "false", "no", "0", "off"] { | ||
| 1876 | assert!(!option_enabled(&keywords(&format!("toc:{off}")), "toc", true), "{off}"); | ||
| 1877 | } | ||
| 1878 | } | ||
tests/snapshots/site__site_guide_html.snap +7
| @@ -21,6 +21,13 @@ expression: "page(&pages, \"guide.org\").html" | |||
| 21 | </header> | 21 | </header> |
| 22 | <main> | 22 | <main> |
| 23 | <h1>Guide</h1> | 23 | <h1>Guide</h1> |
| 24 | <nav class="toc" aria-label="Table of contents"> | ||
| 25 | <h2>Contents</h2> | ||
| 26 | <ul> | ||
| 27 | <li><a href="#setup">Setup</a></li> | ||
| 28 | <li><a href="#data">Data</a></li> | ||
| 29 | </ul> | ||
| 30 | </nav> | ||
| 24 | <h2 id="setup">Setup</h2> | 31 | <h2 id="setup">Setup</h2> |
| 25 | <p>Install the steps in order.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> Then return <a href="index.html">home</a>.</p> | 32 | <p>Install the steps in order.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> Then return <a href="index.html">home</a>.</p> |
| 26 | <h2 id="data">Data</h2> | 33 | <h2 id="data">Data</h2> |