├── .gitignore ├── _extensions └── glossary │ ├── _extension.yml │ └── glossary.lua ├── ex-plants.qmd ├── example.qmd ├── ex-animals.qmd ├── example-2.qmd ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.pdf 3 | *_files/ 4 | .Rproj.user 5 | *.Rproj 6 | .DS_Store 7 | .Rhistory -------------------------------------------------------------------------------- /_extensions/glossary/_extension.yml: -------------------------------------------------------------------------------- 1 | title: Glossary 2 | author: andrewpbray 3 | version: 0.3.5 4 | quarto-required: ">=1.3.0" 5 | contributes: 6 | filters: 7 | - glossary.lua 8 | 9 | -------------------------------------------------------------------------------- /ex-plants.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Plants" 3 | format: html 4 | --- 5 | 6 | Tempus egestas sed sed risus pretium quam vulputate. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Ullamcorper malesuada proin libero nunc consequat interdum varius. Sed tempus urna et pharetra pharetra. Erat imperdiet sed euismod nisi. Sit amet nisl purus in mollis nunc sed id. Turpis egestas sed tempus urna et pharetra pharetra massa massa. 7 | 8 | :::{.definition} 9 | **Sequoia** 10 | 11 | The largest trees on Earth, appearing in three species: Giant Sequoias, Coast Redwoods, and Dawn Redwoods. 12 | ::: 13 | 14 | :::{.function} 15 | `rnorm()` 16 | 17 | Generates random draws from the standard Normal probability distribution. 18 | ::: 19 | 20 | 21 | -------------------------------------------------------------------------------- /example.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Glossary Example" 3 | format: html 4 | filters: 5 | - glossary 6 | glossary: 7 | - id: "my-glossary" 8 | class: "definition" 9 | contents: 10 | - "ex*" 11 | --- 12 | 13 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vestibulum morbi blandit cursus risus at ultrices mi tempus. Quam pellentesque nec nam aliquam. Tortor dignissim convallis aenean et tortor at risus viverra adipiscing. Est placerat in egestas erat imperdiet sed. Amet purus gravida quis blandit turpis cursus. Tortor posuere ac ut consequat semper. Nibh sed pulvinar proin gravida hendrerit. Mauris nunc congue nisi vitae. Nunc sed augue lacus viverra vitae congue eu. 14 | 15 | :::{#my-glossary} 16 | ::: 17 | -------------------------------------------------------------------------------- /ex-animals.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Animals" 3 | format: html 4 | --- 5 | 6 | :::{.definition} 7 | **Elephant** 8 | 9 | The largest living land mammals. 10 | ::: 11 | 12 | Fusce id velit ut tortor. Netus et malesuada fames ac turpis egestas maecenas. A cras semper auctor neque vitae tempus. Odio aenean sed adipiscing diam donec adipiscing tristique risus nec. Scelerisque varius morbi enim nunc faucibus a pellentesque sit. Nascetur ridiculus mus mauris vitae ultricies. 13 | 14 | :::{.definition} 15 | **Hummingbird** 16 | 17 | The smallest of birds. 18 | ::: 19 | 20 | Lacus sed turpis tincidunt id aliquet risus feugiat in. Quam adipiscing vitae proin sagittis. Massa eget egestas purus viverra accumsan in nisl nisi. Viverra mauris in aliquam sem fringilla ut morbi tincidunt augue. 21 | 22 | :::{.callout-note} 23 | ## Note 24 | 25 | This block won't appear in the glossary because it lacks the `.def` class. 26 | ::: -------------------------------------------------------------------------------- /example-2.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Glossary Example 2" 3 | subtitle: "Two different glossaries on one page" 4 | format: html 5 | filters: 6 | - glossary 7 | glossary: 8 | - id: "my-defs" 9 | class: "definition" 10 | contents: 11 | - "ex*" 12 | - id: "my-fns" 13 | class: "function" 14 | contents: 15 | - "ex*" 16 | --- 17 | 18 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vestibulum morbi blandit cursus risus at ultrices mi tempus. Quam pellentesque nec nam aliquam. Tortor dignissim convallis aenean et tortor at risus viverra adipiscing. Est placerat in egestas erat imperdiet sed. Amet purus gravida quis blandit turpis cursus. Tortor posuere ac ut consequat semper. Nibh sed pulvinar proin gravida hendrerit. Mauris nunc congue nisi vitae. Nunc sed augue lacus viverra vitae congue eu. 19 | 20 | ### Definitions 21 | 22 | :::{#my-defs} 23 | ::: 24 | 25 | ### Functions 26 | 27 | :::{#my-fns} 28 | ::: 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Andrew Bray 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glossary Extension For Quarto 2 | 3 | Add a glossary to your document. 4 | 5 | ## Installing 6 | 7 | ```bash 8 | quarto add andrewpbray/glossary 9 | ``` 10 | 11 | This will install the extension under the `_extensions` subdirectory. 12 | If you're using version control, you will want to check in this directory. 13 | 14 | ## Using 15 | 16 | Creating the glossary consists of three steps: 17 | 18 | 1. Activate the filter 19 | 2. Define location of glossary 20 | 3. Specify glossary contents 21 | 22 | #### 1. Activate the filter 23 | 24 | The filter can be activated on a document through the `filters` key in the metadata. 25 | 26 | ```yaml 27 | --- 28 | filters: 29 | - glossary 30 | --- 31 | ``` 32 | 33 | #### 2. Define location of glossary 34 | 35 | The glossary can be added anywhere in the document by inserting a block with a unique id of your choosing. Here, that id is `my-glossary`. 36 | 37 | ``` 38 | The glossary will appear below this line of text. 39 | 40 | :::{#my-glossary} 41 | ::: 42 | 43 | Here is some more text in the document. 44 | ``` 45 | 46 | #### 3. Specify glossary contents 47 | 48 | The content that will fill in your new glossary block are specified in the document metadata through the `glossary` key, which has three options: `id`, `class`, and `contents`. For example: 49 | 50 | ```yaml 51 | --- 52 | filters: 53 | - glossary 54 | glossary: 55 | - id: my-glossary 56 | class: definition 57 | contents: 58 | - "ex*" 59 | --- 60 | ``` 61 | 62 | This specifies that the glossary with the id of `my-glossary` will contain any blocks that have the class `definition` within all files in the document (or project) working directory that begin with `ex`. Note some details about each of these options: 63 | 64 | - `id`: a valid YAML string that matches the id of the block that you created in step 1. When creating the block in the document body, the id begins with `#` but here metadata the `#` is omitted. Note that certain id prefixes like `def-list` and `thm-set` will trigger Quarto to add in cross-references (which is likely undesirable in this implementation of a glossary). See the [cross-references documentation](https://quarto.org/docs/authoring/cross-references.html#theorems-and-proofs) for affected prefixes. 65 | 66 | - `class`: a valid YAML string that matches the class of any blocks in any targeted documents that you wish you include in the glossary. As an example, see `ex-animals.qmd`, where there appear two blocks with the `.definition` class. When adding the class to a block, the class name begins with a `.` but in the YAML option here the `.` is omitted. 67 | 68 | - `contents:` A list of files to scan through for blocks that meet the specified class. These can be a YAML list of full file paths (ending in `.qmd`, `.md`, or `.ipynb`) or include globs, as in the example here, to indicate multiple files (`"ex*"` matches both `ex-plants.qmd` and `ex-animals.qmd`). 69 | 70 | Further details: 71 | 72 | - Directories and files that begin with `.` and `_` will be ignored, as will files called `README.md` and `README.qmd`. So too, will any file not ending in `.qmd`, `.md`, and `.ipynb`. 73 | - If the `contents` key does not appear in the metadata, the filter will scan all files in the working directory that meet the above criteria. 74 | - [Globs](https://en.wikipedia.org/wiki/Glob_(programming)) can be used to match multiple files with a single pattern. `*`, for example, is a wildcard character that can be used to match 0 or more of any character and a glob prefixed with `!` will ignore files that match the glob. 75 | - Lua has no built-in way to process globs, so this filter includes an [implementation](https://github.com/davidm/lua-glob-pattern) written by @davidm. [Globs in base Quarto](https://quarto.org/docs/reference/globs.html) are implemented in TypeScript, so the files matched by a glob in this filter may differ from the files matched by the same glob when, say, specifying the [contents for a listing](https://quarto.org/docs/websites/website-listings.html#listing-contents). 76 | 77 | If you run into issues using this glob syntax, it may be helpful to check the logs (appearing as a background job) to see the list of files matching the `contents` that will be scanned for blocks. 78 | 79 | Each of the options should be specified, as in the example above, as an item in a yaml list (behind a `-`). This permits the inclusion of multiple glossaries within the same document (see `example-2.qmd`). 80 | 81 | ## Example 82 | 83 | See [example.qmd](example.qmd) for an example of a document that inserts a glossary of definitions from two other files: [ex-plants.qmd](ex-plants.qmd) and [ex-animals.qmd](ex-animals.qmd). 84 | 85 | ## Compatibility 86 | 87 | This filter operates before any of the format-specific writer filters, so it should work for a wide range of output formats. It has been tested and shown to work on `html`, `pdf`, `revealjs`, and `docx`. 88 | 89 | In terms of system settings, this filter has been tested on MacOS and Ubuntu. It has not been tested on Windows and may fail on that OS due to the manner in which the lua filter scans the file system for matches to the glob. If that indeed is an issue, PRs welcome. 90 | 91 | -------------------------------------------------------------------------------- /_extensions/glossary/glossary.lua: -------------------------------------------------------------------------------- 1 | quarto.log.output("=== Glossary Log ===") 2 | 3 | 4 | --================-- 5 | -- Core Functions -- 6 | --================-- 7 | 8 | local glossary_meta = {} 9 | 10 | -- Read in YAML options 11 | local function read_meta(meta) 12 | -- permitted options include: 13 | -- glossary: 14 | -- id: string 15 | -- class: string 16 | -- contents: 17 | -- - "first-file.qmd" 18 | -- - "second-file.qmd" 19 | 20 | glossary_meta = meta["glossary"] 21 | 22 | for _,v in pairs(glossary_meta) do 23 | 24 | -- read contents and return list of files to scan for blocks 25 | local files_added = {} 26 | local files_to_scan = {} 27 | if v.contents ~= nil then 28 | for g = 1,#v.contents do 29 | local glob = v.contents[g][1].text 30 | if string.sub(glob, 1, 1) ~= "!" then -- add these files 31 | for f in io.popen("find . -type f \\( -name '*.qmd' -o -name '*.md' -o -name '*.ipynb' \\) -not \\( -path '*/.*' -o -path '*/_*' \\) -not \\( -name 'README.md' -o -name 'README.qmd' \\)"):lines() do 32 | local glob_match = string.match(f, globtopattern("./" .. glob)) 33 | if glob_match ~=nil and new_file(files_added, glob_match) then 34 | files_added[#files_added + 1] = glob_match 35 | end 36 | end 37 | else -- remove these files 38 | ignored_glob = string.sub(glob, 2) 39 | for i = 1,#files_added do 40 | if (string.match(files_added[i], globtopattern("./" .. ignored_glob)) == nil) then 41 | files_to_scan[#files_to_scan + 1] = files_added[i] 42 | end 43 | end 44 | end 45 | end 46 | 47 | if #files_to_scan == 0 then 48 | files_to_scan = files_added 49 | end 50 | 51 | else -- if no contents, scan through all files 52 | for f in io.popen("find . -type f \\( -name '*.qmd' -o -name '*.md' -o -name '*.ipynb' \\) -not \\( -path '*/.*' -o -path '*/_*' \\) -not \\( -name 'README.md' -o -name 'README.qmd' \\)"):lines() do 53 | files_to_scan[#files_to_scan + 1] = f 54 | end 55 | end 56 | 57 | v.files_to_scan = files_to_scan 58 | 59 | end 60 | 61 | end 62 | 63 | 64 | 65 | -- Insert glossary contents into the appropriate Div block 66 | function insert_glossary(div) 67 | 68 | for _,v in pairs(glossary_meta) do -- for each id in glossary meta 69 | local filtered_blocks = {} 70 | 71 | -- find divs that match id 72 | if (div.identifier == v.id[1].text) then 73 | quarto.log.output("> glossary id: ", v.id[1].text) 74 | quarto.log.output(">> files scanned: ", v.files_to_scan) 75 | for _,filename in ipairs(v.files_to_scan) do -- read contents of files 76 | local file_contents = pandoc.read(io.open(filename):read "*a", "markdown", PANDOC_READER_OPTIONS).blocks 77 | for _, block in ipairs(file_contents) do 78 | -- find blocks that meet conditions 79 | if (block.classes ~= nil and block.t == "Div" and block.classes:includes(v.class[1].text)) then 80 | table.insert(filtered_blocks, block) -- Add the block to the filtered table 81 | end 82 | end 83 | end 84 | quarto.log.output(">> Number of blocks inserted: ", #filtered_blocks) 85 | return filtered_blocks 86 | end 87 | end 88 | 89 | end 90 | 91 | 92 | --===================-- 93 | -- Utility Functions -- 94 | --===================-- 95 | 96 | -- check if element is in list 97 | function new_file(list, element) 98 | out = true 99 | for i = 1, #list do 100 | if list[i] == element then 101 | out = false 102 | break 103 | end 104 | end 105 | return out 106 | end 107 | 108 | -- convert globs to Lua patterns 109 | -- written by davidm: https://github.com/davidm/lua-glob-pattern 110 | function globtopattern(g) 111 | -- Some useful references: 112 | -- - apr_fnmatch in Apache APR. For example, 113 | -- http://apr.apache.org/docs/apr/1.3/group__apr__fnmatch.html 114 | -- which cites POSIX 1003.2-1992, section B.6. 115 | 116 | local p = "^" -- pattern being built 117 | local i = 0 -- index in g 118 | local c -- char at index i in g. 119 | 120 | -- unescape glob char 121 | local function unescape() 122 | if c == '\\' then 123 | i = i + 1; c = g:sub(i,i) 124 | if c == '' then 125 | p = '[^]' 126 | return false 127 | end 128 | end 129 | return true 130 | end 131 | 132 | -- escape pattern char 133 | local function escape(c) 134 | return c:match("^%w$") and c or '%' .. c 135 | end 136 | 137 | -- Convert tokens at end of charset. 138 | local function charset_end() 139 | while 1 do 140 | if c == '' then 141 | p = '[^]' 142 | return false 143 | elseif c == ']' then 144 | p = p .. ']' 145 | break 146 | else 147 | if not unescape() then break end 148 | local c1 = c 149 | i = i + 1; c = g:sub(i,i) 150 | if c == '' then 151 | p = '[^]' 152 | return false 153 | elseif c == '-' then 154 | i = i + 1; c = g:sub(i,i) 155 | if c == '' then 156 | p = '[^]' 157 | return false 158 | elseif c == ']' then 159 | p = p .. escape(c1) .. '%-]' 160 | break 161 | else 162 | if not unescape() then break end 163 | p = p .. escape(c1) .. '-' .. escape(c) 164 | end 165 | elseif c == ']' then 166 | p = p .. escape(c1) .. ']' 167 | break 168 | else 169 | p = p .. escape(c1) 170 | i = i - 1 -- put back 171 | end 172 | end 173 | i = i + 1; c = g:sub(i,i) 174 | end 175 | return true 176 | end 177 | 178 | -- Convert tokens in charset. 179 | local function charset() 180 | i = i + 1; c = g:sub(i,i) 181 | if c == '' or c == ']' then 182 | p = '[^]' 183 | return false 184 | elseif c == '^' or c == '!' then 185 | i = i + 1; c = g:sub(i,i) 186 | if c == ']' then 187 | -- ignored 188 | else 189 | p = p .. '[^' 190 | if not charset_end() then return false end 191 | end 192 | else 193 | p = p .. '[' 194 | if not charset_end() then return false end 195 | end 196 | return true 197 | end 198 | 199 | -- Convert tokens. 200 | while 1 do 201 | i = i + 1; c = g:sub(i,i) 202 | if c == '' then 203 | p = p .. '$' 204 | break 205 | elseif c == '?' then 206 | p = p .. '.' 207 | elseif c == '*' then 208 | p = p .. '.*' 209 | elseif c == '[' then 210 | if not charset() then break end 211 | elseif c == '\\' then 212 | i = i + 1; c = g:sub(i,i) 213 | if c == '' then 214 | p = p .. '\\$' 215 | break 216 | end 217 | p = p .. escape(c) 218 | else 219 | p = p .. escape(c) 220 | end 221 | end 222 | return p 223 | end 224 | 225 | 226 | --====================-- 227 | -- Run Core Functions -- 228 | --====================-- 229 | 230 | return{ 231 | {Meta = read_meta}, 232 | {Div = insert_glossary} 233 | } 234 | --------------------------------------------------------------------------------