diff --git a/glossy/0.1.1/LICENSE b/glossy/0.1.1/LICENSE new file mode 100644 index 0000000..35d39a7 --- /dev/null +++ b/glossy/0.1.1/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/glossy/0.1.1/README.md b/glossy/0.1.1/README.md new file mode 100644 index 0000000..01fe0b2 --- /dev/null +++ b/glossy/0.1.1/README.md @@ -0,0 +1,199 @@ +# Glossy + +This package provides utilities to manage and render glossaries within +documents. It includes functions to define and use glossary terms, track their +usage, and generate a glossary list with references to where terms are used in +the document. + +![image of sample output](thumbnail.png) + +## Motivation + +Glossy is heavily inspired by +[glossarium](https://typst.app/universe/package/glossarium), with a few key +different goals: + +1. Provide a simple interface which allows for complete control over glossary + display. To do this, `glossy`'s `#glossary()` function accepts a theme + parameter. The goal here is to separate presentation and logic. +1. Simplify the user interface as much as possible. Glossy has exactly two + exports, `init-glossary` and `glossary`. +1. Double-down on `glossy`'s excellent `@term` reference approach, completely + eliminating the need to make any calls to `gls()` and friends. +1. Mimic established patterns and best practices. For example, `glossy`'s + `#glossary()` function is intentionally similar (in naming and parameters) to + `typst`'s built-int `#bibliography`, to the degree possible. +1. Simplify the implementation. The `glossy` code is significantly shorter and + easier to understand. + +## Features + +- Define glossary terms with short and long forms, descriptions, and grouping. +- Automatically tracks term usage in the document, making references simple + through @labels. +- Supports modifiers to adjust how terms are displayed (capitalize, pluralize, + etc.). +- Generates a formatted glossary section with backlinks to term occurrences in + the document. +- Easily customizable themes for rendering glossary sections, groups, and + entries. + +## Usage + +### Import the package + +```typst +#import "@preview/glossy:0.1.1": * + +``` + +### Defining Glossary Terms + +Use the `init-glossary` function to initialize glossary entries and process +document content: + +```typst +#let myGlossary = ( + ( + key: "html", + short: "HTML", + long: "Hypertext Markup Language", + description: "A standard language for creating web pages", + group: "Web" + ), + ( + key: "css", + short: "CSS", + long: "Cascading Style Sheets", + description: "A stylesheet language used for describing the presentation of a document", + group: "Web" + ), + ( + key: "tps", + short: "TPS", + long: "test procedure specification" + ), + // Add more entries as needed +) + +#show: init-glossary.with(myGlossary) + +// Your document content here, where glossary terms are used. +``` + +Note that you could also load glossary entries from a data file using +[#yaml()](https://typst.app/docs/reference/data-loading/yaml/), +[#json()](https://typst.app/docs/reference/data-loading/json/), or +[similar](https://typst.app/docs/reference/data-loading/). + +### Using Glossary Terms in Your Document + +Use glossary terms within your text using the `@reference` functionality in +typst. + +For example, `@html` and `@css` are used in the following text: + +```typst +In modern web development, languages like @html and @css are essential. + +Now make sure I get your @tps:short reports by 2pm! +``` + +... which should output: + +```text +In modern web development, languages like Hypertext Markup Language (HTML) and +Cascading Style Sheets (CSS) are essential. + +Now make sure I get your TPS reports by 2pm! +``` + +You can control how each term is displayed using the following modifiers: + +- **cap**: Capitalizes the term. +- **pl**: Uses the plural form of the term. +- **both**: Displays both the long and short forms (e.g., "Long Form (Short Form)"). +- **short**: Displays only the short form. +- **long**: Displays only the long form. + +Each modifier is separated by a colon (':') character. Examples: + +| **Input** | **Output** | +| ----------------------- | -------------------------------------------- | +| `@tps` (first use) | "technical procedure specification (TPS)" | +| `@tps` (subsequent use) | "TPS" | +| `@tps:short` | "TPS" | +| `@tps:long` | "technical procedure specification (TPS)" | +| `@tps:both` | "technical procedure specification (TPS)" | +| `@tps:long:cap` | "Technical procedure specification" | +| `@tps:long:pl` | "technical procedure specifications" | +| `@tps:short:pl` | "TPSes" | +| `@tps:both:pl:cap` | "Technical procedure specifications (TPSes)" | + +Combine modifiers in any way you like. But know that if you combine any of +`short`, `long`, and/or `both`, only one will be used. + +### Generating the Glossary + +To display the glossary in your document, use the `glossary` function. Customize +the title and specify a theme if desired: + +```typst +#glossary(title: "Web Development Glossary", theme: my-theme, groups: ("Web")) +``` + +### Themes + +Customize the appearance of the glossary using themes. Each theme defines how +sections, groups, and entries are displayed. + +- **section**: This is a main glossary section. It contains multiple glossary groups. +- **group**: A group within the main glossary section. Contains multiple + entries. Can be an empty string for terms with no group (`""`). +- **entry**: A single entry within a group. This is the actual term, and can + include any of the fields in entry. Contains the following fields: + - `key`: The term key. (always present and unique) + - `short`: The short form of the term. (always present) + - `long`: The long form. (can be `none`) + - `description`: The description or definition. (can be `none`) + - `pages`: Pages with links to term usage in doc. (always present) + +Example of a minimal theme: + +```typst +#let my-theme = ( + section: (title, body) => { + heading(level: 1, title) + body + }, + group: (name, i, n, body) => { + // i is the group's index, n is total number of groups + if name != "" and n > 1 { + heading(level: 2, name) + } + body + }, + entry: (entry, i, n) => { + // i is the entry's index, n is total number of entries + let output = [#entry.short] + if entry.long != none { + output = [#output -- #entry.long] + } + if entry.description != none { + output = [#output: #entry.description] + } + block( + grid( + columns: (auto, 1fr, auto), + output, + repeat([#h(0.25em) . #h(0.25em)]), + entry.pages, + ) + ) + } +) +``` + +## License + +This project is licensed under the MIT License. diff --git a/glossy/0.1.1/examples/readme.pdf b/glossy/0.1.1/examples/readme.pdf new file mode 100644 index 0000000..d828fa0 Binary files /dev/null and b/glossy/0.1.1/examples/readme.pdf differ diff --git a/glossy/0.1.1/examples/readme.typ b/glossy/0.1.1/examples/readme.typ new file mode 100644 index 0000000..7bcb12a --- /dev/null +++ b/glossy/0.1.1/examples/readme.typ @@ -0,0 +1,86 @@ +// a minimal example, also listed in the README + +#import "@preview/glossy:0.1.1": * + +#let myGlossary = ( + (key: "html", short: "HTML", long: "Hypertext Markup Language", description: "A standard language for creating web pages", group: "Web"), + (key: "css", short: "CSS", long: "Cascading Style Sheets", description: "A language used for describing the presentation of a document", group: "Web"), + (key: "tps", short: "TPS", long: "test procedure specification"), + // Add more entries as needed +) + +#show: init-glossary.with(myGlossary) + +#set heading(numbering: "1.1") + += Hello, `glossy`! + +In modern web development, languages like @html and @css are essential. + +Now make sure I get your @tps:short reports by 2pm! + += Examples with modifiers + +#table( + columns: 2, + table.header([*Input*], [*Output*]), + +[`@tps:short` ], [@tps:short], +[`@tps:long` ], [@tps:long], +[`@tps:both` ], [@tps:both], +[`@tps:long:cap` ], [@tps:long:cap], +[`@tps:long:pl` ], [@tps:long:pl], +[`@tps:short:pl` ], [@tps:short:pl], +[`@tps:both:pl:cap`], [@tps:both:pl:cap], +) + +== Using conflicting modifiers (short, long, both) + +These modifiers are semantically mutually exclusive, by they can be combined +syntactically. When multiple are used, `glossy` tries to make a smart choice on +which one to display. + +#table( + columns: 2, + table.header([*Input*], [*Output*]), + +[`@tps:short:long` ], [@tps:short:long], +[`@tps:short:both` ], [@tps:short:both], +[`@tps:long:both` ], [@tps:long:both], +[`@tps:short:long:both` ], [@tps:short:long:both], +) + +#let my-theme = ( + section: (title, body) => { + heading(level: 1, title) + body + }, + group: (name, i, n, body) => { + if name != "" and n > 1 { + heading(level: 2, name) + } + body + }, + entry: (entry, i, n) => { + // i is the entry's index, n is total number of entries + let output = [#entry.short] + if entry.long != none { + output = [#output -- #entry.long] + } + if entry.description != none { + output = [#output: #entry.description] + } + block( + grid( + columns: (auto, 1fr, auto), + output, + repeat([#h(0.25em) . #h(0.25em)]), + entry.pages, + ) + ) + } +) + +#glossary(theme: my-theme) +#glossary(title: "Glossary with just empty group", groups: ("",), theme: my-theme) +#glossary(title: "Glossary with just Web group", groups: ("Web",), theme: my-theme) diff --git a/glossy/0.1.1/lib.typ b/glossy/0.1.1/lib.typ new file mode 100644 index 0000000..f50143e --- /dev/null +++ b/glossy/0.1.1/lib.typ @@ -0,0 +1 @@ +#import "src/gloss.typ": init-glossary, glossary diff --git a/glossy/0.1.1/src/gloss.typ b/glossy/0.1.1/src/gloss.typ new file mode 100644 index 0000000..8c70355 --- /dev/null +++ b/glossy/0.1.1/src/gloss.typ @@ -0,0 +1,325 @@ +#import "./themes.typ": * +#import "./utils.typ": * + +#let __gloss_entries = state("__gloss_entries", (:)) +#let __gloss_used = state("__gloss_used", (:)) + +#let __gloss_label_prefix = "__gloss:" + +// given an array of dictionaries, make sure each has all the keys we'll +// reference, using default values if needed +#let __normalize_entries(entry-list) = { + // TODO: panic if key or short missing, all others optional + let new-list = () + for entry in entry-list { + let long = entry.at("long", default: none) + let longplural = entry.at("longplural", default: none) + if long != none and longplural == none { + longplural = __pluralize(long) + } + + new-list.push(( + key: entry.key, + short: entry.short, + plural: entry.at("plural", default: __pluralize(entry.short)), + long: long, + longplural: longplural, + description: entry.at("description", default: none), + group: entry.at("group", default: ""), + )) + } + return new-list +} + +// update our state with a glossary entry +#let __add_entry(entry) = { + // make sure our final glossary state does not already have this key + if __gloss_entries.final().at(entry.key, default: false) == true { + panic("Glossary error. Duplicate key: " + entry.key) + } + + // add it to the state + __gloss_entries.update(st => { + st.insert(entry.key, entry) + return st + }) +} + +// fetch a glossary entry from our state, or panic +#let __get_entry(key) = { + let entries = __gloss_entries.final() + if key not in entries { + panic("Glossary error. Missing key: " + key) + } + + entries.at(key) +} + +// returns true if an entry with `key` is in our glossary state +#let __has_entry(key) = { + let entries = __gloss_entries.final() + key in entries +} + +// helper to prefix a key for label/reference scoping for the dictionary entry +#let __dict_label_str(key) = { + __gloss_label_prefix + key +} + +// helper to get a label with prefix for the dictionary entry +#let __dict_label(key) = { + label(__dict_label_str(key)) +} + +// helper to prefix a key for label/reference scoping for the term use in a doc +#let __term_label_str(key, index) = { + __gloss_label_prefix + key + "." + str(index) +} + +// helper to get a label with prefix for the term use in a doc +#let __term_label(key, index) = { + label(__term_label_str(key, index)) +} + +// update the usage count for a given key +#let __save_term_usage(key, count) = { + __gloss_used.update(st => { + st.insert(key, count) + return st; + }) +} + +// the main function which emits terms used in a document +// +// handles all the modifiers: +// +// - cap: capitalize the term +// - pl: pluralize the term +// - both: emit "Long form (short form)" +// - short: emit just the short form +// - long: emit just the long form +#let __gls( + key, + modifiers: array +) = { + + // Get the term + let entry = __get_entry(key) + let entry_label = __dict_label_str(key) + + // Lookup and increment the count + let entry_counter = counter(entry_label) + entry_counter.step() + + // See if this is the first use + let key_index = entry_counter.get().first() + let first = key_index == 0 + + // Count the entry as used so we can link back from glossary + __save_term_usage(entry.key, key_index + 1) + + // Helper: Apply pluralization if needed + let pluralize_term = (singular, plural) => { + if "pl" in modifiers and plural != none { plural } else { singular } + } + + // Helper: Capitalize term if "cap" modifier present + let capitalize_term = (term) => { + if "cap" in modifiers { upper(term.first()) + term.slice(1) } else { term } + } + + // Helper: Format a term with pluralization and capitalization + let format_term = (term, plural) => { + capitalize_term(pluralize_term(term, plural)) + } + + // Helper: Select and format the displayed term (long, short, or both) + let select_term = (is_long_mode, use_both) => { + // Derive pluralized and capitalized versions of the long and short forms + let short_form = format_term(entry.short, entry.plural) + let long_form = if entry.long != none { format_term(entry.long, entry.longplural) } else { none } + + // Return logic for term selection: + if use_both and long_form != none { + // 1. If "both" is requested AND we have a long form available: + // Show "Long Form (Short Form)" + [#long_form (#short_form)] + } else if is_long_mode and long_form != none { + // 2. If long mode is requested AND we have a long form available: + // Show just the long form + [#long_form] + } else { + // 3. Fallback case - use short form when: + // - "both" was requested but no long form exists + // - long mode was requested but no long form exists + // - short form was explicitly requested + // - no specific mode was requested + [#short_form] + } + } + + // Determine which form to display: "short", "long", or "both" + let is_both = "both" in modifiers + let is_long = "long" in modifiers and not is_both + let is_short = "short" in modifiers and not is_both and not is_long + + context { + // Final display logic + let display = if is_both or is_long or is_short { + // User requested specific behavior via modifiers + select_term(is_long, is_both) + } else { + // Default behavior: show "both" on first use, else "short" + select_term(false, first) + } + + // TODO: figure out how to link to __dict_label(key) if the glossary exists + // NOTE: this is a low priority, I don't think it's that important or useful. + + // Emit with labels for this instance of the term usage + [#display#metadata(display)#__term_label(key, key_index)] + } +} + +// Create all the backlinks to term uses in a doc, in the form of page numbers +// linked. Used when emitting a glossary, so its page numbers can link back to +// the uses. +#let __create_backlinks(key, count) = { + // create array of labels to link to + let labels = for i in range(count) { + (__term_label(key, i),) + } + + // create arrays of locations, and page number display text + let pages = labels + .map(l => { locate(l) }) + .map((loc) => { numbering(__default(loc.page-numbering(), "1"), loc.page()) }) + + // convert labels to links, filtering out duplicated pages + let seen = () + let links = for i in range(labels.len()) { + let l = labels.at(i) + let p = pages.at(i) + if seen.contains(p) { + (none,) + } else { + seen.push(p) + (link(l, p),) + } + }.filter(l => l != none) + + // connect links with commas and return + links.join(", ") +} + +// Main wrapper (usually used in a `#show: init-glossary`) which loads the +// entries passed in into our state. Furthermore, hooks into references so that +// we can intercept term usage in a doc and label them appropriately. +#let init-glossary(entries, body) = context { + for entry in __normalize_entries(entries) { + __add_entry(entry) + } + + // convert refs we recognize into links with labels + show ref: r => { + let (key, ..modifiers) = str(r.target).split(":") + if __has_entry(key) { + __gls(key, modifiers: modifiers) + } else { + r + } + } + + body +} + +// Used to print a glossary. Can customize title, theme, and/or specify groups. +// +// A theme is a dictionary with three attributes: +// +// #let my-theme = ( +// section: (title, body) => { +// // how to display the glossary section and its body which will contain +// // groups, each with their entries +// }, +// +// group: (name, body) => { +// // how to display a group name and its body (which will contain the +// // entries in that group) +// }, +// +// entry: (entry, i, n) => { +// // how to display a single entry, along with its index and total count +// } +// } + +#let glossary(title: "Glossary", theme: theme-2col, groups: ()) = context { + // our output is a map of group name to array of entries (each entry is a map) + let output = (:) + + // pull in entire dictionary + let all_entries = __gloss_entries.final() + + // filter down to just what we used + let all_used = __gloss_used.final() + + // TODO: what about entries with no group? Or group == "" or == none + + // get all groups + let all_groups = all_entries.values().map(e => e.at("group")).dedup().sorted() + let groups = if groups.len() == 0 { all_groups } else { groups } + + // make sure requested groups are legit + for g in groups { + if g not in all_groups { + panic("Requested group not found: " + g) + } + } + + // iterate one group at a time + for g in groups { + // collect used entries in this group + let cur = () + for (key, count) in all_used { + let e = all_entries.at(key) + if e.at("group") == g { + // this term is both in this group and was used, add to our output + let short = e.at("short") + let long = e.at("long") + let description = e.at("description") + let label = [#metadata(key)#__dict_label(key)] + let pages = __create_backlinks(key, count) + cur.push((short: short, long: long, description: description, label: label, pages: pages)) + } + } + if cur.len() > 0 { + if g == none { g = "" } + output.insert(g, cur.sorted(key: e => e.short)) + } + } + + // TODO: rendering for a) just one group, or b) the default group (ie terms + // with no group)?? -- in this case should we just not use the theme.group() + // function? + + // index for group because dictionary don't have enumerate() + let i_group = 0; + + // render it using our theme + (theme.section)( + title, + // section body (all groups) + for (group, entries) in output { + (theme.group)( + group, + i_group, + output.len(), + // group body (all entries) + for (i,e) in entries.enumerate() { + (theme.entry)(e, i, entries.len()) + }, + ) + i_group += 1 + } + ) +} diff --git a/glossy/0.1.1/src/themes.typ b/glossy/0.1.1/src/themes.typ new file mode 100644 index 0000000..2f61ee1 --- /dev/null +++ b/glossy/0.1.1/src/themes.typ @@ -0,0 +1,47 @@ +#let theme-2col = ( + section: (title, body) => { + set par.line(numbering: none) + heading(level: 1, title) + columns(2, body) + }, + + group: (name, i, n, body) => { + if name != "" and n > 1 { + heading(level: 2, name) + } + body + }, + + entry: (entry, i, n) => { + // build our title, etc + let short-display = text(weight: "regular", entry.short) + let long-display = if entry.long == none { + [] + } else { + [#h(0.25em) -- #entry.long] + } + + // build our description + let description = if entry.description == none { + [] + } else { + [: #entry.description] + } + + text( + size: 0.75em, + weight: "light", + grid( + columns: (auto,auto,auto,1fr,1em,auto), + align: (left, left, left, center, center, right), + [#short-display#entry.label], + [#long-display], + [#description], + [#repeat(h(0.25em) + "." + h(0.25em))], + [ . ], + [#entry.pages] + ) + ) + }, +) + diff --git a/glossy/0.1.1/src/utils.typ b/glossy/0.1.1/src/utils.typ new file mode 100644 index 0000000..9bc45bc --- /dev/null +++ b/glossy/0.1.1/src/utils.typ @@ -0,0 +1,19 @@ +#let __default(val, default) = if val == none { default } else { val } + +#let __pluralize(word) = { + // Define helper functions for checking endings + let ends_with = (suffix) => lower(word).ends-with(suffix) + let ends_with_any = (suffixes) => suffixes.any((suffix) => ends_with(suffix)) + + if ends_with_any(( "s", "x", "z", "sh", "ch" )) { // Handle special cases for "es" endings + word + "es" + } else if ends_with("y") and not ends_with_any(( "a", "e", "i", "o", "u" )) { // Handle "y" -> "ies" + word.slice(0, -1) + "ies" + } else if ends_with("f") { // Handle "f" or "fe" -> "ves" + word.slice(0, -1) + "ves" + } else if ends_with("fe") { + word.slice(0, -2) + "ves" + } else { // Default to adding "s" + word + "s" + } +} diff --git a/glossy/0.1.1/thumbnail.png b/glossy/0.1.1/thumbnail.png new file mode 100644 index 0000000..503272e Binary files /dev/null and b/glossy/0.1.1/thumbnail.png differ diff --git a/glossy/0.1.1/typst.toml b/glossy/0.1.1/typst.toml new file mode 100644 index 0000000..0ca295f --- /dev/null +++ b/glossy/0.1.1/typst.toml @@ -0,0 +1,11 @@ +[package] +name = "glossy" +version = "0.1.1" +entrypoint = "lib.typ" +authors = ["Stephen Waits "] +license = "MIT" +description = "A very simple glossary system with easily customizable output." +categories = ["model"] +keywords = ["glossary", "index", "dictionary", "gloss"] +repository = "https://github.com/swaits/typst-collection" +exclude = ["thumbnail.png", "examples/readme.pdf"]