├── .gitignore ├── LICENSE.ts ├── README.md ├── build.bat ├── build └── build-npm.ts ├── colorsmith.test.ts ├── colorsmith.ts ├── deps.ts ├── deps ├── highlightjs-11.3.1.d.ts └── highlightjs-11.3.1.js ├── main.ts ├── run.bat ├── schema ├── build.bat ├── post.schema.json ├── post.ts ├── site.schema.json └── site.ts ├── templates.ts └── version.ts /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | node_modules/ 3 | .vscode/ 4 | *.exe 5 | md2blog 6 | *.zip 7 | npm/ -------------------------------------------------------------------------------- /LICENSE.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | md2blog uses the following open source libraries: 4 | 5 | * md2blog 6 | * Goldsmith 7 | * Marked 8 | * highlight.js 9 | * Deno and its standard library 10 | * Deno std/encoding/_yaml 11 | * Deno std/path 12 | * event_driven_html_parser 13 | * flags_usage 14 | * literal_html 15 | 16 | Copyright notices for each library follow: 17 | 18 | md2blog: 19 | 20 | MIT License 21 | 22 | Copyright (c) 2021 Jared Krinke 23 | 24 | Permission is hereby granted, free of charge, to any person obtaining a copy 25 | of this software and associated documentation files (the "Software"), to deal 26 | in the Software without restriction, including without limitation the rights 27 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 28 | copies of the Software, and to permit persons to whom the Software is 29 | furnished to do so, subject to the following conditions: 30 | 31 | The above copyright notice and this permission notice shall be included in all 32 | copies or substantial portions of the Software. 33 | 34 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 38 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 39 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 40 | SOFTWARE. 41 | 42 | Goldsmith: 43 | 44 | MIT License 45 | 46 | Copyright (c) 2021 Jared Krinke 47 | 48 | Permission is hereby granted, free of charge, to any person obtaining a copy 49 | of this software and associated documentation files (the "Software"), to deal 50 | in the Software without restriction, including without limitation the rights 51 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 52 | copies of the Software, and to permit persons to whom the Software is 53 | furnished to do so, subject to the following conditions: 54 | 55 | The above copyright notice and this permission notice shall be included in all 56 | copies or substantial portions of the Software. 57 | 58 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 59 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 60 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 61 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 62 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 63 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 64 | SOFTWARE. 65 | 66 | Marked: 67 | 68 | Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) 69 | Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) 70 | 71 | Permission is hereby granted, free of charge, to any person obtaining a copy 72 | of this software and associated documentation files (the "Software"), to deal 73 | in the Software without restriction, including without limitation the rights 74 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 75 | copies of the Software, and to permit persons to whom the Software is 76 | furnished to do so, subject to the following conditions: 77 | 78 | The above copyright notice and this permission notice shall be included in 79 | all copies or substantial portions of the Software. 80 | 81 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 82 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 83 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 84 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 85 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 86 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 87 | THE SOFTWARE. 88 | 89 | MIT License 90 | 91 | Copyright (c) Microsoft Corporation. 92 | 93 | Permission is hereby granted, free of charge, to any person obtaining a copy 94 | of this software and associated documentation files (the "Software"), to deal 95 | in the Software without restriction, including without limitation the rights 96 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 97 | copies of the Software, and to permit persons to whom the Software is 98 | furnished to do so, subject to the following conditions: 99 | 100 | The above copyright notice and this permission notice shall be included in all 101 | copies or substantial portions of the Software. 102 | 103 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 104 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 105 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 106 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 107 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 108 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 109 | SOFTWARE 110 | 111 | highlight.js: 112 | 113 | BSD 3-Clause License 114 | 115 | Copyright (c) 2006, Ivan Sagalaev. 116 | All rights reserved. 117 | 118 | Redistribution and use in source and binary forms, with or without 119 | modification, are permitted provided that the following conditions are met: 120 | 121 | * Redistributions of source code must retain the above copyright notice, this 122 | list of conditions and the following disclaimer. 123 | 124 | * Redistributions in binary form must reproduce the above copyright notice, 125 | this list of conditions and the following disclaimer in the documentation 126 | and/or other materials provided with the distribution. 127 | 128 | * Neither the name of the copyright holder nor the names of its 129 | contributors may be used to endorse or promote products derived from 130 | this software without specific prior written permission. 131 | 132 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 133 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 134 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 135 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 136 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 137 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 138 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 139 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 140 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 141 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 142 | 143 | Deno and its standard library: 144 | 145 | MIT License 146 | 147 | Copyright 2018-2021 the Deno authors. 148 | 149 | Permission is hereby granted, free of charge, to any person obtaining a copy 150 | of this software and associated documentation files (the "Software"), to deal 151 | in the Software without restriction, including without limitation the rights 152 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 153 | copies of the Software, and to permit persons to whom the Software is 154 | furnished to do so, subject to the following conditions: 155 | 156 | The above copyright notice and this permission notice shall be included in all 157 | copies or substantial portions of the Software. 158 | 159 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 160 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 161 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 162 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 163 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 164 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 165 | SOFTWARE. 166 | 167 | Deno std/encoding/_yaml: 168 | 169 | Copyright 2011-2015 by Vitaly Puzrin. All rights reserved. MIT license. 170 | Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. 171 | 172 | Permission is hereby granted, free of charge, to any person obtaining a copy 173 | of this software and associated documentation files (the "Software"), to deal 174 | in the Software without restriction, including without limitation the rights 175 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 176 | copies of the Software, and to permit persons to whom the Software is 177 | furnished to do so, subject to the following conditions: 178 | 179 | The above copyright notice and this permission notice shall be included in all 180 | copies or substantial portions of the Software. 181 | 182 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 183 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 184 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 185 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 186 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 187 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 188 | SOFTWARE. 189 | 190 | Deno std/path: 191 | 192 | Copyright the Browserify authors. MIT License. 193 | 194 | Permission is hereby granted, free of charge, to any person obtaining a copy 195 | of this software and associated documentation files (the "Software"), to deal 196 | in the Software without restriction, including without limitation the rights 197 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 198 | copies of the Software, and to permit persons to whom the Software is 199 | furnished to do so, subject to the following conditions: 200 | 201 | The above copyright notice and this permission notice shall be included in all 202 | copies or substantial portions of the Software. 203 | 204 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 205 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 206 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 207 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 208 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 209 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 210 | SOFTWARE. 211 | 212 | event_driven_html_parser: 213 | 214 | MIT License 215 | 216 | Copyright (c) 2020 Greg Reimer 217 | Copyright (c) 2021 Jared Krinke 218 | 219 | Permission is hereby granted, free of charge, to any person obtaining a copy 220 | of this software and associated documentation files (the "Software"), to deal 221 | in the Software without restriction, including without limitation the rights 222 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 223 | copies of the Software, and to permit persons to whom the Software is 224 | furnished to do so, subject to the following conditions: 225 | 226 | The above copyright notice and this permission notice shall be included in 227 | all copies or substantial portions of the Software. 228 | 229 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 230 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 231 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 232 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 233 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 234 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 235 | THE SOFTWARE. 236 | 237 | 238 | flags_usage: 239 | 240 | MIT-0 License 241 | 242 | Copyright (c) 2021 Jared Krinke 243 | 244 | Permission is hereby granted, free of charge, to any person obtaining a copy 245 | of this software and associated documentation files (the "Software"), to deal 246 | in the Software without restriction, including without limitation the rights 247 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 248 | copies of the Software, and to permit persons to whom the Software is 249 | furnished to do so. 250 | 251 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 252 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 253 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 254 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 255 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 256 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 257 | SOFTWARE. 258 | 259 | literal_html: 260 | 261 | MIT-0 License 262 | 263 | Copyright (c) 2021 Jared Krinke 264 | 265 | Permission is hereby granted, free of charge, to any person obtaining a copy 266 | of this software and associated documentation files (the "Software"), to deal 267 | in the Software without restriction, including without limitation the rights 268 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 269 | copies of the Software, and to permit persons to whom the Software is 270 | furnished to do so. 271 | 272 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 273 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 274 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 275 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 276 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 277 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 278 | SOFTWARE. 279 | 280 | `; 281 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # md2blog 2 | A zero-config static site generator for dev blogs 3 | 4 | # What does md2blog do? 5 | > Convert a *self-contained, organized* set of [Markdown](https://guides.github.com/features/mastering-markdown/) posts into a *minimal, but fully functional* static blog, requiring *zero configuration*. 6 | 7 | # How is md2blog different? 8 | The key differentiator for md2blog is the "self-contained, organized" part. By this, I mean: 9 | 10 | * **Relative links between Markdown files (including anchors) "just work"** (and are validated at build time) 11 | * Posts are **implicitly categorized based on directory structure** (supplemental tags are also supported) 12 | 13 | Additionally, the produced site is "minimal, but fully functional" in the following sense: 14 | 15 | * Page templates use **clean, semantic HTML** with only a few kilobytes of CSS (and no JavaScript) 16 | * **Relative links are used wherever possible**, so the site can be hosted anywhere 17 | * A local web server with automatic reloading is provided, but the site can even be viewed directly from the file system 18 | * **Syntax highlighting** is automatically added to code blocks 19 | * An [Atom](https://validator.w3.org/feed/docs/atom.html) feed is automatically generated 20 | 21 | Note that "zero configuration" implies that md2blog is highly opinionated, to the point that there are (almost) no options to configure. **Instead of fiddling with options and themes, your focus is strictly on writing and publishing content.** 22 | 23 | # How do I use md2blog? 24 | Here's how to get started: 25 | 26 | * **[Quick start](https://jaredkrinke.github.io/md2blog/quick-start.html)** 27 | 28 | # Can I see some examples? 29 | Here are two web sites that are built using md2blog: 30 | 31 | * [My dev blog](https://log.schemescape.com/) 32 | * [The md2blog documentation](https://jaredkrinke.github.io/md2blog/) (which isn't a blog, so not the best example) 33 | 34 | # Additional resources 35 | 36 | * [FAQ](https://jaredkrinke.github.io/md2blog/posts/faq/index.html) 37 | * [Template repository](https://github.com/jaredkrinke/md2blog-template-site) for creating your own dev blog (see [instructions](https://jaredkrinke.github.io/md2blog/quick-start.html#setup)) 38 | * [Example repository](https://github.com/jaredkrinke/log) for a real site using md2blog 39 | * [Source code](https://github.com/jaredkrinke/md2blog) for md2blog ([MIT Licensed](https://github.com/jaredkrinke/md2blog/blob/main/LICENSE.ts)) 40 | -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | deno compile --target x86_64-pc-windows-msvc -o md2blog --allow-net=localhost --allow-read --allow-write ./main.ts 2 | powershell Compress-Archive -Path md2blog.exe -DestinationPath md2blog-x86_64-pc-windows-msvc.zip -Force 3 | 4 | deno compile --target x86_64-unknown-linux-gnu -o md2blog --allow-net=localhost --allow-read --allow-write ./main.ts 5 | powershell Compress-Archive -Path md2blog -DestinationPath md2blog-x86_64-unknown-linux-gnu.zip -Force 6 | 7 | deno compile --target x86_64-apple-darwin -o md2blog --allow-net=localhost --allow-read --allow-write ./main.ts 8 | powershell Compress-Archive -Path md2blog -DestinationPath md2blog-x86_64-apple-darwin.zip -Force 9 | 10 | deno compile --target aarch64-apple-darwin -o md2blog --allow-net=localhost --allow-read --allow-write ./main.ts 11 | powershell Compress-Archive -Path md2blog -DestinationPath md2blog-aarch64-apple-darwin.zip -Force 12 | -------------------------------------------------------------------------------- /build/build-npm.ts: -------------------------------------------------------------------------------- 1 | // Build md2blog for Node/NPM using dnt (to support 32-bit platforms, etc.) 2 | 3 | import { build, emptyDir } from "https://deno.land/x/dnt/mod.ts"; 4 | import { version } from "../version.ts"; 5 | 6 | const outDir = "./npm"; 7 | 8 | await emptyDir(outDir); 9 | 10 | await build({ 11 | entryPoints: [ 12 | { 13 | kind: "bin", 14 | name: "md2blog", 15 | path: "../main.ts", 16 | }, 17 | ], 18 | outDir, 19 | scriptModule: false, 20 | typeCheck: false, 21 | declaration: false, 22 | shims: { 23 | // see JS docs for overview and more options 24 | deno: true, 25 | }, 26 | package: { 27 | // package.json properties 28 | name: "md2blog", 29 | version, 30 | description: "md2blog for NPM", 31 | license: "MIT", 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /colorsmith.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "https://deno.land/std@0.113.0/testing/asserts.ts"; 2 | import { hexToRGB, rgbToHex, rgbToHSL, hslToRGB, ColorsmithError } from "./colorsmith.ts"; 3 | 4 | Deno.test({ 5 | name: "RGB to hex", 6 | fn: () => { 7 | assertEquals(rgbToHex({ r: 0.5, g: 0.5, b: 0.5 }), "#808080"); 8 | assertEquals(rgbToHex({ r: 0, g: 0, b: 0 }), "#000000"); 9 | assertEquals(rgbToHex({ r: 1, g: 1, b: 1 }), "#ffffff"); 10 | }, 11 | }); 12 | 13 | Deno.test({ 14 | name: "Hex to RGB", 15 | fn: () => { 16 | assertEquals(hexToRGB("#808080"), { r: 0.5, g: 0.5, b: 0.5 }); 17 | }, 18 | }); 19 | 20 | Deno.test({ 21 | name: "Hex to RGB, invalid", 22 | fn: () => { 23 | assertThrows(() => hexToRGB("black")); 24 | assertThrows(() => hexToRGB("#000")); 25 | assertThrows(() => hexToRGB("######")); 26 | assertThrows(() => hexToRGB("aabbcc")); 27 | assertThrows(() => hexToRGB("#gggggg")); 28 | }, 29 | }); 30 | 31 | Deno.test({ 32 | name: "Round-trip hex through RGB", 33 | fn: () => { 34 | for (const color of [ 35 | "#000000", 36 | "#ffffff", 37 | "#654321", 38 | "#123456", 39 | ]) { 40 | assertEquals(rgbToHex(hexToRGB(color)), color); 41 | } 42 | }, 43 | }); 44 | 45 | Deno.test({ 46 | name: "RGB to HSL", 47 | fn: () => { 48 | assertEquals(rgbToHSL({ r: 1, g: 0, b: 0}), { h: 0, s: 1, l: 0.5}); 49 | assertEquals(rgbToHSL({ r: 0, g: 1, b: 1}), { h: 180, s: 1, l: 0.5}); 50 | }, 51 | }); 52 | 53 | Deno.test({ 54 | name: "Round-trip hex through RGB and HSL", 55 | fn: () => { 56 | const colors = [ 57 | "#123456", 58 | "#654321", 59 | "#808080", 60 | "#baf00d", 61 | "#999999", 62 | "#f1f2f3", 63 | ]; 64 | 65 | for (const color of colors) { 66 | assertEquals(rgbToHex(hslToRGB(rgbToHSL(hexToRGB(color)))), color); 67 | } 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /colorsmith.ts: -------------------------------------------------------------------------------- 1 | // Color conversions 2 | /** Represents a color in red, green, and blue (all 0 - 1.0). */ 3 | export type RGB = { r: number, g: number, b: number }; 4 | 5 | /** Represents a color using hue (0 - 360 degrees), saturation (0 - 1.0), and lightness (0 - 1.0). */ 6 | export type HSL = { h: number, s: number, l: number }; 7 | 8 | export class ColorsmithError extends Error { 9 | constructor(message: string) { 10 | super(message); 11 | } 12 | } 13 | 14 | const max = 256; 15 | 16 | /** Converts a 6 digit hex color of the form "#RRGGBB" to RGB (0 - 1.0). Note: 3 digit form ("#123") is not supported. */ 17 | const hexColorPattern = /^#[0-9a-fA-F]{6}$/; 18 | export function hexToRGB(hex: string): RGB { 19 | if (!hexColorPattern.test(hex)) { 20 | throw new ColorsmithError(`Invalid color format: "${hex}" (expected: "#789abc")`); 21 | } 22 | 23 | const r = parseInt(hex.substr(1, 2), 16) / max; 24 | const g = parseInt(hex.substr(3, 2), 16) / max; 25 | const b = parseInt(hex.substr(5, 2), 16) / max; 26 | return { r, g, b}; 27 | } 28 | 29 | export function rgbToHex(rgb: RGB): string { 30 | const { r, g, b } = rgb; 31 | return "#" + [r, g, b] 32 | .map(x => Math.min(max - 1, Math.floor(x * max)).toString(16)) 33 | .map(str => str.length === 1 ? "0" + str : str) 34 | .join(""); 35 | } 36 | 37 | export function rgbToHSL(rgb: RGB): HSL { 38 | const { r, g, b} = rgb; 39 | const v = Math.max(r, g, b); 40 | const c = Math.max(r, g, b) - Math.min(r, g, b); 41 | const l = v - c / 2; 42 | const h = 60 * ((c === 0) ? 0 43 | : ((v === r) ? ((g - b) / c) 44 | : ((v === g) ? (2 + (b - r) / c) 45 | : (4 + (r - g) / c) 46 | ))); 47 | 48 | const s = (l === 0 || l === 1) ? 0 : (c / (1 - Math.abs(2 * v - c - 1))); 49 | 50 | return { h, s, l }; 51 | } 52 | 53 | export function hslToRGB(hsl: HSL): RGB { 54 | const { h, s, l } = hsl; 55 | const f: (n: number) => number = (n) => { 56 | const k = (n + h / 30) % 12; 57 | const a = s * Math.min(l, 1 - l); 58 | return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); 59 | } 60 | 61 | return { 62 | r: f(0), 63 | g: f(8), 64 | b: f(4), 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | import type { SiteMetadata } from "./schema/site.ts"; 2 | import type { PostMetadata } from "./schema/post.ts"; 3 | 4 | export type { SiteMetadata }; 5 | export type { PostMetadata }; 6 | export { validate as validateSiteMetadata } from "./schema/site.ts"; 7 | export { validate as validatePostMetadata } from "./schema/post.ts"; 8 | export { Goldsmith } from "https://deno.land/x/goldsmith@1.5.0/mod.ts"; 9 | export type { GoldsmithPlugin, GoldsmithFile } from "https://deno.land/x/goldsmith@1.5.0/mod.ts"; 10 | export { goldsmithJSONMetadata } from "https://deno.land/x/goldsmith@1.5.0/plugins/json_metadata/mod.ts"; 11 | export { goldsmithFrontMatter } from "https://deno.land/x/goldsmith@1.5.0/plugins/front_matter/mod.ts"; 12 | export { goldsmithExcludeDrafts } from "https://deno.land/x/goldsmith@1.5.0/plugins/exclude_drafts/mod.ts"; 13 | export { goldsmithFileMetadata } from "https://deno.land/x/goldsmith@1.5.0/plugins/file_metadata/mod.ts"; 14 | export { goldsmithIndex } from "https://deno.land/x/goldsmith@1.5.0/plugins/index/mod.ts"; 15 | export { goldsmithCollections } from "https://deno.land/x/goldsmith@1.5.0/plugins/collections/mod.ts"; 16 | export { goldsmithInjectFiles } from "https://deno.land/x/goldsmith@1.5.0/plugins/inject_files/mod.ts"; 17 | export { goldsmithMarkdown } from "https://deno.land/x/goldsmith@1.5.0/plugins/markdown/mod.ts"; 18 | export { goldsmithRootPaths } from "https://deno.land/x/goldsmith@1.5.0/plugins/root_paths/mod.ts"; 19 | export { goldsmithLayout } from "https://deno.land/x/goldsmith@1.5.0/plugins/layout/mod.ts"; 20 | export { goldsmithLayoutLiteralHTML } from "https://deno.land/x/goldsmith@1.5.0/plugins/layout/literal_html.ts"; 21 | export type { GoldsmithLiteralHTMLLayoutContext, GoldsmithLiteralHTMLLayoutCallback, GoldsmithLiteralHTMLLayoutMap } from "https://deno.land/x/goldsmith@1.5.0/plugins/layout/literal_html.ts"; 22 | export { goldsmithWatch } from "https://deno.land/x/goldsmith@1.5.0/plugins/watch/mod.ts"; 23 | export { goldsmithServe } from "https://deno.land/x/goldsmith@1.5.0/plugins/serve/mod.ts"; 24 | export { goldsmithFeed } from "https://deno.land/x/goldsmith@1.5.0/plugins/feed/mod.ts"; 25 | export { goldsmithLinkChecker } from "https://deno.land/x/goldsmith@1.5.0/plugins/link_checker/mod.ts"; 26 | export { version } from "./version.ts"; 27 | 28 | declare module "https://deno.land/x/goldsmith@1.5.0/mod.ts" { 29 | interface GoldsmithMetadata { 30 | site?: SiteMetadata; 31 | tagsAll?: string[]; 32 | tagsTop?: string[]; 33 | } 34 | 35 | interface GoldsmithFile extends Partial { 36 | // Generic post schema comes from PostMetadata 37 | 38 | // Derived properties 39 | category?: string; 40 | tags?: string[]; 41 | 42 | // Root properties 43 | isRoot?: boolean; 44 | 45 | // Tag index properties 46 | tag?: string; 47 | isTagIndex?: boolean; 48 | postsWithTag?: GoldsmithFile[]; 49 | } 50 | } -------------------------------------------------------------------------------- /deps/highlightjs-11.3.1.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable no-use-before-define */ 3 | export type HLJSApi = PublicApi & ModesAPI 4 | 5 | export interface VuePlugin { 6 | install: (vue: any) => void 7 | } 8 | 9 | interface PublicApi { 10 | highlight: (codeOrLanguageName: string, optionsOrCode: string | HighlightOptions, ignoreIllegals?: boolean) => HighlightResult 11 | highlightAuto: (code: string, languageSubset?: string[]) => AutoHighlightResult 12 | highlightBlock: (element: any) => void 13 | highlightElement: (element: any) => void 14 | configure: (options: Partial) => void 15 | initHighlighting: () => void 16 | initHighlightingOnLoad: () => void 17 | highlightAll: () => void 18 | registerLanguage: (languageName: string, language: LanguageFn) => void 19 | unregisterLanguage: (languageName: string) => void 20 | listLanguages: () => string[] 21 | registerAliases: (aliasList: string | string[], { languageName } : {languageName: string}) => void 22 | getLanguage: (languageName: string) => Language | undefined 23 | autoDetection: (languageName: string) => boolean 24 | inherit: (original: T, ...args: Record[]) => T 25 | addPlugin: (plugin: HLJSPlugin) => void 26 | debugMode: () => void 27 | safeMode: () => void 28 | versionString: string 29 | vuePlugin: () => VuePlugin 30 | } 31 | 32 | interface ModesAPI { 33 | SHEBANG: (mode?: Partial & {binary?: string | RegExp}) => Mode 34 | BACKSLASH_ESCAPE: Mode 35 | QUOTE_STRING_MODE: Mode 36 | APOS_STRING_MODE: Mode 37 | PHRASAL_WORDS_MODE: Mode 38 | COMMENT: (begin: string | RegExp, end: string | RegExp, modeOpts?: Mode | {}) => Mode 39 | C_LINE_COMMENT_MODE: Mode 40 | C_BLOCK_COMMENT_MODE: Mode 41 | HASH_COMMENT_MODE: Mode 42 | NUMBER_MODE: Mode 43 | C_NUMBER_MODE: Mode 44 | BINARY_NUMBER_MODE: Mode 45 | REGEXP_MODE: Mode 46 | TITLE_MODE: Mode 47 | UNDERSCORE_TITLE_MODE: Mode 48 | METHOD_GUARD: Mode 49 | END_SAME_AS_BEGIN: (mode: Mode) => Mode 50 | // built in regex 51 | IDENT_RE: string 52 | UNDERSCORE_IDENT_RE: string 53 | MATCH_NOTHING_RE: string 54 | NUMBER_RE: string 55 | C_NUMBER_RE: string 56 | BINARY_NUMBER_RE: string 57 | RE_STARTERS_RE: string 58 | } 59 | 60 | export type LanguageFn = (hljs: HLJSApi) => Language 61 | export type CompilerExt = (mode: Mode, parent: Mode | Language | null) => void 62 | 63 | export interface HighlightResult { 64 | code?: string 65 | relevance : number 66 | value : string 67 | language? : string 68 | illegal : boolean 69 | errorRaised? : Error 70 | // * for auto-highlight 71 | secondBest? : Omit 72 | // private 73 | _illegalBy? : illegalData 74 | _emitter : Emitter 75 | _top? : Language | CompiledMode 76 | } 77 | export interface AutoHighlightResult extends HighlightResult {} 78 | 79 | export interface illegalData { 80 | message: string 81 | context: string 82 | index: number 83 | resultSoFar : string 84 | mode: CompiledMode 85 | } 86 | 87 | export type BeforeHighlightContext = { 88 | code: string, 89 | language: string, 90 | result?: HighlightResult 91 | } 92 | export type PluginEvent = keyof HLJSPlugin; 93 | export type HLJSPlugin = { 94 | 'after:highlight'?: (result: HighlightResult) => void, 95 | 'before:highlight'?: (context: BeforeHighlightContext) => void, 96 | 'after:highlightElement'?: (data: { el: any, result: HighlightResult, text: string}) => void, 97 | 'before:highlightElement'?: (data: { el: any, language: string}) => void, 98 | // TODO: Old API, remove with v12 99 | 'after:highlightBlock'?: (data: { block: any, result: HighlightResult, text: string}) => void, 100 | 'before:highlightBlock'?: (data: { block: any, language: string}) => void, 101 | } 102 | 103 | interface EmitterConstructor { 104 | new (opts: any): Emitter 105 | } 106 | 107 | export interface HighlightOptions { 108 | language: string 109 | ignoreIllegals?: boolean 110 | } 111 | 112 | export interface HLJSOptions { 113 | noHighlightRe: RegExp 114 | languageDetectRe: RegExp 115 | classPrefix: string 116 | cssSelector: string 117 | languages?: string[] 118 | __emitter: EmitterConstructor 119 | ignoreUnescapedHTML?: boolean 120 | throwUnescapedHTML?: boolean 121 | } 122 | 123 | export interface CallbackResponse { 124 | data: Record 125 | ignoreMatch: () => void 126 | isMatchIgnored: boolean 127 | } 128 | 129 | export type ModeCallback = (match: RegExpMatchArray, response: CallbackResponse) => void 130 | export type Language = LanguageDetail & Partial 131 | export interface Mode extends ModeCallbacks, ModeDetails {} 132 | 133 | export interface LanguageDetail { 134 | name?: string 135 | unicodeRegex?: boolean 136 | rawDefinition?: () => Language 137 | aliases?: string[] 138 | disableAutodetect?: boolean 139 | contains: (Mode)[] 140 | case_insensitive?: boolean 141 | keywords?: Record | string 142 | isCompiled?: boolean, 143 | exports?: any, 144 | classNameAliases?: Record 145 | compilerExtensions?: CompilerExt[] 146 | supersetOf?: string 147 | } 148 | 149 | // technically private, but exported for convenience as this has 150 | // been a pretty stable API and is quite useful 151 | export interface Emitter { 152 | addKeyword(text: string, kind: string): void 153 | addText(text: string): void 154 | toHTML(): string 155 | finalize(): void 156 | closeAllNodes(): void 157 | openNode(kind: string): void 158 | closeNode(): void 159 | addSublanguage(emitter: Emitter, subLanguageName: string): void 160 | } 161 | 162 | export type HighlightedHTMLElement = any & {result?: object, secondBest?: object, parentNode: any} 163 | 164 | /* modes */ 165 | 166 | interface ModeCallbacks { 167 | "on:end"?: Function, 168 | "on:begin"?: ModeCallback 169 | } 170 | 171 | export interface CompiledLanguage extends LanguageDetail, CompiledMode { 172 | isCompiled: true 173 | contains: CompiledMode[] 174 | keywords: Record 175 | } 176 | 177 | export type CompiledScope = Record & {_emit?: Record, _multi?: boolean, _wrap?: string}; 178 | 179 | export type CompiledMode = Omit & 180 | { 181 | begin?: RegExp | string 182 | end?: RegExp | string 183 | scope?: string 184 | contains: CompiledMode[] 185 | keywords: any 186 | data: Record 187 | terminatorEnd: string 188 | keywordPatternRe: RegExp 189 | beginRe: RegExp 190 | endRe: RegExp 191 | illegalRe: RegExp 192 | matcher: any 193 | isCompiled: true 194 | starts?: CompiledMode 195 | parent?: CompiledMode 196 | beginScope?: CompiledScope 197 | endScope?: CompiledScope 198 | } 199 | 200 | interface ModeDetails { 201 | begin?: RegExp | string | (RegExp | string)[] 202 | match?: RegExp | string | (RegExp | string)[] 203 | end?: RegExp | string | (RegExp | string)[] 204 | // deprecated in favor of `scope` 205 | className?: string 206 | scope?: string | Record 207 | beginScope?: string | Record 208 | endScope?: string | Record 209 | contains?: ("self" | Mode)[] 210 | endsParent?: boolean 211 | endsWithParent?: boolean 212 | endSameAsBegin?: boolean 213 | skip?: boolean 214 | excludeBegin?: boolean 215 | excludeEnd?: boolean 216 | returnBegin?: boolean 217 | returnEnd?: boolean 218 | __beforeBegin?: Function 219 | parent?: Mode 220 | starts?:Mode 221 | lexemes?: string | RegExp 222 | keywords?: Record | string 223 | beginKeywords?: string 224 | relevance?: number 225 | illegal?: string | RegExp | Array 226 | variants?: Mode[] 227 | cachedVariants?: Mode[] 228 | // parsed 229 | subLanguage?: string | string[] 230 | isCompiled?: boolean 231 | label?: string 232 | } 233 | 234 | declare const HighlightJS: HLJSApi; 235 | export default HighlightJS; 236 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Goldsmith, 3 | GoldsmithPlugin, 4 | goldsmithJSONMetadata, 5 | goldsmithFrontMatter, 6 | goldsmithExcludeDrafts, 7 | goldsmithFileMetadata, 8 | goldsmithIndex, 9 | goldsmithCollections, 10 | goldsmithInjectFiles, 11 | goldsmithMarkdown, 12 | goldsmithRootPaths, 13 | goldsmithLayout, 14 | goldsmithLayoutLiteralHTML, 15 | goldsmithWatch, 16 | goldsmithServe, 17 | goldsmithFeed, 18 | goldsmithLinkChecker, 19 | validatePostMetadata, 20 | validateSiteMetadata, 21 | version as md2blogVersion, 22 | } from "./deps.ts"; 23 | 24 | import { processFlags } from "https://deno.land/x/flags_usage@1.0.1/mod.ts"; 25 | import { templates, generateCSS } from "./templates.ts"; 26 | // @deno-types="./deps/highlightjs-11.3.1.d.ts" 27 | import highlightJS from "./deps/highlightjs-11.3.1.js"; 28 | import copyrightNotice from "./LICENSE.ts"; 29 | 30 | // Command line arguments 31 | const { clean, drafts, execute, input, output, serve, watch, version, copyright } = processFlags(Deno.args, { 32 | description: { 33 | clean: "Clean output directory before processing", 34 | drafts: "Include drafts in output", 35 | serve: "Serve web site, with automatic reloading", 36 | watch: "Watch for changes and rebuild automatically", 37 | input: "Input directory", 38 | output: "Output directory", 39 | execute: "Command to run on build completion", 40 | copyright: "Display open source software copyright notices", 41 | version: "Display md2blog version information", 42 | }, 43 | argument: { 44 | execute: "command", 45 | input: "dir", 46 | output: "dir", 47 | }, 48 | string: [ 49 | "execute", 50 | "input", 51 | "output", 52 | ], 53 | boolean: [ 54 | "clean", 55 | "drafts", 56 | "serve", 57 | "watch", 58 | "copyright", 59 | "version", 60 | ], 61 | alias: { 62 | clean: "c", 63 | drafts: "d", 64 | execute: "x", 65 | input: "i", 66 | output: "o", 67 | serve: "s", 68 | watch: "w", 69 | }, 70 | default: { 71 | input: "content", 72 | output: "out", 73 | }, 74 | }); 75 | 76 | if (copyright) { 77 | console.log(copyrightNotice); 78 | Deno.exit(0); 79 | } 80 | 81 | if (version) { 82 | console.log(md2blogVersion); 83 | Deno.exit(0); 84 | } 85 | 86 | // Path format for posts: posts/(:category/)postName.md 87 | // Groups: |-- 2 --| 88 | const postPathPattern = /^posts(\/([^/]+))?\/[^/]+.md$/; 89 | 90 | function replaceLink(link: string) { 91 | return link.replace(/^([^/][^:]*)\.md(#[^#]+)?$/, "$1.html$2") 92 | } 93 | 94 | function capitalize(str: string): string { 95 | if (str.length > 0) { 96 | return str[0].toLocaleUpperCase() + str.substring(1); 97 | } 98 | return str; 99 | } 100 | 101 | function executeCallback(): void { 102 | if (execute) { 103 | Deno.run({ cmd: execute.split(" ") }); 104 | } 105 | } 106 | 107 | // Cache the results of syntax highlighting since that process is somewhat slow 108 | const highlightCache: { [language: string]: { [code: string]: string } } = {}; 109 | function highlight(code: string, language?: string): string { 110 | const key = language ?? "undefined"; 111 | let cache = highlightCache[key]; 112 | if (cache) { 113 | const result = cache[code]; 114 | if (result) { 115 | return result; 116 | } 117 | } else { 118 | const newCache = {}; 119 | highlightCache[key] = newCache; 120 | cache = newCache; 121 | } 122 | 123 | const result = (language && highlightJS.getLanguage(language)) 124 | ? highlightJS.highlight(code, { language }).value 125 | : highlightJS.highlightAuto(code).value; 126 | 127 | cache[code] = result; 128 | 129 | return result; 130 | } 131 | 132 | const noop: GoldsmithPlugin = (_files, _goldsmith) => {}; 133 | const watching = serve || watch; // --serve implies --watch 134 | 135 | await Goldsmith({ lineEndings: "auto" }) 136 | .source(input) 137 | .destination(output) 138 | .clean(clean) 139 | .use(goldsmithJSONMetadata({ "site.json": "site" })) 140 | .use((_files, goldsmith) => { 141 | // Validate site.json 142 | try { 143 | validateSiteMetadata(goldsmith.metadata().site) 144 | } catch (error) { 145 | console.log("Error validating site.json:"); 146 | throw error; 147 | } 148 | }) 149 | .use(goldsmithFrontMatter()) 150 | .use(drafts ? noop : goldsmithExcludeDrafts()) 151 | .use(goldsmithFileMetadata({ 152 | pattern: /\.html$/, 153 | metadata: { layout: false }, // Opt raw HTML files out of layouts so they're copied verbatim 154 | })) 155 | .use(goldsmithFileMetadata({ 156 | pattern: postPathPattern, 157 | metadata: (file, matches) => { 158 | // Verify post metadata 159 | try { 160 | validatePostMetadata(file); 161 | } catch (error) { 162 | console.log(`Error validating ${matches[0]}:`); 163 | throw error; 164 | } 165 | return {}; 166 | }, 167 | })) 168 | .use(goldsmithFileMetadata({ 169 | pattern: postPathPattern, 170 | metadata: (_file, matches) => ({ category: matches[2] ?? "misc" }), 171 | })) 172 | .use(goldsmithFileMetadata({ 173 | pattern: postPathPattern, 174 | metadata: (file) => ({ 175 | layout: "post", 176 | 177 | // Set "tags" to be [ category, ...keywords ] (with duplicates removed) 178 | tags: [...new Set([ file.category!, ...(file.keywords ?? []) ])], 179 | }), 180 | })) 181 | .use(goldsmithIndex({ 182 | pattern: postPathPattern, 183 | property: "tags", 184 | createTermIndexPath: term => `posts/${term}/index.html`, 185 | })) 186 | .use(goldsmithFileMetadata({ 187 | pattern: /^posts\/[^/]+?\/index.html$/, 188 | metadata: (file, _matches, metadata) => ({ 189 | tag: file.term, 190 | layout: "tagIndex", 191 | isTagIndex: true, 192 | postsWithTag: metadata.indexes!.tags[file.term!].sort((a, b) => (b.date!.valueOf() - a.date!.valueOf())), // Note: Sorts the array in place! 193 | }), 194 | })) 195 | .use(goldsmithCollections({ 196 | posts: { 197 | pattern: postPathPattern, 198 | sortBy: "date", 199 | reverse: true, 200 | }, 201 | postsRecent: { 202 | pattern: postPathPattern, 203 | sortBy: "date", 204 | reverse: true, 205 | limit: 5, 206 | }, 207 | nonPosts: { 208 | pattern: /^[^/]+\.(html|md)$/, 209 | sortBy: "title", 210 | }, 211 | })) 212 | .use((_files, goldsmith) => { 213 | // Create index and archive tag lists 214 | const metadata = goldsmith.metadata(); 215 | 216 | // Sort "all tags" list alphabetically 217 | metadata.tagsAll = Object.keys(metadata.indexes!.tags).sort((a, b) => (a < b ? -1 : 1)); 218 | 219 | // Sort "top tags" list by most posts, and then most recent post if there's a tie 220 | metadata.tagsTop = Object.keys(metadata.indexes!.tags).sort((a, b) => { 221 | const postsA = metadata.indexes!.tags[a]; 222 | const postsB = metadata.indexes!.tags[b]; 223 | return (postsB.length - postsA.length) || (postsB[0].date!.getDate() - postsA[0].date!.getDate()); 224 | }).slice(0, 4); 225 | }) 226 | .use(goldsmithInjectFiles({ 227 | "index.html": { layout: "index" }, 228 | "posts/index.html": { layout: "archive" }, 229 | "404.html": { layout: "404" }, 230 | })) 231 | .use(goldsmithInjectFiles({ 232 | "css/style.css": { 233 | data: (metadata) => generateCSS(metadata.site?.colors ?? {}), 234 | }, 235 | })) 236 | .use(goldsmithMarkdown({ 237 | replaceLinks: link => replaceLink(link), 238 | highlight, 239 | cache: watching, 240 | })) 241 | .use(goldsmithRootPaths()) 242 | .use(goldsmithFeed({ getCollection: (metadata) => metadata.collections!.postsRecent! })) 243 | .use((_files, goldsmith) => { 244 | // Set header defaults 245 | const metadata = goldsmith.metadata(); 246 | const site = metadata.site!; 247 | const text = site.header?.text ?? site.description; 248 | let links = site.header?.links; 249 | if (!links) { 250 | links = {}; 251 | for (const file of metadata.collections!.nonPosts) { 252 | const pathFromRoot = file.pathFromRoot!; 253 | if (pathFromRoot !== "index.html") { 254 | const name = capitalize( 255 | pathFromRoot 256 | .replace(/\.[^.]*$/, "") 257 | .replace("-", " ") 258 | ); 259 | links[name] = pathFromRoot; 260 | } 261 | } 262 | } 263 | 264 | for (const [name, link] of Object.entries(links)) { 265 | links[name] = replaceLink(link); 266 | } 267 | 268 | site.header = { 269 | text, 270 | links, 271 | }; 272 | }) 273 | .use(goldsmithLayout({ 274 | pattern: /\.html$/, 275 | layout: goldsmithLayoutLiteralHTML({ 276 | templates, 277 | defaultTemplate: "default", 278 | }) 279 | })) 280 | .use(goldsmithLinkChecker({ background: serve })) // Link-check asynchronously when serving 281 | .use(watching ? goldsmithWatch({ onRebuildCompleted: executeCallback, delayMS: 10 }) : noop) 282 | .use(serve ? goldsmithServe() : noop) 283 | .build(); 284 | 285 | executeCallback(); 286 | 287 | -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | deno run --allow-net=localhost --allow-read=content,out --allow-write=out main.ts %* -------------------------------------------------------------------------------- /schema/build.bat: -------------------------------------------------------------------------------- 1 | deno run --allow-read=site.schema.json --allow-write=site.ts https://deno.land/x/json_schema_aot@0.2.1/main.ts site.schema.json --ts site.ts 2 | deno run --allow-read=post.schema.json --allow-write=post.ts https://deno.land/x/json_schema_aot@0.2.1/main.ts post.schema.json --ts post.ts 3 | -------------------------------------------------------------------------------- /schema/post.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2019-09/schema", 3 | "title": "Post Metadata", 4 | "type": "object", 5 | "properties": { 6 | "title": { 7 | "type": "string" 8 | }, 9 | "date": { 10 | "type": "string", 11 | "format": "date" 12 | }, 13 | "keywords": { 14 | "type": "array", 15 | "items": { 16 | "type": "string" 17 | } 18 | }, 19 | "description": { 20 | "type": "string" 21 | }, 22 | "draft": { 23 | "type": "boolean" 24 | } 25 | }, 26 | "required": [ 27 | "title", 28 | "date" 29 | ] 30 | } -------------------------------------------------------------------------------- /schema/post.ts: -------------------------------------------------------------------------------- 1 | // Do not edit by hand. This file was generated by json-schema-aot. 2 | 3 | export interface PostMetadata { 4 | title: string; 5 | date: Date; 6 | keywords?: string[]; 7 | description?: string; 8 | draft?: boolean; 9 | } 10 | 11 | // deno-lint-ignore no-explicit-any 12 | export function parse(json: any): PostMetadata { 13 | if (json === null) { 14 | throw `JSON validation error at root: expected object, but encountered null`; 15 | } else if (typeof(json) !== "object") { 16 | throw `JSON validation error at root: expected object, but encountered ${typeof(json)}`; 17 | } else if (Array.isArray(json)) { 18 | throw `JSON validation error at root: expected object, but encountered an array`; 19 | } 20 | 21 | let jsonRequiredPropertyCount = 0; 22 | // deno-lint-ignore no-explicit-any 23 | const jsonResultObject: any = {}; 24 | // deno-lint-ignore no-explicit-any 25 | for (const [jsonKey, jsonValue] of Object.entries(json as Record)) { 26 | jsonResultObject[jsonKey] = (() => { 27 | switch (jsonKey) { 28 | case "title": { 29 | 30 | ++jsonRequiredPropertyCount; 31 | if (typeof(jsonValue) !== "string") { 32 | throw `JSON validation error at "title": expected string, but encountered ${typeof(jsonValue)}`; 33 | } 34 | return jsonValue; 35 | 36 | } 37 | 38 | case "date": { 39 | 40 | ++jsonRequiredPropertyCount; 41 | if (typeof(jsonValue) !== "string" && !(jsonValue instanceof Date)) { 42 | throw `JSON validation error at "date": expected string or Date, but encountered ${typeof(jsonValue)}`; 43 | } 44 | 45 | return new Date(jsonValue); 46 | 47 | } 48 | 49 | case "keywords": { 50 | 51 | if (typeof(jsonValue) !== "object" || !Array.isArray(jsonValue)) { 52 | throw `JSON validation error at "keywords": expected array, but encountered ${typeof(jsonValue)}`; 53 | } 54 | 55 | return jsonValue.map(jsonValueElement => { 56 | if (typeof(jsonValueElement) !== "string") { 57 | throw `JSON validation error at "keywords.items": expected string, but encountered ${typeof(jsonValueElement)}`; 58 | } 59 | return jsonValueElement; 60 | 61 | }) 62 | 63 | } 64 | 65 | case "description": { 66 | 67 | if (typeof(jsonValue) !== "string") { 68 | throw `JSON validation error at "description": expected string, but encountered ${typeof(jsonValue)}`; 69 | } 70 | return jsonValue; 71 | 72 | } 73 | 74 | case "draft": { 75 | 76 | if (typeof(jsonValue) !== "boolean") { 77 | throw `JSON validation error at "draft": expected boolean, but encountered ${typeof(jsonValue)}`; 78 | } 79 | 80 | return jsonValue; 81 | 82 | } 83 | 84 | 85 | } 86 | })(); 87 | } 88 | 89 | if (jsonRequiredPropertyCount !== 2) { 90 | throw `JSON validation error at root: missing at least one required property from the list: [title, date]`; 91 | } 92 | return jsonResultObject; 93 | 94 | } 95 | 96 | // deno-lint-ignore no-explicit-any 97 | export function validate(json: any) { 98 | parse(json); 99 | } 100 | 101 | -------------------------------------------------------------------------------- /schema/site.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2019-09/schema", 3 | "title": "Site Metadata", 4 | "description": "Format for md2blog site metadata file (site.json).", 5 | "type": "object", 6 | "$defs": { 7 | "hexColor": { 8 | "type": "string", 9 | "pattern": "^#[0-9a-fA-F]{6}$" 10 | } 11 | }, 12 | "properties": { 13 | "$schema": { 14 | "description": "Optional JSON schema URI (ignored by md2blog, but can be used by text editors to support contextual hints and auto-complete).", 15 | "type": "string" 16 | }, 17 | "title": { 18 | "description": "Title for the site (displayed at the top of every page).", 19 | "type": "string" 20 | }, 21 | "description": { 22 | "description": "Optional (but recommended) description of the site (used in meta tags and also used as the default subtitle on all pages).", 23 | "type": "string" 24 | }, 25 | "url": { 26 | "description": "Optional (but recommended) URL for the root of the site (used in Atom feed and Open Graph links).", 27 | "type": "string" 28 | }, 29 | "colors": { 30 | "description": "Optional object specifying colors to use on the site. Note that all colors are reused for syntax highlighting.", 31 | "type": "object", 32 | "properties": { 33 | "title": { 34 | "description": "Color used for the site title.", 35 | "$ref": "#/$defs/hexColor" 36 | }, 37 | "heading": { 38 | "description": "Color used for headings.", 39 | "$ref": "#/$defs/hexColor" 40 | }, 41 | "link": { 42 | "description": "Color used links.", 43 | "$ref": "#/$defs/hexColor" 44 | }, 45 | "comment": { 46 | "description": "Color used only for syntax highlighting (generally for comments).", 47 | "$ref": "#/$defs/hexColor" 48 | } 49 | } 50 | }, 51 | "header": { 52 | "description": "Optional subtitle and top-level links (added to all pages). By default, the site description is used as the subtitle and links to non-post pages in the site root are shown.", 53 | "type": "object", 54 | "properties": { 55 | "text": { 56 | "description": "Optional subtitle (added to all pages). By default, the site description is used as the subtitle.", 57 | "type": "string" 58 | }, 59 | "links": { 60 | "description": "Optional map of top-level link names to relative paths or URLs. Use `index.html` to link to the home page and `posts/index.html` to link to the archive. By default, links to non-post pages in the site root are shown.", 61 | "type": "object", 62 | "additionalProperties": { 63 | "type": "string" 64 | } 65 | } 66 | } 67 | }, 68 | "footer": { 69 | "description": "Optional footer, e.g. for copyright notices (added to all pages).", 70 | "type": "object", 71 | "properties": { 72 | "text": { 73 | "description": "Text to be shown in the footer (e.g. copyright notice).", 74 | "type": "string" 75 | } 76 | } 77 | } 78 | }, 79 | "required": [ 80 | "title" 81 | ] 82 | } -------------------------------------------------------------------------------- /schema/site.ts: -------------------------------------------------------------------------------- 1 | // Do not edit by hand. This file was generated by json-schema-aot. 2 | 3 | export type HexColor = string; 4 | 5 | /** Format for md2blog site metadata file (site.json). */ 6 | export interface SiteMetadata { 7 | /** Optional JSON schema URI (ignored by md2blog, but can be used by text editors to support contextual hints and auto-complete). */ 8 | $schema?: string; 9 | /** Title for the site (displayed at the top of every page). */ 10 | title: string; 11 | /** Optional (but recommended) description of the site (used in meta tags and also used as the default subtitle on all pages). */ 12 | description?: string; 13 | /** Optional (but recommended) URL for the root of the site (used in Atom feed and Open Graph links). */ 14 | url?: string; 15 | /** Optional object specifying colors to use on the site. Note that all colors are reused for syntax highlighting. */ 16 | colors?: { 17 | /** Color used for the site title. */ 18 | title?: HexColor; 19 | /** Color used for headings. */ 20 | heading?: HexColor; 21 | /** Color used links. */ 22 | link?: HexColor; 23 | /** Color used only for syntax highlighting (generally for comments). */ 24 | comment?: HexColor; 25 | }; 26 | /** Optional subtitle and top-level links (added to all pages). By default, the site description is used as the subtitle and links to non-post pages in the site root are shown. */ 27 | header?: { 28 | /** Optional subtitle (added to all pages). By default, the site description is used as the subtitle. */ 29 | text?: string; 30 | /** Optional map of top-level link names to relative paths or URLs. Use `index.html` to link to the home page and `posts/index.html` to link to the archive. By default, links to non-post pages in the site root are shown. */ 31 | links?: { 32 | [key: string]: string; 33 | }; 34 | }; 35 | /** Optional footer, e.g. for copyright notices (added to all pages). */ 36 | footer?: { 37 | /** Text to be shown in the footer (e.g. copyright notice). */ 38 | text?: string; 39 | }; 40 | } 41 | 42 | // deno-lint-ignore no-explicit-any 43 | function parseHexColor(json: any) { 44 | if (typeof(json) !== "string") { 45 | throw `JSON validation error at "$defs.hexColor": expected string, but encountered ${typeof(json)}`; 46 | } 47 | if (!(/^#[0-9a-fA-F]{6}$/.test(json))) { 48 | throw `JSON validation error at "$defs.hexColor": string did not match pattern /^#[0-9a-fA-F]{6}$/: ${json}`; 49 | } 50 | return json; 51 | 52 | } 53 | 54 | // deno-lint-ignore no-explicit-any 55 | export function parse(json: any): SiteMetadata { 56 | if (json === null) { 57 | throw `JSON validation error at root: expected object, but encountered null`; 58 | } else if (typeof(json) !== "object") { 59 | throw `JSON validation error at root: expected object, but encountered ${typeof(json)}`; 60 | } else if (Array.isArray(json)) { 61 | throw `JSON validation error at root: expected object, but encountered an array`; 62 | } 63 | 64 | let jsonRequiredPropertyCount = 0; 65 | // deno-lint-ignore no-explicit-any 66 | const jsonResultObject: any = {}; 67 | // deno-lint-ignore no-explicit-any 68 | for (const [jsonKey, jsonValue] of Object.entries(json as Record)) { 69 | jsonResultObject[jsonKey] = (() => { 70 | switch (jsonKey) { 71 | case "$schema": { 72 | 73 | if (typeof(jsonValue) !== "string") { 74 | throw `JSON validation error at "$schema": expected string, but encountered ${typeof(jsonValue)}`; 75 | } 76 | return jsonValue; 77 | 78 | } 79 | 80 | case "title": { 81 | 82 | ++jsonRequiredPropertyCount; 83 | if (typeof(jsonValue) !== "string") { 84 | throw `JSON validation error at "title": expected string, but encountered ${typeof(jsonValue)}`; 85 | } 86 | return jsonValue; 87 | 88 | } 89 | 90 | case "description": { 91 | 92 | if (typeof(jsonValue) !== "string") { 93 | throw `JSON validation error at "description": expected string, but encountered ${typeof(jsonValue)}`; 94 | } 95 | return jsonValue; 96 | 97 | } 98 | 99 | case "url": { 100 | 101 | if (typeof(jsonValue) !== "string") { 102 | throw `JSON validation error at "url": expected string, but encountered ${typeof(jsonValue)}`; 103 | } 104 | return jsonValue; 105 | 106 | } 107 | 108 | case "colors": { 109 | 110 | if (jsonValue === null) { 111 | throw `JSON validation error at "colors": expected object, but encountered null`; 112 | } else if (typeof(jsonValue) !== "object") { 113 | throw `JSON validation error at "colors": expected object, but encountered ${typeof(jsonValue)}`; 114 | } else if (Array.isArray(jsonValue)) { 115 | throw `JSON validation error at "colors": expected object, but encountered an array`; 116 | } 117 | 118 | // deno-lint-ignore no-explicit-any 119 | const jsonValueResultObject: any = {}; 120 | // deno-lint-ignore no-explicit-any 121 | for (const [jsonValueKey, jsonValueValue] of Object.entries(jsonValue as Record)) { 122 | jsonValueResultObject[jsonValueKey] = (() => { 123 | switch (jsonValueKey) { 124 | case "title": { 125 | 126 | return parseHexColor(jsonValueValue); 127 | 128 | } 129 | 130 | case "heading": { 131 | 132 | return parseHexColor(jsonValueValue); 133 | 134 | } 135 | 136 | case "link": { 137 | 138 | return parseHexColor(jsonValueValue); 139 | 140 | } 141 | 142 | case "comment": { 143 | 144 | return parseHexColor(jsonValueValue); 145 | 146 | } 147 | 148 | 149 | } 150 | })(); 151 | } 152 | return jsonValueResultObject; 153 | 154 | } 155 | 156 | case "header": { 157 | 158 | if (jsonValue === null) { 159 | throw `JSON validation error at "header": expected object, but encountered null`; 160 | } else if (typeof(jsonValue) !== "object") { 161 | throw `JSON validation error at "header": expected object, but encountered ${typeof(jsonValue)}`; 162 | } else if (Array.isArray(jsonValue)) { 163 | throw `JSON validation error at "header": expected object, but encountered an array`; 164 | } 165 | 166 | // deno-lint-ignore no-explicit-any 167 | const jsonValueResultObject: any = {}; 168 | // deno-lint-ignore no-explicit-any 169 | for (const [jsonValueKey, jsonValueValue] of Object.entries(jsonValue as Record)) { 170 | jsonValueResultObject[jsonValueKey] = (() => { 171 | switch (jsonValueKey) { 172 | case "text": { 173 | 174 | if (typeof(jsonValueValue) !== "string") { 175 | throw `JSON validation error at "header.text": expected string, but encountered ${typeof(jsonValueValue)}`; 176 | } 177 | return jsonValueValue; 178 | 179 | } 180 | 181 | case "links": { 182 | 183 | if (jsonValueValue === null) { 184 | throw `JSON validation error at "header.links": expected object, but encountered null`; 185 | } else if (typeof(jsonValueValue) !== "object") { 186 | throw `JSON validation error at "header.links": expected object, but encountered ${typeof(jsonValueValue)}`; 187 | } else if (Array.isArray(jsonValueValue)) { 188 | throw `JSON validation error at "header.links": expected object, but encountered an array`; 189 | } 190 | 191 | // deno-lint-ignore no-explicit-any 192 | const jsonValueValueResultObject: any = {}; 193 | // deno-lint-ignore no-explicit-any 194 | for (const [jsonValueValueKey, jsonValueValueValue] of Object.entries(jsonValueValue as Record)) { 195 | jsonValueValueResultObject[jsonValueValueKey] = (() => { 196 | switch (jsonValueValueKey) { 197 | 198 | default: { 199 | if (typeof(jsonValueValueValue) !== "string") { 200 | throw `JSON validation error at "header.links.additionalProperties": expected string, but encountered ${typeof(jsonValueValueValue)}`; 201 | } 202 | return jsonValueValueValue; 203 | 204 | } 205 | 206 | } 207 | })(); 208 | } 209 | return jsonValueValueResultObject; 210 | 211 | } 212 | 213 | 214 | } 215 | })(); 216 | } 217 | return jsonValueResultObject; 218 | 219 | } 220 | 221 | case "footer": { 222 | 223 | if (jsonValue === null) { 224 | throw `JSON validation error at "footer": expected object, but encountered null`; 225 | } else if (typeof(jsonValue) !== "object") { 226 | throw `JSON validation error at "footer": expected object, but encountered ${typeof(jsonValue)}`; 227 | } else if (Array.isArray(jsonValue)) { 228 | throw `JSON validation error at "footer": expected object, but encountered an array`; 229 | } 230 | 231 | // deno-lint-ignore no-explicit-any 232 | const jsonValueResultObject: any = {}; 233 | // deno-lint-ignore no-explicit-any 234 | for (const [jsonValueKey, jsonValueValue] of Object.entries(jsonValue as Record)) { 235 | jsonValueResultObject[jsonValueKey] = (() => { 236 | switch (jsonValueKey) { 237 | case "text": { 238 | 239 | if (typeof(jsonValueValue) !== "string") { 240 | throw `JSON validation error at "footer.text": expected string, but encountered ${typeof(jsonValueValue)}`; 241 | } 242 | return jsonValueValue; 243 | 244 | } 245 | 246 | 247 | } 248 | })(); 249 | } 250 | return jsonValueResultObject; 251 | 252 | } 253 | 254 | 255 | } 256 | })(); 257 | } 258 | 259 | if (jsonRequiredPropertyCount !== 1) { 260 | throw `JSON validation error at root: missing at least one required property from the list: [title]`; 261 | } 262 | return jsonResultObject; 263 | 264 | } 265 | 266 | // deno-lint-ignore no-explicit-any 267 | export function validate(json: any) { 268 | parse(json); 269 | } 270 | 271 | -------------------------------------------------------------------------------- /templates.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GoldsmithFile, 3 | GoldsmithLiteralHTMLLayoutContext, 4 | GoldsmithLiteralHTMLLayoutCallback, 5 | GoldsmithLiteralHTMLLayoutMap, 6 | } from "./deps.ts"; 7 | 8 | import { html } from "https://deno.land/x/literal_html@1.1.0/mod.ts"; 9 | import { hexToRGB, rgbToHSL, hslToRGB, rgbToHex } from "./colorsmith.ts"; 10 | 11 | // CSS template 12 | export interface GenerateCSSOptions { 13 | title?: string; 14 | heading?: string; 15 | link?: string; 16 | comment?: string; 17 | } 18 | 19 | const cssTemplate = `:root { color-scheme: dark; } 20 | html, body { margin: 0; } 21 | 22 | img { 23 | max-width: 100%; 24 | object-fit: contain; 25 | } 26 | 27 | body { 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | font-family: sans-serif; 32 | overflow-y: scroll; 33 | } 34 | 35 | body > header, main { width: 40em; } 36 | body > header, main { padding: 0.5em; } 37 | 38 | @media screen and (max-width: 40em) { 39 | body > header, main { width: calc(100% - 1em); } 40 | } 41 | 42 | body { line-height: 1.5; } 43 | pre code { line-height: 1.3; } 44 | h1, h2, h3, h4, h5 { line-height: 1.2; } 45 | td { line-height: 1.2; } 46 | 47 | body > header { text-align: center; } 48 | body > header > h1 { margin-bottom: 0; } 49 | body > header > p { margin: 0.3em 0; } 50 | 51 | nav { padding: 0.3em 0; } 52 | nav > ul { display: inline; padding: 0; } 53 | nav > ul > li { display: inline; } 54 | nav > ul > li + li:before { content: " | "; } 55 | 56 | body > header > h1 > a { 57 | font-size: 1.75rem; 58 | text-decoration: inherit; 59 | } 60 | 61 | article > header > h1 { margin-bottom: 0.25em; } 62 | article > header > p { margin-top: 0em; } 63 | 64 | main { overflow: auto; } 65 | pre { overflow: auto; } 66 | 67 | code { font-size: 1rem; } 68 | pre code { font-size: 0.8125rem; } 69 | 70 | th, td { padding: 0.25em; } 71 | pre { border: solid 1px; padding: 0em 0.25em; } 72 | table, th, tr, td { border-collapse: collapse; border: solid 1px; } 73 | 74 | main > ul { padding-left: 0em; } 75 | main > ul > li { list-style: none; margin-bottom: 2em; } 76 | 77 | ul { padding-left: 1.5em; } 78 | li { margin-bottom: 0.5em; } 79 | 80 | h1 { font-size: 1.6rem; font-weight: bold; } 81 | h2 { font-size: 1.3rem; font-weight: bold; } 82 | h3 { font-size: 1.2rem; font-weight: normal; } 83 | h4 { font-size: 1rem; font-weight: bold; } 84 | h5 { font-size: 1rem; font-weight: normal; } 85 | 86 | :is(h1, h2, h3, h4, h5) :is(a:link, a:visited) { color: inherit; } 87 | 88 | body { 89 | background-color: @background; 90 | color: @textDefault; 91 | } 92 | 93 | body > main { border-bottom: 1px solid @backgroundLightest; } 94 | th { background-color: @backgroundLightest; color: @textLight; } 95 | pre, table, th, tr, td { border-color: @border; } 96 | pre { background-color: @backgroundLighter; } 97 | tr:nth-child(even) { background-color: @backgroundLight; } 98 | 99 | code { 100 | background-color: @backgroundEvenLighter; 101 | border-radius: 0.2em; 102 | padding: 0em 0.1em; 103 | } 104 | 105 | pre code { 106 | background-color: revert; 107 | border-radius: revert; 108 | padding: revert; 109 | } 110 | 111 | body > header > h1 { color: @textTitle; } 112 | nav.site { font-weight: bold; } 113 | nav > strong { font-weight: bold; color: @textHeading; } 114 | h1, h2, h3, h4, h5 { color: @textHeading; } 115 | a:link { color: @textLink; } 116 | a:visited { color: @textLinkVisited; } 117 | /* Also b5cea8 or 7dce52 or 7bbf56 */ 118 | 119 | /* Syntax highlighting */ 120 | .hljs-comment { color: @textCommentDark; } 121 | 122 | .hljs-tag, 123 | .hljs-punctuation { color: @textDark; } 124 | 125 | .hljs-literal { color: @textLinkVisited; } 126 | 127 | .hljs-title.class_, 128 | .hljs-tag .hljs-name, 129 | .hljs-tag .hljs-attr { color: @textComment; } 130 | 131 | .hljs-attr, 132 | .hljs-symbol, 133 | .hljs-variable, 134 | .hljs-template-variable, 135 | .hljs-link, 136 | .hljs-selector-attr, 137 | .hljs-selector-pseudo { color: @textLink; } 138 | 139 | .hljs-keyword, 140 | .hljs-attribute, 141 | .hljs-selector-tag, 142 | .hljs-meta .hljs-keyword, 143 | .hljs-doctag, 144 | .hljs-name { color: @textLinkVisited; } 145 | 146 | .hljs-type, 147 | .hljs-string, 148 | .hljs-number, 149 | .hljs-quote, 150 | .hljs-template-tag, 151 | .hljs-deletion, 152 | .hljs-title, 153 | .hljs-section, 154 | .hljs-meta { color: @textHeading; } 155 | 156 | .hljs-regexp, 157 | .hljs-meta .hljs-string { color: @textHeadingDark; } 158 | 159 | .hljs-title.function_, 160 | .hljs-built_in, 161 | .hljs-bullet, 162 | .hljs-code, 163 | .hljs-addition, 164 | .hljs-selector-id, 165 | .hljs-selector-class { color: @textTitle; } 166 | `; 167 | 168 | export function generateCSS(options: GenerateCSSOptions): string { 169 | let css = cssTemplate; 170 | 171 | // Base colors 172 | const textTitle = options.title ?? "#e6b95c"; 173 | const textHeading = options.heading ?? "#d97c57"; 174 | const textLink = options.link ?? "#59c5ff"; 175 | const textComment = options.comment ?? "#7bbf56"; 176 | const textDefault = "#c8c8c8"; 177 | const background = "#181818"; 178 | 179 | let colors: { [name: string]: string } = { 180 | textTitle, 181 | textHeading, 182 | textLink, 183 | textComment, 184 | textDefault, 185 | background, 186 | }; 187 | 188 | // Validate format of each color 189 | for (const value of Object.values(colors)) { 190 | hexToRGB(value); 191 | } 192 | 193 | // Derived colors 194 | const desaturateStep = -0.15; 195 | const darkenStep = -0.05; 196 | const lightenStep = 0.04; 197 | 198 | function adjust(hex: string, h?: number, s?: number, l?: number): string { 199 | const hsl = rgbToHSL(hexToRGB(hex)); 200 | if (h) { 201 | hsl.h = (hsl.h + h) % 360; 202 | } 203 | if (s) { 204 | hsl.s = Math.min(1, Math.max(0, hsl.s + s)); 205 | } 206 | if (l) { 207 | hsl.l = Math.min(1, Math.max(0, hsl.l + l)); 208 | } 209 | return rgbToHex(hslToRGB(hsl)); 210 | } 211 | 212 | colors = { 213 | ...colors, 214 | textDark: adjust(textDefault, 0, 0, -0.15), 215 | textLight: adjust(textDefault, 0, 0, lightenStep * 2), 216 | textHeadingDark: adjust(textHeading, 0, desaturateStep, darkenStep), 217 | textLinkVisited: adjust(textLink, 0, desaturateStep, darkenStep), 218 | textCommentDark: adjust(textComment, 0, desaturateStep, darkenStep), 219 | backgroundLight: adjust(background, 0, 0, lightenStep), 220 | backgroundLighter: adjust(background, 0, 0, lightenStep * 2), 221 | backgroundEvenLighter: adjust(background, 0, 0, lightenStep * 3), 222 | backgroundLightest: adjust(background, 0, 0, lightenStep * 5), 223 | border: adjust(background, 0, 0, lightenStep * 7), 224 | } 225 | 226 | // Find and replace 227 | for (const colorName of Object.keys(colors)) { 228 | css = css.replaceAll(`@${colorName};`, `${colors[colorName]};`); 229 | } 230 | 231 | return css; 232 | } 233 | 234 | // HTML templates 235 | interface PartialBaseOptions { 236 | navigationVerbatim?: string; 237 | headVerbatim?: string; 238 | } 239 | 240 | function isRelativeLink(href: string): boolean { 241 | return !(href.startsWith("/") || href.includes(":")); 242 | } 243 | 244 | const partialBase = (m: GoldsmithLiteralHTMLLayoutContext, mainVerbatim: string, o?: PartialBaseOptions) => 245 | html` 246 | 247 | 248 | 249 | ${m.title ?? m.site!.title!} 250 | ${{verbatim: m.description ? html`` : ""}} 251 | ${{verbatim: m.keywords ? html`` : ""}} 252 | 253 | 254 | ${{verbatim: m.isRoot ? html`` : ""}} 255 | ${{verbatim: o?.headVerbatim ?? ""}} 256 | 257 | 258 |
259 |

${m.site!.title!}

260 | ${{verbatim: m.site?.header?.text ? html`

${m.site.header.text}

` : ""}} 261 | ${{verbatim: (m.site?.header?.links && Object.keys(m.site.header.links).length > 0) ? html`` : ""}} 264 | ${{verbatim: o?.navigationVerbatim ?? ""}} 265 |
266 |
267 | ${{verbatim: mainVerbatim}} 268 |
269 | ${{verbatim: m.site?.footer ? html`
270 | ${{verbatim: m.site.footer.text ? html`

${m.site.footer.text}

` : ""}} 271 |
` : ""}} 272 | 273 | 274 | `; 275 | 276 | interface PartialNavigationOptions { 277 | incomplete?: boolean; 278 | isTagIndex?: boolean; 279 | tag?: string; 280 | } 281 | 282 | function partialNavigation(m: GoldsmithLiteralHTMLLayoutContext, tags: string[], o?: PartialNavigationOptions): string { 283 | return (tags && tags.length > 0) ? html`` : ""; 289 | } 290 | 291 | const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }); 292 | const formatDateShort = (date: Date) => date.toISOString().replace(/T.*$/, ""); 293 | const formatDate = (date: Date) => dateFormatter.format(date); 294 | function partialDate(date: Date): string { 295 | return html`

`; 296 | } 297 | 298 | const partialArticleSummary: (m: GoldsmithLiteralHTMLLayoutContext, post: GoldsmithFile) => string = (m, post) => { 299 | return html`
300 |
301 |

${post.title!}

302 | ${{verbatim: partialDate(post.date!)}} 303 |
304 | ${{verbatim: post.description ? html`

${post.description}

` : ""}} 305 |
306 | `; 307 | }; 308 | 309 | function partialArticleSummaryList(m: GoldsmithLiteralHTMLLayoutContext, posts: GoldsmithFile[]): string { 310 | return html`
    311 | ${{verbatim: posts.map((post: GoldsmithFile) => html`
  • ${{verbatim: partialArticleSummary(m, post)}}
  • `).join("\n")}} 312 |
`; 313 | } 314 | 315 | const template404: GoldsmithLiteralHTMLLayoutCallback = (_content, m) => partialBase(m, 316 | `

Not found

317 |

The requested page does not exist.

318 |

Click here to go to the home page.

319 | `); 320 | 321 | const templateArchive: GoldsmithLiteralHTMLLayoutCallback = (_content, m) => partialBase( 322 | { 323 | title: `${m.site!.title!}: Archive of all posts since the beginning of time`, 324 | ...m 325 | }, 326 | partialArticleSummaryList(m, m.collections!.posts!), 327 | { navigationVerbatim: partialNavigation(m, m.tagsAll!) } 328 | ); 329 | 330 | const templateDefault: GoldsmithLiteralHTMLLayoutCallback = (content, m) => partialBase( 331 | m, 332 | html`
333 | ${{verbatim: content}} 334 |
`, 335 | { navigationVerbatim: partialNavigation(m, m.tags!) } 336 | ); 337 | 338 | const templatePost: GoldsmithLiteralHTMLLayoutCallback = (content, m) => partialBase( 339 | m, 340 | html`
341 |
342 |

${m.title!}

343 | ${{verbatim: partialDate(m.date!)}} 344 |
345 | ${{verbatim: content}} 346 | 349 |
`, 350 | { 351 | navigationVerbatim: partialNavigation(m, m.tags!), 352 | headVerbatim: html`` 362 | } 363 | ); 364 | 365 | const templateRoot: GoldsmithLiteralHTMLLayoutCallback = (_content, m) => partialBase( 366 | { 367 | isRoot: true, 368 | description: m.site?.description, 369 | ...m, 370 | }, 371 | html`${{verbatim: partialArticleSummaryList(m, m.collections!.postsRecent!)}} 372 | `, 375 | { 376 | navigationVerbatim: partialNavigation(m, m.tagsTop!, { 377 | incomplete: m.tagsTop!.length !== m.tagsAll!.length, 378 | }), 379 | } 380 | ); 381 | 382 | const templateTagIndex: GoldsmithLiteralHTMLLayoutCallback = (_content, m) => partialBase( 383 | { 384 | title: `${m.site!.title!}: Posts tagged with: ${m.term!}`, 385 | ...m 386 | }, 387 | partialArticleSummaryList(m, m.postsWithTag!), 388 | { 389 | navigationVerbatim: partialNavigation(m, m.tagsAll!, { 390 | isTagIndex: true, 391 | tag: m.tag, 392 | }), 393 | } 394 | ); 395 | 396 | export const templates: GoldsmithLiteralHTMLLayoutMap = { 397 | "404": template404, 398 | "archive": templateArchive, 399 | "default": templateDefault, 400 | "index": templateRoot, 401 | "post": templatePost, 402 | "tagIndex": templateTagIndex, 403 | }; 404 | -------------------------------------------------------------------------------- /version.ts: -------------------------------------------------------------------------------- 1 | export const version = "1.2.2"; --------------------------------------------------------------------------------