├── .editorconfig ├── .env.example ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── logo.svg └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── LICENSE ├── README.md ├── data ├── .obsidian │ └── app.json ├── colors.json ├── notes │ ├── Impls and Traits With Rust.md │ ├── Keep Kalm, Rust.md │ ├── Learning Rust.md │ ├── Let's Get Coding With Rust.md │ ├── Rust Variables.md │ ├── Rust Workspaces.md │ └── Rust, the Cool Part.md ├── projects │ ├── 200930-utils-netlify.json │ ├── 200930-utils-nuxt.json │ ├── 210401-nuxt-hue.json │ ├── 210426-eleventy-plugin-prismic.json │ ├── 220314-nuxt-link.json │ ├── 220314-vite-plugin-sdk.json │ ├── 230117-akte.json │ ├── 240529-eslint-config.json │ └── 241017-vscode-theme-farben.json ├── talks │ ├── 210916-an-introduction-to-nuxt-global-modules.json │ ├── 211111-integrating-11ty-with-a-cms-and-making-it-cool-to-use.json │ ├── 220603-nuxt-3-modules-and-open-source.json │ ├── 221103-nuxt-3-modules-and-open-source.json │ ├── 230209-open-source-ecosystem.json │ ├── 230512-nuxt-3-modules-and-open-source.json │ ├── 231018-open-source-ecosystem.json │ ├── 241112-nuxt-mobile-apps.json │ └── 241120-nuxt-mobile-apps.json └── templates │ └── note.md ├── eslint.config.js ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── akte.app.ts ├── akte │ ├── constants.ts │ ├── data.ts │ ├── date.ts │ ├── discogs.ts │ ├── getPrettyContrastRatio.ts │ ├── lib │ │ ├── RateLimiter.ts │ │ ├── getContrastRatio.ts │ │ ├── getRelativeLuminance.ts │ │ ├── getSiteURL.ts │ │ ├── highlightCode.ts │ │ └── markdownToHTML.ts │ ├── prismic.ts │ ├── sha256.ts │ ├── slufigy.ts │ └── types.ts ├── assets │ ├── css │ │ ├── abstract │ │ │ ├── components.css │ │ │ ├── default.css │ │ │ ├── fonts.css │ │ │ ├── highlight.css │ │ │ ├── theme.css │ │ │ └── typography.css │ │ └── style.css │ ├── fonts.tar.enc │ ├── js │ │ ├── _base.ts │ │ ├── albums.ts │ │ ├── albums_slug.ts │ │ ├── code.ts │ │ ├── colors.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── albums.ts │ │ │ ├── alignment.ts │ │ │ ├── applyOnEvent.ts │ │ │ ├── copy.ts │ │ │ ├── format.ts │ │ │ ├── htmlToBlob.ts │ │ │ ├── lcFirst.ts │ │ │ ├── plausible.ts │ │ │ ├── prefersReducedMotion.ts │ │ │ ├── tableSort.ts │ │ │ ├── theme.ts │ │ │ └── usePoliteViewTransition.ts │ │ ├── meteo.ts │ │ ├── records.ts │ │ └── talks_conference_slug.ts │ └── noindex.robots.txt ├── components │ ├── back.ts │ ├── banner.ts │ ├── footer.ts │ ├── heading.ts │ ├── nav.ts │ ├── notIndexed.ts │ └── preferences.ts ├── files │ ├── 404.ts │ ├── [slug].ts │ ├── albums │ │ ├── [slug].ts │ │ └── index.ts │ ├── art │ │ ├── index.ts │ │ └── rss.ts │ ├── code.ts │ ├── colors.ts │ ├── index.ts │ ├── meteo.ts │ ├── notes │ │ ├── [slug].ts │ │ └── rss.ts │ ├── posts │ │ ├── [slug].ts │ │ └── rss.ts │ ├── preview.ts │ ├── private │ │ └── [slug].ts │ ├── records.ts │ ├── sitemap.ts │ └── talks │ │ ├── [conference] │ │ └── [slug].ts │ │ ├── poll.ts │ │ └── rss.ts ├── functions.server.ts ├── functions │ ├── admin │ │ ├── admin.akte.app.ts │ │ ├── files │ │ │ └── admin.ts │ │ └── index.ts │ ├── hr │ │ └── index.ts │ ├── poll-keepalive │ │ └── index.ts │ ├── poll │ │ └── index.ts │ └── preview │ │ ├── index.ts │ │ ├── preview.akte.app.ts │ │ └── prismicPreview.ts ├── layouts │ ├── base.ts │ ├── minimal.ts │ └── page.ts └── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── icon.png │ ├── mstile-150x150.png │ ├── robots.txt │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── tailwind.config.cjs ├── test ├── __testutils__ │ └── readAllFiles.ts └── buildIntegrity.test.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.yml] 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Usage #################################################### 2 | 3 | # Application URL 4 | # Mandatory 5 | APP_URL= 6 | 7 | # Misc ##################################################### 8 | 9 | # Prismic API Endpoint 10 | # Mandatory 11 | PRISMIC_ENDPOINT= 12 | 13 | # Prismic API Token 14 | # Mandatory 15 | PRISMIC_TOKEN= 16 | 17 | # Prismic write API Token 18 | # Mandatory 19 | PRISMIC_WRITE_TOKEN= 20 | 21 | # Upstash API Endpoint 22 | # Mandatory 23 | UPSTASH_ENDPOINT= 24 | 25 | # Upstash Token 26 | # Mandatory 27 | UPSTASH_TOKEN= 28 | 29 | # Discogs User 30 | # Mandatory 31 | DISCOGS_USER= 32 | 33 | # Discogs Key 34 | # Mandatory 35 | DISCOGS_KEY= 36 | 37 | # Discogs Secret 38 | # Mandatory 39 | DISCOGS_SECRET= 40 | 41 | # Slack Netlify Webhook 42 | # Mandatory 43 | SLACK_NETLIFY_WEBHOOK= 44 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # asserts everything is text 2 | * text eol=lf 3 | 4 | # treats lock files as binaries to prevent merge headache 5 | pnpm-lock.json -diff 6 | *.enc -diff 7 | 8 | # treats assets as binaries 9 | *.png binary 10 | *.jpg binary 11 | *.jpeg binary 12 | *.gif binary 13 | *.ico binary 14 | *.tar binary 15 | *.enc binary 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚨 Bug report 3 | about: Report a bug report to help improve the package. 4 | title: "" 5 | labels: "bug" 6 | assignees: "" 7 | --- 8 | 9 | 13 | 14 | ### Versions 15 | 16 | - lihbr-apex: 17 | - node: 18 | 19 | ### Reproduction 20 | 21 | 22 | 23 |
24 | Additional Details 25 |
26 | 27 |
28 | 29 | ### Steps to reproduce 30 | 31 | ### What is expected? 32 | 33 | ### What is actually happening? 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: 🤔 Question 5 | url: https://github.com/lihbr/lihbr-apex/discussions 6 | about: Ask a question about the package. You will usually get support there more quickly! 7 | - name: 🦋 Lucie's Bluesky 8 | url: https://bsky.app/profile/lihbr.com 9 | about: Get in touch with me about this package, or anything else on Bluesky 10 | - name: 🐘 Lucie's Mastodon 11 | url: https://mastodon.social/@lihbr 12 | about: Get in touch with me about this package, or anything else on Mastodon 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🙋‍♀️ Feature request 3 | about: Suggest an idea or enhancement for the package. 4 | title: "" 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | ### Is your feature request related to a problem? Please describe. 12 | 13 | 14 | 15 | ### Describe the solution you'd like 16 | 17 | 18 | 19 | ### Describe alternatives you've considered 20 | 21 | 22 | 23 | ### Additional context 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Types of changes 4 | 5 | 6 | 7 | - [ ] Chore (a non-breaking change which is related to package maintenance) 8 | - [ ] Bug fix (a non-breaking change which fixes an issue) 9 | - [ ] New feature (a non-breaking change which adds functionality) 10 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 11 | 12 | ## Description 13 | 14 | 15 | 16 | 17 | 18 | ## Checklist: 19 | 20 | 21 | 22 | 23 | - [ ] My change requires an update to the documentation. 24 | - [ ] All [TSDoc](https://tsdoc.org) comments are up-to-date and new ones have been added where necessary. 25 | - [ ] All new and existing tests are passing. 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Set node 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: lts/* 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v4 18 | 19 | - name: Install dependencies 20 | env: 21 | LIHBR_APEX: ${{ secrets.LIHBR_APEX }} 22 | run: pnpm install 23 | 24 | - name: Lint 25 | run: pnpm lint 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # custom 2 | .akte 3 | .cache 4 | .netlify 5 | data/.obsidian/** 6 | !data/.obsidian/app.json 7 | dist 8 | src/assets/fonts 9 | src/assets/fonts.tar 10 | src/public/records.json 11 | src/public/images.json 12 | 13 | # os 14 | .DS_Store 15 | ._* 16 | 17 | # node 18 | logs 19 | *.log 20 | node_modules 21 | 22 | # yarn 23 | yarn-debug.log* 24 | yarn-error.log* 25 | lerna-debug.log* 26 | .yarn-integrity 27 | yarn.lock 28 | 29 | # npm 30 | npm-debug.log* 31 | package-lock.json 32 | 33 | # tests 34 | coverage 35 | .eslintcache 36 | 37 | # .env 38 | .env 39 | .env.test 40 | .env*.local 41 | 42 | # vscode 43 | .vscode/* 44 | !.vscode/tasks.json 45 | !.vscode/launch.json 46 | *.code-workspace 47 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present, Lucie Haberer (https://lihbr.com) 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | lihbr-apex 4 | 5 |

6 | 7 | # lihbr-apex 8 | 9 | [![Netlify Status][netlify-status-src]][netlify-status-href] 10 | [![Conventional Commits][conventional-commits-src]][conventional-commits-href] 11 | [![CalVer][calver-src]][calver-href] 12 | [![License][license-src]][license-href] 13 | 14 | Source code of [lihbr.com][lihbr], my personal website, you should check it out~ 15 | 16 | ## Local Setup 17 | 18 | ```bash 19 | npm install 20 | npm run dev 21 | ``` 22 | 23 | ## Stack 24 | 25 | - [Akte][akte] - A minimal static site (and file) generator. 26 | - [Vite][vite] - Next Generation Frontend Tooling. 27 | - [Tailwind CSS][tailwindcss] - Utility-first CSS framework. 28 | - [Prismic][prismic] - Headless CMS for Jamstack. 29 | - [Plausible][plausible] - Simple and privacy-friendly analytics. 30 | - [`starry-night`][starry-night] - Syntax highlighting, like GitHub. 31 | 32 | ## Contributing 33 | 34 | Whether you're helping me fix bugs, improve the site, or spread the word, I'd love to have you as a contributor! 35 | 36 | **Asking a question**: [Open a new topic][repo-question] on GitHub Discussions explaining what you want to achieve / your question. I'll try to get back to you shortly. 37 | 38 | **Reporting a bug**: [Open an issue][repo-bug-report] explaining your application's setup and the bug you're encountering. 39 | 40 | **Suggesting an improvement**: [Open an issue][repo-feature-request] explaining your improvement or feature so we can discuss and learn more. 41 | 42 | **Submitting code changes**: For small fixes, feel free to [open a PR][repo-pull-requests] with a description of your changes. For large changes, please first [open an issue][repo-feature-request] so we can discuss if and how the changes should be implemented. 43 | 44 | ## License 45 | 46 | [MIT License][license] 47 | 48 | 49 | 50 | [lihbr]: https://lihbr.com 51 | [akte]: https://akte.js.org 52 | [vite]: https://vitejs.dev 53 | [tailwindcss]: https://tailwindcss.com/ 54 | [prismic]: https://prismic.io 55 | [plausible]: https://plausible.io 56 | [starry-night]: https://github.com/wooorm/starry-night 57 | [license]: ./LICENSE 58 | [repo-question]: https://github.com/lihbr/lihbr-apex/discussions 59 | [repo-bug-report]: https://github.com/lihbr/lihbr-apex/issues/new?assignees=&labels=bug&template=bug_report.md&title= 60 | [repo-feature-request]: https://github.com/lihbr/lihbr-apex/issues/new?assignees=&labels=enhancement&template=feature_request.md&title= 61 | [repo-pull-requests]: https://github.com/lihbr/lihbr-apex/pulls 62 | 63 | 64 | 65 | [netlify-status-src]: https://api.netlify.com/api/v1/badges/b6c4b56f-2cfe-4762-a68f-6cf7d5c730e7/deploy-status 66 | [netlify-status-href]: https://app.netlify.com/sites/lihbr/deploys 67 | [github-actions-ci-src]: https://github.com/lihbr/lihbr-apex/workflows/ci/badge.svg 68 | [github-actions-ci-href]: https://github.com/lihbr/lihbr-apex/actions?query=workflow%3Aci 69 | [conventional-commits-src]: https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?style=flat&colorA=131010&colorB=f27602&logo=conventionalcommits&logoColor=faf1f1 70 | [conventional-commits-href]: https://conventionalcommits.org 71 | [calver-src]: https://img.shields.io/badge/calver-YY.0M.MICRO-ffb005.svg?style=flat&colorA=131010&colorB=ffb005 72 | [calver-href]: https://calver.org 73 | [license-src]: https://img.shields.io/github/license/lihbr/lihbr-apex.svg?style=flat&colorA=131010&colorB=759f53 74 | [license-href]: https://github.com/lihbr/lihbr-apex/blob/master/LICENSE 75 | -------------------------------------------------------------------------------- /data/.obsidian/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "showFrontmatter": true, 3 | "spellcheckLanguages": [ 4 | "en-US" 5 | ], 6 | "tabSize": 2, 7 | "alwaysUpdateLinks": false, 8 | "newFileLocation": "folder", 9 | "newLinkFormat": "absolute", 10 | "showUnsupportedFiles": true, 11 | "livePreview": false, 12 | "newFileFolderPath": "notes", 13 | "useMarkdownLinks": false, 14 | "attachmentFolderPath": "notes/assets", 15 | "spellcheck": true, 16 | "strictLineBreaks": false, 17 | "showLineNumber": false, 18 | "readableLineLength": true 19 | } 20 | -------------------------------------------------------------------------------- /data/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "primary": { 3 | "slate": ["900", "800", "700", "200", "100", "50"], 4 | "cream": ["900", "800", "700", "200", "100", "50"], 5 | "navy": ["400", "100"], 6 | "beet": ["400", "100"], 7 | "flamingo": ["400", "100"], 8 | "ochre": ["400", "100"], 9 | "butter": ["400", "100"], 10 | "mantis": ["400", "100"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /data/notes/Impls and Traits With Rust.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-03-24 3 | last_publication_date: 2023-03-24 4 | --- 5 | 6 | Last week I went through the basics with [[notes/Rust Variables]], today while [[notes/Learning Rust]] I read about structures (`struct`), implementations (`impl`) and traits `trait`. They quite made sense to me (I think?), so let's try explaining those with some F1 team examples~ 7 | 8 | Let's start with structures, basically they allow you to define, among other things, an object (from a JavaScript point of view) properties, **not** its methods! 9 | ```rust 10 | struct Team { 11 | name: String, 12 | balance_cts: i32, 13 | } 14 | 15 | let alpine = Team { 16 | name: "Alpine".to_string(), 17 | balance_cts: 100_00, 18 | }; 19 | ``` 20 | 21 | So with structures we have properties, but we need methods! That's what implementations are for, defining "an object methods". 22 | ```rust 23 | struct Team { 24 | name: String, 25 | balance_cts: i32, 26 | } 27 | 28 | impl Team { 29 | // static methods 30 | fn new(name: &str, balance_cts: i32) -> Team { 31 | Team { 32 | name: name.to_string(), 33 | balance_cts: balance_cts, 34 | } 35 | } 36 | 37 | // instance methods 38 | fn update_balance(&mut self, new_balance_cts: i32) { 39 | self.balance_cts = new_balance_cts; 40 | } 41 | } 42 | 43 | let mut haas = Team::new("HAAS", 50_00); 44 | 45 | haas.update_balance(75_00); 46 | ``` 47 | 48 | Finally, we have traits. They can be seen as interfaces to implement, but they can also provide their own default implementation. 49 | ```rust 50 | trait Cheers { 51 | // with default implementation 52 | fn cheers(&self) { 53 | println!("Cheers!"); 54 | } 55 | 56 | // with no default implementation 57 | fn cheers_from(&self); 58 | } 59 | 60 | struct Team { 61 | name: String, 62 | balance_cts: i32, 63 | } 64 | 65 | impl Cheers for Team { 66 | fn cheers_from(&self) { 67 | println!("Cheers from {}!", self.name); 68 | } 69 | } 70 | 71 | let williams = Team { 72 | name: "Williams".to_string(), 73 | balance_cts: 20_00, 74 | }; 75 | 76 | williams.cheers(); // "Cheers!" 77 | williams.cheers_from(); // "Cheers from Williams!" 78 | ``` 79 | 80 | Voilà! I'm sure you can do a lot more with `struct`, `impl`, and `trait`, but those are the basics (I guess?) 81 | -------------------------------------------------------------------------------- /data/notes/Keep Kalm, Rust.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-06-02 3 | last_publication_date: 2023-06-16 4 | --- 5 | 6 | I have to admit, I was quite excited getting to that part when I started [[notes/Learning Rust]]. Nothing particular about it, aside that the name is fun, I named: *Rust panicking mechanism*. 7 | 8 | ![Kalm and Panik meme gif](https://images.prismic.io/lihbr/e65747ca-0b1b-41b6-bdd9-ffd71c90ebc3_panik-kalm.gif?auto=compress,format) 9 | 10 | So when do you panic in Rust? Well, just like in real life, you do when something happens and you cannot do anything about it. 11 | 12 | Put more precisely, panicking is what you do when you encounter an unrecoverable error in Rust. As it's a thread-based mechanism, I assume it kills it in some way(?) while other threads can carry around their work, handling the panicked thread if needed. 13 | ```rust [src/main.rs] 14 | fn main() { 15 | if (something_bad) { 16 | panic!(); 17 | 18 | // Alternative with a message 19 | panic!("{:#?}", something_bad); 20 | } 21 | } 22 | ``` 23 | 24 | There exist some more "precise" types of panic. I've learned about `unimplemented!()`, for unfinished code, and `unreachable!()` for code scenarios that shouldn't be possible (e.g. calculated [GPA](https://en.wikipedia.org/wiki/Academic_grading_in_the_United_States) is above 4) 25 | 26 | But that's it for now, `panicking == fatal errors`, and it's the first Rust error handling *- in that case, error not-handling -* API I've read about! 27 | -------------------------------------------------------------------------------- /data/notes/Learning Rust.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-03-24 3 | last_publication_date: 2023-03-24 4 | --- 5 | 6 | I've started to learn Rust for real. It's exciting to pick up a new language! 7 | 8 | I'm looking forward to being able to do a few things with it, notably: 9 | 1. I've heard it's cool to make CLIs with it, designing great CLI experiences is interesting to me 10 | 2. I also know it can compile to WebAssembly, which I could then use on web-related projects 11 | 12 | You can follow some of my progress, and uneducated rants (I'm French, do I get a pass?), by looking through the *links to this note* -^ 13 | -------------------------------------------------------------------------------- /data/notes/Let's Get Coding With Rust.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-04-14 3 | last_publication_date: 2023-04-14 4 | --- 5 | 6 | I've spent some time over the last few weeks [[notes/Learning Rust]]. As always, when you pick up a language *- last time for me it was TypeScript -* you need to get back to the basics: how variables are declared, what a `for` loop looks like, yadda, yadda. 7 | 8 | Rust had its dose of fun things during that process. [[notes/Impls and Traits With Rust]] are really cool, so is memory management with [[notes/Rust, the Cool Part|Ownership and Borrowing]]. 9 | 10 | However, things get really exciting for me when we start to learn about *"How people are actually developing with the language?"*, *"What patterns are they using?"*, etc. 11 | 12 | Indeed, learning how to write it only gets you so far, at one point you also need to know how to actually get an application running, architect a library, etc. 13 | 14 | This starts with code organization. 15 | 16 | Contrary to JavaScript, Rust doesn't have an `import`/`export` system so to say because you don't import files. Instead Rust has "modules", which reminds me vaguely of PHP namespaces (for which I have a *vague* and *distant* memory of) and C includes. 17 | ```rust [src/greetings/mod.rs] 18 | // `greetings` module root, `mod.rs` is recognized as such 19 | 20 | // `hello` is a public function of the `greetings` module 21 | pub fn hello() { 22 | println!("Hello, world!"); 23 | } 24 | ``` 25 | 26 | You cannot *import* modules, instead you have to *reference* them, which you can if they are exposed to the file you're working on. 27 | ```rust [src/main.rs] 28 | mod greetings; // reference of the `greetings` module 29 | 30 | fn main() { 31 | greetings::hello(); // usage of module's public function `hello` 32 | } 33 | ``` 34 | 35 | I find it quite interesting because module resolution is file system-based (when ignoring crate modules), so it kind of forces you adopt a given file structure for your project, making them standardized? I don't know, more coding ahead! 36 | -------------------------------------------------------------------------------- /data/notes/Rust Variables.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-03-17 3 | last_publication_date: 2023-03-24 4 | --- 5 | 6 | First session of [[notes/Learning Rust]], and you gotta start with the basics, right? 7 | 8 | So let's discuss variables in Rust, some interesting things I've learned along the way, and some cursed ones (as someone who's mainly working with JavaScript/TypeScript these days) 9 | 10 | First, you can declare variables, nothing too crazy so far. 11 | ```rust 12 | let x = 4; // type is inferred 13 | let y: i32 = 8; // explicit type 14 | ``` 15 | 16 | However, you **cannot** reassign a variable, unless it's declared as a `mut` (mutable), I guess this makes sense from a memory optimization point of view. 17 | ```rust 18 | let x = 4; 19 | x = 9; // wrong! 20 | 21 | let mut y = 8; 22 | y = 9; // now Rust is happy 23 | ``` 24 | 25 | On the other hand, it's perfectly fine to **redeclare** a variable, even within the same scope. 26 | ```rust 27 | let x = 4; 28 | let x = 9; // perfectly fine! 29 | ``` 30 | 31 | And while you're redeclaring variables, why not also **change** their types? No biggy. 32 | ```rust 33 | let y: i32 = 8; 34 | let y: &str = "Hello World"; 35 | ``` 36 | 37 | A last one to go, you're able to *"assign result of control flow structures (if, else, etc.)"* to a variable. 38 | ```rust 39 | let team_size = 7; 40 | let team_size = if team_size < 5 { 41 | "Small" 42 | } else if team_size < 10 { 43 | "Medium" 44 | } else { 45 | "Large" 46 | }; 47 | ``` 48 | 49 | In the above, the value of `team_size` is now `"Medium"`, we also redeclared it and reassigned it with another type because life is too short to not do things like that. 50 | 51 | So... variables in Rust are... interesting, a bit confusing for now, but I'll guess it'll start making sense at some point~ 52 | -------------------------------------------------------------------------------- /data/notes/Rust Workspaces.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-04-28 3 | last_publication_date: 2023-04-28 4 | --- 5 | 6 | Since I started [[notes/Learning Rust]], I've been quite amazed by the amount of built-in, standardized, coding tools it is provided with, from a package manager, to build tools, style guides, and more! 7 | 8 | I'm not sure yet if this comes as a result of great design and thoughtful architecture, or as a sign of a small community and/or a young language, probably a bit of both. *Maybe* one day people will start creating their own Rust build tools, runtime, and compiler like [pnpm](https://pnpm.io) is today an alternative to npm, and [Deno](https://deno.com) an alternative to Node.js. In the meantime, I quite enjoy the standardization. 9 | 10 | Anyway. Today I've learned about Rust built-in workspace support. Similarly to [npm's workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces), they allow you to develop multiple packages within the same codebase, i.e. developing within a monorepo. 11 | 12 | To leverage them, we just need to create a directory featuring a root `Cargo.toml` declaring our workspace. 13 | ```toml [Cargo.toml] 14 | [workspace] 15 | members = [ 16 | "src", 17 | "examples/hello-world" 18 | ] 19 | ``` 20 | 21 | From here we can just create those crates using `cargo` 22 | ```bash 23 | cargo new src --lib 24 | cargo new examples/hello-world 25 | ``` 26 | 27 | And here we go! Now running `cargo build` or other commands will execute them across all crates declared within the workspace. 28 | -------------------------------------------------------------------------------- /data/notes/Rust, the Cool Part.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: 2023-04-07 3 | last_publication_date: 2023-04-07 4 | --- 5 | 6 | In [the (free!) tutorial](https://learning-rust.github.io) I'm following to [[notes/Learning Rust|Learn Rust]], they call this part *"the Tough Part"*. It's for sure not easy, but it's also kinda cool(?) So what is this *"Cool Part"*? It's the notion of *ownership*, *borrowing*, and *lifetime* of data! 7 | 8 | *Ownership* and *borrowing* are somewhat straightforward in my opinion. 9 | 10 | *Ownership* refers to which variable owns the data it refers to, only one can. If a variable tries to refer to another's data, then the ownership is either copied (for primitives) or moved (for non-primitives) from the former variable. 11 | ```rust 12 | let a = [1, 2, 3]; 13 | let b = a; // ownership is copied 14 | 15 | let c = vec![1, 2, 3]; 16 | let d = c; // ownership is moved, `c` cannot be accessed anymore 17 | ``` 18 | 19 | Instead, if we just want to reference another variable's data, we can borrow it from its owner. 20 | ```rust 21 | let a = vec![1, 2, 3]; 22 | let b = &a; // `a` value is borrowed, `a` can still accessed 23 | let c = &a; // `a` and `b` can still be accessed 24 | ``` 25 | 26 | Alright, makes sense so far. A bit more subtle, if we need to borrow a variable's data with plans to change it, we need to borrow it as a mutable. In such cases, only one variable can do it at a time. 27 | ```rust 28 | let a = vec![1, 2, 3]; 29 | let b = &mut a; // `a` value is borrowed as a mutable 30 | b[0] = 4; // `b` value can be edited, reflects on `a` 31 | 32 | let c = &mut a; // will throw, `a` is still being borrowed by `b` 33 | ``` 34 | 35 | OK, so that's for the part that was somewhat straightforward. Let's now talk about *lifetime*. 36 | 37 | *Lifetime* refers to how much time a referenced data should remain accessible. It looks like the following. 38 | ```rust 39 | fn foo<'a>(x: &'a str) -> &'a str {} 40 | ``` 41 | 42 | In the above, the `'a` notation says that the returned value of `foo` should share the same lifetime as the one of its argument `x`. 43 | 44 | Fortunately, we don't have to add those notations every time: the compiler can do it for us most of the time on functions. However, for other patterns, like [[notes/Impls and Traits With Rust]], we have to do it ourselves when referencing other variables. 45 | 46 | I have to admit, beyond that small example above, usage of *lifetime* annotations still confuses me a lot. It's indeed *the Tough Part*, but also *the Cool Part* of Rust, as this is part of what's allowing the language's memory safety. 47 | -------------------------------------------------------------------------------- /data/projects/200930-utils-netlify.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "utils-netlify", 3 | "title": "Utils Netlify", 4 | "start": "2020-09-30", 5 | "active": false, 6 | "url": "https://utils-netlify.lihbr.com" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/200930-utils-nuxt.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "utils-nuxt", 3 | "title": "Utils Nuxt", 4 | "start": "2020-09-30", 5 | "active": false, 6 | "url": "https://utils-nuxt.lihbr.com" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/210401-nuxt-hue.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-hue", 3 | "title": "Nuxt Hue", 4 | "start": "2021-04-01", 5 | "active": false, 6 | "url": "https://nuxt-hue.lihbr.com" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/210426-eleventy-plugin-prismic.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "eleventy-plugin-prismic", 3 | "title": "eleventy-plugin-prismic", 4 | "start": "2021-04-26", 5 | "active": false, 6 | "url": "https://github.com/prismicio-community/eleventy-plugin-prismic" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/220314-nuxt-link.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-link", 3 | "title": "Nuxt Link", 4 | "start": "2022-03-14", 5 | "active": true, 6 | "url": "https://github.com/nuxt/framework/pull/3544" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/220314-vite-plugin-sdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "vite-plugin-sdk", 3 | "title": "vite-plugin-sdk", 4 | "start": "2022-09-27", 5 | "active": true, 6 | "url": "https://github.com/prismicio-community/vite-plugin-sdk" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/230117-akte.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "akte", 3 | "title": "Akte", 4 | "start": "2023-01-17", 5 | "active": true, 6 | "url": "https://akte.js.org" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/240529-eslint-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "eslint-config", 3 | "title": "@lihbr/eslint-config", 4 | "start": "2024-05-19", 5 | "active": true, 6 | "url": "https://github.com/lihbr/eslint-config" 7 | } 8 | -------------------------------------------------------------------------------- /data/projects/241017-vscode-theme-farben.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "vscode-theme-farben", 3 | "title": "vscode-theme-farben", 4 | "start": "2024-10-17", 5 | "active": true, 6 | "url": "https://github.com/lihbr/vscode-theme-farben" 7 | } 8 | -------------------------------------------------------------------------------- /data/talks/210916-an-introduction-to-nuxt-global-modules.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "an-introduction-to-nuxt-global-modules", 3 | "title": "An Introduction to Nuxt Global Modules", 4 | "lead": "Like Chrome extensions, but for your Nuxt.js environments.", 5 | "date": "2021-09-16", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "nuxtnation", 9 | "name": "Nuxt Nation 2021", 10 | "url": "https://nuxtnation.com", 11 | "location": "Online" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://210916-nuxt-global-modules.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Live coding sources", 20 | "url": "https://github.com/lihbr/nuxt-demo-global-modules/tree/nuxtnation" 21 | }, 22 | { 23 | "name": "Transcript", 24 | "url": "https://docs.google.com/document/d/1jQ-YuW7khDxNLh9fTjycqIMKqbZM_zWLq0WueGGrrzY/edit?usp=sharing" 25 | } 26 | ], 27 | "feedback": { 28 | "hashtags": "nuxtnation", 29 | "related": "li_hbr:Conference Speaker,nuxtnation:Conference Organizer,nuxt_js:Vue.js Framework", 30 | "via": "li_hbr" 31 | }, 32 | "confetti": ["🌲", "💐", "📐", "🌐"] 33 | } 34 | -------------------------------------------------------------------------------- /data/talks/211111-integrating-11ty-with-a-cms-and-making-it-cool-to-use.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "integrating-11ty-with-a-cms-and-making-it-cool-to-use", 3 | "title": "Integrating 11ty With a CMS and Making It Cool to Use!", 4 | "lead": "Getting dynamic data down for 11ty 1.0.", 5 | "date": "2021-11-11", 6 | "durationMinutes": 11, 7 | "conference": { 8 | "slug": "11ties", 9 | "name": "11ties 2021", 10 | "url": "https://www.meetup.com/JAMstack-Toronto/events/281278073", 11 | "location": "Online" 12 | }, 13 | "links": [ 14 | { 15 | "name": "11ty Prismic plugin", 16 | "url": "https://github.com/prismicio-community/eleventy-plugin-prismic" 17 | }, 18 | { 19 | "name": "Slides", 20 | "url": "https://211111-11ty-with-a-cms.diapositiv.lihbr.com/1" 21 | }, 22 | { 23 | "name": "Live coding sources", 24 | "url": "https://github.com/lihbr/eleventy-demo-cms-integration/tree/11ties" 25 | }, 26 | { 27 | "name": "Transcript", 28 | "url": "https://docs.google.com/document/d/1GNzP-DjMa5nglD_N0aMDxPerV79Jsa7xl2C0gS0eTAA/edit?usp=sharing" 29 | } 30 | ], 31 | "feedback": { 32 | "hashtags": "11ties", 33 | "related": "li_hbr:Conference Speaker,JAMstackTORONTO:Conference Organizer,eleven_ty:Static Site Generator", 34 | "via": "li_hbr" 35 | }, 36 | "confetti": ["🎈", "💐", "🐁", "📖"] 37 | } 38 | -------------------------------------------------------------------------------- /data/talks/220603-nuxt-3-modules-and-open-source.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-3-modules-and-open-source", 3 | "title": "Nuxt 3 Modules and Open-Source", 4 | "lead": "Make your own, for your team or the whole community.", 5 | "date": "2022-06-03", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "vueamsterdam", 9 | "name": "Vue.js Amsterdam 2022", 10 | "url": "https://vuejs.amsterdam", 11 | "location": "Amsterdam" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://220603-nuxt-3-modules.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Live coding sources", 20 | "url": "https://github.com/lihbr/nuxt-demo-nuxt-3-modules/tree/vueamsterdam" 21 | }, 22 | { 23 | "name": "Transcript", 24 | "url": "https://docs.google.com/document/d/18Za9Hls-euQwSzTZ9uhOst_K9ifPZeEV1tor1vKfKFg/edit?usp=sharing" 25 | } 26 | ], 27 | "feedback": { 28 | "hashtags": "vuejsamsterdam", 29 | "related": "li_hbr:Conference Speaker,vuejsamsterdam:Conference Organizer,nuxt_js:Vue.js Framework", 30 | "via": "li_hbr" 31 | }, 32 | "confetti": ["🌲", "💐", "📐", "🌐"] 33 | } 34 | -------------------------------------------------------------------------------- /data/talks/221103-nuxt-3-modules-and-open-source.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-3-modules-and-open-source", 3 | "title": "Nuxt 3 Modules and Open-Source", 4 | "lead": "Make your own, for your team or the whole community.", 5 | "date": "2022-11-03", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "vuetoronto", 9 | "name": "VueConf Toronto 2022", 10 | "url": "https://vuetoronto.com", 11 | "location": "Toronto" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://221103-nuxt-3-modules.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Live coding sources", 20 | "url": "https://github.com/lihbr/nuxt-demo-nuxt-3-modules/tree/vuetoronto" 21 | }, 22 | { 23 | "name": "Transcript", 24 | "url": "https://docs.google.com/document/d/1DrBZ0egTZ2XStaPSFXz_PHEXUOYgyo7zst17CwSoMcw/edit?usp=sharing" 25 | } 26 | ], 27 | "feedback": { 28 | "hashtags": "vuetoronto", 29 | "related": "li_hbr:Conference Speaker,vueconftoronto:Conference Organizer,nuxt_js:Vue.js Framework", 30 | "via": "li_hbr" 31 | }, 32 | "confetti": ["🌲", "💐", "📐", "🌐"] 33 | } 34 | -------------------------------------------------------------------------------- /data/talks/230209-open-source-ecosystem.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "open-source-ecosystem", 3 | "title": "Maintaining Your Company's Open-Source Ecosystem", 4 | "lead": "...or how to make great npm packages for the mere mortal.", 5 | "date": "2023-02-09", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "vueamsterdam", 9 | "name": "Vue.js Amsterdam 2023", 10 | "url": "https://vuejs.amsterdam", 11 | "location": "Amsterdam" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://230209-open-source-ecosystem.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Transcript", 20 | "url": "https://docs.google.com/document/d/17-I8Ueq8DVf2UakgmFDAkfAZVwXTHLQOJ9C1dHnJpmk/edit?usp=sharing" 21 | }, 22 | { 23 | "name": "Nuxt 3 link RFC", 24 | "url": "https://github.com/nuxt/nuxt/discussions/16023#discussioncomment-1683819" 25 | } 26 | ], 27 | "feedback": { 28 | "hashtags": "vuejsamsterdam", 29 | "related": "li_hbr:Conference Speaker,vuejsamsterdam:Conference Organizer,nuxt_js:Vue.js Framework", 30 | "via": "li_hbr" 31 | }, 32 | "confetti": ["📦", "💐", "🧰", "🌐"] 33 | } 34 | -------------------------------------------------------------------------------- /data/talks/230512-nuxt-3-modules-and-open-source.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-3-modules-and-open-source", 3 | "title": "Nuxt 3 Modules and Open-Source", 4 | "lead": "Make your own, for your team or the whole community.", 5 | "date": "2023-05-12", 6 | "durationMinutes": 20, 7 | "conference": { 8 | "slug": "vuelondon", 9 | "name": "Vue.js Live Conference London 2023", 10 | "url": "https://vuejslive.com", 11 | "location": "London" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://230512-nuxt-3-modules.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Live coding sources", 20 | "url": "https://github.com/lihbr/nuxt-demo-nuxt-3-modules/tree/vuelondon" 21 | }, 22 | { 23 | "name": "Transcript", 24 | "url": "https://docs.google.com/document/d/1OXlk8H0846CT9EoOr1dQfWHzOHHyn_Fl4dSOKWNvYKI/edit?usp=sharing" 25 | } 26 | ], 27 | "feedback": { 28 | "hashtags": "VueJSLive", 29 | "related": "li_hbr:Conference Speaker,vuejslive:Conference Organizer,nuxt_js:Vue.js Framework", 30 | "via": "li_hbr" 31 | }, 32 | "confetti": ["🌲", "💐", "📐", "🌐"] 33 | } 34 | -------------------------------------------------------------------------------- /data/talks/231018-open-source-ecosystem.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "open-source-ecosystem", 3 | "title": "Maintaining Your Company's Open-Source Ecosystem", 4 | "lead": "...or how to make great npm packages for the mere mortal.", 5 | "date": "2023-10-18", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "nuxtnation", 9 | "name": "Nuxt Nation 2023", 10 | "url": "https://nuxtnation.com", 11 | "location": "Online" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://231018-open-source-ecosystem.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Transcript", 20 | "url": "https://docs.google.com/document/d/173-AKhwitukY3JmDpQn32mUzGWnr5KCMnX9DUJyifU4/edit?usp=sharing" 21 | }, 22 | { 23 | "name": "Nuxt 3 link RFC", 24 | "url": "https://github.com/nuxt/nuxt/discussions/16023#discussioncomment-1683819" 25 | } 26 | ], 27 | "feedback": { 28 | "hashtags": "nuxtnation", 29 | "related": "li_hbr:Conference Speaker,nuxtnation:Conference Organizer,nuxt_js:Vue.js Framework", 30 | "via": "li_hbr" 31 | }, 32 | "confetti": [ 33 | "📦", 34 | "💐", 35 | "🧰", 36 | "🌐" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /data/talks/241112-nuxt-mobile-apps.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-mobile-apps", 3 | "title": "Can Nuxt Make Great Mobile Apps?", 4 | "lead": "...or can the JavaScript ecosystem compete against native code.", 5 | "date": "2024-11-12", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "nuxtnation", 9 | "name": "Nuxt Nation 2024", 10 | "url": "https://nuxtnation.com", 11 | "location": "Online" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://241112-nuxt-mobile-apps.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Transcript", 20 | "url": "https://docs.google.com/document/d/1kBCQsA-8X3Z7e1pC-nhmI8X-Q_FSMoodKlzobCosIFI/edit?usp=sharing" 21 | }, 22 | { 23 | "name": "Capacitor Documentation", 24 | "url": "https://capacitorjs.com" 25 | }, 26 | { 27 | "name": "Nuxt Ionic Module", 28 | "url": "https://ionic.nuxtjs.org" 29 | }, 30 | { 31 | "name": "KEIRIN by VWFNDR™", 32 | "url": "https://vwfndr.substack.com/" 33 | } 34 | ], 35 | "feedback": { 36 | "hashtags": "nuxtnation", 37 | "related": "li_hbr:Conference Speaker,nuxtnation:Conference Organizer,nuxt_js:Vue.js Framework", 38 | "via": "li_hbr" 39 | }, 40 | "confetti": [ 41 | "📱", 42 | "💐", 43 | "📸", 44 | "🌲" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /data/talks/241120-nuxt-mobile-apps.json: -------------------------------------------------------------------------------- 1 | { 2 | "slug": "nuxt-mobile-apps", 3 | "title": "Can Nuxt Make Great Mobile Apps?", 4 | "lead": "...or can the JavaScript ecosystem compete against native code.", 5 | "date": "2024-11-20", 6 | "durationMinutes": 25, 7 | "conference": { 8 | "slug": "vuetoronto", 9 | "name": "VueConf Toronto 2024", 10 | "url": "https://vuetoronto.com", 11 | "location": "Toronto" 12 | }, 13 | "links": [ 14 | { 15 | "name": "Slides", 16 | "url": "https://241120-nuxt-mobile-apps.diapositiv.lihbr.com/1" 17 | }, 18 | { 19 | "name": "Transcript", 20 | "url": "https://docs.google.com/document/d/1nt_wMUZqXhsIIDRsbs2D1cqbIaTp9glnhokdt4JOd0g/edit?usp=sharing" 21 | }, 22 | { 23 | "name": "Capacitor Documentation", 24 | "url": "https://capacitorjs.com" 25 | }, 26 | { 27 | "name": "Nuxt Ionic Module", 28 | "url": "https://ionic.nuxtjs.org" 29 | }, 30 | { 31 | "name": "KEIRIN by VWFNDR™", 32 | "url": "https://vwfndr.substack.com/" 33 | } 34 | ], 35 | "feedback": { 36 | "hashtags": "vuetoronto", 37 | "related": "li_hbr:Conference Speaker,vueconftoronto:Conference Organizer,nuxt_js:Vue.js Framework", 38 | "via": "li_hbr" 39 | }, 40 | "confetti": [ 41 | "📱", 42 | "💐", 43 | "📸", 44 | "🌲" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /data/templates/note.md: -------------------------------------------------------------------------------- 1 | --- 2 | first_publication_date: {{date}} 3 | last_publication_date: {{date}} 4 | --- 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import lihbr from "@lihbr/eslint-config" 3 | 4 | export default lihbr() 5 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "" 3 | command = "npm run build" 4 | publish = "dist" 5 | 6 | [build.environment] 7 | NODE_VERSION = "20.13.1" 8 | 9 | [build.processing] 10 | skip_processing = false 11 | 12 | [build.processing.html] 13 | pretty_urls = false 14 | 15 | [build.processing.css] 16 | bundle = false 17 | minify = false 18 | 19 | [build.processing.js] 20 | bundle = false 21 | minify = false 22 | 23 | [build.processing.images] 24 | compress = true 25 | 26 | [dev] 27 | framework = "#custom" 28 | command = "npm run dev" 29 | port = 8888 30 | targetPort = 5173 31 | autoLaunch = false 32 | 33 | [functions] 34 | directory = "src/functions" 35 | node_bundler = "esbuild" 36 | 37 | [functions.poll-keepalive] 38 | schedule = "0 0 * * 0" 39 | 40 | [context.production.processing] 41 | skip_processing = false 42 | 43 | [context.deploy-preview.processing] 44 | skip_processing = true 45 | 46 | [context.branch-deploy] 47 | command = "npm run build:staging" 48 | 49 | [context.branch-deploy.processing] 50 | skip_processing = true 51 | 52 | # Previews 53 | [[redirects]] 54 | from = "/preview/*" 55 | to = "/.netlify/functions/preview" 56 | status = 200 57 | force = true 58 | 59 | # Admin 60 | [[redirects]] 61 | from = "/admin" 62 | to = "/.netlify/functions/admin" 63 | status = 200 64 | force = true 65 | 66 | # Pretty API 67 | [[redirects]] 68 | from = "/api/*" 69 | to = "/.netlify/functions/:splat" 70 | status = 200 71 | force = true 72 | 73 | # Netlify domain 74 | [[redirects]] 75 | from = "https://lihbr.netlify.app/*" 76 | to = "https://lihbr.com/:splat" 77 | status = 301 78 | force = true 79 | 80 | # Analytics 81 | [[redirects]] 82 | from = "/p7e/api/event" 83 | to = "https://plausible.io/api/event" 84 | status = 202 85 | force = true 86 | 87 | # Old diapositiv (221012) 88 | [[redirects]] 89 | from = "https://diapositiv.lihbr.com/talk/an-introduction-to-nuxt-global-modules" 90 | to = "https://lihbr.com/talks/nuxtnation/an-introduction-to-nuxt-global-modules" 91 | status = 301 92 | force = true 93 | 94 | [[redirects]] 95 | from = "https://diapositiv.lihbr.com/talk/integrating-11ty-with-a-cms-and-making-it-cool-to-use" 96 | to = "https://lihbr.com/talks/11ties/integrating-11ty-with-a-cms-and-making-it-cool-to-use" 97 | status = 301 98 | force = true 99 | 100 | [[redirects]] 101 | from = "https://diapositiv.lihbr.com/talk/nuxt-3-modules-and-open-source" 102 | to = "https://lihbr.com/talks/vueamsterdam/nuxt-3-modules-and-open-source" 103 | status = 301 104 | force = true 105 | 106 | # Old diapositiv (230103) 107 | [[redirects]] 108 | from = "https://diapositiv.lihbr.com/talk/*" 109 | to = "https://lihbr.com/talks/:splat" 110 | status = 301 111 | force = true 112 | 113 | [[redirects]] 114 | from = "https://diapositiv.lihbr.com/*" 115 | to = "https://lihbr.com/:splat" 116 | status = 301 117 | force = true 118 | 119 | # Old blog (230103) 120 | [[redirects]] 121 | from = "/blog" 122 | to = "/#posts" 123 | status = 301 124 | force = true 125 | 126 | [[redirects]] 127 | from = "/blog/*" 128 | to = "/posts/:splat" 129 | status = 301 130 | force = true 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lihbr-apex", 3 | "type": "module", 4 | "version": "24.05.0", 5 | "private": true, 6 | "packageManager": "pnpm@9.8.0", 7 | "description": "Source code of lihbr.com, my personal blog, you should check it out~", 8 | "author": "Lucie Haberer (https://lihbr.com)", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "ssh://git@github.com/lihbr/lihbr-apex.git" 13 | }, 14 | "keywords": [ 15 | "lihbr", 16 | "akte" 17 | ], 18 | "engines": { 19 | "node": ">=20.0.0" 20 | }, 21 | "scripts": { 22 | "dev": "cross-env DEBUG=akte:vite* vite", 23 | "build": "npm run fonts:decrypt && vite build && vitest run buildIntegrity --no-coverage", 24 | "build:staging": "npm run build && shx cp src/assets/noindex.robots.txt dist/robots.txt", 25 | "fonts:encrypt": "tar --gzip --create --file src/assets/fonts.tar src/assets/fonts && openssl enc -aes-256-cbc -e -pbkdf2 -iter 100000 -salt -pass env:LIHBR_APEX -in src/assets/fonts.tar -out src/assets/fonts.tar.enc && rm src/assets/fonts.tar", 26 | "fonts:decrypt": "openssl enc -aes-256-cbc -d -pbkdf2 -iter 100000 -salt -pass env:LIHBR_APEX -in src/assets/fonts.tar.enc -out src/assets/fonts.tar && tar --gzip --extract --file src/assets/fonts.tar && rm src/assets/fonts.tar", 27 | "prepare": "husky && npm run fonts:decrypt", 28 | "lint": "eslint .", 29 | "typecheck": "tsc --noEmit", 30 | "unit": "vitest run --coverage", 31 | "unit:watch": "vitest watch", 32 | "test": "npm run lint && npm run typecheck && npm run build && npm run unit" 33 | }, 34 | "dependencies": { 35 | "@11ty/eleventy-fetch": "5.0.2", 36 | "@lihbr/farben": "0.1.0", 37 | "@prismicio/client": "7.14.0", 38 | "@wooorm/starry-night": "3.6.0", 39 | "cookie-es": "1.2.2", 40 | "dotenv": "16.4.7", 41 | "escape-html": "1.0.3", 42 | "hast-util-to-html": "9.0.4", 43 | "html-to-image": "1.11.11", 44 | "js-confetti": "0.12.0", 45 | "mdast": "3.0.0", 46 | "plausible-tracker": "0.3.9", 47 | "rehype-autolink-headings": "7.1.0", 48 | "rehype-external-links": "3.0.0", 49 | "rehype-slug": "6.0.0", 50 | "rehype-stringify": "10.0.1", 51 | "remark-frontmatter": "5.0.0", 52 | "remark-gfm": "4.0.0", 53 | "remark-parse": "11.0.0", 54 | "remark-rehype": "11.1.1", 55 | "remark-wiki-link": "2.0.1", 56 | "slugify": "1.6.6", 57 | "unified": "11.0.5", 58 | "unist-util-visit": "5.0.0", 59 | "vfile": "6.0.3", 60 | "vfile-matter": "5.0.0" 61 | }, 62 | "devDependencies": { 63 | "@lihbr/eslint-config": "0.0.3", 64 | "@netlify/functions": "3.0.0", 65 | "@types/escape-html": "1.0.4", 66 | "@types/node": "22.10.6", 67 | "@vitest/coverage-v8": "2.1.8", 68 | "akte": "0.4.2", 69 | "autoprefixer": "10.4.20", 70 | "cross-env": "7.0.3", 71 | "cssnano": "7.0.6", 72 | "eslint": "9.18.0", 73 | "get-port": "7.1.0", 74 | "globby": "14.0.2", 75 | "h3": "1.13.1", 76 | "html-minifier-terser": "7.2.0", 77 | "husky": "9.1.7", 78 | "listhen": "1.9.0", 79 | "postcss-import": "16.1.0", 80 | "postcss-nesting": "13.0.1", 81 | "shx": "0.3.4", 82 | "tailwindcss": "3.4.17", 83 | "typescript": "5.7.3", 84 | "vite": "6.0.7", 85 | "vitest": "2.1.8" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const process = require("node:process") 2 | 3 | module.exports = { 4 | plugins: { 5 | "postcss-import": {}, 6 | "tailwindcss/nesting": "postcss-nesting", 7 | "tailwindcss": {}, 8 | "autoprefixer": process.env.NODE_ENV === "production" ? {} : false, 9 | "cssnano": process.env.NODE_ENV === "production" ? {} : false, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/akte.app.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "./akte/types" 2 | 3 | import { defineAkteApp } from "akte" 4 | 5 | import { slug } from "./files/[slug]" 6 | import { fourOFour } from "./files/404" 7 | 8 | import { slug as albumsSlug } from "./files/albums/[slug]" 9 | import { index as albums } from "./files/albums/index" 10 | 11 | import { index as art } from "./files/art/index" 12 | import { rss as artRSS } from "./files/art/rss" 13 | 14 | import { code } from "./files/code" 15 | import { colors } from "./files/colors" 16 | import { index } from "./files/index" 17 | import { meteo } from "./files/meteo" 18 | 19 | import { slug as notesSlug } from "./files/notes/[slug]" 20 | import { rss as notesRSS } from "./files/notes/rss" 21 | 22 | import { slug as postsSlug } from "./files/posts/[slug]" 23 | import { rss as postsRSS } from "./files/posts/rss" 24 | 25 | import { slug as privateSlug } from "./files/private/[slug]" 26 | import { records } from "./files/records" 27 | 28 | import { sitemap } from "./files/sitemap" 29 | import { slug as talksSlug } from "./files/talks/[conference]/[slug]" 30 | 31 | import { poll as talksPoll } from "./files/talks/poll" 32 | import { rss as talksRSS } from "./files/talks/rss" 33 | 34 | export const app = defineAkteApp({ 35 | files: [ 36 | fourOFour, 37 | sitemap, 38 | 39 | index, 40 | colors, 41 | records, 42 | code, 43 | meteo, 44 | slug, 45 | 46 | privateSlug, 47 | 48 | talksSlug, 49 | talksPoll, 50 | talksRSS, 51 | 52 | postsSlug, 53 | postsRSS, 54 | 55 | notesSlug, 56 | notesRSS, 57 | 58 | albums, 59 | albumsSlug, 60 | 61 | art, 62 | artRSS, 63 | ], 64 | globalData() { 65 | return {} 66 | }, 67 | }) 68 | -------------------------------------------------------------------------------- /src/akte/constants.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | 3 | import * as dotenv from "dotenv" 4 | 5 | import { getSiteURL } from "./lib/getSiteURL" 6 | 7 | dotenv.config() 8 | 9 | export const SITE_LANG = "en" 10 | export const SITE_TITLE = "lihbr" 11 | export const SITE_DESCRIPTION = 12 | "Lucie's place on the internet to share things with friends, students, and digital people." 13 | export const SITE_MAIN_AUTHOR = "Lucie Haberer" 14 | export const SITE_ACCENT_COLOR = "#e84311" 15 | export const SITE_BACKGROUND_COLOR = "#fff7f7" 16 | export const SITE_META_IMAGE = { 17 | openGraph: 18 | "https://images.prismic.io/lihbr/e524336e-aebe-41c1-a158-0cf957139e6a_lihbr-apex--1.91_1.png?auto=compress,format", 19 | } as const 20 | export const SITE_TITLE_FORMAT = `%page% - ${SITE_TITLE}` 21 | export const PAGE_DEFAULT_TITLE = "💐" 22 | 23 | export const TITLE_LIMIT = 50 24 | export const DESCRIPTION_LIMIT = 155 25 | 26 | export const IS_SERVERLESS = !!process.env.AWS_LAMBDA_FUNCTION_NAME 27 | 28 | export const SITE_URL = getSiteURL() 29 | 30 | const commitRef = process.env.COMMIT_REF || "unknown" 31 | const repositoryURL = process.env.REPOSITORY_URL || "https://example.com/dev" 32 | export const NETLIFY = { 33 | commitRef, 34 | commitRefShort: commitRef.slice(0, 7), 35 | commitURL: `${repositoryURL}/commit/${commitRef}`, 36 | repositoryURL, 37 | buildTime: Date.now(), 38 | } as const 39 | -------------------------------------------------------------------------------- /src/akte/data.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs/promises" 2 | import * as path from "node:path" 3 | import { globby } from "globby" 4 | 5 | import { markdownToHTML } from "./lib/markdownToHTML" 6 | 7 | const dataDir = path.resolve(__dirname, "../../data") 8 | 9 | export function readData(file: string): Promise { 10 | return fs.readFile(path.resolve(dataDir, file), "utf-8") 11 | } 12 | 13 | export async function readDataJSON(file: string): Promise { 14 | const data = await readData(file) 15 | 16 | return JSON.parse(data) 17 | } 18 | 19 | type ReadAllDataArgs = { 20 | type: "notes" | "projects" | "talks" 21 | } 22 | 23 | export async function readAllData(args: ReadAllDataArgs): Promise> { 24 | const files = await globby(`${args.type}/*`, { cwd: dataDir }) 25 | 26 | const entries = await Promise.all( 27 | files.map(async (file) => { 28 | return [file, await readData(file)] as const 29 | }), 30 | ) 31 | 32 | return Object.fromEntries(entries) 33 | } 34 | 35 | export async function readAllDataJSON(args: ReadAllDataArgs): Promise> { 36 | const allData = await readAllData(args) 37 | 38 | const allDataJSON: Record = {} 39 | 40 | for (const file in allData) { 41 | allDataJSON[file] = JSON.parse(allData[file]) 42 | } 43 | 44 | return allDataJSON 45 | } 46 | 47 | type ReadAllHTMLReturnType> = Record< 48 | string, 49 | { 50 | matter: TMatter 51 | links: { 52 | outbound: string[] 53 | } 54 | html: string 55 | } 56 | > 57 | 58 | export async function readAllDataHTML>(args: ReadAllDataArgs): Promise> { 59 | const allData = await readAllData(args) 60 | 61 | const allDataHTML: ReadAllHTMLReturnType = {} 62 | 63 | await Promise.all( 64 | Object.entries(allData).map(async ([file, body]) => { 65 | allDataHTML[file] = await markdownToHTML(body) 66 | }), 67 | ) 68 | 69 | return allDataHTML 70 | } 71 | -------------------------------------------------------------------------------- /src/akte/date.ts: -------------------------------------------------------------------------------- 1 | const format = { 2 | usDate: new Intl.DateTimeFormat("en-US", { 3 | year: "numeric", 4 | month: "2-digit", 5 | day: "2-digit", 6 | }), 7 | } as const 8 | 9 | export function dateToUSDate(rawDate: string | number): string { 10 | const date = new Date(rawDate) 11 | 12 | return format.usDate.format(date) 13 | } 14 | 15 | export function dateToISO(rawDate: string | number): string { 16 | const date = new Date(rawDate) 17 | 18 | return date.toISOString().replace(/\.\d\d\dZ$/, "+00:00") 19 | } 20 | -------------------------------------------------------------------------------- /src/akte/discogs.ts: -------------------------------------------------------------------------------- 1 | import type { DiscogsRelease } from "./types" 2 | 3 | import fs from "node:fs/promises" 4 | import path from "node:path" 5 | import process from "node:process" 6 | 7 | // @ts-expect-error 11ty doesn't provide TypeScript definitions 8 | import eleventyFetch from "@11ty/eleventy-fetch" 9 | 10 | const DISCOGS_API = "https://api.discogs.com" 11 | const FALLBACK_JSON_DUMP = "https://lihbr.com/records.json" 12 | 13 | async function getAllReleases(page = 1): Promise { 14 | const result = await eleventyFetch( 15 | `${DISCOGS_API}/users/${process.env.DISCOGS_USER}/collection/folders/0/releases?key=${process.env.DISCOGS_KEY}&secret=${process.env.DISCOGS_SECRET}&sort=added&sort_order=desc&per_page=500&page=${page}`, 16 | { 17 | duration: "1d", 18 | type: "json", 19 | fetchOptions: { 20 | headers: { 21 | "user-agent": "lihbrApex/0.1 +https://lihbr.com", 22 | }, 23 | }, 24 | }, 25 | ) 26 | 27 | if (result.pagination.page < result.pagination.pages) { 28 | return [...result.releases, ...(await getAllReleases(page + 1))] 29 | } else { 30 | return result.releases 31 | } 32 | } 33 | 34 | export async function getAllReleasesSafely(): Promise { 35 | let releases: DiscogsRelease[] 36 | 37 | try { 38 | releases = await getAllReleases() 39 | } catch { 40 | (async () => { 41 | try { 42 | await fetch(process.env.SLACK_NETLIFY_WEBHOOK!, { 43 | headers: { "content-type": "application/json" }, 44 | method: "POST", 45 | body: JSON.stringify({ 46 | text: "Used fallback JSON dump for Discogs releases", 47 | blocks: [ 48 | { 49 | type: "section", 50 | text: { 51 | type: "mrkdwn", 52 | text: ":warning: Used fallback JSON dump for Discogs releases", 53 | }, 54 | }, 55 | ], 56 | }), 57 | }) 58 | } catch { 59 | // Noop 60 | } 61 | })() 62 | 63 | const result = await fetch(FALLBACK_JSON_DUMP) 64 | 65 | if (!result.ok) { 66 | throw new Error(`Failed to fetch fallback releases: ${result.statusText}`) 67 | } 68 | 69 | releases = await result.json() 70 | } 71 | 72 | if (process.env.NODE_ENV === "production") { 73 | await fs.writeFile(path.join(__dirname, "../public/records.json"), JSON.stringify(releases)) 74 | } 75 | 76 | return releases 77 | } 78 | -------------------------------------------------------------------------------- /src/akte/getPrettyContrastRatio.ts: -------------------------------------------------------------------------------- 1 | import { getContrastRatio } from "./lib/getContrastRatio" 2 | import { getRelativeLuminance } from "./lib/getRelativeLuminance" 3 | 4 | export function getPrettyContrastRatio(foreground: string, background: string, precision = 2): string { 5 | return getContrastRatio( 6 | getRelativeLuminance(foreground), 7 | getRelativeLuminance(background), 8 | ) 9 | .toFixed(precision) 10 | .replace(/\.?0+$/, "") 11 | } 12 | -------------------------------------------------------------------------------- /src/akte/lib/RateLimiter.ts: -------------------------------------------------------------------------------- 1 | import type { HandlerEvent } from "@netlify/functions" 2 | 3 | type RateLimitHeaders = { 4 | "x-ratelimit-limit": number 5 | "x-ratelimit-remaining": number 6 | "x-ratelimit-used": number 7 | "x-ratelimit-reset": number 8 | } 9 | 10 | type RateLimitConstructorArgs = { 11 | cache: Map 12 | options: { limit: number, window: number } 13 | } 14 | 15 | export class RateLimiter { 16 | cache: RateLimitConstructorArgs["cache"] 17 | options: RateLimitConstructorArgs["options"] 18 | 19 | constructor(args: RateLimitConstructorArgs) { 20 | this.cache = args.cache 21 | this.options = args.options 22 | } 23 | 24 | trackUsage(event: HandlerEvent): { 25 | headers: RateLimitHeaders 26 | hasReachedLimit: boolean 27 | } { 28 | const now = Date.now() 29 | 30 | // Identify consumer based on IP 31 | const consumerID = ( 32 | event.headers["x-forwarded-for"] || 33 | event.headers["x-nf-client-connection-ip"] || 34 | event.headers["client-ip"] || 35 | "" 36 | ).split(", ")[0] 37 | 38 | // Get consumer 39 | let consumerInfo = this.cache.get(consumerID) 40 | 41 | // If consumer is now OR reset window has been reached, create new window 42 | if (!consumerInfo || consumerInfo.reset < now) { 43 | consumerInfo = { 44 | used: 0, 45 | reset: now + this.options.window, 46 | } 47 | } 48 | 49 | // If consumer has reached the limit, return for 429 50 | if (consumerInfo.used >= this.options.limit) { 51 | return { 52 | headers: this._getHeadersForConsumer(consumerInfo), 53 | hasReachedLimit: true, 54 | } 55 | } 56 | 57 | // Else update consumer 58 | consumerInfo.used++ 59 | this.cache.set(consumerID, consumerInfo) 60 | 61 | // And return 62 | return { 63 | headers: this._getHeadersForConsumer(consumerInfo), 64 | hasReachedLimit: false, 65 | } 66 | } 67 | 68 | private _getHeadersForConsumer(consumerInfo: { 69 | used: number 70 | reset: number 71 | }): RateLimitHeaders { 72 | return { 73 | "x-ratelimit-limit": this.options.limit, 74 | "x-ratelimit-remaining": this.options.limit - consumerInfo.used, 75 | "x-ratelimit-used": consumerInfo.used, 76 | "x-ratelimit-reset": consumerInfo.reset, 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/akte/lib/getContrastRatio.ts: -------------------------------------------------------------------------------- 1 | /** @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef */ 2 | export function getContrastRatio(l1: number, l2: number): number { 3 | if (l1 > l2) { 4 | return (l1 + 0.05) / (l2 + 0.05) 5 | } else { 6 | return (l2 + 0.05) / (l1 + 0.05) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/akte/lib/getRelativeLuminance.ts: -------------------------------------------------------------------------------- 1 | /** @see https://www.w3.org/TR/WCAG20/#relativeluminancedef */ 2 | export function getRelativeLuminance(hex: string): number { 3 | const maybeMatch = hex.match(/^#(?\w{2})(?\w{2})(?\w{2})$/) 4 | 5 | if (!maybeMatch || !maybeMatch.groups) { 6 | throw new Error( 7 | `Provided value is not a valid HEX color string: \`${hex}\``, 8 | ) 9 | } 10 | 11 | const r8 = Number.parseInt(maybeMatch.groups.r, 16) 12 | const g8 = Number.parseInt(maybeMatch.groups.g, 16) 13 | const b8 = Number.parseInt(maybeMatch.groups.b, 16) 14 | 15 | const tweak = (value: number): number => { 16 | const sRGB = value / 255 17 | 18 | if (sRGB <= 0.03928) { 19 | return sRGB / 12.92 20 | } else { 21 | return ((sRGB + 0.055) / 1.055) ** 2.4 22 | } 23 | } 24 | 25 | return 0.2126 * tweak(r8) + 0.7152 * tweak(g8) + 0.0722 * tweak(b8) 26 | } 27 | -------------------------------------------------------------------------------- /src/akte/lib/getSiteURL.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process" 2 | 3 | /** 4 | * Resolve Netlify deploy URL 5 | * @param config - configuration object 6 | * @param config.branchDomains - branches having their own domains 7 | * @return - final deploy URL 8 | */ 9 | export function getSiteDeployURL({ 10 | branchDomains = [], 11 | }: { 12 | branchDomains: string[] 13 | }): string | null { 14 | if ( 15 | !process.env.URL || 16 | !process.env.BRANCH || 17 | !process.env.DEPLOY_PRIME_URL 18 | ) { 19 | return null 20 | } 21 | 22 | switch (process.env.CONTEXT) { 23 | // Production gets production URL 24 | case "production": 25 | return process.env.URL 26 | 27 | // Branch deploys having dedicated domains get their dedicated domains 28 | case "branch-deploy": 29 | if (branchDomains.includes(process.env.BRANCH)) { 30 | return process.env.URL.replace( 31 | /^(https?:\/\/)/, 32 | `$1${process.env.BRANCH.toLowerCase()}.`, 33 | ) 34 | } 35 | return process.env.DEPLOY_PRIME_URL 36 | 37 | // Everything else gets prime URL 38 | default: 39 | return process.env.DEPLOY_PRIME_URL 40 | } 41 | } 42 | 43 | export function getSiteURL(): string { 44 | const maybeDeployURL = getSiteDeployURL({ 45 | branchDomains: ["staging"], 46 | }) 47 | 48 | if (maybeDeployURL) { 49 | return maybeDeployURL 50 | } 51 | 52 | if (process.env.APP_URL) { 53 | return process.env.APP_URL 54 | } 55 | 56 | // Preview edge case 57 | if (process.env.AWS_LAMBDA_FUNCTION_NAME && process.env.URL) { 58 | return process.env.URL 59 | } 60 | 61 | throw new Error("Could not resolve site URL") 62 | } 63 | -------------------------------------------------------------------------------- /src/akte/lib/markdownToHTML.ts: -------------------------------------------------------------------------------- 1 | import type { VFile } from "vfile" 2 | 3 | import rehypeAutolinkHeadings from "rehype-autolink-headings" 4 | import rehypeExternalLinks from "rehype-external-links" 5 | import rehypeSlug from "rehype-slug" 6 | import rehypeStringify from "rehype-stringify" 7 | import remarkFrontmatter from "remark-frontmatter" 8 | import remarkGfm from "remark-gfm" 9 | import remarkParse from "remark-parse" 10 | 11 | import remarkRehype from "remark-rehype" 12 | import remarkWikiLink from "remark-wiki-link" 13 | import { type Plugin, type Processor, unified } from "unified" 14 | import { visit } from "unist-util-visit" 15 | 16 | import { matter } from "vfile-matter" 17 | // @ts-expect-error - Missing types 18 | import type { Code as MDCode, Parent as MDParent, Root as MDRoot } from "mdast" 19 | 20 | import { slugify } from "../slufigy" 21 | import { highlightCode, parseMarkdownCodeBlock } from "./highlightCode" 22 | 23 | const remarkHighlightCode: Plugin<[], MDRoot> = () => { 24 | return async (tree) => { 25 | const promises: Promise[] = [] 26 | 27 | const highlightCodeAndReplace = async ( 28 | node: MDCode, 29 | index: number, 30 | parent: MDParent, 31 | ): Promise => { 32 | const value = await highlightCode( 33 | parseMarkdownCodeBlock(`/${node.lang} ${node.meta || ""}/\n${node.value}`), 34 | ) 35 | 36 | parent.children.splice(index, 1, { type: "html", value }) 37 | } 38 | 39 | visit(tree, (node, index, parent) => { 40 | if (!parent || index === null) { 41 | return 42 | } 43 | 44 | switch (node.type) { 45 | case "code": 46 | promises.push(highlightCodeAndReplace(node, index || 0, parent)) 47 | break 48 | 49 | case "inlineCode": 50 | node.data = { hProperties: { className: "inline" } } 51 | break 52 | } 53 | }) 54 | 55 | await Promise.all(promises) 56 | } 57 | } 58 | 59 | const remarkExtendedWikiLink: Plugin<[], MDRoot> = () => { 60 | return async (tree, file) => { 61 | const outboundLinks: Record = {} 62 | 63 | visit(tree, (node: any, index, parent) => { 64 | if (!parent || index === null || node.type !== "wikiLink") { 65 | return 66 | } 67 | 68 | delete node.data.hProperties.className 69 | 70 | const url = `/${slugify(node.value)}` 71 | const value = node.data.alias.split("/").pop() 72 | 73 | parent.children.splice(index, 1, { 74 | type: "link", 75 | url, 76 | children: [{ type: "text", value }], 77 | }) 78 | 79 | outboundLinks[url] = true 80 | }) 81 | 82 | file.data.links = { 83 | outbound: Object.keys(outboundLinks), 84 | } 85 | } 86 | } 87 | 88 | let processor: Processor 89 | 90 | export async function markdownToHTML>(markdown: string): Promise<{ 91 | matter: TMatter 92 | links: { 93 | outbound: string[] 94 | } 95 | html: string 96 | }> { 97 | if (!processor) { 98 | processor = unified() 99 | // Parse string 100 | .use(remarkParse) 101 | // GitHub MarkDown 102 | .use(remarkGfm) 103 | // Frontmatter 104 | .use(remarkFrontmatter, ["yaml"]) 105 | .use(() => (_tree: MDRoot, file: VFile) => { 106 | matter(file) 107 | }) 108 | // Wiki links 109 | .use(remarkWikiLink, { aliasDivider: "|" }) 110 | .use(remarkExtendedWikiLink) 111 | // Highlight code 112 | // @ts-expect-error - Missing types 113 | .use(remarkHighlightCode) 114 | .use(remarkRehype, { allowDangerousHtml: true }) 115 | .use(rehypeExternalLinks, { 116 | rel: ["noreferrer"], 117 | target: "_blank", 118 | }) 119 | .use(rehypeSlug) 120 | .use(rehypeAutolinkHeadings, { behavior: "wrap" }) 121 | .use(rehypeStringify, { allowDangerousHtml: true }) 122 | } 123 | 124 | const virtualFile = await processor.process(markdown) 125 | 126 | return { 127 | matter: virtualFile.data.matter as TMatter, 128 | links: virtualFile.data.links as { 129 | outbound: string[] 130 | }, 131 | html: virtualFile.toString(), 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/akte/sha256.ts: -------------------------------------------------------------------------------- 1 | export async function sha256(input: string, salt: string, truncate?: number): Promise { 2 | const encoder = new TextEncoder() 3 | const data = encoder.encode(input + salt) 4 | 5 | const hashBuffer = await crypto.subtle.digest("SHA-256", data) 6 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 7 | 8 | const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("") 9 | 10 | return truncate ? hashHex.slice(0, truncate) : hashHex 11 | } 12 | -------------------------------------------------------------------------------- /src/akte/slufigy.ts: -------------------------------------------------------------------------------- 1 | import _slufigy from "slugify" 2 | 3 | export function slugify(value: string): string { 4 | return _slufigy(value, { 5 | replacement: "-", 6 | remove: /[*+~()`'"!?:;,.@°_]/g, 7 | lower: true, 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /src/akte/types.ts: -------------------------------------------------------------------------------- 1 | import type * as prismic from "@prismicio/client" 2 | 3 | export type GlobalData = Record 4 | 5 | export type TalkData = { 6 | slug: string 7 | title: string 8 | lead: string 9 | date: string 10 | durationMinutes: number 11 | hidden?: boolean 12 | conference: { 13 | slug: string 14 | name: string 15 | url: string 16 | location: string 17 | } 18 | links: { 19 | name: string 20 | url: string 21 | }[] 22 | feedback: { 23 | hashtags: string 24 | related: string 25 | via: string 26 | } 27 | confetti: string[] 28 | } 29 | 30 | export type NoteData = { 31 | title: string 32 | first_publication_date: string 33 | last_publication_date: string 34 | body: string 35 | links: { 36 | outbound: string[] 37 | inbound: Record< 38 | string, 39 | { path: string, title: string, first_publication_date: string } 40 | > 41 | } 42 | } 43 | 44 | export type ProjectData = { 45 | slug: string 46 | title: string 47 | start: string 48 | active: boolean 49 | url: string 50 | } 51 | 52 | // eslint-disable-next-line unused-imports/no-unused-vars 53 | const Shade = { 54 | 900: "900", 55 | 800: "800", 56 | 700: "700", 57 | 600: "600", 58 | 500: "500", 59 | 400: "400", 60 | 300: "300", 61 | 200: "200", 62 | 100: "100", 63 | 50: "50", 64 | } as const 65 | export type Shades = (typeof Shade)[keyof typeof Shade] 66 | 67 | // eslint-disable-next-line unused-imports/no-unused-vars 68 | const Color = { 69 | slate: "slate", 70 | cream: "cream", 71 | navy: "navy", 72 | beet: "beet", 73 | flamingo: "flamingo", 74 | ochre: "ochre", 75 | butter: "butter", 76 | mantis: "mantis", 77 | } as const 78 | export type Colors = (typeof Color)[keyof typeof Color] 79 | 80 | export type ColorsData = { 81 | primary: Record 82 | } 83 | 84 | export type DiscogsRelease = { 85 | id: number 86 | date_added: string 87 | rating: number 88 | basic_information: { 89 | id: number 90 | thumb: string 91 | cover_image: string 92 | title: string 93 | year: number 94 | artists: { name: string }[] 95 | genres: string[] 96 | styles: string[] 97 | } 98 | } 99 | 100 | export type ImgixJson = { 101 | Exif: { 102 | FocalLength?: number 103 | FocalLengthIn35mmFilm?: number 104 | ExposureTime?: number 105 | ISOSpeedRatings?: number 106 | FNumber?: number 107 | DateTimeOriginal?: string 108 | OffsetTimeOriginal?: string 109 | } 110 | TIFF: { 111 | Make?: string 112 | Model?: string 113 | DateTime?: string 114 | } 115 | XMP?: never 116 | } 117 | 118 | export type PrismicImage = prismic.ImageFieldImage<"filled"> & { tags: string[], json: ImgixJson } 119 | -------------------------------------------------------------------------------- /src/assets/css/abstract/components.css: -------------------------------------------------------------------------------- 1 | .section { 2 | /* `42.5rem` refers to `680px` itself refering to `65ch` for Graphit font, this avoids some font loading CLS */ 3 | @apply max-w-[42.5rem] px-6 mt-16; 4 | } 5 | 6 | html { 7 | &.left .section { 8 | @apply mr-auto; 9 | } 10 | 11 | &.center .section { 12 | @apply mx-auto; 13 | } 14 | 15 | &.right .section { 16 | @apply ml-auto; 17 | } 18 | } 19 | 20 | .form { 21 | & label { 22 | @apply lowercase; 23 | } 24 | 25 | & input, 26 | & textarea { 27 | @apply bg-transparent w-full placeholder-slate-100 dark:placeholder-cream-100 p-2 border-2 border-slate-100 dark:border-cream-100 focus:outline-none focus:border-theme focus:dark:border-theme; 28 | } 29 | 30 | & input[type="search"]::-webkit-calendar-picker-indicator { 31 | display: none !important; 32 | } 33 | } 34 | 35 | .dl { 36 | @apply space-y-6 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-6; 37 | 38 | & dt { 39 | @apply lowercase text-theme; 40 | } 41 | } 42 | 43 | table.sort { 44 | @apply w-full border-collapse table-fixed; 45 | 46 | & th { 47 | @apply pb-6 font-normal; 48 | 49 | & button { 50 | @apply w-full font-normal text-left; 51 | } 52 | } 53 | 54 | & [data-sortable] button::before { 55 | content: "~"; 56 | width: 1rem; 57 | display: inline-block; 58 | } 59 | & [data-sortable][aria-sort] button::before { 60 | content: "↑"; 61 | } 62 | & [data-sortable][aria-sort="descending"] button::before { 63 | content: "↓"; 64 | } 65 | 66 | & td { 67 | @apply py-3 pl-4; 68 | 69 | &:last-child { 70 | @apply pr-4; 71 | } 72 | } 73 | } 74 | 75 | .bg-grid { 76 | background-image: linear-gradient(45deg,#1f1919bf 25%,transparent 0),linear-gradient(-45deg,#1f1919bf 25%,transparent 0),linear-gradient(45deg,transparent 75%,#1f1919bf 0),linear-gradient(-45deg,transparent 75%,#1f1919bf 0); 77 | background-position: 0 0,0 10px,10px -10px,-10px 0; 78 | background-size: 20px 20px; 79 | } 80 | 81 | .marquee { 82 | @apply fixed top-0 left-0 w-full bg-theme overflow-hidden text-slate py-1; 83 | 84 | &::before { 85 | @apply block whitespace-pre; 86 | content: attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text) " " attr(data-text); 87 | width: fit-content; 88 | transform: translate3d(-2%, 0, 0); 89 | will-change: transform; 90 | } 91 | } 92 | 93 | @media (prefers-reduced-motion: no-preference) { 94 | .marquee::before { 95 | animation: marquee 6s linear infinite; 96 | } 97 | } 98 | 99 | @keyframes marquee { 100 | 0% { 101 | transform: translate3d(-2%, 0, 0); 102 | } 103 | 100% { 104 | transform: translate3d(calc(-2% - 5% - 1px), 0, 0); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/assets/css/abstract/default.css: -------------------------------------------------------------------------------- 1 | /* Not much yet, and it's probably better to write a Tailwind plugin anyway... */ 2 | html { 3 | @apply antialiased box-border text-slate bg-cream-900 overflow-x-hidden; 4 | font-size: 100%; /* Don't change this! */ 5 | word-spacing: 1px; 6 | -ms-text-size-adjust: 100%; 7 | -webkit-text-size-adjust: 100%; 8 | color-scheme: light; 9 | margin-right: calc(100% - 100vw); /* Prevents scrollbar jump */ 10 | 11 | scrollbar-width: thin; 12 | & ::-webkit-scrollbar { 13 | width: .5rem; 14 | height: .5rem; 15 | } 16 | & ::-webkit-scrollbar-corner { 17 | background: transparent; 18 | } 19 | 20 | scrollbar-color: var(--color-theme) transparent; 21 | -webkit-tap-highlight-color: var(--color-theme-o-20); 22 | & ::selection, 23 | & ::-webkit-scrollbar-thumb { 24 | @apply text-cream-900 bg-theme; 25 | } 26 | 27 | &.dark { 28 | @apply text-cream bg-slate-900; 29 | color-scheme: dark; 30 | 31 | & ::selection, 32 | & ::-webkit-scrollbar-thumb { 33 | @apply text-slate-900; 34 | } 35 | 36 | & img:not(.nofilter) { 37 | filter: grayscale(0.2); 38 | } 39 | } 40 | } 41 | 42 | body { 43 | @apply w-full overflow-x-auto; 44 | } 45 | 46 | a:focus-visible, 47 | button:focus-visible, 48 | .action:focus-visible, 49 | input[type="radio"]:focus-visible + label { 50 | @apply outline-none relative bg-theme text-cream-900 dark:text-slate-900; 51 | } 52 | 53 | a:focus:not(:focus-visible), 54 | button:focus:not(:focus-visible), 55 | .action:focus:not(:focus-visible) { 56 | @apply outline-none; 57 | } 58 | 59 | a:hover, 60 | button:hover, 61 | .action:hover { 62 | @apply text-theme; 63 | } 64 | 65 | a[target="_blank"]::after, a[href^="mailto:"]::after { 66 | content: " ↗"; 67 | } 68 | 69 | .scrollbar-thin { 70 | scrollbar-width: thin; 71 | 72 | & ::-webkit-scrollbar { 73 | width: .5rem; 74 | height: .5rem; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/assets/css/abstract/fonts.css: -------------------------------------------------------------------------------- 1 | /* Import local fonts here */ 2 | 3 | @layer base { 4 | /* Excalidraw */ 5 | @font-face { 6 | font-family: "Cascadia"; 7 | font-style: normal; 8 | font-display: swap; 9 | font-weight: 400; 10 | src: local("CascadiaRegular"), 11 | url("/assets/fonts/cascadia-400.woff2") format("woff2"), 12 | url("/assets/fonts/cascadia-400.woff") format("woff"); 13 | } 14 | 15 | @font-face { 16 | font-family: "Virgil"; 17 | font-style: normal; 18 | font-display: swap; 19 | font-weight: 400; 20 | src: local("VirgilRegular"), 21 | url("/assets/fonts/virgil-400.woff2") format("woff2"), 22 | url("/assets/fonts/virgil-400.woff") format("woff"); 23 | } 24 | 25 | /* Graphit */ 26 | @font-face { 27 | font-family: "Graphit"; 28 | font-weight: 100; 29 | font-style: normal; 30 | font-display: swap; 31 | src: local("GraphitThin"), 32 | url("/assets/fonts/graphit-100.woff2") format("woff2"), 33 | url("/assets/fonts/graphit-100.woff") format("woff"); 34 | } 35 | 36 | @font-face { 37 | font-family: "Graphit"; 38 | font-weight: 100; 39 | font-style: italic; 40 | font-display: swap; 41 | src: local("GraphitThin-Italic"), local("GraphicThinItalic"), 42 | url("/assets/fonts/graphit-100i.woff2") format("woff2"), 43 | url("/assets/fonts/graphit-100i.woff") format("woff"); 44 | } 45 | 46 | @font-face { 47 | font-family: "Graphit"; 48 | font-weight: 300; 49 | font-style: normal; 50 | font-display: swap; 51 | src: local("GraphitLight"), 52 | url("/assets/fonts/graphit-300.woff2") format("woff2"), 53 | url("/assets/fonts/graphit-300.woff") format("woff"); 54 | } 55 | 56 | @font-face { 57 | font-family: "Graphit"; 58 | font-weight: 300; 59 | font-style: italic; 60 | font-display: swap; 61 | src: local("GraphitLight-Italic"), local("GraphitLightItalic"), 62 | url("/assets/fonts/graphit-300i.woff2") format("woff2"), 63 | url("/assets/fonts/graphit-300i.woff") format("woff"); 64 | } 65 | 66 | @font-face { 67 | font-family: "Graphit"; 68 | font-weight: 400; 69 | font-style: normal; 70 | font-display: swap; 71 | src: local("GraphitRegular"), 72 | url("/assets/fonts/graphit-400.woff2") format("woff2"), 73 | url("/assets/fonts/graphit-400.woff") format("woff"); 74 | } 75 | 76 | @font-face { 77 | font-family: "Graphit CLS"; 78 | font-weight: 400; 79 | font-style: normal; 80 | src: local("Arial"); 81 | ascent-override: 93.08%; 82 | descent-override: 26.10%; 83 | line-gap-override: 0.00%; 84 | size-adjust: 103.45%; 85 | } 86 | 87 | @font-face { 88 | font-family: "Graphit"; 89 | font-weight: 400; 90 | font-style: italic; 91 | font-display: swap; 92 | src: local("GraphitRegular-Italic"), local("GraphitRegularItalic"), 93 | url("/assets/fonts/graphit-400i.woff2") format("woff2"), 94 | url("/assets/fonts/graphit-400i.woff") format("woff"); 95 | } 96 | 97 | @font-face { 98 | font-family: "Graphit"; 99 | font-weight: 500; 100 | font-style: normal; 101 | font-display: swap; 102 | src: local("GraphitMedium"), 103 | url("/assets/fonts/graphit-500.woff2") format("woff2"), 104 | url("/assets/fonts/graphit-500.woff") format("woff"); 105 | } 106 | 107 | @font-face { 108 | font-family: "Graphit"; 109 | font-weight: 500; 110 | font-style: italic; 111 | font-display: swap; 112 | src: local("GraphitMedium-Italic"), local("GraphitMediumItalic"), 113 | url("/assets/fonts/graphit-500i.woff2") format("woff2"), 114 | url("/assets/fonts/graphit-500i.woff") format("woff"); 115 | } 116 | 117 | @font-face { 118 | font-family: "Graphit"; 119 | font-weight: 700; 120 | font-style: normal; 121 | font-display: swap; 122 | src: local("GraphitBold"), 123 | url("/assets/fonts/graphit-700.woff2") format("woff2"), 124 | url("/assets/fonts/graphit-700.woff") format("woff"); 125 | } 126 | 127 | @font-face { 128 | font-family: "Graphit"; 129 | font-weight: 700; 130 | font-style: italic; 131 | font-display: swap; 132 | src: local("GraphitBold-Italic"), local("GraphitBoldItalic"), 133 | url("/assets/fonts/graphit-700i.woff2") format("woff2"), 134 | url("/assets/fonts/graphit-700i.woff") format("woff"); 135 | } 136 | 137 | @font-face { 138 | font-family: "Graphit"; 139 | font-weight: 900; 140 | font-style: normal; 141 | font-display: swap; 142 | src: local("GraphitBlack"), 143 | url("/assets/fonts/graphit-900.woff2") format("woff2"), 144 | url("/assets/fonts/graphit-900.woff") format("woff"); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/assets/css/abstract/theme.css: -------------------------------------------------------------------------------- 1 | .neutral { 2 | --color-theme: theme("colors.slate.DEFAULT"); 3 | --color-theme-o-20: theme("colors.slate.o-20"); 4 | --color-theme-100: theme("colors.slate.100"); 5 | } 6 | html.dark { 7 | &.neutral, & .neutral { 8 | --color-theme: theme("colors.cream.DEFAULT"); 9 | --color-theme-o-20: theme("colors.cream.o-20"); 10 | --color-theme-100: theme("colors.cream.100"); 11 | } 12 | } 13 | 14 | .navy { 15 | --color-theme: theme("colors.navy.DEFAULT"); 16 | --color-theme-o-20: theme("colors.navy.o-20"); 17 | --color-theme-100: theme("colors.navy.100"); 18 | } 19 | 20 | .beet { 21 | --color-theme: theme("colors.beet.DEFAULT"); 22 | --color-theme-o-20: theme("colors.beet.o-20"); 23 | --color-theme-100: theme("colors.beet.100"); 24 | } 25 | 26 | html:not([class*="navy"]):not([class*="beet"]):not([class*="ochre"]):not([class*="butter"]):not([class*="mantis"]), 27 | .flamingo { 28 | --color-theme: theme("colors.flamingo.DEFAULT"); 29 | --color-theme-o-20: theme("colors.flamingo.o-20"); 30 | --color-theme-100: theme("colors.flamingo.100"); 31 | } 32 | 33 | .ochre { 34 | --color-theme: theme("colors.ochre.DEFAULT"); 35 | --color-theme-o-20: theme("colors.ochre.o-20"); 36 | --color-theme-100: theme("colors.ochre.100"); 37 | } 38 | 39 | .butter { 40 | --color-theme: theme("colors.butter.DEFAULT"); 41 | --color-theme-o-20: theme("colors.butter.o-20"); 42 | --color-theme-100: theme("colors.butter.100"); 43 | } 44 | 45 | .mantis { 46 | --color-theme: theme("colors.mantis.DEFAULT"); 47 | --color-theme-o-20: theme("colors.mantis.o-20"); 48 | --color-theme-100: theme("colors.mantis.100"); 49 | } 50 | 51 | @media (prefers-reduced-motion: no-preference) { 52 | ::view-transition-group(root) { 53 | animation-timing-function: var(linear( 54 | 0 0%, 55 | 0.0085 31.26%, 56 | 0.0167 40.94%, 57 | 0.0289 48.86%, 58 | 0.0471 55.92%, 59 | 0.0717 61.99%, 60 | 0.1038 67.32%, 61 | 0.1443 72.07%, 62 | 0.1989 76.7%, 63 | 0.2659 80.89%, 64 | 0.3465 84.71%, 65 | 0.4419 88.22%, 66 | 0.554 91.48%, 67 | 0.6835 94.51%, 68 | 0.8316 97.34%, 69 | 1 100% 70 | )); 71 | } 72 | 73 | ::view-transition-new(root) { 74 | mask: url("https://images.prismic.io/lihbr/ZnmnPpbWFbowe0Dg_chikachika.gif?auto=format,compress") center / 0 no-repeat; 75 | animation: scale 1.8s; 76 | } 77 | 78 | ::view-transition-old(root) { 79 | animation: scale 1.8s; 80 | } 81 | 82 | @keyframes scale { 83 | 0% { 84 | mask-size: 0; 85 | } 86 | 10% { 87 | mask-size: 50vmax; 88 | } 89 | 90% { 90 | mask-size: 50vmax; 91 | } 92 | 100% { 93 | mask-size: 2000vmax; 94 | } 95 | } 96 | } 97 | 98 | -------------------------------------------------------------------------------- /src/assets/css/abstract/typography.css: -------------------------------------------------------------------------------- 1 | html.font-feature-settings { 2 | &, & button, & .ff-text { 3 | /** 4 | * Prefering `font-feature-settings` over `font-variant` despite the latter 5 | * being recommended by MDN due to partial browser support of `font-variant` 6 | * 7 | * See: https://caniuse.com/font-variant-alternates 8 | */ 9 | font-feature-settings: 'pnum' on, 'lnum' on, 'ss02' on, 'ss03' on, 'ss04' on, 'ss05' on, 'ss06' on, 'ss07' on, 'ss08' on, 'liga' off; 10 | } 11 | 12 | & .ff-numeric { 13 | font-feature-settings: 'tnum' on, 'lnum' on, 'case' on; 14 | } 15 | 16 | & .ff-display, 17 | & .heading-0, 18 | & .heading-1, 19 | & .heading-2, 20 | & .heading-3, 21 | & .heading-4, 22 | & .prose h1, 23 | & .prose h2, 24 | & .prose h3, 25 | & .prose h4, 26 | & .prose h5, 27 | & .prose h6 { 28 | font-feature-settings: 'pnum' on, 'lnum' on; 29 | } 30 | } 31 | 32 | .heading-0, 33 | .heading-1, 34 | .heading-2, 35 | .heading-3, 36 | .heading-4, 37 | .prose h1, 38 | .prose h2, 39 | .prose h3, 40 | .prose h4, 41 | .prose h5, 42 | .prose h6 { 43 | @apply text-theme leading-snug lowercase; 44 | } 45 | 46 | .heading-0 { 47 | @apply text-[3rem] sm:text-[4rem]; 48 | } 49 | 50 | .heading-1, .prose h1 { 51 | @apply text-[2rem] sm:text-[3rem]; 52 | } 53 | 54 | .heading-2, .prose h2 { 55 | @apply text-[1.5rem] sm:text-[2rem]; 56 | } 57 | 58 | .heading-3, .prose h3 { 59 | @apply text-[1.25rem] sm:text-[1.5rem]; 60 | } 61 | 62 | .heading-4, .prose h4 { 63 | @apply text-[1.25rem]; 64 | } 65 | 66 | .prose { 67 | & h1, 68 | & h2, 69 | & h3, 70 | & h4, 71 | & h5, 72 | & h6 { 73 | @apply mb-6; 74 | } 75 | 76 | & a:not([href^="#"]) { 77 | @apply underline; 78 | } 79 | 80 | & ul { 81 | @apply list-disc pl-6; 82 | } 83 | 84 | & ol { 85 | @apply list-decimal pl-6; 86 | } 87 | 88 | & code.inline { 89 | @apply bg-cream p-1 -my-1; 90 | } 91 | 92 | & blockquote { 93 | @apply italic pl-3 border-l-2 border-l-theme; 94 | } 95 | 96 | & figure > svg { 97 | @apply w-full fill-current h-auto; 98 | } 99 | } 100 | 101 | html.dark .prose { 102 | & code.inline { 103 | @apply bg-slate; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/assets/css/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @import "./abstract/fonts.css"; 6 | @import "./abstract/default.css"; 7 | @import "./abstract/typography.css"; 8 | @import "./abstract/components.css"; 9 | @import "./abstract/highlight.css"; 10 | @import "./abstract/theme.css"; 11 | -------------------------------------------------------------------------------- /src/assets/fonts.tar.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/assets/fonts.tar.enc -------------------------------------------------------------------------------- /src/assets/js/_base.ts: -------------------------------------------------------------------------------- 1 | import { setAlignment } from "./lib/alignment" 2 | import { applyOnEvent } from "./lib/applyOnEvent" 3 | import { trackEvent } from "./lib/plausible" 4 | import { toggleTheme } from "./lib/theme" 5 | 6 | applyOnEvent("click", "toggle-theme", toggleTheme) 7 | applyOnEvent("click", "set-alignment", setAlignment) 8 | 9 | // Page view 10 | trackEvent({ event: "pageView" }) 11 | 12 | // 2-minute page time goal 13 | setTimeout(async () => { 14 | await trackEvent({ event: "pageTime:120" }) 15 | }, 2 * 60 * 1000) 16 | 17 | // Outbound links (using custom solution because Plausible implementation has issues) 18 | document.querySelectorAll("a").forEach((node) => { 19 | if (node.host !== location.host) { 20 | const trackOutboundLink = (event: MouseEvent) => { 21 | trackEvent({ 22 | event: "outboundLink:click", 23 | props: { url: node.href }, 24 | }) 25 | 26 | if (!node.target) { 27 | event.preventDefault() 28 | setTimeout(() => { 29 | location.href = node.href 30 | }, 150) 31 | } 32 | } 33 | node.addEventListener("click", trackOutboundLink) 34 | node.addEventListener("auxclick", (event) => { 35 | if (event.button === 1) { 36 | trackOutboundLink(event) 37 | } 38 | }) 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /src/assets/js/albums.ts: -------------------------------------------------------------------------------- 1 | import { read } from "./lib/albums" 2 | import { dateToUSDate } from "./lib/format" 3 | import "./_base" 4 | 5 | const albums = read() 6 | const $main = document.querySelector("article") 7 | 8 | if ($main) { 9 | if (!albums.length) { 10 | $main.innerHTML = /* html */ `` 11 | } else { 12 | $main.innerHTML = /* html */ ` 13 |
    14 | ${albums.map((album) => { 15 | return /* html */ ` 16 |
  • 17 | 20 | 21 | ${album.title} 22 | 23 |
  • ` 24 | }).join("\n")} 25 |
26 | ` 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/js/albums_slug.ts: -------------------------------------------------------------------------------- 1 | import { add } from "./lib/albums" 2 | import "./_base" 3 | 4 | const title = document.querySelector("h1")?.textContent 5 | const date = document.querySelector("time")?.getAttribute("datetime") 6 | const slug = location.pathname.split("/").pop() 7 | 8 | if (title && date && slug) { 9 | add({ title, date, slug }) 10 | } 11 | 12 | const $article = document.querySelector("article") 13 | 14 | if ($article) { 15 | let isDown = false 16 | let startX: number 17 | let scrollLeft: number 18 | 19 | $article.addEventListener("mousedown", (event) => { 20 | if (event.target instanceof HTMLAnchorElement) { 21 | return 22 | } 23 | event.preventDefault() 24 | isDown = true 25 | startX = event.pageX - $article.offsetLeft 26 | scrollLeft = $article.scrollLeft 27 | }) 28 | 29 | window.addEventListener("mouseup", () => { 30 | isDown = false 31 | }) 32 | 33 | window.addEventListener("mousemove", (event) => { 34 | if (!isDown) { 35 | return 36 | } 37 | event.preventDefault() 38 | const x = event.pageX - $article.offsetLeft 39 | const walk = (x - startX) * 3 40 | $article.scrollLeft = scrollLeft - walk 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/assets/js/code.ts: -------------------------------------------------------------------------------- 1 | import { highlightStarryNight } from "../../akte/lib/highlightCode" 2 | import { applyOnEvent } from "./lib/applyOnEvent" 3 | import { htmlToBlob } from "./lib/htmlToBlob" 4 | import "./_base" 5 | 6 | // Textarea 7 | const $input = document.querySelector("#input") 8 | if (!$input) { 9 | throw new Error("Input element not found") 10 | } 11 | 12 | const $output = document.querySelector("#output") 13 | if (!$output) { 14 | throw new Error("Output element not found") 15 | } 16 | 17 | const input = { 18 | $name: $input.querySelector("figcaption"), 19 | $code: $input.querySelector("code"), 20 | } 21 | 22 | const output = { 23 | $name: $output.querySelector("figcaption"), 24 | $code: $output.querySelector("code"), 25 | } 26 | 27 | async function copyContent(from: HTMLElement, to: HTMLElement, highlight = false) { 28 | if (highlight) { 29 | to.innerHTML = await highlightStarryNight( 30 | from.textContent ?? "", 31 | input.$name?.textContent?.split(".").pop() ?? "typescript", 32 | ) 33 | } else { 34 | to.textContent = from.textContent 35 | } 36 | } 37 | 38 | async function copyName() { 39 | if (input.$name && output.$name) { 40 | input.$name.textContent = input.$name.textContent?.trim() ?? "" 41 | await copyContent(input.$name, output.$name) 42 | await copyCode() 43 | } 44 | } 45 | 46 | async function copyCode() { 47 | if (input.$code && output.$code) { 48 | await copyContent(input.$code, output.$code, true) 49 | } 50 | } 51 | 52 | function observe(input: HTMLElement, callback: () => void | Promise) { 53 | const observer = new MutationObserver((mutations) => { 54 | mutations.forEach((mutation) => { 55 | if (mutation.type === "characterData" || mutation.type === "childList") { 56 | callback() 57 | } 58 | }) 59 | }) 60 | observer.observe(input, { characterData: true, subtree: true, childList: true }) 61 | } 62 | 63 | function observeName() { 64 | if (input.$name && output.$name) { 65 | observe(input.$name, copyName) 66 | } 67 | } 68 | 69 | function observeCode() { 70 | if (input.$code && output.$code) { 71 | observe(input.$code, copyCode) 72 | } 73 | } 74 | 75 | copyName().then(() => { 76 | copyCode().then(() => { 77 | observeName() 78 | observeCode() 79 | }) 80 | }) 81 | 82 | // Configuration 83 | const $preview = document.querySelector("#preview") as HTMLElement | null 84 | if (!$preview) { 85 | throw new Error("Preview element not found") 86 | } 87 | 88 | const $innerPreview = document.querySelector("#inner-preview") as HTMLElement | null 89 | if (!$innerPreview) { 90 | throw new Error("Inner preview element not found") 91 | } 92 | 93 | const $transparent = document.querySelector("#transparent") 94 | if (!$transparent) { 95 | throw new Error("Transparent element not found") 96 | } 97 | 98 | const PADDINGS = ["0rem", "1.5rem", "3rem", "6rem"] 99 | 100 | function applyPadding(padding: string): void { 101 | $preview!.style.padding = padding 102 | } 103 | 104 | function setPadding(event: Event): void { 105 | const padding = event.target instanceof HTMLInputElement && event.target.value 106 | 107 | if (padding && PADDINGS.includes(padding)) { 108 | applyPadding(padding) 109 | } 110 | } 111 | 112 | applyOnEvent("change", "set-padding", setPadding) 113 | 114 | const SIZES = ["container", "fit", "2:1"] as const 115 | 116 | function applySize(size: typeof SIZES[number]): void { 117 | switch (size) { 118 | case "container": 119 | $preview!.style.width = "auto" 120 | $preview!.style.aspectRatio = "auto" 121 | $innerPreview!.style.width = "39.5rem" 122 | break 123 | 124 | case "fit": 125 | $preview!.style.width = "auto" 126 | $preview!.style.aspectRatio = "auto" 127 | $innerPreview!.style.width = "auto" 128 | break 129 | 130 | case "2:1": 131 | default: 132 | $preview!.style.width = "100vw" 133 | $preview!.style.aspectRatio = "2 / 1" 134 | $innerPreview!.style.width = "auto" 135 | break 136 | } 137 | } 138 | 139 | function setSize(event: Event): void { 140 | const size = event.target instanceof HTMLInputElement && event.target.value 141 | 142 | if (size && SIZES.includes(size as typeof SIZES[number])) { 143 | applySize(size as typeof SIZES[number]) 144 | } 145 | } 146 | 147 | applyOnEvent("change", "set-size", setSize) 148 | 149 | function applyBackground(background: string): void { 150 | if (background === "transparent") { 151 | $preview!.style.background = "" 152 | $transparent?.classList.add("bg-grid") 153 | } else { 154 | $preview!.style.background = background 155 | $transparent?.classList.remove("bg-grid") 156 | } 157 | } 158 | 159 | function setBackground(event: Event): void { 160 | if (event.target instanceof HTMLSelectElement) { 161 | const background = event.target.value 162 | event.target.style.background = background 163 | applyBackground(background) 164 | } 165 | } 166 | 167 | // Export 168 | applyOnEvent("change", "set-background", setBackground) 169 | 170 | async function copyImage(event: Event): Promise { 171 | event.preventDefault() 172 | 173 | const maybeBlob = await htmlToBlob($preview!) 174 | 175 | if (maybeBlob) { 176 | navigator.clipboard.write([ 177 | new ClipboardItem({ 178 | "image/png": maybeBlob, 179 | }), 180 | ]) 181 | 182 | if (event.target instanceof HTMLElement) { 183 | const value = event.target.textContent 184 | if (value) { 185 | event.target.textContent = value.replace("copy", "copied") 186 | setTimeout(() => { 187 | if (event.target instanceof HTMLElement) { 188 | const value = event.target.textContent 189 | if (value) { 190 | event.target.textContent = value.replace("copied", "copy") 191 | } 192 | } 193 | }, 600) 194 | } 195 | } 196 | } 197 | } 198 | 199 | applyOnEvent("click", "copy-image", copyImage) 200 | 201 | async function downloadImage(event: Event): Promise { 202 | event.preventDefault() 203 | 204 | const maybeBlob = await htmlToBlob($preview!) 205 | 206 | if (maybeBlob) { 207 | const url = URL.createObjectURL(maybeBlob) 208 | const a = document.createElement("a") 209 | a.href = url 210 | a.download = `${input.$name?.textContent ?? "code"}.png` 211 | a.click() 212 | URL.revokeObjectURL(url) 213 | } 214 | } 215 | applyOnEvent("click", "download-image", downloadImage) 216 | -------------------------------------------------------------------------------- /src/assets/js/colors.ts: -------------------------------------------------------------------------------- 1 | import { applyOnEvent } from "./lib/applyOnEvent" 2 | import { copy } from "./lib/copy" 3 | import "./_base" 4 | 5 | applyOnEvent("click", "copy", copy) 6 | -------------------------------------------------------------------------------- /src/assets/js/index.ts: -------------------------------------------------------------------------------- 1 | import "./_base" 2 | 3 | // nav ul 4 | const $nav = document.querySelector("nav ul") 5 | if (!$nav) { 6 | throw new Error("Nav element not found") 7 | } 8 | 9 | if (localStorage.admin === "true") { 10 | $nav.innerHTML += /* html */ `
  • Admin
  • ` 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/js/lib/albums.ts: -------------------------------------------------------------------------------- 1 | const ALBUMS_KEY = "lihbr_albums" 2 | 3 | type Album = { 4 | date: string 5 | title: string 6 | slug: string 7 | } 8 | 9 | function sort(albums: Album[]): Album[] { 10 | return albums.sort((a, b) => b.date.localeCompare(a.date)) 11 | } 12 | 13 | export function read(): Album[] { 14 | const albums = localStorage.getItem(ALBUMS_KEY) 15 | return albums ? sort(JSON.parse(albums)) : [] 16 | } 17 | 18 | function write(albums: Album[]): void { 19 | localStorage.setItem(ALBUMS_KEY, JSON.stringify(albums)) 20 | } 21 | 22 | export function add(album: Album): void { 23 | const albums = read() 24 | 25 | write(sort([...albums, album].filter( 26 | (album, index, self) => self.findIndex((a) => a.slug === album.slug) === index, 27 | ))) 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/js/lib/alignment.ts: -------------------------------------------------------------------------------- 1 | const ALIGNMENTS = ["left", "center", "right"] 2 | 3 | export function applyAlignment(alignment: string): void { 4 | document.documentElement.classList.add(alignment) 5 | document.documentElement.classList.remove( 6 | ...ALIGNMENTS.filter((a) => a !== alignment), 7 | ) 8 | } 9 | 10 | export function setAlignment(event: Event): void { 11 | event.preventDefault() 12 | 13 | const alignment = 14 | event.target instanceof HTMLElement && event.target.dataset.alignment 15 | 16 | if (alignment && ALIGNMENTS.includes(alignment)) { 17 | localStorage.alignment = alignment 18 | applyAlignment(alignment) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/assets/js/lib/applyOnEvent.ts: -------------------------------------------------------------------------------- 1 | export function applyOnEvent(event: string, callbackName: string, callback: (event: Event) => void): void { 2 | document 3 | .querySelectorAll(`[data-on-${event}="${callbackName}"]`) 4 | .forEach((element: Element): void => { 5 | element.addEventListener(event, callback) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/assets/js/lib/copy.ts: -------------------------------------------------------------------------------- 1 | export function copy(event: Event): void { 2 | event.preventDefault() 3 | 4 | const $target = event.target 5 | 6 | if (!($target instanceof HTMLElement)) { 7 | return 8 | } 9 | 10 | const value = $target.dataset.value 11 | 12 | if (!value) { 13 | return 14 | } 15 | 16 | navigator.clipboard?.writeText(value) 17 | 18 | $target.textContent = "copied" 19 | setTimeout(() => { 20 | $target.textContent = value 21 | }, 600) 22 | } 23 | -------------------------------------------------------------------------------- /src/assets/js/lib/format.ts: -------------------------------------------------------------------------------- 1 | const dateFormat = { 2 | usDate: new Intl.DateTimeFormat("en-US", { 3 | year: "numeric", 4 | month: "2-digit", 5 | day: "2-digit", 6 | }), 7 | usDay: new Intl.DateTimeFormat("en-US", { 8 | weekday: "long", 9 | }), 10 | usTime: new Intl.DateTimeFormat("en-US", { 11 | hour: "2-digit", 12 | minute: "2-digit", 13 | hour12: false, 14 | }), 15 | usRelativeDays: new Intl.RelativeTimeFormat("en-US", { 16 | numeric: "auto", 17 | }), 18 | } as const 19 | 20 | export function dateToUSDate(rawDate: string | number): string { 21 | const date = new Date(rawDate) 22 | 23 | return dateFormat.usDate.format(date) 24 | } 25 | 26 | export function dateToUSDay(rawDate: string | number): string { 27 | const date = new Date(rawDate) 28 | 29 | return dateFormat.usDay.format(date) 30 | } 31 | 32 | export function dateToUSTime(rawDate: string | number): string { 33 | const date = new Date(rawDate) 34 | 35 | return dateFormat.usTime.format(date) 36 | } 37 | 38 | const ONE_DAY_MS = 1000 * 60 * 60 * 24 39 | export function dateToUSRelativeDays(rawDate: string | number): string { 40 | const date = new Date(rawDate) 41 | const time = date.getTime() 42 | const to = time - (time % ONE_DAY_MS) 43 | 44 | const now = Date.now() 45 | const today = now - (now % ONE_DAY_MS) 46 | 47 | const diff = to - today 48 | const days = Math.floor(diff / ONE_DAY_MS) 49 | 50 | if (days >= 2) { 51 | return dateToUSDay(rawDate) 52 | } 53 | 54 | return dateFormat.usRelativeDays.format(days, "day") 55 | } 56 | 57 | const numberFormat = { 58 | us: new Intl.NumberFormat("en-US"), 59 | } as const 60 | 61 | export function numberToUS(rawNumber: number): string { 62 | return numberFormat.us.format(rawNumber) 63 | } 64 | -------------------------------------------------------------------------------- /src/assets/js/lib/htmlToBlob.ts: -------------------------------------------------------------------------------- 1 | /* 2 | ** Core logic from https://github.com/raycast/ray-so/blob/0b7b5c6f8ae78c055b9d1a71fcefe5ed4c1173f6/app/(navigation)/(code)/lib/image.ts 3 | ** Many thanks to @raycast 4 | */ 5 | import { toBlob } from "html-to-image" 6 | 7 | const imageFilter = (node: HTMLElement) => node.tagName !== "TEXTAREA" && !node.dataset?.ignoreInExport 8 | 9 | const htmlToImageOptions = { 10 | filter: imageFilter, 11 | pixelRatio: 2, 12 | skipAutoScale: true, 13 | } 14 | 15 | type BlobOptions = Parameters[1] 16 | export async function htmlToBlob(node: HTMLElement, options?: BlobOptions): Promise { 17 | return toBlob(node, { 18 | ...htmlToImageOptions, 19 | ...options, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/assets/js/lib/lcFirst.ts: -------------------------------------------------------------------------------- 1 | export function lcFirst(str: string): string { 2 | return str.charAt(0).toLowerCase() + str.slice(1) 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/js/lib/plausible.ts: -------------------------------------------------------------------------------- 1 | import Plausible from "plausible-tracker" 2 | 3 | const isDev = import.meta.env.DEV 4 | const staging = "staging.lihbr.com" 5 | 6 | const plausible = Plausible( 7 | isDev 8 | ? { 9 | domain: staging, 10 | trackLocalhost: true, 11 | } 12 | : { 13 | apiHost: "/p7e", 14 | }, 15 | ) 16 | 17 | type Event< 18 | TType = string, 19 | TProps extends Record | void = void, 20 | > = TProps extends void 21 | ? { event: TType, props?: Record } 22 | : { 23 | event: TType 24 | props: TProps 25 | } 26 | 27 | type PageViewEvent = Event<"pageView"> 28 | type PageTime120Event = Event<"pageTime:120"> 29 | type OutboundLinkClickEvent = Event<"outboundLink:click", { url: string }> 30 | 31 | type TrackEventArgs = PageViewEvent | OutboundLinkClickEvent | PageTime120Event 32 | 33 | const MachineToHumanEventTypes: Record = { 34 | "pageView": "pageview", 35 | "pageTime:120": "Page time: 2 minutes", 36 | "outboundLink:click": "Outbound Link: Click", 37 | } 38 | 39 | export function trackEvent(args: TrackEventArgs): Promise { 40 | return new Promise((resolve) => { 41 | plausible.trackEvent(MachineToHumanEventTypes[args.event], { 42 | callback: resolve, 43 | props: args.props, 44 | }) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /src/assets/js/lib/prefersReducedMotion.ts: -------------------------------------------------------------------------------- 1 | export const prefersReducedMotion = !window.matchMedia( 2 | "(prefers-reduced-motion: no-preference)", 3 | ).matches 4 | -------------------------------------------------------------------------------- /src/assets/js/lib/tableSort.ts: -------------------------------------------------------------------------------- 1 | const SortDirection = { 2 | Ascending: "ascending", 3 | Descending: "descending", 4 | } as const 5 | type SortDirections = (typeof SortDirection)[keyof typeof SortDirection] 6 | 7 | const SortType = { 8 | ABC: "abc", 9 | 123: "123", 10 | } as const 11 | type SortTypes = (typeof SortType)[keyof typeof SortType] 12 | 13 | function sortBaseAlgorythm(a: T, b: T): number { 14 | return a === b ? 0 : (a ?? Number.NEGATIVE_INFINITY) > (b ?? Number.NEGATIVE_INFINITY) ? 1 : -1 15 | } 16 | 17 | const SortAlgorythm: Record< 18 | SortTypes, 19 | (a: HTMLTableCellElement, b: HTMLTableCellElement) => number 20 | > = { 21 | abc: (a, b) => { 22 | return sortBaseAlgorythm(a.textContent, b.textContent) 23 | }, 24 | 123: (a, b) => { 25 | const [_a, _b] = [ 26 | Number.parseFloat(a.textContent ?? ""), 27 | Number.parseFloat(b.textContent ?? ""), 28 | ] 29 | 30 | return sortBaseAlgorythm( 31 | Number.isNaN(_a) ? Number.NEGATIVE_INFINITY : _a, 32 | Number.isNaN(_b) ? Number.NEGATIVE_INFINITY : _b, 33 | ) 34 | }, 35 | } 36 | 37 | export function tableSort($table: HTMLTableElement): void { 38 | const $thead = $table.querySelector("thead") as HTMLTableSectionElement 39 | const $tbody = $table.querySelector("tbody") as HTMLTableSectionElement 40 | const $ths = $thead.querySelectorAll("th") 41 | 42 | $ths.forEach(($th, index) => { 43 | if (typeof $th.dataset.sortable === "string") { 44 | const $button = $th.querySelector("button") as HTMLButtonElement 45 | 46 | $button.addEventListener("click", () => { 47 | // Define direction 48 | const sortDirection: SortDirections = 49 | !$th.ariaSort || $th.ariaSort === SortDirection.Ascending 50 | ? SortDirection.Descending 51 | : SortDirection.Ascending 52 | 53 | // Define sort type 54 | const sortType: SortTypes = Object.values(SortType).includes( 55 | $th.dataset.sortable as SortTypes, 56 | ) 57 | ? ($th.dataset.sortable as SortTypes) 58 | : SortType.ABC 59 | 60 | // Sort rows 61 | Array.from($tbody.rows) 62 | .sort( 63 | (a, b) => 64 | SortAlgorythm[sortType](a.cells[index], b.cells[index]) * 65 | (sortDirection === SortDirection.Ascending ? 1 : -1), 66 | ) 67 | .forEach(($tr) => $tbody.appendChild($tr)) 68 | 69 | // Update `aria-sort` attributes 70 | $ths.forEach(($th, index2) => { 71 | if (index === index2) { 72 | $th.ariaSort = sortDirection 73 | } else { 74 | $th.ariaSort = null 75 | } 76 | }) 77 | }) 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/assets/js/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import { usePoliteViewTransition } from "./usePoliteViewTransition" 2 | 3 | function isDark(): boolean { 4 | return ( 5 | localStorage.theme === "dark" || 6 | (!("theme" in localStorage) && 7 | window.matchMedia("(prefers-color-scheme: dark)").matches) 8 | ) 9 | } 10 | 11 | export function applyTheme(): void { 12 | if (isDark()) { 13 | document.documentElement.classList.add("dark") 14 | } else { 15 | document.documentElement.classList.remove("dark") 16 | } 17 | } 18 | 19 | export function toggleTheme(event: Event): void { 20 | event.preventDefault() 21 | 22 | if (!isDark()) { 23 | localStorage.theme = "dark" 24 | } else { 25 | localStorage.theme = "light" 26 | } 27 | 28 | usePoliteViewTransition(applyTheme) 29 | } 30 | -------------------------------------------------------------------------------- /src/assets/js/lib/usePoliteViewTransition.ts: -------------------------------------------------------------------------------- 1 | import { prefersReducedMotion } from "./prefersReducedMotion" 2 | 3 | export async function usePoliteViewTransition(callback: () => unknown): Promise { 4 | if (prefersReducedMotion || !("startViewTransition" in document)) { 5 | callback() 6 | } else { 7 | const transition = document.startViewTransition(() => { 8 | callback() 9 | }) 10 | 11 | await transition.finished 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/js/records.ts: -------------------------------------------------------------------------------- 1 | import { tableSort } from "./lib/tableSort" 2 | import "./_base" 3 | 4 | const $table = document.querySelector("table.sort") as HTMLTableElement 5 | tableSort($table) 6 | -------------------------------------------------------------------------------- /src/assets/js/talks_conference_slug.ts: -------------------------------------------------------------------------------- 1 | import JSConfetti from "js-confetti" 2 | 3 | import { prefersReducedMotion } from "./lib/prefersReducedMotion" 4 | import "./_base" 5 | 6 | const referred = document.location.search.includes("source=conference") 7 | 8 | if (referred) { 9 | const $referred = document.querySelector("figure#referred") 10 | 11 | if ($referred) { 12 | $referred.classList.remove("hidden") 13 | 14 | if (!prefersReducedMotion) { 15 | const confetti = $referred.dataset.confetti 16 | if (confetti) { 17 | new JSConfetti().addConfetti({ 18 | emojis: confetti.split(","), 19 | emojiSize: 80, 20 | }) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/assets/noindex.robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /src/components/back.ts: -------------------------------------------------------------------------------- 1 | import { preferences } from "./preferences" 2 | 3 | export function back(args?: { 4 | to?: string 5 | withPreferences?: boolean 6 | class?: string 7 | }): string { 8 | return /* html */ ` 9 | ` 17 | } 18 | -------------------------------------------------------------------------------- /src/components/banner.ts: -------------------------------------------------------------------------------- 1 | export function banner(slot: string): string { 2 | return /* html */ `` 7 | } 8 | -------------------------------------------------------------------------------- /src/components/footer.ts: -------------------------------------------------------------------------------- 1 | import { NETLIFY } from "../akte/constants" 2 | import { heading } from "./heading" 3 | 4 | export function footer(): string { 5 | return /* html */ ` 6 |
    7 | ${heading("Footer", { as: "h2", class: "heading-2 !text-inherit" })} 8 |

    9 | This place is a collection of things I make, produce, or enjoy.
    10 | They are meant to be shared with friends, students, and digital people. 11 |

    12 |

    13 | Most of my work is open-source on GitHub
    14 | You can support it through GitHub Sponsors 15 |

    16 |

    17 | Read more and chat with me on Bluesky, Instagram, or Mastodon
    18 | To contact me, here's a mail and a contact page. 19 |

    20 |
    21 |

    22 | terms, privacy 23 |

    24 |

    25 | commit ref: ${NETLIFY.commitRefShort}
    31 | cc by-nc-sa 4.0 © 2020-present lucie haberer 32 |

    33 |
    ` 34 | } 35 | -------------------------------------------------------------------------------- /src/components/heading.ts: -------------------------------------------------------------------------------- 1 | import { slugify } from "../akte/slufigy" 2 | 3 | export function heading(slot: string, args: { as: string, class?: string }): string { 4 | const as = args.as 5 | const classes = args.class ? ` class="${args.class}"` : "" 6 | const id = slugify(slot) 7 | 8 | return /* html */ ` 9 | <${as}${classes} id="${id}"> 10 | ${slot} 15 | ` 16 | } 17 | -------------------------------------------------------------------------------- /src/components/nav.ts: -------------------------------------------------------------------------------- 1 | import { heading } from "./heading" 2 | import { preferences } from "./preferences" 3 | 4 | export function nav(args: { currentPath: string }): string { 5 | const navItem = (item: { href: string, label: string }): string => { 6 | const ariaCurrent = 7 | args.currentPath === item.href ? " aria-current=\"page\"" : "" 8 | 9 | return /* html */ ` 10 |
  • 11 | 15 | ${item.label} 16 | 17 |
  • ` 18 | } 19 | 20 | return /* html */ ` 21 | ` 34 | } 35 | -------------------------------------------------------------------------------- /src/components/notIndexed.ts: -------------------------------------------------------------------------------- 1 | import { banner } from "./banner" 2 | 3 | export function notIndexed(path: string, kind?: string): string { 4 | return banner(/* html */ `This ${kind || "page"} is private. It is not indexed on this website or by search engines
    5 | You can only access it through its direct link.`) 6 | } 7 | -------------------------------------------------------------------------------- /src/components/preferences.ts: -------------------------------------------------------------------------------- 1 | export function preferences(): string { 2 | return /* html */ ` 3 | 7 | ` 11 | } 12 | -------------------------------------------------------------------------------- /src/files/404.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | 5 | import { heading } from "../components/heading" 6 | 7 | import { minimal } from "../layouts/minimal" 8 | 9 | export const fourOFour = defineAkteFile().from({ 10 | path: "/404", 11 | render(context) { 12 | const slot = /* html */ ` 13 |
    14 | ${heading("Page Not Found", { as: "h1", class: "heading-1" })} 15 |

    16 | This page does not exists. 17 |

    18 |
    ` 19 | 20 | return minimal(slot, { path: context.path, title: "404" }) 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /src/files/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../akte/types" 2 | import * as prismic from "@prismicio/client" 3 | 4 | import { defineAkteFiles, NotFoundError } from "akte" 5 | import { dateToUSDate } from "../akte/date" 6 | import { asHTML, asyncAsHTML, getClient } from "../akte/prismic" 7 | 8 | import { heading } from "../components/heading" 9 | 10 | import { page } from "../layouts/page" 11 | 12 | export const slug = defineAkteFiles().from({ 13 | path: "/:slug", 14 | async data(context) { 15 | const doc = await getClient().getByUID( 16 | "post__document", 17 | context.params.slug, 18 | ) 19 | 20 | if (!doc) { 21 | throw new NotFoundError(context.path) 22 | } 23 | 24 | return doc 25 | }, 26 | async bulkData() { 27 | const docs = await getClient().getAllByType("post__document") 28 | 29 | const files: Record = {} 30 | for (const doc of docs) { 31 | if (!doc.url) { 32 | throw new Error( 33 | `Unable to resolve URL for document: ${JSON.stringify(doc)}`, 34 | ) 35 | } 36 | files[doc.url] = doc 37 | } 38 | 39 | return files 40 | }, 41 | async render(context) { 42 | const doc = context.data 43 | 44 | const title = prismic.asText(doc.data.title) || "unknown" 45 | const lead = asHTML(doc.data.lead) 46 | const body = await asyncAsHTML(doc.data.body) 47 | 48 | const pubDate = doc.last_publication_date 49 | 50 | const slot = /* html */ ` 51 |
    52 | ${heading(title, { as: "h1" })} 53 | ${lead} 54 |
    55 |
    56 | ${body} 57 |

    58 | Last updated: 59 |

    60 |
    ` 61 | 62 | const meta = { 63 | title: doc.data.meta_title, 64 | description: doc.data.meta_description, 65 | image: { openGraph: doc.data.meta_image?.url }, 66 | } 67 | 68 | return page(slot, { path: context.path, ...meta }) 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /src/files/albums/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData, PrismicImage } from "../../akte/types" 2 | 3 | import process from "node:process" 4 | import * as prismic from "@prismicio/client" 5 | import { defineAkteFiles, NotFoundError } from "akte" 6 | 7 | import { dateToUSDate } from "../../akte/date" 8 | import { getClient, getImagesWithJson } from "../../akte/prismic" 9 | import { sha256 } from "../../akte/sha256" 10 | 11 | import { heading } from "../../components/heading" 12 | import { notIndexed } from "../../components/notIndexed" 13 | 14 | import { minimal } from "../../layouts/minimal" 15 | 16 | export const slug = defineAkteFiles().from({ 17 | path: "/albums/:slugWithHash", 18 | async data(context) { 19 | const [hash, ...guls] = context.params.slugWithHash.split("-").reverse() 20 | const slug = guls.reverse().join("-") 21 | 22 | if (hash !== await sha256(slug, process.env.PRISMIC_TOKEN!, 7)) { 23 | throw new NotFoundError(context.path) 24 | } 25 | 26 | const [doc, pictures] = await Promise.all([ 27 | getClient().getByUID("post__album", slug), 28 | getImagesWithJson({ tag: slug }), 29 | ]) 30 | 31 | if (!doc || !pictures.length) { 32 | throw new NotFoundError(context.path) 33 | } 34 | 35 | return { doc, pictures } 36 | }, 37 | async bulkData() { 38 | const docs = await getClient().getAllByType("post__album") 39 | 40 | const files: Record = {} 41 | for (const doc of docs) { 42 | if (!doc.url) { 43 | throw new Error( 44 | `Unable to resolve URL for document: ${JSON.stringify(doc)}`, 45 | ) 46 | } 47 | files[`${doc.url}-${await sha256(doc.uid!, process.env.PRISMIC_TOKEN!, 7)}`] = { 48 | doc, 49 | pictures: await getImagesWithJson({ tag: doc.uid! }), 50 | } 51 | } 52 | 53 | return files 54 | }, 55 | async render(context) { 56 | const { doc, pictures } = context.data 57 | 58 | const title = prismic.asText(doc.data.title) || "unknown" 59 | const pubDate = doc.data.published_date 60 | 61 | const slot = /* html */ ` 62 | ${notIndexed(context.path, "album")} 63 |
    64 | ${heading(title, { as: "h1" })} 65 |
    66 |
    67 |
    Time
    68 |
    69 |
    70 |
    71 |
    Pictures
    72 |
    ${pictures.length}
    73 |
    74 |
    75 |
    76 |
    77 | ${pictures.map((picture) => { 78 | const src = prismic.asImageSrc(picture, { auto: ["format"], h: 800 }) 79 | const raw = prismic.asImageSrc(picture, { 80 | auto: ["format"], 81 | rect: undefined, 82 | w: undefined, 83 | h: undefined, 84 | }) 85 | 86 | return /* html */ ` 87 |
    88 | ${picture.alt || 89 |
    90 | 91 | View full size 92 | 93 |
    94 |
    95 | ` 96 | }).join("\n")} 97 |
    ` 98 | 99 | const metaImageBase = doc.data.meta_image?.url 100 | ? { url: doc.data.meta_image?.url?.split("?")[0] } 101 | : pictures[0] 102 | const mask = prismic.asImageSrc(metaImageBase, { auto: ["format"], h: 800, pad: 40, bg: "#fffefe" })! 103 | const metaImage = prismic.asImageSrc(metaImageBase, { 104 | auto: undefined, 105 | w: 1200, 106 | h: 630, 107 | fit: "crop", 108 | exp: -40, 109 | blur: 80, 110 | duotone: ["131010", "fffefe"], 111 | markW: 1080, 112 | markH: 510, 113 | markAlign: ["center", "middle"], 114 | mark: mask, 115 | })! 116 | 117 | const meta = { 118 | title: doc.data.meta_title || `${dateToUSDate(pubDate)} ${title}`, 119 | description: doc.data.meta_description, 120 | image: { openGraph: metaImage }, 121 | } 122 | 123 | return minimal(slot, { 124 | path: context.path, 125 | ...meta, 126 | noindex: true, 127 | backTo: "/albums", 128 | script: "/assets/js/albums_slug.ts", 129 | }) 130 | }, 131 | }) 132 | -------------------------------------------------------------------------------- /src/files/albums/index.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | 5 | import { heading } from "../../components/heading" 6 | 7 | import { minimal } from "../../layouts/minimal" 8 | 9 | export const index = defineAkteFile().from({ 10 | path: "/albums", 11 | render(context) { 12 | const slot = /* html */ ` 13 |
    14 | ${heading("Albums", { as: "h1", class: "heading-1" })} 15 |

    16 | Aligned with my interest in art, I'm into photography. Particularly street and landscape photography. 17 |

    18 |

    19 | Admitedly a recent hobby, I've been enjoying taking pictures with various cameras, from Y2K phones to modern mirrorless cameras. 20 |

    21 |

    22 | Albums are private. Find below the ones you've been granted access to -^ 23 |

    24 |
    25 |
    26 | ` 27 | 28 | return minimal(slot, { path: context.path, title: "Albums", script: "/assets/js/albums.ts" }) 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /src/files/art/index.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | import * as prismic from "@prismicio/client" 3 | 4 | import { defineAkteFile } from "akte" 5 | import { getClient } from "../../akte/prismic" 6 | 7 | import { heading } from "../../components/heading" 8 | 9 | import { page } from "../../layouts/page" 10 | 11 | export const index = defineAkteFile().from({ 12 | path: "/art", 13 | async data() { 14 | const arts = await getClient().getAllByType("post__art", { 15 | orderings: { 16 | field: "my.post__art.published_date", 17 | direction: "desc", 18 | }, 19 | }) 20 | 21 | return { arts } 22 | }, 23 | render(context) { 24 | const hero = /* html */ ` 25 |
    26 | ${heading("Art", { as: "h1", class: "heading-1" })} 27 |

    28 | I share now and then artists' work.
    29 | Their work is really inspiring to me. 30 |

    31 |

    32 | If you'd like an artist to be shared here, or if you're one, 33 | let me know~ 34 |

    35 |

    36 | Enjoy all entries below -^ 37 |

    38 |
    ` 39 | 40 | const arts = context.data.arts 41 | .map((art) => { 42 | const title = prismic.asText(art.data.title) || "unknown" 43 | const type = art.data.type 44 | const artist = { 45 | name: prismic.asText(art.data.credit_artist_name), 46 | link: prismic.asLink(art.data.credit_artist_link), 47 | } 48 | const submitter = { 49 | name: prismic.asText(art.data.credit_submitter_name), 50 | link: prismic.asLink(art.data.credit_submitter_link), 51 | } 52 | const image = { 53 | src: prismic.asImageSrc(art.data.picture, { auto: ["format"] }), 54 | alt: art.data.picture.alt || "", 55 | raw: prismic.asImageSrc(art.data.picture, { 56 | auto: ["format"], 57 | rect: undefined, 58 | w: undefined, 59 | h: undefined, 60 | }), 61 | } 62 | 63 | return /* html */ ` 64 |
    65 | ${heading(title, { as: "h2", class: "heading-2" })} 66 |

    67 | ${type} by ${ 70 | artist.name 71 | } 72 | ${ 73 | submitter.name 74 | ? /* html */ `
    75 | Submission by ${submitter.name}` 76 | : "" 77 | } 78 |

    79 |
    80 | ${image.alt} 81 |
    82 | 85 | View full size 86 | 87 |
    88 |
    89 |
    90 | ` 91 | }) 92 | .join("\n") 93 | 94 | return page([hero, arts].join("\n"), { path: context.path, title: "Art" }) 95 | }, 96 | }) 97 | -------------------------------------------------------------------------------- /src/files/art/rss.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | import * as prismic from "@prismicio/client" 3 | import { defineAkteFile } from "akte" 4 | 5 | import escapeHTML from "escape-html" 6 | import { 7 | NETLIFY, 8 | SITE_LANG, 9 | SITE_META_IMAGE, 10 | SITE_URL, 11 | } from "../../akte/constants" 12 | import { dateToISO } from "../../akte/date" 13 | import { getClient } from "../../akte/prismic" 14 | import { slugify } from "../../akte/slufigy" 15 | 16 | export const rss = defineAkteFile().from({ 17 | path: "/art/rss.xml", 18 | async data() { 19 | const arts = await getClient().getAllByType("post__art", { 20 | orderings: { 21 | field: "my.post__art.published_date", 22 | direction: "desc", 23 | }, 24 | }) 25 | 26 | return { arts } 27 | }, 28 | render(context) { 29 | const items = context.data.arts 30 | .map((art) => { 31 | const title = prismic.asText(art.data.title) || "unknown" 32 | const url = `${SITE_URL}/art#${slugify(title)}` 33 | 34 | const pubDate = art.data.published_date 35 | const artist = prismic.asText(art.data.credit_artist_name) || "unknown" 36 | const image = escapeHTML(prismic.asImageSrc(art.data.picture)) 37 | 38 | return /* xml */ ` 39 | <![CDATA[${title}]]> 40 | ${url} 41 | ${url} 42 | ${dateToISO(pubDate)} 43 | "${title}" by ${artist} is available, you can check it out here.]]> 44 | 45 | ` 46 | }) 47 | .join("\n") 48 | 49 | return /* xml */ ` 50 | 51 | 52 | <![CDATA[lihbr.com art]]> 53 | ${SITE_URL}/art/rss.xml 54 | 55 | ${dateToISO(NETLIFY.buildTime)} 56 | https://validator.w3.org/feed/docs/rss2.html 57 | ${SITE_LANG} 58 | 59 | <![CDATA[lihbr.com art]]> 60 | ${SITE_META_IMAGE.openGraph} 61 | ${SITE_URL}/art/rss.xml 62 | 63 | ${items} 64 | 65 | ` 66 | }, 67 | }) 68 | -------------------------------------------------------------------------------- /src/files/code.ts: -------------------------------------------------------------------------------- 1 | import type { Colors, ColorsData, GlobalData, Shades } from "../akte/types" 2 | 3 | import { farben } from "@lihbr/farben" 4 | import { defineAkteFile } from "akte" 5 | 6 | import { readDataJSON } from "../akte/data" 7 | import { heading } from "../components/heading" 8 | 9 | import { minimal } from "../layouts/minimal" 10 | 11 | export const code = defineAkteFile().from({ 12 | path: "/code", 13 | async data() { 14 | const { primary } = await readDataJSON("colors.json") 15 | 16 | return { colors: { primary, all: farben } } 17 | }, 18 | render(context) { 19 | const slot = /* html */ ` 20 |
    21 | ${heading("Code Images", { as: "h1", class: "heading-1" })} 22 |

    23 | Create beautiful lihbr images of your code. 24 |

    25 |
    26 |
    27 |
    28 |

    29 | Change padding to 30 | 31 | , 32 | 33 | , 34 | 35 | , or 36 | 37 | . 38 |

    39 |

    40 | Resize to 41 | 42 | , 43 | 44 | , or 45 | 46 | 47 |

    48 |

    49 |

    62 |
    63 |
    64 |
    65 |
    66 |
    67 |
    68 |
    69 |
    index.ts
    70 |
    71 |
    import { defineAkteApp } from "akte"
     72 | 
     73 | export const app = defineAkteApp({
     74 | 	files: [],
     75 | })
    76 |
    77 |
    78 |
    79 |
    80 |
    81 |
    
     82 | 
     83 | 
     84 | 
     85 | 										
    86 |
    87 |
    88 |
    89 |
    90 |
    91 |
    92 |
    93 |

    94 | or 95 | . 96 |

    97 |
    98 |
    99 | ` 100 | 101 | return minimal(slot, { path: context.path, title: "Code Images", script: "/assets/js/code.ts" }) 102 | }, 103 | }) 104 | -------------------------------------------------------------------------------- /src/files/colors.ts: -------------------------------------------------------------------------------- 1 | import type { ColorsData, GlobalData } from "../akte/types" 2 | 3 | import { farben } from "@lihbr/farben" 4 | import { defineAkteFile } from "akte" 5 | import { readDataJSON } from "../akte/data" 6 | import { getPrettyContrastRatio } from "../akte/getPrettyContrastRatio" 7 | 8 | import { heading } from "../components/heading" 9 | 10 | import { page } from "../layouts/page" 11 | 12 | export const colors = defineAkteFile().from({ 13 | path: "/colors", 14 | async data() { 15 | const { primary } = await readDataJSON("colors.json") 16 | 17 | return { colors: { primary, all: farben } } 18 | }, 19 | render(context) { 20 | const hero = /* html */ ` 21 |
    22 | ${heading("Colors", { as: "h1", class: "heading-1" })} 23 |

    24 | While far from perfect, here are the colors of lihbr. 25 |

    26 |
    ` 27 | 28 | const colors = Object.entries(context.data.colors.all) 29 | .map(([color, shades]) => { 30 | const theme = ["slate", "cream"].includes(color) ? "neutral" : color 31 | 32 | const formattedShades = Object.entries(shades) 33 | .map(([shade, value]) => { 34 | return /* html */ ` 35 |
  • 36 |
    37 | 38 | 39 | 40 |
    41 |
    42 |
    Shade
    43 |
    ${shade}
    44 |
    Value
    45 |
    46 | 49 |
    50 |
    Contrast ratio
    51 |
    52 | 53 | ${getPrettyContrastRatio(context.data.colors.all.cream["900"], value)}:1 54 | 55 | 58 |
    59 |
    60 |
  • ` 61 | }) 62 | .join("\n") 63 | 64 | return /* html */ ` 65 |
    66 | ${heading(color, { as: "h2", class: "heading-2" })} 67 |
      68 | ${formattedShades} 69 |
    70 |
    ` 71 | }) 72 | .join("\n") 73 | 74 | return page([hero, colors].join("\n"), { 75 | path: context.path, 76 | title: "Colors", 77 | script: "/assets/js/colors.ts", 78 | }) 79 | }, 80 | }) 81 | -------------------------------------------------------------------------------- /src/files/index.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData, ProjectData, TalkData } from "../akte/types" 2 | import * as prismic from "@prismicio/client" 3 | 4 | import { defineAkteFile } from "akte" 5 | import { readAllDataJSON } from "../akte/data" 6 | import { dateToUSDate } from "../akte/date" 7 | import { getClient } from "../akte/prismic" 8 | 9 | import { banner } from "../components/banner" 10 | import { footer } from "../components/footer" 11 | import { heading } from "../components/heading" 12 | 13 | import { nav } from "../components/nav" 14 | import { base } from "../layouts/base" 15 | 16 | export const index = defineAkteFile().from({ 17 | path: "/", 18 | async data() { 19 | const promises = [ 20 | readAllDataJSON({ type: "talks" }), 21 | getClient().getAllByType("post__blog", { 22 | orderings: { 23 | field: "my.post__blog.published_date", 24 | direction: "desc", 25 | }, 26 | }), 27 | readAllDataJSON({ type: "projects" }), 28 | ] as const 29 | 30 | const [talks, posts, projects] = await Promise.all(promises) 31 | 32 | return { 33 | talks: Object.values(talks) 34 | .filter((talk) => !talk.hidden) 35 | .sort((a, b) => b.date.localeCompare(a.date)), 36 | posts, 37 | projects, 38 | } 39 | }, 40 | render(context) { 41 | const announcement = banner(/* html */ `lucie->tokyo, my photography exhibition from Japan is live! Check it out now`) 42 | 43 | const hero = /* html */ ` 44 |
    45 | ${heading("Hi! Lucie here", { as: "h1", class: "heading-0" })} 46 |

    47 | I'm an internet citizen, currently working as a 48 | DevExp Engineer @prismic.io 49 |
    50 | I'm also a contributor and team member @nuxt.com, 51 | enjoying open-source overall. 52 |
    53 | Learn more about me and this place on the about page, 54 | or appreciate the following~ 55 |

    56 |
    ` 57 | 58 | const talks = /* html */ ` 59 |
    60 | ${heading("Talks", { as: "h2", class: "heading-2" })} 61 |

    62 | I really enjoy speaking at conferences.
    63 | I'm thankful for them to have me. 64 |

    65 |

    66 | Check out my past talks for resources, slides, and more -^ 67 |

    68 |
      69 | ${context.data.talks 70 | .map((talk) => { 71 | return /* html */ ` 72 |
    • 73 | 76 | 79 | ${talk.title} 80 | 81 |
    • ` 82 | }) 83 | .join("\n")} 84 |
    85 |
    ` 86 | 87 | const projects = /* html */ ` 88 |
    89 | ${heading("Projects", { as: "h2", class: "heading-2" })} 90 |

    91 | I create an maintain different code projects during my free time. 92 |

    93 |

    94 | Here's a list of them, dates refer to first release -^ 95 |

    96 |
      97 | ${Object.values(context.data.projects) 98 | .reverse() 99 | .map((project) => { 100 | return /* html */ ` 101 |
    • 102 | 105 | 108 | ${project.title} 109 | 110 |
    • ` 111 | }) 112 | .join("\n")} 113 |
    114 |
    ` 115 | 116 | const art = /* html */ ` 117 |
    118 | ${heading("Art", { as: "h2", class: "heading-2" })} 119 |

    120 | I share now and then artists' work.
    121 | Their work is really inspiring to me. 122 |

    123 |

    124 | If you'd like an artist to be shared here, or if you're one, 125 | let me know~ 126 |

    127 |

    128 | Your can browse all entries there -> 129 |

    130 |
    ` 131 | 132 | const formattedPosts = context.data.posts.map((post) => { 133 | return /* html */ ` 134 |
  • 135 | 138 | 141 | ${prismic.asText(post.data.title)} 142 | 143 |
  • ` 144 | }) 145 | 146 | const posts = /* html */ ` 147 |
    148 | ${heading("Posts", { as: "h2", class: "heading-2" })} 149 |

    150 | I don't really write anymore.
    151 | I went through tech stuff, tutorials, and experience feedback. 152 |

    153 |

    154 | Here's an archive of my old posts -^ 155 |

    156 |
      157 | ${formattedPosts.join("\n")} 158 |
    159 |
    ` 160 | 161 | return base( 162 | [ 163 | announcement, 164 | hero, 165 | nav({ currentPath: context.path }), 166 | talks, 167 | projects, 168 | art, 169 | posts, 170 | footer(), 171 | ].join("\n"), 172 | { path: context.path, script: "/assets/js/index.ts" }, 173 | ) 174 | }, 175 | }) 176 | -------------------------------------------------------------------------------- /src/files/meteo.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | 5 | import { heading } from "../components/heading" 6 | import { minimal } from "../layouts/minimal" 7 | 8 | export const meteo = defineAkteFile().from({ 9 | path: "/meteo", 10 | render(context) { 11 | const hero = /* html */ ` 12 |
    13 | ${heading("Meteo", { as: "h1", class: "heading-1" })} 14 |

    15 | Meteo around you. 16 |

    17 | 18 |
    19 | 20 | 29 | 30 |

    Or ->

    31 |
    32 |
    33 |
    ` 34 | 35 | const forecast = /* html */ `
    ` 36 | 37 | return minimal( 38 | [hero, forecast].join("\n"), 39 | { path: context.path, title: "Meteo", script: "/assets/js/meteo.ts" }, 40 | ) 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /src/files/notes/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData, NoteData } from "../../akte/types" 2 | 3 | import { defineAkteFiles } from "akte" 4 | import { readAllDataHTML } from "../../akte/data" 5 | import { dateToUSDate } from "../../akte/date" 6 | import { slugify } from "../../akte/slufigy" 7 | 8 | import { heading } from "../../components/heading" 9 | 10 | import { minimal } from "../../layouts/minimal" 11 | 12 | export const slug = defineAkteFiles().from({ 13 | path: "/notes/:slug", 14 | async bulkData() { 15 | const notes = await readAllDataHTML<{ 16 | first_publication_date: string 17 | last_publication_date: string 18 | }>({ type: "notes" }) 19 | 20 | const files: Record = {} 21 | for (const path in notes) { 22 | const title = path.split("/").pop()!.replace(".md", "") 23 | const data: NoteData = { 24 | ...notes[path].matter, 25 | title, 26 | body: notes[path].html, 27 | links: { 28 | outbound: notes[path].links.outbound, 29 | inbound: {}, 30 | }, 31 | } 32 | 33 | files[`/notes/${slugify(title)}`] = data 34 | } 35 | 36 | // Compute inbound links 37 | for (const path in files) { 38 | const file = files[path] 39 | for (const outboundLink of file.links.outbound) { 40 | if (outboundLink in files) { 41 | files[outboundLink].links.inbound[path] = { 42 | path, 43 | title: file.title, 44 | first_publication_date: file.first_publication_date, 45 | } 46 | } 47 | } 48 | } 49 | 50 | return files 51 | }, 52 | async render(context) { 53 | const note = context.data 54 | 55 | const dates = [] 56 | dates.push( 57 | /* html */ `First published: `, 60 | ) 61 | if (note.first_publication_date !== note.last_publication_date) { 62 | dates.push( 63 | /* html */ `Last updated: `, 66 | ) 67 | } 68 | 69 | const body = /* html */ ` 70 |
    71 | ${heading(note.title, { as: "h1" })} 72 | ${note.body} 73 |

    ${dates.join("
    \n")}

    74 |
    ` 75 | 76 | const inboundNotes = Object.values(note.links.inbound).sort( 77 | (note1, note2) => 78 | note2.first_publication_date.localeCompare( 79 | note1.first_publication_date, 80 | ), 81 | ) 82 | 83 | const links = inboundNotes.length 84 | ? /* html */ ` 85 | ` 103 | : null 104 | 105 | return minimal([body, links].filter(Boolean).join("\n"), { 106 | path: context.path, 107 | title: note.title, 108 | }) 109 | }, 110 | }) 111 | -------------------------------------------------------------------------------- /src/files/notes/rss.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | import { 5 | NETLIFY, 6 | SITE_LANG, 7 | SITE_META_IMAGE, 8 | SITE_URL, 9 | } from "../../akte/constants" 10 | import { readAllDataHTML } from "../../akte/data" 11 | import { dateToISO } from "../../akte/date" 12 | import { slugify } from "../../akte/slufigy" 13 | 14 | export const rss = defineAkteFile().from({ 15 | path: "/notes/rss.xml", 16 | async data() { 17 | const notes = await readAllDataHTML<{ 18 | first_publication_date: string 19 | last_publication_date: string 20 | }>({ type: "notes" }) 21 | 22 | return { 23 | notes: Object.keys(notes) 24 | .map((path) => { 25 | const title = path.split("/").pop()!.replace(".md", "") 26 | 27 | return { 28 | first_publication_date: notes[path].matter.first_publication_date, 29 | slug: slugify(title), 30 | title, 31 | } 32 | }) 33 | .sort((note1, note2) => 34 | note2.first_publication_date.localeCompare( 35 | note1.first_publication_date, 36 | ), 37 | ), 38 | } 39 | }, 40 | render(context) { 41 | const items = context.data.notes 42 | .map((note) => { 43 | const url = `${SITE_URL}/notes/${note.slug}` 44 | 45 | const title = note.title 46 | 47 | const pubDate = note.first_publication_date 48 | 49 | return /* xml */ ` 50 | <![CDATA[${title}]]> 51 | ${url} 52 | ${url} 53 | ${dateToISO(pubDate)} 54 | "${title}" is available, you can check it out here.]]> 55 | ` 56 | }) 57 | .join("\n") 58 | 59 | return /* xml */ ` 60 | 61 | 62 | <![CDATA[lihbr.com notes]]> 63 | ${SITE_URL}/notes/rss.xml 64 | 65 | ${dateToISO(NETLIFY.buildTime)} 66 | https://validator.w3.org/feed/docs/rss2.html 67 | ${SITE_LANG} 68 | 69 | <![CDATA[lihbr.com notes]]> 70 | ${SITE_META_IMAGE.openGraph} 71 | ${SITE_URL}/notes/rss.xml 72 | 73 | ${items} 74 | 75 | 76 | ` 77 | }, 78 | }) 79 | -------------------------------------------------------------------------------- /src/files/posts/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | import * as prismic from "@prismicio/client" 3 | import { defineAkteFiles, NotFoundError } from "akte" 4 | 5 | import escapeHTML from "escape-html" 6 | import { 7 | SITE_MAIN_AUTHOR, 8 | SITE_META_IMAGE, 9 | SITE_TITLE, 10 | SITE_URL, 11 | } from "../../akte/constants" 12 | import { dateToUSDate } from "../../akte/date" 13 | import { asyncAsHTML, getClient } from "../../akte/prismic" 14 | 15 | import { heading } from "../../components/heading" 16 | 17 | import { page } from "../../layouts/page" 18 | 19 | export const slug = defineAkteFiles().from({ 20 | path: "/posts/:slug", 21 | async data(context) { 22 | const post = await getClient().getByUID("post__blog", context.params.slug) 23 | 24 | if (!post) { 25 | throw new NotFoundError(context.path) 26 | } 27 | 28 | return post 29 | }, 30 | async bulkData() { 31 | const posts = await getClient().getAllByType("post__blog") 32 | 33 | const files: Record = {} 34 | for (const post of posts) { 35 | if (!post.url) { 36 | throw new Error( 37 | `Unable to resolve URL for document: ${JSON.stringify(post)}`, 38 | ) 39 | } 40 | files[post.url] = post 41 | } 42 | 43 | return files 44 | }, 45 | async render(context) { 46 | const post = context.data 47 | 48 | const title = prismic.asText(post.data.title) || "unknown" 49 | const lead = prismic.asText(post.data.lead) 50 | const body = await asyncAsHTML(post.data.body) 51 | 52 | const pubDate = post.data.published_date 53 | const category = post.data.category 54 | const thumbnail = prismic.asImageSrc(post.data.thumbnail, { 55 | rect: undefined, 56 | w: undefined, 57 | h: undefined, 58 | }) 59 | 60 | const slot = /* html */ ` 61 |
    62 | ${heading(title, { as: "h1", class: "heading-1" })} 63 |

    ${lead}

    64 |
    65 |
    66 |
    Time
    67 |
    68 |
    69 |
    70 |
    Category
    71 |
    ${category}
    72 |
    73 |
    74 |
    Thumbnail
    75 |
    76 |
    77 |
    78 | 79 | View full size 80 | 81 |
    82 |
    83 |
    84 |
    85 |
    86 |
    87 |
    88 | ${body} 89 |
    ` 90 | 91 | const meta = { 92 | title: post.data.meta_title, 93 | description: post.data.meta_description, 94 | image: { openGraph: post.data.meta_image?.url }, 95 | structuredData: [ 96 | { 97 | "@context": "http://schema.org", 98 | "@type": "BlogPosting", 99 | 100 | "mainEntityOfPage": { 101 | "@type": "WebSite", 102 | "@id": escapeHTML(SITE_URL), 103 | }, 104 | 105 | "url": escapeHTML(`${SITE_URL}${post.url}`), 106 | "name": escapeHTML(title), 107 | "alternateName": escapeHTML(SITE_TITLE), 108 | "headline": escapeHTML(title), 109 | "image": escapeHTML( 110 | post.data.meta_image?.url ?? SITE_META_IMAGE.openGraph, 111 | ), 112 | "description": escapeHTML(lead), 113 | "datePublished": escapeHTML(pubDate), 114 | "dateModified": escapeHTML(post.last_publication_date), 115 | 116 | "author": { 117 | "@type": "Person", 118 | "name": escapeHTML(SITE_MAIN_AUTHOR), 119 | }, 120 | 121 | "publisher": { 122 | "@type": "Organization", 123 | "url": escapeHTML(SITE_URL), 124 | "logo": { 125 | "@type": "ImageObject", 126 | "url": escapeHTML(SITE_META_IMAGE.openGraph), 127 | }, 128 | "name": escapeHTML(SITE_TITLE), 129 | }, 130 | }, 131 | ], 132 | } 133 | 134 | return page(slot, { path: context.path, ...meta }) 135 | }, 136 | }) 137 | -------------------------------------------------------------------------------- /src/files/posts/rss.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | import * as prismic from "@prismicio/client" 3 | import { defineAkteFile } from "akte" 4 | 5 | import escapeHTML from "escape-html" 6 | import { 7 | NETLIFY, 8 | SITE_LANG, 9 | SITE_META_IMAGE, 10 | SITE_URL, 11 | } from "../../akte/constants" 12 | import { dateToISO } from "../../akte/date" 13 | import { getClient } from "../../akte/prismic" 14 | 15 | export const rss = defineAkteFile().from({ 16 | path: "/posts/rss.xml", 17 | async data() { 18 | const posts = await getClient().getAllByType("post__blog", { 19 | orderings: { 20 | field: "my.post__blog.published_date", 21 | direction: "desc", 22 | }, 23 | }) 24 | 25 | return { posts } 26 | }, 27 | render(context) { 28 | const items = context.data.posts 29 | .map((post) => { 30 | const url = `${SITE_URL}${post.url}` 31 | 32 | const title = prismic.asText(post.data.title) 33 | const lead = prismic.asText(post.data.lead) 34 | 35 | const pubDate = post.data.published_date 36 | const thumbnail = escapeHTML(prismic.asImageSrc(post.data.meta_image)) 37 | 38 | return /* xml */ ` 39 | <![CDATA[${title}]]> 40 | ${url} 41 | ${url} 42 | ${dateToISO(pubDate)} 43 | "${title}" is available, you can check it out here.
    ${lead}]]>
    44 | 45 |
    ` 46 | }) 47 | .join("\n") 48 | 49 | return /* xml */ ` 50 | 51 | 52 | <![CDATA[lihbr.com posts]]> 53 | ${SITE_URL}/posts/rss.xml 54 | 55 | ${dateToISO(NETLIFY.buildTime)} 56 | https://validator.w3.org/feed/docs/rss2.html 57 | ${SITE_LANG} 58 | 59 | <![CDATA[lihbr.com posts]]> 60 | ${SITE_META_IMAGE.openGraph} 61 | ${SITE_URL}/posts/rss.xml 62 | 63 | ${items} 64 | 65 | 66 | ` 67 | }, 68 | }) 69 | -------------------------------------------------------------------------------- /src/files/preview.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | 5 | import { heading } from "../components/heading" 6 | 7 | import { minimal } from "../layouts/minimal" 8 | 9 | export const preview = defineAkteFile().from({ 10 | path: "/preview", 11 | render(context) { 12 | const slot = /* html */ ` 13 |
    14 | ${heading("Loading Preview", { as: "h1", class: "heading-1" })} 15 |

    16 | Hang on a little while... 17 |

    18 |
    ` 19 | 20 | return minimal(slot, { path: context.path, title: "Loading Preview" }) 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /src/files/private/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | 3 | import process from "node:process" 4 | import * as prismic from "@prismicio/client" 5 | 6 | import { defineAkteFiles, NotFoundError } from "akte" 7 | import { dateToUSDate } from "../../akte/date" 8 | import { asHTML, asyncAsHTML, getClient } from "../../akte/prismic" 9 | import { sha256 } from "../../akte/sha256" 10 | 11 | import { heading } from "../../components/heading" 12 | import { notIndexed } from "../../components/notIndexed" 13 | 14 | import { minimal } from "../../layouts/minimal" 15 | 16 | export const slug = defineAkteFiles().from({ 17 | path: "/private/:slugWithHash", 18 | async data(context) { 19 | const [hash, ...guls] = context.params.slugWithHash.split("-").reverse() 20 | const slug = guls.reverse().join("-") 21 | 22 | if (hash !== await sha256(slug, process.env.PRISMIC_TOKEN!, 7)) { 23 | throw new NotFoundError(context.path) 24 | } 25 | 26 | const doc = await getClient().getByUID("post__document--private", slug) 27 | 28 | if (!doc) { 29 | throw new NotFoundError(context.path) 30 | } 31 | 32 | return doc 33 | }, 34 | async bulkData() { 35 | const docs = await getClient().getAllByType("post__document--private") 36 | 37 | const files: Record = {} 38 | for (const doc of docs) { 39 | if (!doc.url) { 40 | throw new Error( 41 | `Unable to resolve URL for document: ${JSON.stringify(doc)}`, 42 | ) 43 | } 44 | files[`${doc.url}-${await sha256(doc.uid!, process.env.PRISMIC_TOKEN!, 7)}`] = doc 45 | } 46 | 47 | return files 48 | }, 49 | async render(context) { 50 | const doc = context.data 51 | 52 | const title = prismic.asText(doc.data.title) || "unknown" 53 | const lead = asHTML(doc.data.lead) 54 | const body = await asyncAsHTML(doc.data.body) 55 | 56 | const pubDate = doc.last_publication_date 57 | 58 | const slot = /* html */ ` 59 | ${notIndexed(context.path)} 60 |
    61 | ${heading(title, { as: "h1" })} 62 | ${lead} 63 |
    64 |
    65 | ${body} 66 |

    67 | Last updated: 68 |

    69 |
    ` 70 | 71 | const meta = { 72 | title: doc.data.meta_title, 73 | description: doc.data.meta_description, 74 | image: { openGraph: doc.data.meta_image?.url }, 75 | } 76 | 77 | return minimal(slot, { path: context.path, ...meta, noindex: true }) 78 | }, 79 | }) 80 | -------------------------------------------------------------------------------- /src/files/records.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | import { getAllReleasesSafely } from "../akte/discogs" 5 | 6 | import { heading } from "../components/heading" 7 | 8 | import { page } from "../layouts/page" 9 | 10 | export const records = defineAkteFile().from({ 11 | path: "/records", 12 | async data() { 13 | const releases = await getAllReleasesSafely() 14 | 15 | return { releases } 16 | }, 17 | render(context) { 18 | const hero = /* html */ ` 19 |
    20 | ${heading("Records", { as: "h1", class: "heading-1" })} 21 |

    22 | I collect vinyl records of artists I like.
    23 | Here's my humble collection. 24 |

    25 |
    ` 26 | 27 | const desktop = /* html */ ` 28 | ` 86 | 87 | const mobile = /* html */ ` 88 |
      89 | ${context.data.releases 90 | .map((release) => { 91 | const id = release.basic_information.id 92 | const image = release.basic_information.thumb 93 | const title = release.basic_information.title 94 | const artist = release.basic_information.artists[0].name.replace( 95 | /\(\d+\)$/, 96 | "", 97 | ) 98 | const genre = release.basic_information.genres.sort().join(", ") 99 | const year = release.basic_information.year 100 | 101 | return /* html */ ` 102 |
    • 103 | ${title}'s cover 109 |
      110 |
      111 |
      Title - Artist
      112 |
      ${title} - ${artist}
      113 |
      114 |
      115 |
      Genre - Year
      116 |
      117 | ${genre} - ${year ? `` : "n/a"} 118 |
      119 |
      120 |
      121 | 122 | Discogs 123 |
      124 |
      125 |
    • ` 126 | }) 127 | .join("\n")} 128 |
    ` 129 | 130 | return page([hero, desktop, mobile].join("\n"), { 131 | path: context.path, 132 | title: "Records", 133 | script: "/assets/js/records.ts", 134 | }) 135 | }, 136 | }) 137 | -------------------------------------------------------------------------------- /src/files/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type * as prismic from "@prismicio/client" 2 | import type { GlobalData, TalkData } from "../akte/types" 3 | 4 | import { defineAkteFile } from "akte" 5 | import { NETLIFY, SITE_URL } from "../akte/constants" 6 | import { readAllDataHTML, readAllDataJSON } from "../akte/data" 7 | import { dateToISO } from "../akte/date" 8 | import { getClient } from "../akte/prismic" 9 | import { slugify } from "../akte/slufigy" 10 | 11 | export const sitemap = defineAkteFile().from({ 12 | path: "/sitemap.xml", 13 | async data() { 14 | const mapPrismicDocuments = ( 15 | docs: prismic.PrismicDocument[], 16 | ): { loc: string, lastMod: string | number }[] => { 17 | return docs.map((doc) => { 18 | return { 19 | loc: `${SITE_URL}${doc.url}`, 20 | lastMod: doc.last_publication_date || NETLIFY.buildTime, 21 | } 22 | }) 23 | } 24 | 25 | const promises = [ 26 | readAllDataJSON({ type: "talks" }), 27 | readAllDataHTML<{ 28 | first_publication_date: string 29 | last_publication_date: string 30 | }>({ type: "notes" }), 31 | getClient().getAllByType("post__blog"), 32 | getClient().getAllByType("post__document"), 33 | ] as const 34 | 35 | const [talks, notes, posts, documents] = await Promise.all(promises) 36 | 37 | return { 38 | pages: [ 39 | { loc: SITE_URL, lastMod: NETLIFY.buildTime }, 40 | { loc: `${SITE_URL}/colors`, lastMod: NETLIFY.buildTime }, 41 | { loc: `${SITE_URL}/records`, lastMod: NETLIFY.buildTime }, 42 | { loc: `${SITE_URL}/meteo`, lastMod: NETLIFY.buildTime }, 43 | { loc: `${SITE_URL}/code`, lastMod: NETLIFY.buildTime }, 44 | { loc: `${SITE_URL}/art`, lastMod: NETLIFY.buildTime }, 45 | { loc: `${SITE_URL}/albums`, lastMod: NETLIFY.buildTime }, 46 | { loc: `${SITE_URL}/talks/poll`, lastMod: NETLIFY.buildTime }, 47 | ...mapPrismicDocuments(posts), 48 | ...mapPrismicDocuments(documents), 49 | ...Object.values(talks).map((talk) => { 50 | return { 51 | loc: `${SITE_URL}/talks/${talk.conference.slug}/${talk.slug}`, 52 | lastMod: NETLIFY.buildTime, 53 | } 54 | }), 55 | ...Object.keys(notes).map((path) => { 56 | const title = path.split("/").pop()!.replace(".md", "") 57 | 58 | return { 59 | loc: `${SITE_URL}/notes/${slugify(title)}`, 60 | lastMod: notes[path].matter.last_publication_date, 61 | } 62 | }), 63 | ], 64 | } 65 | }, 66 | render(context) { 67 | const urls = context.data.pages 68 | .map((page) => { 69 | return /* xml */ ` 70 | ${page.loc} 71 | ${dateToISO(page.lastMod)} 72 | ` 73 | }) 74 | .join("\n") 75 | 76 | return /* xml */ ` 77 | 78 | ${urls} 79 | 80 | ` 81 | }, 82 | }) 83 | -------------------------------------------------------------------------------- /src/files/talks/[conference]/[slug].ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData, TalkData } from "../../../akte/types" 2 | 3 | import { defineAkteFiles } from "akte" 4 | import { readAllDataJSON } from "../../../akte/data" 5 | import { dateToUSDate } from "../../../akte/date" 6 | 7 | import { heading } from "../../../components/heading" 8 | 9 | import { page } from "../../../layouts/page" 10 | 11 | export const slug = defineAkteFiles().from({ 12 | path: "/talks/:conference/:slug", 13 | async bulkData() { 14 | const talks = await readAllDataJSON({ type: "talks" }) 15 | 16 | const files: Record = {} 17 | for (const talk of Object.values(talks)) { 18 | files[`/talks/${talk.conference.slug}/${talk.slug}`] = talk 19 | } 20 | 21 | return files 22 | }, 23 | async render(context) { 24 | const talk = context.data 25 | 26 | const marquee = /* html */ `` 29 | 30 | const hero = /* html */ ` 31 |
    32 | ${heading(talk.title, { as: "h1", class: "heading-1" })} 33 |

    ${talk.lead}

    34 |
    35 |
    36 |
    Time
    37 |
    38 |
    39 |
    40 |
    Duration
    41 |
    ${talk.durationMinutes}-minute
    42 |
    43 |
    44 |
    Conference
    45 |
    46 | 49 | ${talk.conference.name} 50 | 51 |
    52 |
    53 |
    54 |
    ` 55 | 56 | const links = /* html */ ` 57 |
    58 | ${heading("Resources", { as: "h2", class: "heading-2" })} 59 |
      60 | ${talk.links 61 | .map((link) => { 62 | return /* html */ ` 63 |
    • 64 | 65 | ${link.name} 66 | 67 |
    • ` 68 | }) 69 | .join("\n")} 70 |
    71 |
    ` 72 | 73 | const feedback = /* html */ ` 74 |
    75 | ${heading("Any feedback? Drop me a line~", { as: "h2", class: "heading-2" })} 76 |

    77 | I'd love to hear your thoughts, whether about my talk or anything else on your mind! You can reach out to me on any of the following platforms. 78 |

    79 | 90 |
    ` 91 | 92 | const meta = { 93 | title: talk.title, 94 | description: `Resources from my talk during ${talk.conference.name}`, 95 | } 96 | 97 | return page([marquee, hero, links, feedback].join("\n"), { 98 | path: context.path, 99 | ...meta, 100 | script: "/assets/js/talks_conference_slug.ts", 101 | }) 102 | }, 103 | }) 104 | -------------------------------------------------------------------------------- /src/files/talks/poll.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | 5 | import { heading } from "../../components/heading" 6 | 7 | import { minimal } from "../../layouts/minimal" 8 | 9 | export const poll = defineAkteFile().from({ 10 | path: "/talks/poll", 11 | render(context) { 12 | const slot = /* html */ ` 13 |
    14 | ${heading("Poll", { as: "h1", class: "heading-1" })} 15 |

    16 | Thanks for voting, your answer has been recorded. 17 |

    18 |

    19 | Other questions might be up soon, stay tuned! 20 |

    21 |
    ` 22 | 23 | return minimal(slot, { path: context.path, title: "Poll" }) 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /src/files/talks/rss.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData, TalkData } from "../../akte/types" 2 | 3 | import { defineAkteFile } from "akte" 4 | import { 5 | NETLIFY, 6 | SITE_LANG, 7 | SITE_META_IMAGE, 8 | SITE_URL, 9 | } from "../../akte/constants" 10 | import { readAllDataJSON } from "../../akte/data" 11 | import { dateToISO } from "../../akte/date" 12 | 13 | export const rss = defineAkteFile().from({ 14 | path: "/talks/rss.xml", 15 | async data() { 16 | const talks = await readAllDataJSON({ type: "talks" }) 17 | 18 | return { talks: Object.values(talks).reverse() } 19 | }, 20 | render(context) { 21 | const items = context.data.talks 22 | .map((talk) => { 23 | const url = `${SITE_URL}/talks/${talk.conference.slug}/${talk.slug}` 24 | 25 | const title = talk.title 26 | const lead = talk.lead 27 | 28 | const pubDate = talk.date 29 | 30 | return /* xml */ ` 31 | <![CDATA[${title}]]> 32 | ${url} 33 | ${url} 34 | ${dateToISO(pubDate)} 35 | "${title}" is available, you can check it out here.
    ${lead}]]>
    36 |
    ` 37 | }) 38 | .join("\n") 39 | 40 | return /* xml */ ` 41 | 42 | 43 | <![CDATA[lihbr.com talks]]> 44 | ${SITE_URL}/talks/rss.xml 45 | 46 | ${dateToISO(NETLIFY.buildTime)} 47 | https://validator.w3.org/feed/docs/rss2.html 48 | ${SITE_LANG} 49 | 50 | <![CDATA[lihbr.com talks]]> 51 | ${SITE_META_IMAGE.openGraph} 52 | ${SITE_URL}/talks/rss.xml 53 | 54 | ${items} 55 | 56 | 57 | ` 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /src/functions.server.ts: -------------------------------------------------------------------------------- 1 | import type { Handler, HandlerContext, HandlerEvent } from "@netlify/functions" 2 | import { 3 | appendResponseHeaders, 4 | createApp, 5 | createRouter, 6 | defineEventHandler, 7 | getHeaders, 8 | getQuery, 9 | getRequestURL, 10 | readRawBody, 11 | sendRedirect, 12 | setResponseStatus, 13 | } from "h3" 14 | 15 | import { handler as admin } from "./functions/admin" 16 | import { handler as hr } from "./functions/hr" 17 | import { handler as poll } from "./functions/poll" 18 | import { handler as pollKeepalive } from "./functions/poll-keepalive" 19 | import { handler as preview } from "./functions/preview" 20 | 21 | import "dotenv/config" 22 | 23 | const app = createApp() 24 | const router = createRouter() 25 | app.use(router) 26 | 27 | function serve(handler: Handler) { 28 | return defineEventHandler(async (event) => { 29 | const url = getRequestURL(event) 30 | const headers = getHeaders(event) 31 | const rawQuery = getQuery(event) 32 | const [query, multiQuery] = Object.entries(rawQuery).reduce<[Record, Record]>((acc, [key, value]) => { 33 | if (Array.isArray(value)) { 34 | acc[1][key] = value.map((v) => `${v}`) 35 | } else { 36 | acc[0][key] = `${value}` 37 | } 38 | 39 | return acc 40 | }, [{}, {}]) 41 | const body = event.method !== "GET" ? await readRawBody(event) ?? null : null 42 | 43 | const netlifyEvent: HandlerEvent = { 44 | rawUrl: url.toString(), 45 | rawQuery: url.search, 46 | path: url.pathname, 47 | httpMethod: event.method, 48 | headers, 49 | multiValueHeaders: {}, 50 | queryStringParameters: query, 51 | multiValueQueryStringParameters: multiQuery, 52 | body, 53 | isBase64Encoded: false, 54 | } 55 | 56 | const response = await handler(netlifyEvent, {} as HandlerContext) 57 | 58 | if (response) { 59 | appendResponseHeaders(event, response.headers ?? {}) 60 | setResponseStatus(event, response.statusCode ?? 200) 61 | 62 | if (response.headers?.location) { 63 | return sendRedirect(event, response.headers?.location as string, response.statusCode) 64 | } 65 | return response.body || "" 66 | } 67 | }) 68 | } 69 | 70 | router.use("/admin", serve(admin)) 71 | router.use("/api/hr", serve(hr)) 72 | router.use("/api/poll", serve(poll)) 73 | router.use("/api/poll-keepalive", serve(pollKeepalive)) 74 | router.use("/preview", serve(preview)) 75 | router.use("/preview/**", serve(preview)) 76 | 77 | export { app } 78 | -------------------------------------------------------------------------------- /src/functions/admin/admin.akte.app.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | 3 | import { defineAkteApp } from "akte" 4 | 5 | import { fourOFour } from "../../files/404" 6 | import { admin } from "./files/admin" 7 | 8 | export const app = defineAkteApp({ 9 | files: [fourOFour, admin], 10 | globalData() { 11 | return {} 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/functions/admin/files/admin.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../../akte/types" 2 | 3 | import process from "node:process" 4 | import * as prismic from "@prismicio/client" 5 | import { defineAkteFile } from "akte" 6 | 7 | import { dateToUSDate } from "../../../akte/date" 8 | import { getClient } from "../../../akte/prismic" 9 | import { sha256 } from "../../../akte/sha256" 10 | 11 | import { heading } from "../../../components/heading" 12 | 13 | import { minimal } from "../../../layouts/minimal" 14 | 15 | export const admin = defineAkteFile().from({ 16 | path: "/admin", 17 | async data() { 18 | let [docs, albums] = await Promise.all([ 19 | getClient().getAllByType("post__document--private"), 20 | getClient().getAllByType("post__album"), 21 | ]) 22 | 23 | for (const doc of docs) { 24 | if (!doc.url) { 25 | throw new Error( 26 | `Unable to resolve URL for doc: ${JSON.stringify(doc)}`, 27 | ) 28 | } 29 | doc.url = `${doc.url}-${await sha256(doc.uid!, process.env.PRISMIC_TOKEN!, 7)}` 30 | } 31 | docs = docs.sort((a, b) => b.first_publication_date.localeCompare(a.first_publication_date)) 32 | 33 | for (const album of albums) { 34 | if (!album.url) { 35 | throw new Error( 36 | `Unable to resolve URL for album: ${JSON.stringify(album)}`, 37 | ) 38 | } 39 | album.url = `${album.url}-${await sha256(album.uid!, process.env.PRISMIC_TOKEN!, 7)}` 40 | } 41 | albums = albums.sort((a, b) => b.data.published_date.localeCompare(a.data.published_date)) 42 | 43 | return { albums, docs } 44 | }, 45 | render(context) { 46 | const hero = /* html */ ` 47 |
    48 | ${heading("Admin", { as: "h1", class: "heading-1" })} 49 |

    50 | Manage private pages and data. 51 |

    52 |
    ` 53 | 54 | const tools = /* html */ ` 55 |
    56 | ${heading("Tools", { as: "h2", class: "heading-2" })} 57 |

    58 | Useful tools. 59 |

    60 |

    61 | Code - 62 | Colors - 63 | Meteo - 64 | Records 65 |

    66 |
    ` 67 | 68 | const docs = /* html */ ` 69 |
    70 | ${heading("Documents", { as: "h2", class: "heading-2" })} 71 |

    72 | Private documents. 73 |

    74 | 87 |
    ` 88 | 89 | const albums = /* html */ ` 90 |
    91 | ${heading("Albums", { as: "h2", class: "heading-2" })} 92 |

    93 | Private albums. 94 |

    95 | 108 |
    ` 109 | 110 | const script = /* html */ `` 111 | 112 | return minimal( 113 | [ 114 | hero, 115 | tools, 116 | docs, 117 | albums, 118 | script, 119 | ].join("\n"), 120 | { path: context.path, title: "Admin" }, 121 | ) 122 | }, 123 | }) 124 | -------------------------------------------------------------------------------- /src/functions/admin/index.ts: -------------------------------------------------------------------------------- 1 | import type { Handler, HandlerEvent } from "@netlify/functions" 2 | 3 | import { Buffer } from "node:buffer" 4 | import process from "node:process" 5 | 6 | import { RateLimiter } from "../../akte/lib/RateLimiter" 7 | import { sha256 } from "../../akte/sha256" 8 | import { app } from "./admin.akte.app" 9 | 10 | const JSON_HEADERS = { 11 | "content-type": "application/json", 12 | } 13 | 14 | const HTML_HEADERS = { 15 | "content-type": "text/html; charset=utf-8", 16 | } 17 | 18 | // const SESSION_NAME = "lihbr-session" as const 19 | // const SESSION_EXPIRY = 3_600_000 20 | // const ACTIVE_SESSIONS: Map = new Map() 21 | 22 | const rateLimiter = new RateLimiter({ 23 | cache: new Map(), 24 | options: { limit: 6, window: 3_600_000 }, 25 | }) 26 | 27 | async function authenticate(event: HandlerEvent): Promise<{ session: string } | false> { 28 | if (!event.headers.authorization) { 29 | // const cookies = cookie.parse(event.headers.cookie || "") 30 | 31 | // const expiresAt = ACTIVE_SESSIONS.get(cookies[SESSION_NAME]) 32 | // if (expiresAt) { 33 | // if (expiresAt > Date.now()) { 34 | // return { session: cookies[SESSION_NAME] } 35 | // } 36 | 37 | // ACTIVE_SESSIONS.delete(cookies[SESSION_NAME]) 38 | // } 39 | 40 | return false 41 | } 42 | 43 | const [username, password] = Buffer.from( 44 | event.headers.authorization.split(" ").pop()!, 45 | "base64", 46 | ).toString().split(":") 47 | 48 | const credentials = `${username}:${await sha256(password, process.env.PRISMIC_TOKEN!)}` 49 | if (credentials === process.env.APP_ADMIN_CREDENTIALS) { 50 | const session = await sha256(credentials, `${process.env.PRISMIC_TOKEN}:${Date.now()}`) 51 | // ACTIVE_SESSIONS.set(session, Date.now() + SESSION_EXPIRY) 52 | 53 | return { session } 54 | } 55 | 56 | return false 57 | } 58 | 59 | export const handler: Handler = async (event) => { 60 | if (event.httpMethod.toUpperCase() !== "GET") { 61 | return { 62 | statusCode: 400, 63 | headers: { ...JSON_HEADERS }, 64 | body: JSON.stringify({ error: "Bad Request" }), 65 | } 66 | } 67 | 68 | const user = await authenticate(event) 69 | if (!user) { 70 | const usage = rateLimiter.trackUsage(event) 71 | 72 | if (usage.hasReachedLimit) { 73 | return { 74 | statusCode: 429, 75 | headers: { ...JSON_HEADERS, ...usage.headers }, 76 | body: JSON.stringify({ error: "Too Many Requests" }), 77 | } 78 | } 79 | 80 | return { 81 | statusCode: 401, 82 | headers: { 83 | ...HTML_HEADERS, 84 | "www-authenticate": "Basic realm=\"Access to the admin area\"", 85 | }, 86 | body: await app.render(app.lookup("/404")), 87 | } 88 | } 89 | 90 | // const url = getSiteURL() 91 | // const cookies = cookie.serialize(SESSION_NAME, user.session, { 92 | // domain: url.startsWith("https://") ? url.replace(/^(https:\/\/)/, "").replace(/\/$/, "") : undefined, 93 | // path: "/admin", 94 | // expires: new Date(ACTIVE_SESSIONS.get(user.session)!), 95 | // httpOnly: true, 96 | // secure: url.startsWith("https://"), 97 | // sameSite: "strict", 98 | // }) 99 | 100 | return { 101 | statusCode: 200, 102 | headers: { ...HTML_HEADERS }, 103 | body: await app.render(app.lookup("/admin")), 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/functions/hr/index.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "@netlify/functions" 2 | 3 | const JSON_HEADERS = { 4 | "content-type": "application/json", 5 | } 6 | 7 | const COLORS = [ 8 | "#54669c", 9 | "#a54a5e", 10 | "#e84311", 11 | "#f27502", 12 | "#ffb005", 13 | "#759f53", 14 | ] 15 | 16 | export const handler: Handler = async (event) => { 17 | if (event.httpMethod.toUpperCase() !== "GET") { 18 | return { 19 | statusCode: 400, 20 | // Netlify is not really helpful with its Handler type here... 21 | headers: { ...JSON_HEADERS } as Record, 22 | body: JSON.stringify({ error: "Bad Request" }), 23 | } 24 | } 25 | 26 | return { 27 | statusCode: 200, 28 | headers: { 29 | "content-type": "image/svg+xml", 30 | "cache-control": "public, max-age=2, must-revalidate", 31 | }, 32 | body: /* html */ ``, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/functions/poll-keepalive/index.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "@netlify/functions" 2 | 3 | import process from "node:process" 4 | 5 | const JSON_HEADERS = { 6 | "content-type": "application/json", 7 | } 8 | 9 | function upstash(endpoint: string, body?: string): Promise { 10 | const url = new URL(endpoint, process.env.UPSTASH_ENDPOINT!) 11 | 12 | const method = body ? "POST" : "GET" 13 | const headers: Record = body ? { ...JSON_HEADERS } : {} 14 | headers.authorization = `Bearer ${process.env.UPSTASH_TOKEN}` 15 | 16 | return fetch(url.toString(), { 17 | body, 18 | method, 19 | headers, 20 | }) 21 | } 22 | 23 | export const handler: Handler = async (event) => { 24 | if (event.httpMethod.toUpperCase() !== "POST") { 25 | return { 26 | statusCode: 400, 27 | headers: { ...JSON_HEADERS }, 28 | body: JSON.stringify({ error: "Bad Request" }), 29 | } 30 | } 31 | 32 | await upstash(`./set/ping/${Date.now()}`) 33 | 34 | const res = await upstash(`./get/ping`) 35 | const json = await res.json() 36 | 37 | if ( 38 | !res.ok || 39 | typeof json !== "object" || 40 | !json || 41 | !("result" in json) || 42 | !Array.isArray(json.result) 43 | ) { 44 | throw new Error(JSON.stringify(json)) 45 | } 46 | 47 | await fetch(process.env.SLACK_NETLIFY_WEBHOOK!, { 48 | headers: { ...JSON_HEADERS }, 49 | method: "POST", 50 | body: JSON.stringify({ 51 | text: "New keep alive report~", 52 | blocks: [{ 53 | type: "section", 54 | text: { 55 | type: "mrkdwn", 56 | text: `:bouquet: Kept alive at: '${JSON.stringify(json.result)}'`, 57 | }, 58 | }], 59 | }), 60 | }) 61 | 62 | return { 63 | statusCode: 200, 64 | headers: { ...JSON_HEADERS }, 65 | body: JSON.stringify({}), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/functions/poll/index.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "@netlify/functions" 2 | 3 | import process from "node:process" 4 | 5 | const JSON_HEADERS = { 6 | "content-type": "application/json", 7 | } 8 | 9 | function GET_CORS_HEADERS(origin = ""): Record { 10 | if ( 11 | !/^http:\/\/localhost:3030\/?$/i.test(origin) && 12 | !/^https:\/\/[\w-]+\.diapositiv\.lihbr\.com\/?$/i.test(origin) 13 | ) { 14 | return {} 15 | } 16 | 17 | return { 18 | "access-control-allow-origin": origin, 19 | "vary": "Origin", 20 | } 21 | } 22 | 23 | function upstash(endpoint: string, body?: string): Promise { 24 | const url = new URL(endpoint, process.env.UPSTASH_ENDPOINT!) 25 | 26 | const method = body ? "POST" : "GET" 27 | const headers: Record = body ? { ...JSON_HEADERS } : {} 28 | headers.authorization = `Bearer ${process.env.UPSTASH_TOKEN}` 29 | 30 | return fetch(url.toString(), { 31 | body, 32 | method, 33 | headers, 34 | }) 35 | } 36 | 37 | export const handler: Handler = async (event) => { 38 | const CORS_HEADERS = GET_CORS_HEADERS(event.headers.origin) 39 | 40 | if (event.httpMethod.toUpperCase() !== "GET") { 41 | return { 42 | statusCode: 400, 43 | headers: { ...JSON_HEADERS, ...CORS_HEADERS }, 44 | body: JSON.stringify({ error: "Bad Request" }), 45 | } 46 | } 47 | 48 | const body = event.queryStringParameters || {} 49 | 50 | const errors: string[] = [] 51 | if (!body.id) { 52 | errors.push("`id` is missing in body") 53 | } else if (body.id.length > 8) { 54 | errors.push("`id` cannot be longer than 8 characters") 55 | } 56 | 57 | if (body.vote && body.vote.length > 8) { 58 | errors.push("`vote` cannot be longer than 8 characters") 59 | } 60 | 61 | if (errors.length) { 62 | return { 63 | statusCode: 400, 64 | headers: { ...JSON_HEADERS, ...CORS_HEADERS }, 65 | body: JSON.stringify({ 66 | error: "Bad Request", 67 | message: errors.join(", "), 68 | }), 69 | } 70 | } 71 | 72 | if (body.vote) { 73 | try { 74 | const res = await upstash( 75 | "/", 76 | JSON.stringify([ 77 | "EVAL", 78 | ` 79 | local id = KEYS[1] 80 | local vote = ARGV[1] 81 | 82 | local r = redis.call("HINCRBY", id, vote, 1) 83 | 84 | local ttl = redis.call("TTL", id) 85 | if ttl == -1 then 86 | redis.call("EXPIRE", id, 3600) 87 | end 88 | 89 | return r 90 | `, 91 | 1, 92 | body.id, 93 | body.vote, 94 | ]), 95 | ) 96 | 97 | if (!res.ok) { 98 | console.error(await res.json()) 99 | } 100 | } catch (error) { 101 | console.error(error) 102 | } 103 | 104 | return { 105 | statusCode: 302, 106 | headers: { location: "/talks/poll" }, 107 | } as any 108 | } 109 | 110 | const res = await upstash(`./hgetall/${body.id}`) 111 | const json = await res.json() 112 | 113 | if ( 114 | !res.ok || 115 | typeof json !== "object" || 116 | !json || 117 | !("result" in json) || 118 | !Array.isArray(json.result) 119 | ) { 120 | throw new Error(JSON.stringify(json)) 121 | } 122 | 123 | const results: Record = {} 124 | for (let i = 0; i < json.result.length; i += 2) { 125 | results[json.result[i]] = Number.parseInt(json.result[i + 1]) 126 | } 127 | 128 | return { 129 | statusCode: 200, 130 | headers: { ...JSON_HEADERS, ...CORS_HEADERS }, 131 | body: JSON.stringify({ 132 | id: body.id, 133 | results, 134 | }), 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/functions/preview/index.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from "@netlify/functions" 2 | 3 | import { get, resolve } from "./prismicPreview" 4 | 5 | const JSON_HEADERS = { 6 | "content-type": "application/json", 7 | } 8 | 9 | export const handler: Handler = async (event) => { 10 | if (event.httpMethod.toUpperCase() !== "GET") { 11 | return { 12 | statusCode: 400, 13 | headers: { ...JSON_HEADERS }, 14 | body: JSON.stringify({ error: "Bad Request" }), 15 | } 16 | } 17 | 18 | return (await resolve(event)) || (await get(event)) 19 | } 20 | -------------------------------------------------------------------------------- /src/functions/preview/preview.akte.app.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalData } from "../../akte/types" 2 | 3 | import { defineAkteApp } from "akte" 4 | 5 | import { slug } from "../../files/[slug]" 6 | import { fourOFour } from "../../files/404" 7 | import { slug as albumsSlug } from "../../files/albums/[slug]" 8 | import { index as art } from "../../files/art/index" 9 | import { slug as postsSlug } from "../../files/posts/[slug]" 10 | import { preview } from "../../files/preview" 11 | import { slug as privateSlug } from "../../files/private/[slug]" 12 | 13 | export const app = defineAkteApp({ 14 | files: [preview, fourOFour, slug, privateSlug, postsSlug, albumsSlug, art], 15 | globalData() { 16 | return {} 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /src/functions/preview/prismicPreview.ts: -------------------------------------------------------------------------------- 1 | import type { HandlerEvent, HandlerResponse } from "@netlify/functions" 2 | 3 | import process from "node:process" 4 | import * as prismic from "@prismicio/client" 5 | 6 | import { getClient } from "../../akte/prismic" 7 | import { sha256 } from "../../akte/sha256" 8 | import { app } from "./preview.akte.app" 9 | 10 | const HTML_HEADERS = { 11 | "content-type": "text/html; charset=utf-8", 12 | } 13 | 14 | const ROBOTS_HEADERS = { 15 | "x-robots-tag": "noindex, nofollow", 16 | } 17 | 18 | export async function resolve(event: HandlerEvent): Promise { 19 | const { token: previewToken, documentId: documentID } = 20 | event.queryStringParameters ?? {} 21 | 22 | if (!previewToken || !documentID) { 23 | return null 24 | } 25 | 26 | const client = getClient() 27 | let href = await client.resolvePreviewURL({ 28 | documentID, 29 | previewToken, 30 | defaultURL: "/", 31 | }) 32 | 33 | if (href.startsWith("/private") || href.startsWith("/albums")) { 34 | href = `${href}-${await sha256(href.split("/").pop()!, process.env.PRISMIC_TOKEN!, 7)}` 35 | } 36 | 37 | const previewCookie = { 38 | [new URL(client.endpoint).host.replace(/\.cdn/i, "")]: { 39 | preview: previewToken, 40 | }, 41 | } 42 | 43 | return { 44 | statusCode: 302, 45 | headers: { 46 | ...ROBOTS_HEADERS, 47 | "location": `/preview${href}?preview=true`, 48 | "set-cookie": `${prismic.cookie.preview}=${encodeURIComponent( 49 | JSON.stringify(previewCookie), 50 | )}; Path=/${process.env.AWS_LAMBDA_FUNCTION_NAME ? "; Secure" : ""}`, 51 | }, 52 | } 53 | } 54 | 55 | export async function get(event: HandlerEvent): Promise { 56 | const cookie = event.headers.cookie ?? "" 57 | 58 | const repository = new URL(getClient().endpoint).host.replace(/\.cdn/i, "") 59 | 60 | const response: HandlerResponse = { 61 | statusCode: 500, 62 | headers: { 63 | ...HTML_HEADERS, 64 | ...ROBOTS_HEADERS, 65 | }, 66 | } 67 | 68 | if (cookie.includes(repository)) { 69 | globalThis.document = globalThis.document || {} 70 | globalThis.document.cookie = cookie 71 | app.clearCache(true) 72 | 73 | try { 74 | const file = await app.render( 75 | app.lookup(event.path.replace("/preview", "") ?? "/"), 76 | ) 77 | response.statusCode = 200 78 | response.body = file 79 | } catch { 80 | response.statusCode = 404 81 | response.body = await app.render(app.lookup("/404")) 82 | } 83 | } else { 84 | response.statusCode = 202 85 | response.body = await app.render(app.lookup("/preview")) 86 | } 87 | 88 | return response 89 | } 90 | -------------------------------------------------------------------------------- /src/layouts/base.ts: -------------------------------------------------------------------------------- 1 | import escapeHTML from "escape-html" 2 | 3 | import { 4 | DESCRIPTION_LIMIT, 5 | IS_SERVERLESS, 6 | PAGE_DEFAULT_TITLE, 7 | SITE_ACCENT_COLOR, 8 | SITE_BACKGROUND_COLOR, 9 | SITE_DESCRIPTION, 10 | SITE_LANG, 11 | SITE_META_IMAGE, 12 | SITE_TITLE, 13 | SITE_TITLE_FORMAT, 14 | SITE_URL, 15 | TITLE_LIMIT, 16 | } from "../akte/constants" 17 | import { getClient } from "../akte/prismic" 18 | 19 | const inlineScript = /* html */ `` 20 | 21 | const prismicToolbarScript = IS_SERVERLESS 22 | ? /* html */ `` 28 | : "" 29 | 30 | /** 31 | * Cap a string to a given number of characters correctly 32 | */ 33 | function limitLength(string = "", limit = -1): string { 34 | let sanitizedString = string.trim() 35 | if (limit > 0 && sanitizedString.length > limit) { 36 | sanitizedString = sanitizedString.slice(0, limit) 37 | sanitizedString = sanitizedString.slice( 38 | 0, 39 | sanitizedString.lastIndexOf(" "), 40 | ) 41 | sanitizedString = `${sanitizedString}...` 42 | } 43 | 44 | return sanitizedString 45 | } 46 | 47 | export type BaseArgs = { 48 | path: string 49 | title?: string | null 50 | description?: string | null 51 | image?: { 52 | openGraph?: string 53 | } 54 | structuredData?: unknown[] 55 | noindex?: boolean 56 | script?: string 57 | } 58 | 59 | export function base(slot: string, args: BaseArgs): string { 60 | const url = `${SITE_URL}${args.path}`.replace(/\/$/, "") 61 | 62 | const title = escapeHTML( 63 | SITE_TITLE_FORMAT.replace( 64 | "%page%", 65 | limitLength(args.title || PAGE_DEFAULT_TITLE, TITLE_LIMIT), 66 | ), 67 | ) 68 | const description = escapeHTML( 69 | limitLength(args.description || SITE_DESCRIPTION, DESCRIPTION_LIMIT), 70 | ) 71 | const image = { 72 | openGraph: args?.image?.openGraph || SITE_META_IMAGE.openGraph, 73 | } 74 | 75 | const structuredData: unknown[] = [ 76 | { 77 | "@context": "http://schema.org", 78 | "@type": "WebSite", 79 | "url": escapeHTML(url), 80 | "name": args.title || PAGE_DEFAULT_TITLE, 81 | "alternateName": SITE_TITLE, 82 | }, 83 | ] 84 | if (args.structuredData) { 85 | structuredData.push(...args.structuredData) 86 | } 87 | 88 | const script = (args.script || "/assets/js/_base.ts").replace( 89 | IS_SERVERLESS ? ".ts" : ".js", 90 | ".js", 91 | ) 92 | 93 | return /* html */ ` 94 | 95 | 96 | 97 | 98 | 99 | 100 | ${title} 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ${args.noindex ? /* html */ `` : ""} 124 | 125 | 126 | 127 | 128 | ${inlineScript} 129 | 130 | 131 | ${slot} 132 | ${prismicToolbarScript} 133 | 134 | 135 | ` 136 | } 137 | -------------------------------------------------------------------------------- /src/layouts/minimal.ts: -------------------------------------------------------------------------------- 1 | import { back } from "../components/back" 2 | import { base, type BaseArgs } from "./base" 3 | 4 | export function minimal(slot: string, args: BaseArgs & { backTo?: string }): string { 5 | return base( 6 | /* html */ `${back({ to: args.backTo })} 7 | ${slot} 8 | ${back({ to: args.backTo, withPreferences: true, class: "mb-16" })}`, 9 | args, 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/layouts/page.ts: -------------------------------------------------------------------------------- 1 | import { back } from "../components/back" 2 | import { footer } from "../components/footer" 3 | 4 | import { base, type BaseArgs } from "./base" 5 | 6 | export function page(slot: string, args: BaseArgs & { backTo?: string }): string { 7 | return base( 8 | /* html */ `${back({ to: args.backTo })} 9 | ${slot} 10 | ${back({ to: args.backTo, withPreferences: true })} 11 | ${footer()}`, 12 | args, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #e84311 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/favicon-16x16.png -------------------------------------------------------------------------------- /src/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/favicon.ico -------------------------------------------------------------------------------- /src/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/icon.png -------------------------------------------------------------------------------- /src/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/lihbr-apex/4e74f7efe39fa691cec65436e468f9df1524e807/src/public/mstile-150x150.png -------------------------------------------------------------------------------- /src/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | 3 | Allow: / 4 | 5 | Disallow: /api/ 6 | Disallow: /.netlify/ 7 | Disallow: /admin 8 | Disallow: /preview 9 | Disallow: /404 10 | Disallow: /talks/poll 11 | Disallow: /private/ 12 | 13 | Sitemap: https://lihbr.com/sitemap.xml 14 | -------------------------------------------------------------------------------- /src/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 31 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lihbr", 3 | "short_name": "lihbr", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#e84311", 17 | "background_color": "#fffefe", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const process = require("node:process") 2 | const { farben, alpha } = require("@lihbr/farben") 3 | 4 | const content = ["./src/**/*.ts"] 5 | 6 | // Vite does not like it when we watch the `render` folder in development 7 | if (process.env.NODE_ENV === "production") { 8 | content.push("./src/.akte/render/**/*.html") 9 | } else { 10 | content.push("./src/.akte/data/**/*.data") 11 | } 12 | 13 | /** @type {import('tailwindcss').Config} */ 14 | module.exports = { 15 | prefix: "", 16 | important: false, 17 | separator: ":", 18 | content, 19 | darkMode: "class", 20 | theme: { 21 | fontFamily: { 22 | sans: [ 23 | "Graphit", 24 | "\"Graphit CLS\"", 25 | "Roboto", 26 | "-apple-system", 27 | "BlinkMacSystemFont", 28 | "\"Segoe UI\"", 29 | "Helvetica", 30 | "Arial", 31 | "sans-serif", 32 | "\"Apple Color Emoji\"", 33 | "\"Segoe UI Emoji\"", 34 | ], 35 | mono: [ 36 | "Consolas", 37 | "SFMono-Regular", 38 | "\"SF Mono\"", 39 | "Menlo", 40 | "\"Liberation Mono\"", 41 | "monospace", 42 | ], 43 | }, 44 | colors: { 45 | transparent: "transparent", 46 | current: "currentColor", 47 | inherit: "inherit", 48 | theme: { 49 | "DEFAULT": "var(--color-theme)", 50 | "o-20": "var(--color-theme-o-20)", 51 | "100": "var(--color-theme-100)", 52 | }, 53 | slate: { 54 | "DEFAULT": farben.slate[800], // 800 55 | "o-20": alpha(farben.slate[800], 0.2), 56 | "900": farben.slate[900], 57 | "700": farben.slate[700], 58 | "200": farben.slate[200], 59 | "100": farben.slate[100], 60 | "50": farben.slate[50], 61 | }, 62 | cream: { 63 | "DEFAULT": farben.cream[800], // 800 64 | "o-20": alpha(farben.cream[800], 0.2), 65 | "900": farben.cream[900], 66 | "700": farben.cream[700], 67 | "200": farben.cream[200], 68 | "100": farben.cream[100], 69 | "50": farben.cream[50], 70 | }, 71 | // o-20 used for tap highlight and inline code only 72 | navy: { 73 | "DEFAULT": farben.navy[400], 74 | "o-20": alpha(farben.navy[400], 0.2), 75 | "100": farben.navy[100], 76 | }, 77 | beet: { 78 | "DEFAULT": farben.beet[400], 79 | "o-20": alpha(farben.beet[400], 0.2), 80 | "100": farben.beet[100], 81 | }, 82 | flamingo: { 83 | "DEFAULT": farben.flamingo[400], 84 | "o-20": alpha(farben.flamingo[400], 0.2), 85 | "100": farben.flamingo[100], 86 | }, 87 | ochre: { 88 | "DEFAULT": farben.ochre[400], 89 | "o-20": alpha(farben.ochre[400], 0.2), 90 | "100": farben.ochre[100], 91 | }, 92 | butter: { 93 | "DEFAULT": farben.butter[400], 94 | "o-20": alpha(farben.butter[400], 0.2), 95 | "100": farben.butter[100], 96 | }, 97 | mantis: { 98 | "DEFAULT": farben.mantis[400], 99 | "o-20": alpha(farben.mantis[400], 0.2), 100 | "100": farben.mantis[100], 101 | }, 102 | }, 103 | extend: { 104 | opacity: { 105 | inherit: "inherit", 106 | }, 107 | spacing: { 108 | inherit: "inherit", 109 | }, 110 | minWidth: { 111 | inherit: "inherit", 112 | }, 113 | maxWidth: { 114 | inherit: "inherit", 115 | }, 116 | minHeight: { 117 | inherit: "inherit", 118 | }, 119 | maxHeight: { 120 | inherit: "inherit", 121 | }, 122 | lineHeight: { 123 | 0: 0, 124 | }, 125 | transitionDuration: { 126 | 0: "0ms", 127 | }, 128 | }, 129 | }, 130 | plugins: [ 131 | ({ addBase, addVariant, theme }) => { 132 | addBase({ 133 | "strong": { fontWeight: theme("fontWeight.medium") }, 134 | "small": { fontSize: "inherit" }, 135 | "label, input, textarea, select": { 136 | display: "block", 137 | fontWeight: "inherit", 138 | fontStyle: "inherit", 139 | }, 140 | }) 141 | 142 | addVariant("hocus", ["&:hover", "&:focus"]) 143 | addVariant("current", "&[aria-current=\"page\"]") 144 | addVariant("left", "html.left &") 145 | addVariant("center", "html.center &") 146 | addVariant("right", "html.right &") 147 | addVariant("open", "details[open] > summary &") 148 | }, 149 | ], 150 | } 151 | -------------------------------------------------------------------------------- /test/__testutils__/readAllFiles.ts: -------------------------------------------------------------------------------- 1 | import type { Buffer } from "node:buffer" 2 | import { readFile } from "node:fs/promises" 3 | import { resolve } from "node:path" 4 | 5 | /** 6 | * Bulk version of `readFile` 7 | * 8 | * @param paths - Paths to files 9 | * @param cwd - Current working directory 10 | * 11 | * @returns Read files as buffer array 12 | */ 13 | export function readAllFiles(paths: string[], cwd = ""): Promise<{ path: string, content: Buffer }[]> { 14 | return Promise.all( 15 | paths.map(async (path) => { 16 | return { 17 | path, 18 | content: await readFile(resolve(cwd, path)), 19 | } 20 | }), 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /test/buildIntegrity.test.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs" 2 | import { resolve } from "node:path" 3 | 4 | import { globbySync } from "globby" 5 | import { expect, it } from "vitest" 6 | 7 | import { readAllFiles } from "./__testutils__/readAllFiles" 8 | 9 | // Paths 10 | const akteOutputPath = resolve(__dirname, "../src/.akte/render") 11 | const akteAssetsPath = resolve(__dirname, "../src/assets") 12 | 13 | const viteOutputPath = resolve(__dirname, "../dist") 14 | const viteAssetsPath = resolve(__dirname, "../dist/assets") 15 | 16 | // Globs 17 | const aktePagesGlob = globbySync(["**/*.html"], { cwd: akteOutputPath }) 18 | const vitePagesGlob = globbySync(["**/*.html"], { cwd: viteOutputPath }) 19 | 20 | it("builds exist", () => { 21 | expect(existsSync(akteOutputPath)).toBe(true) 22 | expect(existsSync(viteOutputPath)).toBe(true) 23 | }) 24 | 25 | it("builds output same pages", () => { 26 | expect(aktePagesGlob.length).toBeGreaterThan(0) 27 | expect(vitePagesGlob.length).toBe(aktePagesGlob.length) 28 | }) 29 | 30 | it("builds output canonical links", async () => { 31 | const extractCanonicalFromPage = (content: string): string | undefined => { 32 | return content.match( 33 | // eslint-disable-next-line regexp/no-escape-backspace, regexp/no-potentially-useless-backreference 34 | /['"\b])?canonical\k href=(?['"\b])?(?[/\w.:-]+)\k/, 35 | )?.groups?.href 36 | } 37 | 38 | const vitePages = (await readAllFiles(vitePagesGlob, viteOutputPath)).map( 39 | ({ content }) => content.toString(), 40 | ) 41 | 42 | const canonicals = vitePages 43 | .map(extractCanonicalFromPage) 44 | .filter(Boolean) 45 | .filter((href, index, arr) => arr.indexOf(href) === index) 46 | .sort() 47 | 48 | expect(canonicals.length).toBeGreaterThan(0) 49 | expect(canonicals.length).toBe(vitePagesGlob.length) 50 | }) 51 | 52 | it("builds have no undefined alt attributes", async () => { 53 | const hasUndefinedAlts = (path: string, content: string): string | undefined => { 54 | if ( 55 | content.includes("alt=\"undefined\"") || 56 | content.includes("alt='undefined'") || 57 | content.includes("alt=undefined") 58 | ) { 59 | return path 60 | } 61 | } 62 | 63 | const aktePages = (await readAllFiles(aktePagesGlob, akteOutputPath)).map( 64 | ({ path, content }) => [path, content.toString()], 65 | ) 66 | 67 | const akteUndefinedAlts = aktePages.map(([path, content]) => hasUndefinedAlts(path, content)).filter(Boolean) 68 | 69 | expect(akteUndefinedAlts).toStrictEqual([]) 70 | }) 71 | 72 | it("builds reference same script modules", async () => { 73 | /** 74 | * Extracts sources of script modules from an HTML string 75 | */ 76 | const extractSourcesFromPage = (content: string, ignore?: string[]): string[] => { 77 | const matches: string[] = [] 78 | 79 | /** @see regex101 @link{https://regex101.com/r/fR5vWO/1} */ 80 | const regex = 81 | // eslint-disable-next-line regexp/no-escape-backspace, regexp/no-potentially-useless-backreference 82 | /]*?(?type=(?['"\b])?module\k)[^>]*>/gi 83 | let match = regex.exec(content) 84 | while (match) { 85 | matches.push(match[0]) 86 | 87 | match = regex.exec(content) 88 | } 89 | 90 | return matches 91 | .map((match) => { 92 | return match.match( 93 | /** @see regex101 @link{https://regex101.com/r/t5iTUq/1} */ 94 | // eslint-disable-next-line regexp/no-escape-backspace, regexp/no-potentially-useless-backreference 95 | /src=(?['"\b])?(?[/\w.-]+)\k/i, 96 | )?.groups?.src 97 | }) 98 | .filter((match, index): match is string => { 99 | if (typeof match !== "string") { 100 | console.warn(`Unexpected script tag missing \`${matches[index]}\``) 101 | 102 | return false 103 | } else if (match.match(/_base\.[jt]s/)) { 104 | return false 105 | } 106 | 107 | return true 108 | }) 109 | .map((match) => match.replace(/\.[jt]s$/, "")) 110 | .filter((match) => !ignore?.includes(match)) 111 | } 112 | 113 | const aktePages = (await readAllFiles(aktePagesGlob, akteOutputPath)).map( 114 | ({ content }) => content.toString(), 115 | ) 116 | const akteScripts = aktePages.map((html) => extractSourcesFromPage(html)) 117 | 118 | const vitePages = (await readAllFiles(vitePagesGlob, viteOutputPath)).map( 119 | ({ content }) => content.toString(), 120 | ) 121 | const viteScripts = vitePages.map((html) => extractSourcesFromPage(html, ["/assets/js/albums2"])) 122 | 123 | expect(viteScripts).toStrictEqual(akteScripts) 124 | }) 125 | 126 | it("builds preserve same font assets structure", async () => { 127 | const akteFontsGlob = globbySync(["**/*.{woff,woff2}"], { 128 | cwd: akteAssetsPath, 129 | }) 130 | const viteFontsGlob = globbySync(["**/*.{woff,woff2}"], { 131 | cwd: viteAssetsPath, 132 | }) 133 | 134 | expect(akteFontsGlob).toMatchInlineSnapshot(` 135 | [ 136 | "fonts/cascadia-400.woff", 137 | "fonts/cascadia-400.woff2", 138 | "fonts/graphit-100.woff", 139 | "fonts/graphit-100.woff2", 140 | "fonts/graphit-100i.woff", 141 | "fonts/graphit-100i.woff2", 142 | "fonts/graphit-300.woff", 143 | "fonts/graphit-300.woff2", 144 | "fonts/graphit-300i.woff", 145 | "fonts/graphit-300i.woff2", 146 | "fonts/graphit-400.woff", 147 | "fonts/graphit-400.woff2", 148 | "fonts/graphit-400i.woff", 149 | "fonts/graphit-400i.woff2", 150 | "fonts/graphit-500.woff", 151 | "fonts/graphit-500.woff2", 152 | "fonts/graphit-500i.woff", 153 | "fonts/graphit-500i.woff2", 154 | "fonts/graphit-700.woff", 155 | "fonts/graphit-700.woff2", 156 | "fonts/graphit-700i.woff", 157 | "fonts/graphit-700i.woff2", 158 | "fonts/graphit-900.woff", 159 | "fonts/graphit-900.woff2", 160 | "fonts/virgil-400.woff", 161 | "fonts/virgil-400.woff2", 162 | ] 163 | `) 164 | expect(viteFontsGlob).toStrictEqual(akteFontsGlob) 165 | }) 166 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "jsx": "preserve", 5 | "lib": ["esnext", "dom"], 6 | "module": "esnext", 7 | "moduleResolution": "bundler", 8 | "resolveJsonModule": true, 9 | "types": ["node", "vite/client"], 10 | "strict": true, 11 | "declaration": false, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | 15 | "forceConsistentCasingInFileNames": true, 16 | "skipLibCheck": true 17 | }, 18 | "exclude": ["node_modules", "dist", "examples"] 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path" 2 | import process from "node:process" 3 | 4 | import akte from "akte/vite" 5 | import getPort from "get-port" 6 | import { listenAndWatch } from "listhen" 7 | 8 | import { defineConfig } from "vite" 9 | 10 | import { app } from "./src/akte.app" 11 | 12 | export default defineConfig({ 13 | root: path.resolve(__dirname, "src"), 14 | server: { 15 | proxy: { 16 | "^/admin.*": "http://localhost:5174", 17 | "^/api.*": "http://localhost:5174", 18 | "^/preview.*": "http://localhost:5174", 19 | }, 20 | }, 21 | build: { 22 | cssCodeSplit: false, 23 | emptyOutDir: true, 24 | outDir: path.resolve(__dirname, "dist"), 25 | rollupOptions: { 26 | output: { 27 | entryFileNames(chunkInfo) { 28 | // Files being the unique user of a script requires special handling 29 | // of their chunk name for Akte and Vite build to match 30 | if (chunkInfo.moduleIds.some((id) => id.endsWith(".ts"))) { 31 | return `assets/js/${chunkInfo.name.replace(".html", "")}.js` 32 | } 33 | 34 | return "assets/js/[name].js" 35 | }, 36 | chunkFileNames: "assets/js/[name].js", 37 | assetFileNames(assetInfo) { 38 | const extension = assetInfo.name?.split(".").pop() 39 | 40 | switch (extension) { 41 | case "css": 42 | return "assets/css/[name][extname]" 43 | 44 | case "woff": 45 | case "woff2": 46 | return "assets/fonts/[name][extname]" 47 | 48 | default: 49 | return "assets/[name][extname]" 50 | } 51 | }, 52 | }, 53 | }, 54 | }, 55 | plugins: [ 56 | akte({ app }), 57 | { 58 | name: "markdown:watch", 59 | configureServer(server) { 60 | // Hot reload on Markdown updates 61 | server.watcher.add("data/notes") 62 | server.watcher.on("change", (path) => { 63 | if (path.endsWith(".md")) { 64 | app.clearCache(true) 65 | server.ws.send({ 66 | type: "full-reload", 67 | }) 68 | } 69 | }) 70 | }, 71 | }, 72 | { 73 | name: "functions:watch", 74 | async configureServer() { 75 | if (process.env.NODE_ENV === "development") { 76 | const port = await getPort({ port: 5174 }) 77 | 78 | // Ensures we only run the secondary server once 79 | if (port === 5174) { 80 | listenAndWatch("./src/functions.server.ts", { 81 | port, 82 | autoClose: true, 83 | staticDirs: [], 84 | }) 85 | } 86 | } 87 | }, 88 | }, 89 | ], 90 | // @ts-expect-error Vite 6 issue(?) 91 | test: { 92 | include: ["../test/**/*.test.ts"], 93 | coverage: { 94 | reporter: ["lcovonly", "text"], 95 | }, 96 | }, 97 | }) 98 | --------------------------------------------------------------------------------