diff --git a/packages/preview/glossy/0.9.1/LICENSE b/packages/preview/glossy/0.9.1/LICENSE new file mode 100644 index 0000000000..234cfc691c --- /dev/null +++ b/packages/preview/glossy/0.9.1/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025, 2026 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/packages/preview/glossy/0.9.1/README.md b/packages/preview/glossy/0.9.1/README.md new file mode 100644 index 0000000000..9cf62db888 --- /dev/null +++ b/packages/preview/glossy/0.9.1/README.md @@ -0,0 +1,439 @@ +# 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. +2. Simplify the user interface as much as possible. Glossy has exactly two + exports, `init-glossary` and `glossary`. +3. Double-down on `glossy`'s excellent `@term` reference approach, completely + eliminating the need to make any calls to `gls()` and friends. +4. Mimic established patterns and best practices. For example, `glossy`'s + `#glossary()` function is intentionally similar (in naming and parameters) to + `typst`'s built-in `#bibliography`, to the degree possible. +5. 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 through `@labels` +- Supports modifiers to adjust how terms are displayed (capitalize, pluralize, etc.) +- Generates a formatted glossary section with backlinks to term occurrences +- Customizable themes for rendering glossary sections, groups, and entries +- Automatic pluralization of terms with custom override options +- Page number references back to term usage locations + +## Usage + +### Import the package + +```typst +#import "@preview/glossy:0.9.1": * +``` + +### Defining Glossary Terms + +Use the `init-glossary` function to initialize glossary entries: + +```typst +#let myGlossary = ( + html: ( + short: "HTML", + long: "Hypertext Markup Language", + description: "A standard language for creating web pages", + group: "Web" + ), + css: ( + short: "CSS", + long: "Cascading Style Sheets", + description: "A stylesheet language used for describing the presentation of a document", + group: "Web" + ), + tps: ( + short: "TPS", + long: "test procedure specification", + description: "A formal document describing test steps and expected results", + // Optional: Override automatic pluralization + plural: "TPSes", + longplural: "test procedure specifications", + reference: ( + key: "doe2025", + supplement: "p. 42" // Supplement is optional + ) + ), + WWW: "World Wide Web", // concise entry with only short: long +) + +#show: init-glossary.with(myGlossary) +``` + +Each glossary entry supports the following fields: + +- `short` (required): Short form of the term +- `article` (optional): Article for use with the short form (i.e. "a", "an") +- `long` (optional): Long form of the term +- `longarticle` (optional): Article for use with the long form (i.e. "a", "an") +- `description` (optional): Term description (often a definition) +- `group` (optional): Category grouping +- `plural` (optional): Override automatic pluralization of short form +- `longplural` (optional): Override automatic pluralization of long form + +Note that if you just want an entry with `short` and `long`, you can use the +abbreviated syntax. In this case, they `key` is used as the short form, and its +value is used as the long form. See `WWW` as an example below. + +You can also load glossary entries from a data file using #yaml(), #json(), or similar. + +For example, the above glossary could be in this YAML file: + +```yaml +html: + short: HTML + article: an + long: Hypertext Markup Language + longarticle: a + description: A standard language for creating web pages + group: Web + +css: + short: CSS + long: Cascading Style Sheets + description: A stylesheet language used for describing the presentation of a document + group: Web + +tps: + short: TPS + long: test procedure specification + description: A formal document describing test steps and expected results + plural: TPSes + longplural: test procedure specifications + reference: + key: "doe2025" + supplement: "p. 42" # Supplement is optional + +WWW: World Wide Web +``` + +And then loaded during initialization as follows: + +```typst +#show: init-glossary.with(yaml("glossary.yaml")) +``` + +`init-glossary()` supports the following parameters: + +- `format-term` // Function deciding how to format a term, depending on + the 'mode' (short, long, both). See example below. +- `show-term` // Function which can customize display of a term (see example below) +- `term-links: false` // True if you want terms to link to their glossary entry + +### Using Glossary Terms + +Reference glossary terms using Typst's `@reference` syntax: + +```typst +In modern web development, languages like @html and @css are essential. +The @tps:pl need to be submitted by Friday. +``` + +Available *modes* (they determine which information is printed and are +mutually exclusive): + +- auto: (Default) Shows the form depending on the previous usage. + The mode when no other mode is appended with a colon. +- **both**: Shows "Long Form (Short Form)" +- **short**: Shows only short form +- **long**: Shows only long form +- **def** or **desc**: Shows the description +- See [the reference](#reference-for-using-glossary-terms) + for a complete overview. + +Available *modifiers* (they modify how this information is printed): + +- **cap**: Capitalizes the term +- **pl**: Uses the plural form +- **noref** or **noindex**: Don't show the term in the glossary. +- **a** or **an**: Include the article (`an` is just an alias of `a`, they're + equivalent) + +Modes and modifiers can be combined with colons: + +| **Input** | **Output** | +| ------------------- | -------------------------------------------------------------- | +| `@tps` (first use) | "test procedure specification (TPS)" | +| `@tps` (subsequent) | "TPS" | +| `@tps:short` | "TPS" | +| `@tps:long` | "test procedure specification" | +| `@tps:both` | "test procedure specification (TPS)" | +| `@tps:long:cap` | "Test procedure specification" | +| `@tps:long:pl` | "test procedure specifications" | +| `@tps:short:pl` | "TPSes" | +| `@tps:both:pl:cap` | "Technical procedure specifications (TPSes)" | +| `@tps:def` | "A formal document describing test steps and expected results" | +| `@tps:desc` | _same as above, `desc` is an alias for `def`_ | +| `@tps:noref` | _works as normal, just won't show up in glossary_ | +| `@tps:noindex` | _same as above, `noindex` is an alias for `noref`_ | + +The `a`/`an` modifier is special because it can either precede or follow the +term's key. + +For the English language, you don't need to define `article` and `longarticle` +in most cases. Glossy makes a decent attempt at computing those automatically. + +For example: + +| **Input** | **Output** | +| --------------------- | ---------------------------------------------- | +| `@a:tps`(first use) | "a test procedure specification (TPS)" | +| `@tps:an`(subsequent) | "a TPS" | +| `@an:tps:long:cap` | "A test procedure specification" | +| `@tps:long:an` | "a test procedure specifications" | +| `@tps:a:both:cap` | "A technical procedure specifications (TPSes)" | + +Note that the `a`/`an` (article) modifier cannot be combined with the `pl` +(plural) modifier. + +### Overriding Term Text + +There may be cases where you want to completely control what `glossy` displays +when you reference a term. For example, imagine you want to reference `@tps` +which would normally render as something like "test procedure specification." +But for some reason you want it to actually say "an annoying report." You can do +that like this: + +| **Input** | **Output** | +| -------------------------- | -------------------- | +| `@tps[an annoying report]` | "an annoying report" | +| `@tps[]` | #none | + +In case you want to reference a term from the Glossary/Index, but don't +want to display it for whatever reason, the second form can be used. + +### First use logic + +The 'mode' of a glossary term determines which information is printed about this +term. When using a plain `@term`, possibly with some modifiers, the `auto` mode +is selected. This mode displays the form depending on the usage counter. On the +first use (`usage counter == 0`), 'both' forms are printed. On subsequent usage +(`usage counter > 0`) the 'short' form is printed. + +Not all occurrences of a term are counted towards its usage, this depends on +the mode of the term. Furthermore, this can be manually controlled by modifiers. +Both are documented in the [reference tables](#reference-for-using-glossary-terms). + +This control over the usage counter is practical, for example, when you want to +use a term in a heading or a caption. +In that case, your term might end up in an `outline()` at the top of +your document. Normally you don't want that to count as a term's first real use. +Normally you want that to happen in the body of your document. So, by using +the appropriate mode and modifiers in such situations, you can not only specify +exactly how you want your term to appear, but also control whether it counts +as a "first use". + +### Reference for Using Glossary Terms + +A reference about +- the different 'modes' and how they determine which information is printed about a term +- the impact of modes on the [usage counter and ensuing first use behaviour](#first-use-logic) +- how modifiers can change the behaviour with respect to the usage counter +- how modifiers impact the presentation of the information printed + +A glossary term can display different types of information, which is determined +by the 'mode' in which it is printed. These modes are all mutually exclusive. + +| Mode | Utilization | Default first use behaviour | Description | +| ------------- | ------------------- | --------------------------- | ----------- | +| `auto` | `@term` | use | The default mode when no 'mode' modifier or supplement is specified. The form depends on the first use counter. | +| `both` | `:both` | use | Shows both forms of the term, by default ([see `format-term`](#customizing-term-display)) like "Long Form (Short Form)". Falls back to short when long form not available. | +| `short` | `:short` | no-use | Shows only short form. | +| `long` | `:long` | no-use | Shows only long form. Falls back to short when long form not available. | +| `description` | `:def`
`:desc` | no-use | Shows the description (None of the modifiers apply here & no link is created towards the glossary). | +| `supplement` | `[content]` | no-use | Shows the content given by [the supplement](#overriding-term-text). | +| `reset` | `:reset` | reset usage counter to 0 | Don't output any content & don't link to the glossary. Typical usage would be after an abstract or even before the start of each chapter, maybe in an injected rule. (This could be made a modifier like `:use` or `:nouse`, but would mostly be utilized as `@term:reset:noindex[]` anyways, thus this is a mode. Write `@term@term:reset` to utilize it as a modifier.) | + +Minor modifications to how these different types of information are printed, +linked or counted towards the usage counter, are controlled by the 'modifiers'. +They do not influence the default first use behaviour of the mode (unless +explicitely created for this purpose). Moreover, they neatly compose together +and with most modes (some exceptions exists, but will print a clear error - +report a bug if not). + +| Modifier | Description | +| ------------------------------ | ---------------------------------------------------------------------- | +| `:cap` | Capitalizes the term | +| `:pl` | Uses the plural form | +| `:noref`
`:noindex` | Don't show the term in the glossary (i.e. no page will be linked - a link from the term towards the glossary is always provided.). | +| `:a`
`a:`
`:an`
`an:` | Include the article (`an` is just an alias of `a`, they're equivalent) | +| `:use`
`:spend` | After this occurrence, the term is 'used', i.e. the usage counter is increased by 1. Write `@term:use:noindex[]` to uniquely control the usage counter without output. | +| `:nouse`
`:nospend` | After this occurrence, the usage counter remains exactly as the same as before. As if the term is transparent with respect to the usage counter. | + +### Generating the Glossary + +Display the glossary using the `glossary()` function: + +```typst +#glossary( + title: "Web Development Glossary", // Optional: defaults to Glossary theme: + theme: my-theme, // Optional: defaults to theme-academic + sort: true, // Optional: whether or not to sort the glossary + ignore-case: false, // Optional: ignore case when sorting terms + groups: ("Web"), // Optional: Filter to specific groups + show-all: false, // Optional; Show all terms even if unreferenced +) +``` + +Note that if you want to display terms without a group, you specify that with an +empty string. For example, to show the empty group and then the _Web_ group: + +```typst +#glossary(groups: ("", "Web")) +``` + +Or to just show the empty group (i.e. terms without a group): + +```typst +#glossary(groups: ("")) +``` + +### Customizing Term Display + +Control how terms are styled in the document by providing a custom `show-term` function: + +```typst +#let emph-term(term-body) = { emph(term-body) } + +#show: init-glossary.with( + myGlossary, + show-term: emph-term +) +``` + +Terms can be formatted depending on the 'mode' by providing a custom +`format-term` function. The mode is one of ("short", "long", "both"). + +```typst +// When displaying both, reverse the display order by showing +// "short (long)" instead of the normal "long (short)" +#let short-long-term(mode, short-form, long-form) = { + if mode == "short" { short-form } + else if mode == "long" { long-form } + else { // mode assumed to be "both" + short-form + " (" + long-form + ")" + } +} + +#show: init-glossary.with( + myGlossary, + format-term: short-long-term +) +``` + +### Glossary Themes + +#### Included Themes + +Glossy comes with several built-in themes that can be used directly or serve as +examples for custom themes: + +![theme gallery image](themeshots.png) + +#### Custom Themes + +Customize glossary appearance by defining a theme with three functions: + +```typst +#let my-theme = ( + // Main glossary section + section: (title, body) => { + heading(level: 1, title) + body + }, + + // Group of related terms + group: (name, index, total, body) => { + // index = group index, total = total groups + if name != "" and total > 1 { + heading(level: 2, name) + } + body + }, + + // Individual glossary entry + entry: (entry, index, total) => { + // index = entry index, total = total entries in group + let output = [#entry.short#entry.label] // **NOTE:** Label here! + 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.join(", "), + ) + ) + } +) +``` + +Entry fields available to themes: + +- `short`: Short form (always present) +- `long`: Long form (can be `none`) +- `description`: Term description (can be `none`) +- `label`: Term's dictionary label +- `pages`: Array of linked page numbers where term appears +- `reference`: Literature for glossary definitions + - `key`: The bibtex key of the literature + - `supplement`: Added supplement, like a page + +**NOTE:** If the theme does not emit `entry.label`, linking from terms to their +glossary entry will not work. + +## Development and testing + +See: [README_development.md](README_development.md) + +See: [README_publish.md](README_publish.md) + +## License + +This project is licensed under the MIT License. + +## Changelog + +### v0.9.1 + +- **Feat**, @philxws692, [#15](https://github.com/swaits-typst-packages/glossy/pull/15), [#60](https://github.com/swaits/typst-collection/pull/60) + +- **Feat**, @swouf, [#17](https://github.com/swaits-typst-packages/glossy/pull/17), fixes [#16](https://github.com/swaits-typst-packages/glossy/issues/16) + +- **Fix**, @hmaerki, [#20](https://github.com/swaits-typst-packages/glossy/pull/20) + +### v0.9.0 +- **Breaking**: In themes, entry.pages now returns an array of linked + page numbers, instead of opaque content. Use `#entry.pages.join(", ")` + in your custom theme to keep the previous behaviour. +- **Breaking**: The `both` mode (previously called modifier) by default + now counts towards the first use of a term. +- **Breaking**: When multiple conflicting modes are supplied (like 'short' + and 'long'), glossy now throws an error (with a clear message). +- **Breaking**: When an unrecognized modifier is supplied with the term, + glossy will now panic instead of ignoring the modifier. diff --git a/packages/preview/glossy/0.9.1/README_development.md b/packages/preview/glossy/0.9.1/README_development.md new file mode 100644 index 0000000000..4dddc91c6e --- /dev/null +++ b/packages/preview/glossy/0.9.1/README_development.md @@ -0,0 +1,55 @@ +# Testing + + +Links + + * [Devcontainer](https://containers.dev/) + * [Devcontainer in VSCode (Visual Studio Code)](https://code.visualstudio.com/docs/devcontainers/containers) + * [GitHub Codespaces](https://github.com/features/codespaces) + +Folders + +* `tests`: Folder with tests +* `bin/autotest.sh`: Script which runs all tests +* Tytanic: https://github.com/typst-community/tytanic + +## Running the tests manually + +```bash +just check +``` + +### Test environment: Devcontainer in VSCode + +Clone this repo and open VSCode + +`glossy.code-workspace` + +VSCode will ask you if you would like to start the devcontainer: Yes + +Wait for a few minutes until you see `*** Container build successfully ***` + +Open a terminal and type + +```bash +$ typst --version +typst +$ tt --version +tytanic +$ tt list +$ tt run --no-fail-fast +``` + +### Test environment: Devcontainer in a GitHub Codespace + +Browser: Open https://github.com/swaits-typst-packages/glossy +Click `Code` -> `Codespaces` -> `Create codespace on` and select the branch you want to test. + +Wait for a few minutes until you see `*** Container build successfully ***` + +Open a terminal and type as above. + +## Testrunner and CI pipelines + +* Testrunner: https://github.com/typst-community/tytanic +* Repo using the testrunner in a ci pipeline: https://github.com/janekfleper/typst-fancy-units/blob/main/.github/workflows/ci.yaml diff --git a/packages/preview/glossy/0.9.1/README_publish.md b/packages/preview/glossy/0.9.1/README_publish.md new file mode 100644 index 0000000000..f1c89979b8 --- /dev/null +++ b/packages/preview/glossy/0.9.1/README_publish.md @@ -0,0 +1,76 @@ +# Publish changes + +## Links + +* repository to publish to: https://github.com/typst/packages/tree/main/packages/preview/glossy + +* doc: https://github.com/typst/packages/blob/main/docs/README.md +* doc: https://github.com/typst/packages/blob/main/docs/tips.md#what-to-commit-what-to-exclude + +Example PR from glossy: + +- [v0.9.0](https://github.com/typst/packages/pull/3323) +- [v0.8.0](https://github.com/typst/packages/pull/2122) + +## Prepare to publish + +**There is a branch `submitted-upstream`: It will no longer be used for publishing.** + +1. Make these commands run without error + + ```bash + just fmt # format all .typ files in place + just check # fmt-check + full test suite + ``` + +2. Update changelog in `README.md` + +3. Update version in `typst.toml` + + ```bash + just bump patch # or minor + ``` + +4. Run full pre-submission checks + + ```bash + just pre-submit # clean โ†’ images โ†’ check + ``` + +5. Commit to `main` and create a version tag. + + ```bash + git tag -a vx.y.z -m "x.y.z" + git push && git push --tags + ``` + +6. Create a fork of https://github.com/typst/packages/ + +7. Copy package files into a local clone of the upstream registry. + + ```bash + cd ~/src/glossy + TYPST_PACKAGES_REPO=~/typst-packages + + ver=$(grep '^version' "typst.toml" | cut -d'"' -f2) + dest="$TYPST_PACKAGES_REPO/packages/preview/glossy/$ver" + + rm -r "$dest" + mkdir -p "$dest" + cp -r src "$dest/" + cp -r justfile lib.typ LICENSE README.md README_publish.md README_development.md typst.toml themeshots.png thumbnail.png "$dest/" + ``` + +8. On fork of https://github.com/typst/packages/ + + ```bash + cd $TYPST_PACKAGES_REPO + git switch -c submit_glossy + git add packages/preview/glossy/$ver + git commit -m "glossy:$ver" + git push -u origin submit_glossy + ``` + +## Notes on just + +Run `just` (or `just help`) to list all available recipes. \ No newline at end of file diff --git a/packages/preview/glossy/0.9.1/lib.typ b/packages/preview/glossy/0.9.1/lib.typ new file mode 100644 index 0000000000..c0f59b947e --- /dev/null +++ b/packages/preview/glossy/0.9.1/lib.typ @@ -0,0 +1,2 @@ +#import "src/gloss.typ": glossary, init-glossary +#import "src/themes.typ": * diff --git a/packages/preview/glossy/0.9.1/src/gloss.typ b/packages/preview/glossy/0.9.1/src/gloss.typ new file mode 100644 index 0000000000..0e97206728 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/gloss.typ @@ -0,0 +1,846 @@ +#import "@preview/valkyrie:0.2.2" as z + +#import "./schemas.typ": * +#import "./themes.typ": * +#import "./utils.typ": * + +#let __gloss_entries = state("__gloss_entries", (:)) + +#let __gloss_label_prefix = "__gloss:" +#let __gloss_first_use_counter_postfix = ":first-use-count" +#let __gloss_entry_postfix = ":entry" + +#let __normalize_reference(reference) = { + if reference == none { + return none + } + + if type(reference) != dictionary { + panic("Reference has to be a dictionary") + } + + if not "key" in reference { + panic("Reference must contain a key named 'key'") + } + + if type(reference.key) != str { + panic("Reference 'key' must be a string") + } + + if reference.key.trim() == "" { + panic("Reference 'key' cannot be empty") + } + + ( + key: reference.key, + supplement: reference.at("supplement", default: none), + ) +} + +// Normalizes a dictionary entry by ensuring all required and optional keys exist +// with appropriate default values. +// +// Parameters: +// key: The key from the input dictionary +// entry (dictionary): Input dictionary containing at least 'short' and optionally +// 'long', 'plural', 'longplural', 'description', and 'group' +// +// entry (string): If you pass a string to entry, we assume you're using the +// abbreviated syntax `short: "long"`, where the `key` is used +// for `short` and `entry` is used for `long`. +// +// Returns: +// dictionary: Normalized dictionary with all expected keys populated +// +// Panics: +// - If 'short' key is missing when using normal (non-abbreviated) syntax +// - If 'short' value is not a string +// - If 'short' value is an empty string +// +#let __normalize_entry(key, entry) = { + // If it's a string, it's using the abbreviated syntax `short: "long"`. + if (type(entry) == str) { + entry = (short: key, long: entry) + } + + // Validate required fields + if not "short" in entry { + panic("Entry must contain a 'short' key") + } + if type(entry.short) != str { + panic("Entry 'short' must be a string") + } + if entry.short.trim() == "" { + panic("Entry 'short' cannot be empty") + } + + // Extract values with defaults, using consistent none for missing optionals + let long = entry.at("long", default: none) + + // Return the normalized entry + ( + short: entry.short, + plural: entry.at("plural", default: __pluralize(entry.short)), + article: entry.at("article", default: __determine_article(entry.short)), + long: long, + longplural: entry.at("longplural", default: __pluralize(long)), + longarticle: entry.at("longarticle", default: __determine_article(long)), + description: entry.at("description", default: none), + group: entry.at("group", default: ""), + reference: __normalize_reference(entry.at("reference", default: none)), + ) +} + +// Checks if a key exists in the glossary state. +// +// Parameters: +// key (string): The key to look up in the glossary entries +// +// Returns: +// boolean: True if the key exists in the glossary, false otherwise +// +#let __has_entry(key) = { + key in __gloss_entries.final().keys() +} + +// Updates the glossary state by adding or updating an entry. +// +// Parameters: +// key (string): The key under which to store the entry +// entry (dictionary): The normalized glossary entry to store +// +// Returns: +// none: Updates state as a side effect +// +#let __add_entry(key, entry) = { + __gloss_entries.update(state => { + state.insert(key, entry) + state + }) +} + +// Retrieves a glossary entry from the state. +// +// Parameters: +// key (string): The key of the entry to retrieve +// +// Returns: +// dictionary: The glossary entry associated with the key +// +// Panics: +// - If the key does not exist in the glossary +// +#let __get_entry(key) = { + let entries = __gloss_entries.final() + if key not in entries.keys() { + panic("Glossary error: Missing key '" + key + "'") + } + + entries.at(key) +} + +// Creates a label object for term usage in documents. +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// label: A Typst label object for the term usage +// +#let __term_label(key) = { + label(__gloss_label_prefix + key) +} + +// Creates a label object for the entry in the glossary. +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// label: A Typst label object for the term usage +// +#let __entry_label(key) = { + label(__gloss_label_prefix + key + __gloss_entry_postfix) +} + +// Updates the term usage (first use counter & wants to be referenced counter) +// in the glossary state. +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// none: Updates state as a side effect +// +#let __mark_term_used(key, count-as-referenced, count-as-first-use) = { + if count-as-referenced { + counter(__gloss_label_prefix + key).step() + } + if count-as-first-use { + counter( + __gloss_label_prefix + key + __gloss_first_use_counter_postfix, + ).step() + } +} + +// Queries whether the term wants to be referenced from ANYWHERE +// +// Parameters: +// key (string): The glossary entry key +// location (location): The location in the document. Use none for end of document +// +// Returns: +// boolean: If the entry is used above the location in the document +// +#let __is_term_ever_referenced(key, location: none) = { + let c = counter(__gloss_label_prefix + key) + c = if location == none { c.final() } else { c.at(location) } + c.at(0) > 0 +} + +// Queries whether the term has been "first used" +// +// "First used" is used to determine how to display the term, either "long +// (short)" or just "(short)" +// +// Parameters: +// key (string): The glossary entry key +// location (location): The location in the document. Use none for end of document +// +// Returns: +// boolean: If the entry is used above the location in the document +// +#let __is_term_first_used(key, location: none) = { + let c = counter( + __gloss_label_prefix + key + __gloss_first_use_counter_postfix, + ) + c = if location == none { c.final() } else { c.at(location) } + c.at(0) > 0 +} + +// Reset the first use counter to 0 +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// none: Updates state as a side effect +// +#let __reset_term_first_used(key, location: none) = { + counter( + __gloss_label_prefix + key + __gloss_first_use_counter_postfix, + ).update(0) +} + +// Determine if the glossary contains a visible entry +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// boolean: If the term is visible in the glossary (and the label is unique) +// +#let __has_glossary_entry(key) = { + query(__entry_label(key)).len() > 0 +} + +// Renders a glossary term with various formatting options. +// +// Parameters: +// key (string): The glossary entry key to render. +// modifiers (array): Array of modifier strings that control term rendering: +// - "cap": Capitalize the first letter of the term. +// - "pl": Use plural form of the term. +// - "both": Show "Long form (short form)". +// - "short": Show only the short form. +// - "long": Show only the long form. +// - "def" or "desc": Show the term's description instead of its name. +// - "a" or "an": Prepend an article, chosen from the entry (short or long form). +// show-term (function): A function that renders the chosen term. +// term-links (boolean): If terms should be clickable links leading to glossary +// +// Behavior: +// - Without modifiers, the first use of a term shows "Long form (short form)", +// subsequent uses show only the short form. +// - Explicit modifiers ("long", "short", "both") override the default behavior. +// - If a requested long form doesn't exist, the logic falls back to the short form. +// - A usage counter tracks how many times a term has been referenced. +// +// Returns: +// content: The fully formatted term, including optional article, capitalization, +// usage tracking metadata, and the chosen form (short, long, or both). +// +#let __gls( + key, + modes-modifiers: array, + format-term: function, + show-term: function, + term-links: true, + display-text: none, +) = { + let possible_modes = ( + "auto", + "both", + "short", + "long", + "supplement", + "description", + "reset", + ) + // --------------------------------------------------------------------------- + // Normalize mode & modifier inputs AND check if the modifiers are valid + // --------------------------------------------------------------------------- + let modes-modifiers = modes-modifiers.map(it => { + if it == "def" or it == "desc" { + return "description" + } else if it == "a" or it == "an" { + return "an" + } else if it == "use" or it == "spend" { + return "use" + } else if it == "nouse" or it == "nospend" { + return "no-use" + } else if it == "noref" or it == "noindex" { + return "noindex" + } else if it in ("both", "short", "long", "reset", "cap", "pl") { + return it + } else if it in ("auto", "description", "supplement") { + panic( + it, + "is a valid mode, but is not used by applying a modifier with this name. Read the documentation.", + ) + } else { + panic(it, "is not a recognized mode or modifier.") + } + }) + if display-text != none and display-text != auto { + // A supplement is provided, thus the mode is "supplement" + modes-modifiers.push("supplement") + } + + // --------------------------------------------------------------------------- + // Determine the requested modes (plural) and the modifier array + // --------------------------------------------------------------------------- + modes-modifiers.push("__MIDDLE_ITEM") // unused modifier + let (requested_modes, modifiers) = modes-modifiers + .sorted(key: it => { + if it in possible_modes { + // It is a 'mode' + return -1 + } else if it == "__MIDDLE_ITEM" { + return 0 + } else { + // It is a 'modifier' + return 1 + } + }) + .split("__MIDDLE_ITEM") + + // --------------------------------------------------------------------------- + // Check for illegal mode and/or modifier combinations + // --------------------------------------------------------------------------- + if requested_modes.len() > 1 { + panic("Cannot mix modes ", requested_modes, ", pick one.") + } + if "description" in requested_modes and modifiers.len() > 0 { + panic("Cannot use mode 'def'/'desc' with other modifiers.") + } + if "reset" in requested_modes and modifiers.len() > 0 { + panic("Cannot use mode 'reset' with other modifiers.") + } + if "an" in modifiers and "pl" in modifiers { + panic("Cannot use 'a'/'an' and 'pl' together.") + } + if "use" in modifiers and "no-use" in modifiers { + panic("Cannot use 'use'/'spend' and 'nouse'/'nospend' together.") + } + + // --------------------------------------------------------------------------- + // Retrieve the glossary entry and its label + // --------------------------------------------------------------------------- + let entry = __get_entry(key) + + // --------------------------------------------------------------------------- + // Determine the requested mode (singular) + // --------------------------------------------------------------------------- + let requested_mode = if requested_modes.len() == 0 { + "auto" + } else { + requested_modes.at(0) + } + + // --------------------------------------------------------------------------- + // If mode is "description", show the entry's description immediately + // --------------------------------------------------------------------------- + if requested_mode == "description" { + if entry.description == none { + panic("Use of 'def'/'desc' requires a description be defined.") + } + return show-term([#entry.description]) + } + // --------------------------------------------------------------------------- + // If mode is "reset", reset the (first) usage counter and return immediately + // --------------------------------------------------------------------------- + if requested_mode == "reset" { + __reset_term_first_used(key) + return + } + + // --------------------------------------------------------------------------- + // Manage term usage counting and determine if it's the first reference + // --------------------------------------------------------------------------- + let is_first_use = not __is_term_first_used(key, location: here()) + let default-count-as-first-use = ( + requested_mode == "auto" or requested_mode == "both" + ) + let force-count-as-first-use = "use" in modifiers + let force-skip-as-first-use = "no-use" in modifiers + let wants_reference = "noindex" not in modifiers + __mark_term_used( + key, + wants_reference, + force-count-as-first-use + or (not force-skip-as-first-use and default-count-as-first-use), + ) + + // --------------------------------------------------------------------------- + // Helper Functions + // --------------------------------------------------------------------------- + + // pluralize_term(singular, plural): Returns plural form if "pl" modifier is present and available; + // otherwise returns the singular form. + let pluralize_term = (singular, plural) => { + if "pl" in modifiers and plural != none { + plural + } else { + singular + } + } + + // capitalize_term(article, term): If "cap" is in modifiers, capitalizes the first letter + // of whichever text appears first (the article if present, otherwise the term). + // Returns a tuple (article, term) which may be modified to have uppercase first letters. + let capitalize_term = (article, term) => { + if "cap" not in modifiers { + (article, term) + } else if article == none { + // No article present, so capitalize the term's first letter. + (article, upper(term.first()) + term.slice(1)) + } else { + // Article is present; capitalize its first letter instead. + (upper(article.first()) + article.slice(1), term) + } + } + + // get_article(mode): Returns the appropriate article string based on the mode. + // - If wants_article is false, returns none + // - If mode is "short", return the short article. + // - If mode is "long" or "both", return the long article. + // The calling logic ensures that when mode = "long" or "both", a long form exists. + let get_article = mode => { + let wants_article = "an" in modifiers + if not wants_article { + none + } else if mode == "short" { + entry.article + " " + } else { + // mode == "long" or "both" + entry.longarticle + " " + } + } + + // --------------------------------------------------------------------------- + // Determine desired options based on modifiers and entry.long availability + // --------------------------------------------------------------------------- + // Decide which mode ("short", "long", or "both" or "supplement") to use. + // If the requested mode can't be fulfilled due to no long form, fall back to "short". + // If no explicit mode is given (i.e. we are in 'auto' mode), the default behavior is: + // - On first use and if a long form exists, "both". + // - Otherwise, "short". + if requested_mode == "auto" { + // Set requested_mode to automatically determined mode + requested_mode = if is_first_use { + "both" + } else { + "short" + } + } + + let long_available = entry.long != none + let mode = if ( + (requested_mode == "both" or requested_mode == "long") + and not long_available + ) { + // The fall back case, because long form is not available, but it is requested + "short" + } else { + // No fall back needed, the requested mode can be fulfilled + requested_mode + } + + // Pluralize (if requested) + let short-form = pluralize_term(entry.short, entry.plural) + let long-form = pluralize_term(entry.long, entry.longplural) // `none` safe here + + // Apply format-term() to get the term + let formatted-term = if mode == "supplement" { + // Display text was overriden by user, just accept it (string or content) + display-text + } else { + // Normal display (ie no override from user) + format-term(mode, short-form, long-form) + } + if "cap" in modifiers and type(formatted-term) != str { + // This is because we still need to capitalize the term, and we cannot do + // that with content, thus requiring a string here. + // TODO: consider if we want to capitalize *before* this. First intuition is + // "no". + panic("Your custom format-term() function must return a string.") + } + + // Get the article, then capitalize either the article or term (if requested) + let (article, term) = capitalize_term(get_article(mode), formatted-term) + + // --------------------------------------------------------------------------- + // Construct and return the final output + // --------------------------------------------------------------------------- + context { + let linked-term = if term-links and __has_glossary_entry(key) { + link(__entry_label(key), term) + } else { + term + } + // Figure out our label (unless not wanted) + let term-label = if wants_reference { + __term_label(key) + } else { + [] + } + + // Create the output content + [#article#show-term(linked-term)#metadata(term)#term-label] + // |^^^^^|^^^^^^^^^ |^^^^^^^^ |^^^^^^^^^^ + // | | | \_ the label for backlink from + // | | | glossary i.e. <__gloss:key>, etc. + // | | \_ metadata lets us label (ie makes it "labelable") + // | \_ apply user formatting function to the term + // \_art. + } +} + +// Creates page number links back to each usage of a glossary term. +// +// This function generates an array of page numbers, where each number links +// back to a usage of the term in the document. Duplicate page numers are +// removed to avoid redundancy. +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// array: Linked page numbers +// +// Example output after a typical .join(", "): +// "1, 3, 5" where each number links to the term usage on that page +// +#let __create_backlinks(key) = { + return query(__term_label(key)) // find all reference + .map(meta => { + // extract location and page number (or symbol) + let loc = meta.location() + let page = numbering( + __default(loc.page-numbering(), "1"), + ..counter(page).at(loc), + ) + (loc, page) + }) + .dedup(key: ((loc, page)) => page) // deduplicate by page + .map(((loc, page)) => link(loc, page)) // create links +} + +// Formats a term string based on the specified mode. +// +// Given mode, long-form, and short-form, this function returns the formatted +// term based on the mode. When mode is ... +// +// - "short" -> returns "short-form" +// - "long" -> returns "long-form" +// - "both" -> returns "short-form (long-form)" +// +// Parameters: +// mode (string): The mode in which to format the term. Possible values are "short", "long", or "both". +// short-form (string): The short form of the term. +// long-form (string): The long form of the term. +// +// Returns: +// string: The formatted term based on the specified mode. +// +#let __default-format-term(mode, short-form, long-form) = { + if mode == "short" { + short-form + } else if mode == "long" { + long-form + } else { + // mode assumed to be "both" + long-form + " (" + short-form + ")" + } +} + +// Styles a term to control its display in a document. +// +// Parameters: +// term-body (content'ish): the unstyled output from __gls() +// +// Returns: +// content: display ready content (assumed based on `term-body`) +// +#let __default-show-term(term-body) = { + // Default: just render it like normal text + term-body +} + +// Initializes the glossary system and sets up term reference handling. +// +// This function is typically used with `#show: init-glossary`. It performs +// three main tasks: +// 1. Validates and loads glossary entries into the state +// 2. Sets up reference handling to intercept and format term usage +// 3. Applies custom term formatting if provided +// +// Parameters: +// entries (dictionary): Dictionary of glossary entries where: +// - keys are term identifiers +// - values are entry dictionaries containing term details +// show-term ((content) => content): Optional function to customize term rendering +// Default: Returns term content unchanged +// term-links (boolean): If terms should be clickable links leading to glossary +// body (content): Document content to process +// +// Returns: +// content: Processed document content with glossary functionality enabled +// +// Panics: +// - If entries parameter is not a dictionary +// +#let init-glossary( + entries, + format-term: __default-format-term, + show-term: __default-show-term, + term-links: false, + body, +) = context { + // Type checking + let checked-entries = (:) + for (key, entry) in entries { + let checked-key = z.parse(key, z.string(), scope: ("dictionary key",)) + let checked-entry = z.parse(entry, dict-schema, scope: ( + "dictionary entry", + )) + checked-entries.insert(checked-key, checked-entry) + } + let checked-format-term = z.parse(show-term, z.function(), scope: ( + "format-term", + )) + let checked-show-term = z.parse(show-term, z.function(), scope: ( + "show-term", + )) + let checked-body = z.parse(body, z.content(), scope: ("body",)) + + // Process and store each glossary entry + for (key, entry) in entries { + __add_entry(key, __normalize_entry(key, entry)) + // Create placeholder labels for autocompletion + [#metadata(key)#label(key)] + } + + // Set up reference handling for glossary terms + show ref: r => { + let (raw_key, ..raw_modifiers) = str(r.target).split(":") + let supplement = r.supplement // used for term display overrides + + // Determine if we need to swap the key and first modifier. + // Conditions for swapping: + // - The original key is "a" or "an" (case-insensitive), + // - That "key" does not correspond to an actual entry, + // - There is at least one modifier, + // - The first modifier corresponds to an existing entry key. + let can_swap = ( + (lower(raw_key) == "a" or lower(raw_key) == "an") + and not __has_entry(raw_key) + and raw_modifiers.len() > 0 + and __has_entry(raw_modifiers.first()) + ) + + // If we can swap, use the first modifier as the key and insert "a" as the first modifier. + let (key, modifiers) = if can_swap { + (raw_modifiers.first(), ("a",) + raw_modifiers.slice(1)) + } else { + (raw_key, raw_modifiers) + } + + // Now see if this is an actual glossary term key + if __has_entry(key) { + // Found in dictionary, render via __gls() + __gls( + key, + modes-modifiers: modifiers.map(lower), + format-term: format-term, + show-term: show-term, + term-links: term-links, + display-text: supplement, + ) + } else { + // Not one of ours, so just pass it through + r + } + } + + body +} + +// Renders a complete glossary with customizable formatting and grouping. +// +// The glossary displays all used terms, optionally grouped by category, with page +// references back to term usage. The appearance is controlled by a theme that +// defines how sections, groups, and entries are formatted. +// +// Parameters: +// title (string): Glossary section title (default: "Glossary") +// theme (dictionary): Controls glossary appearance with three functions: +// - section(title, body): Renders the main glossary section +// - title: The glossary title +// - body: Content containing all groups and entries +// - group(name, index, total, body): Renders a group of related terms +// - name: Group name +// - index: Zero-based group index +// - total: Total number of groups +// - body: Content containing the group's entries +// - entry(entry, index, total): Renders a single glossary entry +// - entry: Dictionary containing: +// - short: Short form of term +// - long: Long form of term (optional) +// - description: Term description (optional) +// - label: Term's dictionary label +// - pages: Array of linked page numbers where term appears +// - index: Zero-based entry index within group +// - total: Total entries in group +// groups (array): Optional list of groups to include +// If empty, includes all groups +// +// Returns: +// content: Formatted glossary content +// +// Panics: +// - If a requested group doesn't exist +// +#let glossary( + title: "Glossary", + theme: theme-academic, + sort: true, + ignore-case: false, + groups: (), + show-all: false, +) = context { + // Type checking + let checked-title = z.parse(title, z.content(), scope: ("title",)) + let checked-groups = z.parse(groups, groups-list-schema, scope: ("groups",)) + let checked-ignore-case = z.parse(ignore-case, z.boolean(), scope: ( + "ignore-case", + )) + let checked-theme = z.parse(theme, theme-schema, scope: ("theme",)) + + // Collect and organize entries by group + let output = (:) + let all_entries = __gloss_entries.final() + let all_used = if not show-all { + all_entries.keys().filter(key => __is_term_ever_referenced(key)) + } else { + all_entries.keys() + } + + // Determine which groups to process + let all_groups = all_entries + .values() + .map(e => e.at("group")) + .map(g => { if g == none { "" } else { g } }) + .dedup() + .sorted() + + let target_groups = if checked-groups.len() == 0 { + all_groups + } else { + // Validate requested groups exist + for g in checked-groups { + if g == none { + g = "" + } + if g not in all_groups { + panic("Requested group, '" + g + "', not found.") + } + } + checked-groups + } + + // Process entries group by group + for group in target_groups { + let current_entries = () + + // Collect all used entries for this group + for key in all_used { + let entry = all_entries.at(key) + if entry.at("group") == group { + current_entries.push(( + short: entry.at("short"), + long: entry.at("long"), + description: entry.at("description"), + reference: entry.at("reference"), + label: [#metadata(key)#__entry_label(key)], + pages: __create_backlinks(key), + )) + } + } + + // Add non-empty groups to output + if current_entries.len() > 0 { + group = if group == none { "" } else { group } + + // sort entries by case insensitivity if requested + let sorted_entries = if sort { + current_entries + // 1. create array of tuples with (lower [if ignore-case], entry) + .map(e => { + if ignore-case { (lower(e.short), e) } else { (e.short, e) } + }) + // 2. sort the tuples (by first element then second) + .sorted(key: it => it.first()) // NOTE: sorted() is NOT language-aware + // 3. strip away the tuple's first element, leaving an array of entries + .map(t => t.last()) + } else { + current_entries + } + + // add entries to this group's output map + output.insert(group, sorted_entries) + } + } + + // Render the glossary using the theme + let group_index = 0 + + [ + #metadata("glossary") + #(checked-theme.section)( + title, + for (group, entries) in output { + (checked-theme.group)( + group, + group_index, + output.len(), + for (i, entry) in entries.enumerate() { + (checked-theme.entry)(entry, i, entries.len()) + }, + ) + group_index += 1 + }, + ) + ] +} diff --git a/packages/preview/glossy/0.9.1/src/schemas.typ b/packages/preview/glossy/0.9.1/src/schemas.typ new file mode 100644 index 0000000000..2e965175d8 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/schemas.typ @@ -0,0 +1,32 @@ +#import "@preview/valkyrie:0.2.2" as z + +#let dict-schema = z.either( + z.string(), + z.dictionary( + ( + short: z.content(), // this is the only required field + plural: z.content(optional: true), + long: z.content(optional: true), + longplural: z.content(optional: true), + description: z.content(optional: true), + group: z.content(optional: true), + reference: z.dictionary( + optional: true, + ( + key: z.content(), + supplement: z.content(optional: true), + ), + ), + ), + ), +) + +#let theme-schema = z.dictionary( + ( + section: z.function(), + group: z.function(), + entry: z.function(), + ), +) + +#let groups-list-schema = z.array(pre-transform: z.coerce.array) diff --git a/packages/preview/glossy/0.9.1/src/themes.typ b/packages/preview/glossy/0.9.1/src/themes.typ new file mode 100644 index 0000000000..dc3f4b855f --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes.typ @@ -0,0 +1,6 @@ +#import "themes/academic.typ": theme-academic +#import "themes/basic.typ": theme-basic +#import "themes/chicago-index.typ": theme-chicago-index +#import "themes/compact.typ": theme-compact +#import "themes/table.typ": theme-table +#import "themes/twocol.typ": theme-twocol diff --git a/packages/preview/glossy/0.9.1/src/themes/academic.typ b/packages/preview/glossy/0.9.1/src/themes/academic.typ new file mode 100644 index 0000000000..1a4f00e8a4 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes/academic.typ @@ -0,0 +1,61 @@ +// Inspired by academic textbook glossaries +#let theme-academic = ( + section: (title, body) => { + heading(level: 1, title) + v(1em) + body + }, + group: (name, index, total, body) => { + if name != "" and total > 1 { + v(1.5em) + align(left, text(weight: "bold", size: 1.2em, name)) + v(0.75em) + line(length: 100%, stroke: 0.5pt) + v(0.75em) + } + body + }, + entry: (entry, index, total) => { + let short-display = text(weight: "bold", entry.short) + let long-display = if entry.long == none { + [] + } else { + [. #entry.long] + } + + let description = if entry.description == none { + [] + } else { + [. #entry.description] + } + + // Format the reference + let reference = if entry.reference == none { + [] + } else { + if entry.reference.supplement == none { + [ #cite(label(entry.reference.key))] + } else { + [ #cite( + label(entry.reference.key), + supplement: entry.reference.supplement, + )] + } + } + + block( + below: 1em, + text( + size: 0.95em, + { + grid( + columns: (1fr, auto), + gutter: 0.75em, + [#short-display#long-display#description#reference#entry.label], + text(fill: rgb("#666666"), entry.pages.join(", ")), + ) + }, + ), + ) + }, +) diff --git a/packages/preview/glossy/0.9.1/src/themes/basic.typ b/packages/preview/glossy/0.9.1/src/themes/basic.typ new file mode 100644 index 0000000000..24d7fb1f4b --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes/basic.typ @@ -0,0 +1,82 @@ +// Theme definition for a basic single-column glossary layout +// +// This theme provides a traditional glossary format with: +// 1. Bold terms +// 2. Indented descriptions +// 3. Simple page references +// 4. Optional group headings +// +#let theme-basic = ( + // Renders the main glossary section as a single column + // Parameters: + // title: The glossary section title + // body: Content containing all groups and entries + section: (title, body) => { + heading(level: 1, title) + body + }, + // Renders a group of related glossary terms + // Parameters: + // name: Group name (empty string for ungrouped terms) + // index: Zero-based group index + // total: Total number of groups + // body: Content containing the group's entries + group: (name, index, total, body) => { + if name != "" and total > 1 { + heading(level: 2, name) + } + body + }, + // Renders a single glossary entry with term, definition, and page references + // Parameters: + // entry: Dictionary containing term data: + // - short: Short form of term + // - long: Long form of term (optional) + // - description: Term description (optional) + // - label: Term's dictionary label + // - pages: Array of linked page numbers where term appears + // index: Zero-based entry index within group + // total: Total entries in group + entry: (entry, index, total) => { + // Format the term parts + let term = text(weight: "bold", entry.short) + let long-form = if entry.long == none { + [] + } else { + [, #entry.long] + } + + // Format the description with proper spacing + let description = if entry.description == none { + [] + } else { + [: #entry.description] + } + + // Format the reference + let reference = if entry.reference == none { + [] + } else { + if entry.reference.supplement == none { + [ #cite(label(entry.reference.key))] + } else { + [ #cite( + label(entry.reference.key), + supplement: entry.reference.supplement, + )] + } + } + + // Create the complete entry with hanging indent + block( + spacing: 0.5em, + pad( + left: 1em, + bottom: 0.5em, + block( + [#term#entry.label#long-form#description#reference #h(1em) (pp. #entry.pages.join(", "))], + ), + ), + ) + }, +) diff --git a/packages/preview/glossy/0.9.1/src/themes/chicago-index.typ b/packages/preview/glossy/0.9.1/src/themes/chicago-index.typ new file mode 100644 index 0000000000..6d78dc3074 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes/chicago-index.typ @@ -0,0 +1,59 @@ +// Inspired by the Chicago Manual of Style's index format +#let theme-chicago-index = ( + section: (title, body) => { + set par(hanging-indent: 1em) + heading(level: 1, smallcaps(title)) + body + }, + group: (name, index, total, body) => { + if name != "" and total > 1 { + v(1em) + text(weight: "medium", style: "italic", name) + v(0.5em) + } + body + }, + entry: (entry, index, total) => { + let short-display = text(weight: "regular", entry.short) + let long-display = if entry.long == none { + [] + } else { + [ โ€” #entry.long] // Using em-dash for Chicago style + } + + let description = if entry.description == none { + [] + } else { + text(style: "italic", [: #entry.description]) + } + + // Format the reference + let reference = if entry.reference == none { + [] + } else { + if entry.reference.supplement == none { + text(style: "italic", [ #cite(label(entry.reference.key))]) + } else { + text(style: "italic", [ #cite( + label(entry.reference.key), + supplement: entry.reference.supplement, + )]) + } + } + + block( + below: 0.65em, + text( + size: 0.9em, + { + grid( + columns: (1fr, auto), + gutter: 1em, + [#short-display#entry.label#long-display#description#reference#entry.label], + [#entry.pages.join(", ")], + ) + }, + ), + ) + }, +) diff --git a/packages/preview/glossy/0.9.1/src/themes/compact.typ b/packages/preview/glossy/0.9.1/src/themes/compact.typ new file mode 100644 index 0000000000..d276f568d3 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes/compact.typ @@ -0,0 +1,103 @@ +// Theme definition for a compact glossary layout optimized for space efficiency +// +// This theme provides a dense but readable format with: +// 1. Terms and definitions on same line +// 2. Smaller font size +// 3. Minimal vertical spacing +// 4. Left-aligned groups for quick scanning +// 5. Condensed page references +// +#let theme-compact = ( + // Renders the main glossary section with minimal spacing + // Parameters: + // title: The glossary section title + // body: Content containing all groups and entries + section: (title, body) => { + set par(leading: 0.65em) + heading(level: 1, outlined: false, text(weight: "bold", size: 1.1em, title)) + body + }, + // Renders a group of related glossary terms + // Parameters: + // name: Group name (empty string for ungrouped terms) + // index: Zero-based group index + // total: Total number of groups + // body: Content containing the group's entries + group: (name, index, total, body) => { + if name != "" and total > 1 { + block( + spacing: 0.5em, + pad( + top: 0.5em, + text(weight: "bold", size: 0.9em, name), + ), + ) + } + body + }, + // Renders a single glossary entry with term, definition, and page references + // Parameters: + // entry: Dictionary containing term data: + // - short: Short form of term + // - long: Long form of term (optional) + // - description: Term description (optional) + // - label: Term's dictionary label + // - pages: Array of linked page numbers where term appears + // index: Zero-based entry index within group + // total: Total entries in group + entry: (entry, index, total) => { + // Format term components with minimal spacing + let term = text( + size: 0.65em, + weight: "medium", + fill: gray.darken(60%), + entry.short, + ) + + let long-form = if entry.long == none { + [] + } else { + text(size: 0.65em, fill: gray.darken(20%), [ (#entry.long)]) + } + + let description = if entry.description == none { + [] + } else { + text(size: 0.65em, [ยท #entry.description]) + } + + // Format the reference + let reference = if entry.reference == none { + [] + } else { + if entry.reference.supplement == none { + text(size: 0.65em, [ #cite(label(entry.reference.key))]) + } else { + text(size: 0.65em, [ #cite( + label(entry.reference.key), + supplement: entry.reference.supplement, + )]) + } + } + + // Create the complete entry with tight spacing + block( + spacing: 0.4em, + grid( + columns: (auto, 1fr, auto), + align: left + bottom, + gutter: 0.5em, + // Term and description column + box[#term#entry.label#long-form #description#reference], + // Dots.... + repeat(h(0.25em) + text(fill: gray, ".") + h(0.25em)), + // Page references with smaller font + text( + size: 0.6em, + fill: gray.darken(20%), + entry.pages.join(", "), + ), + ), + ) + }, +) diff --git a/packages/preview/glossy/0.9.1/src/themes/table.typ b/packages/preview/glossy/0.9.1/src/themes/table.typ new file mode 100644 index 0000000000..ddfbf02f15 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes/table.typ @@ -0,0 +1,57 @@ +// A tabular theme courtesy of [@Fevol](https://github.com/Fevol) +#let theme-table = ( + section: (title, body) => { + heading(level: 1, title) + body + }, + group: (name, index, total, body) => { + if name != "" and total > 1 { + heading(level: 2, name) + } + + table( + columns: 4, + stroke: none, + inset: (x, y) => { + if (x == 0) { + (left: 0pt, rest: 5pt) + } else if (x == 3) { + (right: 0pt, rest: 5pt) + } else { + 5pt + } + }, + table.header([*Abbreviation*], [*Full Name*], [*Description*], [*Pages*]), + ..body, + ) + }, + entry: (entry, index, total) => { + if entry.reference == none { + ( + entry.short + entry.label, + entry.long, + entry.description, + entry.pages.join(", "), + ) + } else { + if entry.reference.supplement == none { + ( + entry.short + entry.label, + entry.long, + [#entry.description #cite(label(entry.reference.key))], + entry.pages.join(", "), + ) + } else { + ( + entry.short + entry.label, + entry.long, + [#entry.description #cite( + label(entry.reference.key), + supplement: entry.reference.supplement, + )], + entry.pages.join(", "), + ) + } + } + }, +) diff --git a/packages/preview/glossy/0.9.1/src/themes/twocol.typ b/packages/preview/glossy/0.9.1/src/themes/twocol.typ new file mode 100644 index 0000000000..2e4e27fbbe --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/themes/twocol.typ @@ -0,0 +1,88 @@ +// Theme definition for a two-column glossary layout with hierarchical sections +// +// This theme provides formatting for three levels of glossary content: +// 1. Section: The overall glossary container +// 2. Groups: Optional categorization of related terms +// 3. Entries: Individual glossary terms and their definitions +// +#let theme-twocol = ( + // Renders the main glossary section as a two-column layout + // Parameters: + // title: The glossary section title + // body: Content containing all groups and entries + section: (title, body) => { + set par.line(numbering: none) + heading(level: 1, title) + columns(2, body) + }, + // Renders a group of related glossary terms + // Parameters: + // name: Group name (empty string for ungrouped terms) + // index: Zero-based group index + // total: Total number of groups + // body: Content containing the group's entries + group: (name, index, total, body) => { + if name != "" and total > 1 { + heading(level: 2, name) + } + body + }, + // Renders a single glossary entry with term, definition, and page references + // Parameters: + // entry: Dictionary containing term data: + // - short: Short form of term + // - long: Long form of term (optional) + // - description: Term description (optional) + // - label: Term's dictionary label + // - pages: Array of linked page numbers where term appears + // index: Zero-based entry index within group + // total: Total entries in group + entry: (entry, index, total) => { + // Format the short form and optional long form of the term + let short-display = text(weight: "regular", entry.short) + let long-display = if entry.long == none { + [] + } else { + [#h(0.25em) -- #entry.long] + } + + // Format the optional description + let description = if entry.description == none { + [] + } else { + [: #entry.description] + } + + // Format the reference + let reference = if entry.reference == none { + [] + } else { + if entry.reference.supplement == none { + [ #cite(label(entry.reference.key))] + } else { + [ #cite( + label(entry.reference.key), + supplement: entry.reference.supplement, + )] + } + } + + // Render the complete entry with dotted leader line to page numbers + text( + size: 0.75em, + weight: "light", + grid( + columns: (auto, 1fr, 1em, auto), + align: (left, center, center, right), + [#short-display#entry.label#long-display#description#reference], + // Term with label + [#repeat(h(0.25em) + "." + h(0.25em))], + // Dotted leader line + [ . ], + // A 1em wide dot so we definitely get some break between term and pages + [#entry.pages.join(", ")], + // Page references + ), + ) + }, +) diff --git a/packages/preview/glossy/0.9.1/src/utils.typ b/packages/preview/glossy/0.9.1/src/utils.typ new file mode 100644 index 0000000000..9a6be1b2d1 --- /dev/null +++ b/packages/preview/glossy/0.9.1/src/utils.typ @@ -0,0 +1,272 @@ +// Returns the provided value if it is not none, otherwise returns the default value +// Parameters: +// val: The value to check +// default: The fallback value to use if val is none +// Returns: val if not none, otherwise default +#let __default(val, default) = if val == none { default } else { val } + +// Converts an English word to its plural form following standard English pluralization rules +// Parameters: +// word: The singular word to pluralize +// Returns: The pluralized form of the word, or none if input is none +#let __pluralize(word) = { + // Early exit for invalid input + if word == none { + return none + } + + // 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)) + + // Map of irregular plurals for common words + let irregulars = ( + "alumna": "alumnae", + "alumnus": "alumni", + "analysis": "analyses", + "appendix": "appendices", + "basis": "bases", + "cactus": "cacti", + "child": "children", + "crisis": "crises", + "criterion": "criteria", + "datum": "data", + "foot": "feet", + "fungus": "fungi", + "goose": "geese", + "man": "men", + "medium": "media", + "mouse": "mice", + "nucleus": "nuclei", + "oasis": "oases", + "person": "people", + "phenomenon": "phenomena", + "stimulus": "stimuli", + "thesis": "theses", + "tooth": "teeth", + "woman": "women", + ) + + // Set of plural-only words + let plural_only = ( + "advice", + "alumni", + "bison", + "children", + "criteria", + "data", + "deer", + "feet", + "fish", + "geese", + "information", + "media", + "men", + "mice", + "money", + "moose", + "octopi", + "octopodes", + "people", + "scissors", + "series", + "sheep", + "species", + "teeth", + "trousers", + "women", + ) + + // Irregular suffixes + let f_to_ves = ("leaf", "loaf", "calf", "half", "wolf", "thief") + let fe_to_ves = ("wife", "knife", "life") + let o_to_os = ( + "photo", + "piano", + "halo", + "radio", + "video", + "studio", + "solo", + "taco", + "memo", + "zero", + ) + + // Convenience + let w = lower(word) + + // Check for words already plural + if plural_only.contains(w) { + return word + } + + // Check for irregular singulars + if irregulars.keys().contains(w) { + return irregulars.at(w) + } + + // Standard pluralization rules + if ends_with("iz") { + word + "zes" + } else if ends_with_any(("s", "x", "z", "sh", "ch")) { + word + "es" + } else if ends_with("y") { + if not ends_with_any(("ay", "ey", "iy", "oy", "uy")) { + word.slice(0, -1) + "ies" + } else { + word + "s" + } + } else if ends_with("f") { + if f_to_ves.contains(w) { + word.slice(0, -1) + "ves" + } else { + word + "s" + } + } else if ends_with("fe") { + if fe_to_ves.contains(w) { + word.slice(0, -2) + "ves" + } else { + word + "s" + } + } else if ends_with("us") { + word.slice(0, -2) + "i" + } else if ends_with("is") { + word.slice(0, -2) + "es" + } else if ends_with("on") { + word + "s" + } else if ends_with("o") { + if o_to_os.contains(w) { + word + "s" + } else { + word + "es" + } + } else { + word + "s" + } +} + +// Determines the appropriate indefinite article ("a" or "an") for a given English word. +// +// Heuristic rules: +// 1. If word looks like an acronym (all uppercase or letters separated by dots): +// - Use "an" if the first letter's name starts with a vowel sound, e.g. A (ay), E (ee), F (eff), H (aitch), I (eye), L (el), M (em), N (en), O (oh), R (ar), S (ess), X (ex). +// - Otherwise "a". +// +// 2. Otherwise, lowercase the word and apply standard rules: +// - Starts with a vowel (a, e, i, o): "an". +// - Starts with "u": +// * If it begins with "uni", "use", "user", or "eu" (e.g. "university", "use", "Europe"), we treat it as a "yoo" sound -> "a". +// * Otherwise -> "an". +// - Special silent 'h' words: if it starts with "hour", "honest", "honor", "honour", or other, use "an". +// Otherwise if starts with 'h', use "a". +// - Default: "a". +// +// Notes: +// - This is a best-effort heuristic and won't be perfect. +// - These are for singular forms. If plural form is requested alongside +// articles, the caller should panic before calling this function. +// +// Returns: +// "a" or "an" +// +// Example: +// __determine_article("RF") -> "an" (Ar-Ef) +// __determine_article("radio") -> "a" +// __determine_article("hour") -> "an" +// __determine_article("honest") -> "an" +// __determine_article("university") -> "a" +// __determine_article("umbrella") -> "an" +// __determine_article("Europe") -> "a" (because we assume "yoo-ruhp") +#let __determine_article(word) = { + // Check input + if word == none or word.trim() == "" { + // If invalid, default to none + return none + } + + // Normalize the word + let upper_word = word.trim() + let lower_word = lower(upper_word) + + // When these letters are spelled out, they get an "an" (i.e. "an RFC") + let acronym_vowels = ( + "A", // "ay" + "E", // "ee" + "F", // "eff" + "H", // "aitch" + "I", // "eye" + "L", // "el" + "M", // "em" + "N", // "en" + "O", // "oh" + "R", // "ar" + "S", // "ess" + "X", // "ex" + ) + + // Helper to detect if word looks like an acronym + let is_acronym = { + // All uppercase or letters separated by non-alphabetical chars + let cleaned = upper_word.replace(".", "").replace("-", "") + cleaned.codepoints().all(c => c == upper(c)) and cleaned.len() > 0 + } + + if is_acronym { + // Use acronym rules + let first_char = upper_word.codepoints().slice(0, 1).first() + if acronym_vowels.contains(first_char) { + "an" + } else { + "a" + } + } else { + // Non-acronym rules + let first_char = lower_word.codepoints().slice(0, 1).first() + + // Special cases for words matching a set of known 'silent h' prefixes + let silent_h_words = ( + "heir", // "air" + "herb", // "erb" (in American English) + "homage", // "oh-mij" or "ah-mij" + "honest", // "on-est" + "honor", // "on-or" (American English) + "honour", // "on-our" (British English) + "hour", // "our" + ) + if silent_h_words.any(w => lower_word.starts-with(w)) { + return "an" + } + + // Words starting with 'eu', which has the 'yoo' sound + if lower_word.starts-with("eu") { + return "a" + } + + // Words starting with 'u', that get the 'yoo' sound + if first_char == "u" { + let yoo_sounds = ( + "ubi", // ubiquitous + "uni", // unicycle, uniform, universe, university, unilateral, unique, union, etc. + "usa", // usage, usability + "use", // use, useful, useless + "usu", // usual, usually + "usur", // usurp + "ute", // utensil, uterus + "uti", // utility, utilize, utilization + "uto", // utopia, utopian + ) + if yoo_sounds.any(w => lower_word.starts-with(w)) { + return "a" + } + } + + // Vowel start + if "aeiou".contains(first_char) { + return "an" + } + + // the default case, plus 'h' words not in the silent list + "a" + } +} diff --git a/packages/preview/glossy/0.9.1/themeshots.png b/packages/preview/glossy/0.9.1/themeshots.png new file mode 100644 index 0000000000..9bf9497712 Binary files /dev/null and b/packages/preview/glossy/0.9.1/themeshots.png differ diff --git a/packages/preview/glossy/0.9.1/thumbnail.png b/packages/preview/glossy/0.9.1/thumbnail.png new file mode 100644 index 0000000000..70c32c8aad Binary files /dev/null and b/packages/preview/glossy/0.9.1/thumbnail.png differ diff --git a/packages/preview/glossy/0.9.1/typst.toml b/packages/preview/glossy/0.9.1/typst.toml new file mode 100644 index 0000000000..6cb24a5d1e --- /dev/null +++ b/packages/preview/glossy/0.9.1/typst.toml @@ -0,0 +1,12 @@ +[package] +name = "glossy" +version = "0.9.1" +compiler = "0.14.0" +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-packages/glossy/" +exclude = ["justfile", "thumbnail.png", "themeshots.png", "tests", "bin"]