├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .size-limit.cjs ├── .versionrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── akte.app.ts ├── akte │ └── markdownToHTML.ts ├── assets │ ├── css │ │ ├── defaults.css │ │ ├── footer.css │ │ ├── header.css │ │ ├── main.css │ │ ├── nav.css │ │ ├── properties.css │ │ └── style.css │ └── js │ │ └── base.ts ├── content │ ├── 404.md │ ├── api.md │ ├── comparisons.md │ ├── examples.md │ ├── get-started.md │ ├── guide.md │ ├── index.md │ └── welcome.md ├── files │ ├── pages.ts │ └── sitemap.ts ├── layouts │ └── base.ts ├── package.json ├── postcss.config.cjs ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-48x48.png │ ├── favicon.ico │ ├── icon.png │ ├── logo.svg │ ├── meta.png │ ├── mstile-150x150.png │ ├── robots.txt │ ├── sad-pablo.gif │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── tsconfig.json └── vite.config.ts ├── examples ├── common │ ├── basic │ │ ├── README.md │ │ ├── akte.app.js │ │ ├── akte.app.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── catch-all │ │ ├── README.md │ │ ├── akte.app.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── non-html │ │ ├── README.md │ │ ├── akte.app.ts │ │ ├── package.json │ │ └── tsconfig.json ├── programmatic │ └── basic │ │ ├── README.md │ │ ├── akte.app.ts │ │ ├── package.json │ │ ├── programmatic.ts │ │ └── tsconfig.json └── vite │ └── basic │ ├── .gitignore │ ├── README.md │ ├── akte.app.ts │ ├── assets │ └── main.ts │ ├── files │ ├── index.ts │ ├── jsons.ts │ ├── pages.ts │ └── posts.ts │ ├── layouts │ └── basic.ts │ ├── package.json │ ├── tsconfig.json │ └── vite.config.ts ├── netlify.toml ├── package-lock.json ├── package.json ├── playground ├── akte.app.ts ├── package.json ├── src │ ├── assets │ │ └── main.ts │ ├── layouts │ │ └── basic.ts │ └── pages │ │ ├── catchAll │ │ └── index.ts │ │ ├── index.ts │ │ ├── posts │ │ └── slug.ts │ │ └── sitemap.ts ├── tsconfig.json └── vite.config.ts ├── src ├── AkteApp.ts ├── AkteFiles.ts ├── akteWelcome.ts ├── defineAkteApp.ts ├── defineAkteFile.ts ├── defineAkteFiles.ts ├── errors.ts ├── index.ts ├── lib │ ├── __PRODUCTION__.ts │ ├── commandsAndFlags.ts │ ├── createDebugger.ts │ ├── hasFlag.ts │ ├── isCLI.ts │ ├── pathToFilePath.ts │ ├── pathToRouterPath.ts │ ├── pkg.ts │ └── toReadonlyMap.ts ├── runCLI.ts ├── types.ts └── vite │ ├── AkteViteCache.ts │ ├── aktePlugin.ts │ ├── createAkteViteCache.ts │ ├── index.ts │ ├── plugins │ ├── buildPlugin.ts │ └── serverPlugin.ts │ └── types.ts ├── test ├── AkteApp-buildAll.test.ts ├── AkteApp-getGlobalData.test.ts ├── AkteApp-getRouter.test.ts ├── AkteApp-lookup.test.ts ├── AkteApp-render.test.ts ├── AkteApp-renderAll.test.ts ├── AkteApp-writeAll.test.ts ├── AkteFile-getBulkData.test.ts ├── AkteFile-getData.test.ts ├── __fixtures__ │ ├── about.ts │ ├── index.ts │ ├── jsons.ts │ ├── noGlobalData.ts │ ├── pages.ts │ ├── posts.ts │ └── renderError.ts ├── __setup__.ts ├── defineAkteApp.test-d.ts ├── defineAkteFile.test-d.ts ├── defineAkteFiles.test-d.ts ├── index.test.ts └── runCLI.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # .gitignore copy 2 | 3 | # custom 4 | .akte 5 | dist 6 | examples/**/package-lock.json 7 | 8 | # os 9 | .DS_Store 10 | ._* 11 | 12 | # node 13 | logs 14 | *.log 15 | node_modules 16 | 17 | # yarn 18 | yarn-debug.log* 19 | yarn-error.log* 20 | lerna-debug.log* 21 | .yarn-integrity 22 | yarn.lock 23 | 24 | # npm 25 | npm-debug.log* 26 | 27 | # tests 28 | coverage 29 | .eslintcache 30 | .nyc_output 31 | 32 | # .env 33 | .env 34 | .env.test 35 | .env*.local 36 | 37 | # vscode 38 | .vscode/* 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | *.code-workspace 42 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["@antfu", "plugin:prettier/recommended"], 4 | rules: { 5 | "jsonc/indent": ["error", "tab"], 6 | "@typescript-eslint/consistent-type-imports": [ 7 | "error", 8 | { 9 | prefer: "type-imports", 10 | fixStyle: "inline-type-imports", 11 | disallowTypeAnnotations: false, 12 | }, 13 | ], 14 | "@typescript-eslint/consistent-type-definitions": "off", 15 | "@typescript-eslint/explicit-module-boundary-types": "error", 16 | "@typescript-eslint/no-explicit-any": "error", 17 | "@typescript-eslint/no-non-null-assertion": "error", 18 | "@typescript-eslint/no-unused-vars": [ 19 | "error", 20 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 21 | ], 22 | "antfu/top-level-function": "off", 23 | "n/prefer-global/process": "off", 24 | "no-cond-assign": ["error", "except-parens"], 25 | "no-fallthrough": "off", 26 | "padding-line-between-statements": [ 27 | "error", 28 | { blankLine: "always", prev: "*", next: "return" }, 29 | ], 30 | }, 31 | overrides: [ 32 | { 33 | files: "*.cjs", 34 | rules: { 35 | "@typescript-eslint/no-var-requires": "off", 36 | }, 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # asserts everything is text 2 | * text eol=lf 3 | 4 | # treats lock files as binaries to prevent merge headache 5 | package-lock.json -diff 6 | yarn.lock -diff 7 | 8 | # treats assets as binaries 9 | *.png binary 10 | *.jpg binary 11 | *.jpeg binary 12 | *.gif binary 13 | *.ico binary 14 | -------------------------------------------------------------------------------- /.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 | 16 | 17 | ### Versions 18 | 19 | - akte: 20 | - node: 21 | 22 | ### Reproduction 23 | 24 | 25 | 26 |
27 | Additional Details 28 |
29 | 30 |
31 | 32 | ### Steps to reproduce 33 | 34 | ### What is expected? 35 | 36 | ### What is actually happening? 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: 🤔 Question 5 | url: https://github.com/lihbr/akte/discussions 6 | about: Ask a question about the package. You will usually get support there more quickly! 7 | - name: 🐦 Lucie's Twitter 8 | url: https://twitter.com/li_hbr 9 | about: Get in touch with me about this package, or anything else 🤷‍♀️ 10 | -------------------------------------------------------------------------------- /.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 official 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/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | ci: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | node: [18] 19 | 20 | steps: 21 | - name: Set up Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node }} 25 | 26 | - name: Checkout 27 | uses: actions/checkout@master 28 | 29 | - name: Cache node_modules 30 | uses: actions/cache@v3 31 | with: 32 | path: node_modules 33 | key: ${{ matrix.os }}-node-v${{ matrix.node }}-deps-${{ hashFiles(format('{0}{1}', github.workspace, '/package-lock.json')) }} 34 | 35 | - name: Install dependencies 36 | if: steps.cache.outputs.cache-hit != 'true' 37 | run: npm ci 38 | 39 | - name: Lint 40 | run: npm run lint 41 | 42 | - name: Types 43 | run: npm run types 44 | 45 | - name: Unit 46 | run: npm run unit 47 | 48 | - name: Build 49 | run: npm run build 50 | 51 | - name: Coverage 52 | if: matrix.os == 'ubuntu-latest' && matrix.node == 16 53 | uses: codecov/codecov-action@v3 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | 57 | - name: Size 58 | if: github.event_name == 'pull_request' && matrix.os == 'ubuntu-latest' && matrix.node == 16 59 | uses: andresz1/size-limit-action@v1 60 | with: 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # custom 2 | .akte 3 | dist 4 | examples/**/package-lock.json 5 | 6 | # os 7 | .DS_Store 8 | ._* 9 | 10 | # node 11 | logs 12 | *.log 13 | node_modules 14 | 15 | # yarn 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | .yarn-integrity 20 | yarn.lock 21 | 22 | # npm 23 | npm-debug.log* 24 | 25 | # tests 26 | coverage 27 | .eslintcache 28 | .nyc_output 29 | 30 | # .env 31 | .env 32 | .env.test 33 | .env*.local 34 | 35 | # vscode 36 | .vscode/* 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | *.code-workspace 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": false, 6 | "quoteProps": "consistent", 7 | "jsxSingleQuote": false, 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "bracketSameLine": false, 11 | "arrowParens": "always", 12 | "requirePragma": false, 13 | "insertPragma": false, 14 | "htmlWhitespaceSensitivity": "css", 15 | "endOfLine": "lf" 16 | } 17 | -------------------------------------------------------------------------------- /.size-limit.cjs: -------------------------------------------------------------------------------- 1 | const pkg = require("./package.json"); 2 | 3 | module.exports = [pkg.module, pkg.main] 4 | .filter(Boolean) 5 | .map((path) => ({ 6 | path, 7 | ignore: ["node:*"] 8 | })); 9 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "Bug Fixes" 10 | }, 11 | { 12 | "type": "refactor", 13 | "section": "Refactor" 14 | }, 15 | { 16 | "type": "docs", 17 | "section": "Documentation" 18 | }, 19 | { 20 | "type": "chore", 21 | "section": "Chore" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.4.2](https://github.com/lihbr/akte/compare/v0.4.1...v0.4.2) (2024-05-12) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * package type exports ([9079a2c](https://github.com/lihbr/akte/commit/9079a2caae989cf5b349a721ab8c0493af4837ab)) 11 | 12 | ### [0.4.1](https://github.com/lihbr/akte/compare/v0.4.0...v0.4.1) (2024-05-09) 13 | 14 | 15 | ### Documentation 16 | 17 | * typo ([28ff1d8](https://github.com/lihbr/akte/commit/28ff1d8e206e88d91353196b9d04fce72f6eb912)) 18 | 19 | 20 | ### Chore 21 | 22 | * **deps:** maintain dependencies ([8106724](https://github.com/lihbr/akte/commit/810672435ef0a5590ada9e231d5d0391d386b4c3)) 23 | 24 | ## [0.4.0](https://github.com/lihbr/akte/compare/v0.3.2...v0.4.0) (2024-01-06) 25 | 26 | 27 | ### Features 28 | 29 | * **deps:** support vite 5 ([18eb159](https://github.com/lihbr/akte/commit/18eb1593d41acc54cec0f07a7b642862591b1d10)) 30 | 31 | 32 | ### Documentation 33 | 34 | * update doc ([20a927a](https://github.com/lihbr/akte/commit/20a927a23313a41041a4adb8c1d89eaf50e28e85)) 35 | 36 | 37 | ### Chore 38 | 39 | * **deps:** maintain lock file ([f04f104](https://github.com/lihbr/akte/commit/f04f10456b118053d2974053ec6a6c4e63ceb35a)) 40 | 41 | ### [0.3.2](https://github.com/lihbr/akte/compare/v0.3.1...v0.3.2) (2023-03-21) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * **vite:** bundle filename renaming ([c2f80f6](https://github.com/lihbr/akte/commit/c2f80f6aa58ec6dc768b984381eb86e5f2587ecd)) 47 | * **vite:** wait for server to reload before sending revalidation event ([fb6cb61](https://github.com/lihbr/akte/commit/fb6cb613b0b0a3463882d4eee5370c8488624db9)) 48 | 49 | ### [0.3.1](https://github.com/lihbr/akte/compare/v0.3.0...v0.3.1) (2023-03-19) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * **vite:** consistently invalidate cache directory ([c9d35f0](https://github.com/lihbr/akte/commit/c9d35f0bac99523210b570135af7ca844e3d6341)) 55 | 56 | 57 | ### Chore 58 | 59 | * **deps:** maintain dependencies ([e5e9e33](https://github.com/lihbr/akte/commit/e5e9e330557c3fc07d0e001512d61214901161b5)) 60 | 61 | ## [0.3.0](https://github.com/lihbr/akte/compare/v0.2.0...v0.3.0) (2023-03-18) 62 | 63 | 64 | ### Features 65 | 66 | * expose `files` and various memory cache ([af6021a](https://github.com/lihbr/akte/commit/af6021a5c59af83c4430f9d4424d1419ead7939f)) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * prevent dual cache revalidation ([da3af4e](https://github.com/lihbr/akte/commit/da3af4e1237fa5ba32b82525df0811242119fc87)) 72 | * **vite:** actually skip full build on restart ([98182c0](https://github.com/lihbr/akte/commit/98182c017084fe8f156b3de1a516d1277fcc38c4)) 73 | * **vite:** strip query parameters from path ([af8ed28](https://github.com/lihbr/akte/commit/af8ed280f8b0359444bfd626a82fcef6506e183f)) 74 | 75 | 76 | ### Chore 77 | 78 | * **deps:** maintain dependencies ([448e151](https://github.com/lihbr/akte/commit/448e151c5595af4da6e2332025d1cd5f27f5d805)) 79 | * package keywords ([51f3ca7](https://github.com/lihbr/akte/commit/51f3ca7db50d38f8c20af3ac52da856e796a0627)) 80 | 81 | 82 | ### Documentation 83 | 84 | * enhanced programmatic cache API ([62528f0](https://github.com/lihbr/akte/commit/62528f014e5da33d9ee6901c89f2e034930f9d78)) 85 | * refine copy for clarity ([615baeb](https://github.com/lihbr/akte/commit/615baebc878de41262a31537d2e5e85a168a8f27)) 86 | * wrong stackblitz url ([a961ca0](https://github.com/lihbr/akte/commit/a961ca0b72b0c058f13fd7bf5e262fdd36ddc2dc)) 87 | 88 | ## [0.2.0](https://github.com/lihbr/akte/compare/v0.1.0...v0.2.0) (2023-01-17) 89 | 90 | 91 | ### Features 92 | 93 | * welcome page ([e744fc7](https://github.com/lihbr/akte/commit/e744fc7b77ca5e2333d1453079d09a3f09f0ac34)) 94 | 95 | 96 | ### Bug Fixes 97 | 98 | * **vite:** don't build app upon preview ([48305af](https://github.com/lihbr/akte/commit/48305afffd816db68fefe1bf0c540d8fad4e8d46)) 99 | * welcome page ([7e8ffd3](https://github.com/lihbr/akte/commit/7e8ffd3041ed374cd9e832e231aa9c07ef4b5cb8)) 100 | 101 | 102 | ### Chore 103 | 104 | * add missing tsconfig ([b04854f](https://github.com/lihbr/akte/commit/b04854f694fd8b28a4d68e807a94735139a4c23d)) 105 | 106 | 107 | ### Documentation 108 | 109 | * `/get-started`, `/api`, `/404`, `/sitemap.xml` ([fb51f5c](https://github.com/lihbr/akte/commit/fb51f5c451f13bfcdb395f859522642513855548)) 110 | * finalize doc ([a2ca0f8](https://github.com/lihbr/akte/commit/a2ca0f88fe651f4ac921b524d37e2ff88022021e)) 111 | * fix alignement ([67289b6](https://github.com/lihbr/akte/commit/67289b64cb8bba7b8a168120fce7c1591b5c266f)) 112 | * fix plausible ([7f64499](https://github.com/lihbr/akte/commit/7f64499bd64760b03baf5696fadd85ceb4168ab7)) 113 | * fix plausible ([d2cc326](https://github.com/lihbr/akte/commit/d2cc32680f3f333f1da146e4ec3c9f803e16e92c)) 114 | * get started and examples page ([1e5a91b](https://github.com/lihbr/akte/commit/1e5a91b2189cdba7b4003703b38e6741238d1fba)) 115 | * guide section ([7ef5112](https://github.com/lihbr/akte/commit/7ef5112712a865052adc53e14f39e194fa174c8d)) 116 | * improve example navigation ([d0a3039](https://github.com/lihbr/akte/commit/d0a303924dd16a67e143cabd174b806c7f8bff61)) 117 | * missing gitignore mention ([d27cb97](https://github.com/lihbr/akte/commit/d27cb97aa155c81a8c925953a9141af10819f805)) 118 | * plug plausible ([ce47301](https://github.com/lihbr/akte/commit/ce473016c3411b6adbd349b7f7527814553a5fdf)) 119 | * refactor examples ([ab798c8](https://github.com/lihbr/akte/commit/ab798c81ca61e4da803246e498a8b71412636e43)) 120 | * typos (wrong imports) ([e0ff703](https://github.com/lihbr/akte/commit/e0ff7031330ae551840f5032a0bd5b4431a8493f)) 121 | * update tsdocs ([7fcbb2c](https://github.com/lihbr/akte/commit/7fcbb2cc9d95e11789fcb2b3ceccf8e7c304bd8b)) 122 | * work on documentation website ([dfc5247](https://github.com/lihbr/akte/commit/dfc52472d359678543b29a3892afd02bf0632138)) 123 | 124 | ## [0.1.0](https://github.com/lihbr/akte/compare/v0.0.3...v0.1.0) (2023-01-11) 125 | 126 | 127 | ### ⚠ BREAKING CHANGES 128 | 129 | * make `html-minifier-terser` an optional peer dependency 130 | 131 | ### Chore 132 | 133 | * **deps:** fix lock file ([2951a50](https://github.com/lihbr/akte/commit/2951a50562c6747886d5b6cdac720910257020f5)) 134 | 135 | 136 | ### Refactor 137 | 138 | * make `html-minifier-terser` an optional peer dependency ([8b23301](https://github.com/lihbr/akte/commit/8b23301ad76da9327f9a526c6365dc589bce771e)) 139 | 140 | 141 | ### Documentation 142 | 143 | * add tsdocs ([2484d2e](https://github.com/lihbr/akte/commit/2484d2e25c0dbb1400cd84c58e2a09e2d12d4aa8)) 144 | * provide missing tsconfig.json for stackblitz/standalone download ([bd403ab](https://github.com/lihbr/akte/commit/bd403abda9fd805a6614670189fb53d95d4b96a1)) 145 | 146 | ### [0.0.3](https://github.com/lihbr/akte/compare/v0.0.2...v0.0.3) (2023-01-09) 147 | 148 | 149 | ### Features 150 | 151 | * allow `globalData()` to be optional according to context ([ea202f9](https://github.com/lihbr/akte/commit/ea202f90c45e22615315cdf31f4a43fc54d7fae0)) 152 | 153 | 154 | ### Bug Fixes 155 | 156 | * render error propagation ([00fb23d](https://github.com/lihbr/akte/commit/00fb23d213a5d04cdc29ef95c7795fbfa29126b5)) 157 | 158 | 159 | ### Documentation 160 | 161 | * add examples ([9cd213f](https://github.com/lihbr/akte/commit/9cd213fd9b85a72dfd63aa0b930e23a8b42123ee)) 162 | * add programmatic example ([3b6d2cc](https://github.com/lihbr/akte/commit/3b6d2ccb22a93a350f38c6bbf83e280c46f048c7)) 163 | * update readme ([5e4b7f4](https://github.com/lihbr/akte/commit/5e4b7f45e946b2905dfab367051d1bf010dde38c)) 164 | 165 | ### [0.0.2](https://github.com/lihbr/akte/compare/v0.0.1...v0.0.2) (2023-01-08) 166 | 167 | 168 | ### Features 169 | 170 | * allow vite plugin to minify generated HML ([298f22a](https://github.com/lihbr/akte/commit/298f22ab7bd52ed03d8e54f97bebf88b5a1b0b07)) 171 | * basic vite support ([e8f641b](https://github.com/lihbr/akte/commit/e8f641b4864cf28d4d289f1e5ece8af133a92a92)) 172 | * debug & catch-all routes ([af5472a](https://github.com/lihbr/akte/commit/af5472a52122fc568d1e0c47a3962881fb14661a)) 173 | 174 | 175 | ### Bug Fixes 176 | 177 | * server rewrite for subpaths ([993e509](https://github.com/lihbr/akte/commit/993e50947fc8e10c4b2b83995ef04e80557fea2b)) 178 | * typing ([7708bd9](https://github.com/lihbr/akte/commit/7708bd962b5d22cbe50c7502ad3c9371aadd23db)) 179 | 180 | 181 | ### Refactor 182 | 183 | * clean class and helpers ([c7b2860](https://github.com/lihbr/akte/commit/c7b2860baa6dadc713e935fe7711a870822f42d3)) 184 | 185 | ### 0.0.1 (2023-01-07) 186 | 187 | 188 | ### Chore 189 | 190 | * **deps:** fix dependencies ([4e693e6](https://github.com/lihbr/akte/commit/4e693e61864cd79439bf19120ad3bd48e0e25a79)) 191 | * init ([f8d5d9c](https://github.com/lihbr/akte/commit/f8d5d9c12015923fcbaf7e2e3d1ec25444c920b3)) 192 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-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 | Akte logo 4 | 5 |

6 | 7 | # akte 8 | 9 | [![npm version][npm-version-src]][npm-version-href] 10 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 11 | [![Github Actions CI][github-actions-ci-src]][github-actions-ci-href] 12 | [![Codecov][codecov-src]][codecov-href] 13 | [![Conventional Commits][conventional-commits-src]][conventional-commits-href] 14 | [![License][license-src]][license-href] 15 | 16 | > **Warning** 17 | > Akte is still pre-major (pre-1.0.0), it's usable, however, consider pinning the patch version before using. 18 | 19 | A minimal static site (and file) generator. 20 | 21 | - 🚕  Minimal and flexible; 22 | - ⚡  Vite integration; 23 | - 🛰  Serverless ready; 24 | - 🎹  Programmatic API; 25 | - 🌊  Controllable data cascade; 26 | - 🈂  TypeScript supercharged; 27 | - 🗜  Tiny install, 600kB; 28 | - 💼  Portable, 6kB bundle size. 29 | 30 | ## Install 31 | 32 | ```bash 33 | npm install --save-dev akte 34 | ``` 35 | 36 | ## Documentation 37 | 38 | To discover what's new on this package check out [the changelog][changelog]. For full documentation, check out the [official Akte documentation][documentation]. 39 | 40 | ## Contributing 41 | 42 | Whether you're helping me fix bugs, improve the site, or spread the word, I'd love to have you as a contributor! 43 | 44 | **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. 45 | 46 | **Reporting a bug**: [Open an issue][repo-bug-report] explaining your application's setup and the bug you're encountering. 47 | 48 | **Suggesting an improvement**: [Open an issue][repo-feature-request] explaining your improvement or feature so we can discuss and learn more. 49 | 50 | **Submitting code changes**: For small fixes, feel free to [open a pull request][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. 51 | 52 | 53 | 54 | ## License 55 | 56 | [MIT License][license] 57 | 58 | 59 | 60 | [documentation]: https://akte.js.org 61 | [changelog]: ./CHANGELOG.md 62 | [contributing]: ./CONTRIBUTING.md 63 | [license]: ./LICENSE 64 | [repo-question]: https://github.com/lihbr/akte/discussions 65 | [repo-bug-report]: https://github.com/lihbr/akte/issues/new?assignees=&labels=bug&template=bug_report.md&title= 66 | [repo-feature-request]: https://github.com/lihbr/akte/issues/new?assignees=&labels=enhancement&template=feature_request.md&title= 67 | [repo-pull-requests]: https://github.com/lihbr/akte/pulls 68 | 69 | 70 | 71 | [npm-version-src]: https://img.shields.io/npm/v/akte/latest.svg 72 | [npm-version-href]: https://npmjs.com/package/akte 73 | [npm-downloads-src]: https://img.shields.io/npm/dm/akte.svg 74 | [npm-downloads-href]: https://npmjs.com/package/akte 75 | [github-actions-ci-src]: https://github.com/lihbr/akte/workflows/ci/badge.svg 76 | [github-actions-ci-href]: https://github.com/lihbr/akte/actions?query=workflow%3Aci 77 | [codecov-src]: https://img.shields.io/codecov/c/github/lihbr/akte.svg 78 | [codecov-href]: https://codecov.io/gh/lihbr/akte 79 | [conventional-commits-src]: https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg 80 | [conventional-commits-href]: https://conventionalcommits.org 81 | [license-src]: https://img.shields.io/npm/l/akte.svg 82 | [license-href]: https://npmjs.com/package/akte 83 | -------------------------------------------------------------------------------- /docs/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp } from "akte"; 2 | 3 | import { version } from "../package.json"; 4 | 5 | import { pages } from "./files/pages"; 6 | import { sitemap } from "./files/sitemap"; 7 | 8 | export const app = defineAkteApp({ 9 | files: [pages, sitemap], 10 | globalData() { 11 | return { 12 | version, 13 | }; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /docs/akte/markdownToHTML.ts: -------------------------------------------------------------------------------- 1 | import { type Plugin, type Processor, unified } from "unified"; 2 | 3 | import remarkParse from "remark-parse"; 4 | import remarkGfm from "remark-gfm"; 5 | import remarkFrontmatter from "remark-frontmatter"; 6 | import type { VFile } from "vfile"; 7 | import { matter } from "vfile-matter"; 8 | import remarkDirective from "remark-directive"; 9 | import remarkRehype from "remark-rehype"; 10 | 11 | import rehypeSlug from "rehype-slug"; 12 | import rehypeAutolinkHeadings from "rehype-autolink-headings"; 13 | import rehypeToc from "rehype-toc"; 14 | import rehypeStringify from "rehype-stringify"; 15 | 16 | import { common, createStarryNight } from "@wooorm/starry-night"; 17 | 18 | // @ts-expect-error - Cannot resolve type 19 | import ignoreGrammar from "@wooorm/starry-night/source.gitignore"; 20 | 21 | // @ts-expect-error - Cannot resolve type 22 | import tsxGrammar from "@wooorm/starry-night/source.tsx"; 23 | import { visit } from "unist-util-visit"; 24 | import { toString } from "hast-util-to-string"; 25 | import { h } from "hastscript"; 26 | import type { ElementContent, Root as HRoot } from "hast"; 27 | import type { Content, Root as MDRoot } from "mdast"; 28 | 29 | const rehypeStarryNight: Plugin<[], HRoot> = () => { 30 | const starryNightPromise = createStarryNight([ 31 | ...common, 32 | ignoreGrammar, 33 | tsxGrammar, 34 | ]); 35 | const prefix = "language-"; 36 | 37 | return async (tree) => { 38 | const starryNight = await starryNightPromise; 39 | 40 | visit(tree, "element", (node, index, parent) => { 41 | if (!parent || index === null || node.tagName !== "pre") { 42 | return; 43 | } 44 | 45 | const head = node.children[0]; 46 | 47 | if ( 48 | !head || 49 | head.type !== "element" || 50 | head.tagName !== "code" || 51 | !head.properties 52 | ) { 53 | return; 54 | } 55 | 56 | const classes = head.properties.className; 57 | 58 | if (!Array.isArray(classes)) { 59 | return; 60 | } 61 | 62 | const language = classes.find( 63 | (d) => typeof d === "string" && d.startsWith(prefix), 64 | ); 65 | 66 | if (typeof language !== "string") { 67 | return; 68 | } 69 | 70 | const scope = starryNight.flagToScope(language.slice(prefix.length)); 71 | 72 | // Maybe warn? 73 | if (!scope || !index) { 74 | return; 75 | } 76 | 77 | const fragment = starryNight.highlight(toString(head), scope); 78 | const children = fragment.children as ElementContent[]; 79 | 80 | parent.children.splice(index, 1, { 81 | type: "element", 82 | tagName: "figure", 83 | properties: { 84 | className: [ 85 | "highlight", 86 | `highlight-${scope.replace(/^source\./, "").replace(/\./g, "-")}`, 87 | ], 88 | }, 89 | children: [ 90 | { type: "element", tagName: "pre", properties: {}, children }, 91 | ], 92 | }); 93 | }); 94 | }; 95 | }; 96 | 97 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 98 | let processor: Processor; 99 | 100 | export const markdownToHTML = async >( 101 | markdown: string, 102 | ): Promise<{ 103 | matter: TMatter; 104 | html: string; 105 | }> => { 106 | if (!processor) { 107 | processor = unified() 108 | .use(remarkParse) 109 | .use(remarkGfm) 110 | .use(remarkFrontmatter, ["yaml"]) 111 | .use(() => (_tree: MDRoot, file: VFile) => { 112 | matter(file); 113 | }) 114 | .use(remarkDirective) 115 | .use(() => (tree: MDRoot) => { 116 | visit( 117 | tree, 118 | (node: { 119 | type: string; 120 | name?: string; 121 | data?: Record; 122 | attributes?: Record; 123 | children?: Content[]; 124 | }) => { 125 | if ( 126 | node.type === "textDirective" || 127 | node.type === "leafDirective" || 128 | node.type === "containerDirective" 129 | ) { 130 | if (node.name === "callout") { 131 | const data = node.data || (node.data = {}); 132 | const tagName = 133 | node.type === "textDirective" ? "span" : "article"; 134 | const properties = 135 | h(tagName, node.attributes as Record) 136 | .properties || {}; 137 | properties.className ||= []; 138 | (properties.className as string[]).push("callout"); 139 | 140 | const icon = properties.icon as string | undefined; 141 | delete properties.icon; 142 | const title = properties.title as string | undefined; 143 | delete properties.title; 144 | const level = properties.level as 145 | | 1 146 | | 2 147 | | 3 148 | | 4 149 | | 5 150 | | 6 151 | | undefined; 152 | delete properties.level; 153 | 154 | if (icon) { 155 | properties.dataIcon = icon; 156 | } 157 | 158 | const children = node.children || []; 159 | if (title) { 160 | children.unshift({ 161 | type: "heading", 162 | depth: level || 4, 163 | children: [{ type: "text", value: title }], 164 | }); 165 | } 166 | 167 | data.hName = tagName; 168 | data.hProperties = properties; 169 | // @ts-expect-error I don't know how to straighten that :'( 170 | node.children = [{ type: "div", children }]; 171 | } 172 | } 173 | }, 174 | ); 175 | }) 176 | .use(remarkRehype, { allowDangerousHtml: true }) 177 | 178 | .use(rehypeSlug) 179 | .use(rehypeAutolinkHeadings, { behavior: "wrap" }) 180 | .use(rehypeToc, { 181 | headings: ["h2", "h3"], 182 | cssClasses: { 183 | list: "", 184 | listItem: "", 185 | link: "", 186 | }, 187 | }) 188 | .use(() => (tree: HRoot, file: VFile) => { 189 | // Extract nav and wrap article 190 | if ( 191 | tree.children[0].type === "element" && 192 | tree.children[0].tagName === "nav" 193 | ) { 194 | const [nav, ...children] = tree.children; 195 | tree.children = []; 196 | 197 | if ((file.data.matter as Record).toc !== false) { 198 | tree.children.push(nav); 199 | } 200 | 201 | tree.children.push(h("main", ...children)); 202 | } 203 | 204 | visit(tree, "element", (node, index, parent) => { 205 | if (!parent || index === null) { 206 | return; 207 | } 208 | 209 | switch (node.tagName) { 210 | case "nav": 211 | node.children.unshift({ 212 | type: "element", 213 | tagName: "h2", 214 | children: [ 215 | { 216 | type: "text", 217 | value: "Table of Contents", 218 | }, 219 | ], 220 | properties: {}, 221 | }); 222 | 223 | return; 224 | 225 | case "a": 226 | if ( 227 | typeof node.properties?.href === "string" && 228 | /^https?:\/\//.test(node.properties.href) 229 | ) { 230 | node.properties.target = "_blank"; 231 | node.properties.rel = "noopener noreferrer"; 232 | } 233 | 234 | default: 235 | } 236 | }); 237 | }) 238 | 239 | .use(rehypeStarryNight) 240 | .use(rehypeStringify, { allowDangerousHtml: true }); 241 | } 242 | const virtualFile = await processor.process(markdown); 243 | 244 | return { 245 | matter: virtualFile.data.matter as TMatter, 246 | html: virtualFile.toString(), 247 | }; 248 | }; 249 | -------------------------------------------------------------------------------- /docs/assets/css/defaults.css: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | *:focus { 8 | outline: none; 9 | } 10 | *:focus-visible { 11 | outline: 1px solid var(--foreground); 12 | } 13 | 14 | html { 15 | padding: 0; 16 | background: var(--background); 17 | color-scheme: var(--scheme); 18 | color: var(--foreground); 19 | -webkit-text-size-adjust: 100%; 20 | -moz-tab-size: 2; 21 | tab-size: 2; 22 | font-size: 100%; 23 | line-height: 1.5; 24 | font-family: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 25 | } 26 | 27 | body { 28 | margin: 2rem 1rem; 29 | } 30 | 31 | hr { 32 | height: 0; 33 | color: inherit; 34 | border-top-width: 1px; 35 | } 36 | 37 | h1, 38 | h2, 39 | h3, 40 | h4, 41 | h5, 42 | h6 { 43 | font-size: inherit; 44 | font-weight: inherit; 45 | } 46 | 47 | a { 48 | color: inherit; 49 | } 50 | 51 | a:hover, a:focus { 52 | color: var(--flamingo); 53 | } 54 | 55 | code, 56 | kbd, 57 | samp, 58 | pre { 59 | font-family: "Consolas", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 60 | font-size: 1em; 61 | } 62 | 63 | small { 64 | font-size: 80%; 65 | } 66 | 67 | blockquote, 68 | dl, 69 | dd, 70 | h1, 71 | h2, 72 | h3, 73 | h4, 74 | h5, 75 | h6, 76 | hr, 77 | figure, 78 | p, 79 | pre { 80 | margin: 0; 81 | } 82 | 83 | fieldset { 84 | margin: 0; 85 | padding: 0; 86 | } 87 | 88 | legend { 89 | padding: 0; 90 | } 91 | 92 | ol, 93 | ul, 94 | menu { 95 | list-style: none; 96 | margin: 0; 97 | padding: 0; 98 | } 99 | 100 | img, 101 | svg, 102 | video, 103 | canvas, 104 | audio, 105 | iframe, 106 | embed, 107 | object { 108 | display: block; 109 | vertical-align: middle; 110 | } 111 | 112 | img, 113 | video { 114 | max-width: 100%; 115 | height: auto; 116 | } 117 | 118 | header, main, footer { 119 | max-width: 40rem; 120 | margin-left: clamp(0rem, 110vw - 66rem, calc(50vw - 22rem)); 121 | } 122 | 123 | @media (min-width: 860px) { 124 | body { 125 | margin: 2rem; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /docs/assets/css/footer.css: -------------------------------------------------------------------------------- 1 | footer { 2 | margin-top: 4rem; 3 | 4 | & hr { 5 | margin-bottom: 1rem; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/assets/css/header.css: -------------------------------------------------------------------------------- 1 | header { 2 | margin-bottom: 4rem; 3 | display: flex; 4 | flex-wrap: wrap; 5 | justify-content: space-between; 6 | align-items: center; 7 | 8 | & img { 9 | height: 128px; 10 | width: auto; 11 | } 12 | 13 | & figure { 14 | text-align: center; 15 | } 16 | 17 | & ul { 18 | text-align: right; 19 | } 20 | 21 | & a.active { 22 | color: var(--flamingo); 23 | cursor: default; 24 | text-decoration: none; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docs/assets/css/main.css: -------------------------------------------------------------------------------- 1 | main { 2 | & h1, & h2, & h3, & h4 { 3 | color: var(--flamingo); 4 | 5 | & a { 6 | text-decoration: none; 7 | 8 | &:hover, &:focus { 9 | text-decoration: underline; 10 | 11 | &::after { 12 | content: " #"; 13 | } 14 | } 15 | } 16 | } 17 | 18 | & h2 { 19 | margin-top: 4rem; 20 | } 21 | 22 | & h3, & h4 { 23 | margin-top: 2rem; 24 | } 25 | 26 | & h1 { 27 | font-size: 2rem; 28 | } 29 | 30 | & h2 { 31 | font-size: 1.625rem; 32 | } 33 | 34 | & h3 { 35 | font-size: 1.25rem; 36 | } 37 | 38 | & > p, & > blockquote, & > figure, & > ul, & > ol, & > table, & > .callout { 39 | margin-top: 1rem; 40 | } 41 | 42 | & ul, & ol { 43 | list-style-position: inside; 44 | } 45 | 46 | & ul ul, & ul ol, & ol ol, & ol ul { 47 | margin-left: 1.5rem; 48 | } 49 | 50 | & ul { 51 | list-style-type: disc; 52 | } 53 | 54 | & ol { 55 | list-style-type: numeric; 56 | } 57 | 58 | & blockquote { 59 | border-left: 2px solid var(--flamingo); 60 | padding-left: .75em; 61 | font-style: italic; 62 | } 63 | 64 | & p code, & li code, & figure.highlight { 65 | color: var(--altForeground); 66 | } 67 | 68 | & h2 code, & h3 code, & h4 code, & p code, & li code, & table code { 69 | background: var(--altBackground); 70 | padding: .25em; 71 | margin: -.25em 0; 72 | } 73 | 74 | & table { 75 | width: 100%; 76 | border-collapse: collapse; 77 | 78 | & th { 79 | color: var(--flamingo); 80 | text-align: left; 81 | font-weight: 400; 82 | } 83 | 84 | & th, & td { 85 | padding: .5rem 0; 86 | } 87 | 88 | & tbody tr { 89 | border-top: 1px solid var(--dimmedForeground); 90 | } 91 | } 92 | 93 | & figure.highlight { 94 | background: var(--altBackground); 95 | overflow: auto; 96 | padding: .5rem; 97 | } 98 | 99 | & .callout { 100 | display: flex; 101 | padding: .5rem; 102 | align-items: flex-start; 103 | gap: 1rem; 104 | background: var(--altBackground); 105 | color: var(--dimmedForeground); 106 | 107 | &::before { 108 | content: attr(data-icon); 109 | padding: .5rem; 110 | margin: .5rem 0; 111 | background: var(--flamingo); 112 | clip-path: polygon(12% 0%, 100% 11%, 92% 88%, 0% 100%); 113 | } 114 | 115 | & h2, & h3, & h4 { 116 | margin-top: 0; 117 | font-size: 1rem; 118 | color: var(--foreground); 119 | } 120 | } 121 | 122 | & a:has(code) { 123 | & code:hover, & code:focus { 124 | color: var(--flamingo); 125 | } 126 | } 127 | 128 | 129 | & a.button { 130 | display: inline-block; 131 | text-decoration: none; 132 | background: var(--flamingo); 133 | color: var(--background); 134 | padding: 1rem; 135 | clip-path: polygon(12% 0%, 100% 11%, 92% 88%, 0% 100%); 136 | transition: clip-path cubic-bezier(.54,.1,0,.99) .25s; 137 | font-size: 1.25rem; 138 | font-weight: 700; 139 | 140 | &:hover, &:focus { 141 | clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); 142 | } 143 | } 144 | 145 | & a[data-footnote-ref] { 146 | text-decoration: none; 147 | 148 | &::before { 149 | content: "[" 150 | } 151 | 152 | &::after { 153 | content: "]" 154 | } 155 | } 156 | 157 | & section[data-footnotes] { 158 | color: var(--dimmedForeground); 159 | margin-top: 4rem; 160 | font-size: .875rem; 161 | 162 | & h2 { 163 | display: none; 164 | } 165 | 166 | & p { 167 | display: inline; 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /docs/assets/css/nav.css: -------------------------------------------------------------------------------- 1 | nav.toc { 2 | position: fixed; 3 | left: clamp(44rem, 110vw - 66rem + 44rem, calc(50vw + 22rem)); 4 | top: 16.5rem; 5 | bottom: 2rem; 6 | right: 2rem; 7 | display: none; 8 | flex-direction: column; 9 | overflow: hidden; 10 | 11 | & h2 { 12 | color: var(--flamingo); 13 | font-size: 1rem; 14 | margin-top: 0; 15 | margin-bottom: 1rem; 16 | } 17 | 18 | & > ol { 19 | overflow: auto; 20 | flex: 1; 21 | } 22 | 23 | & ol { 24 | list-style-type: none; 25 | } 26 | 27 | & ol ol { 28 | margin-left: 1rem; 29 | } 30 | 31 | & a[href^="#-breaking-changes"], 32 | & a[href^="#documentation"], 33 | & a[href^="#features"], 34 | & a[href^="#bug-fixes"], 35 | & a[href^="#refactor"], 36 | & a[href^="#chore"], 37 | & a[href^="#footnote-label"] { 38 | display: none; 39 | } 40 | } 41 | 42 | @media (min-width: 860px) { 43 | nav.toc { 44 | display: flex; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docs/assets/css/properties.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #fffefe; 3 | --foreground: #1f1919; 4 | --dimmedForeground: #594646; 5 | --altBackground: #faf1f1; 6 | --altForeground: #131010; 7 | --scheme: light; 8 | --flamingo: #e84311; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --background: #131010; 14 | --foreground: #faf1f1; 15 | --dimmedForeground: #cdbcbc; 16 | --altBackground: #1f1919; 17 | --altForeground: #fffefe; 18 | --scheme: dark; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/assets/css/style.css: -------------------------------------------------------------------------------- 1 | @import "@fontsource/ibm-plex-sans"; 2 | @import "@wooorm/starry-night/style/both"; 3 | 4 | @import "./properties"; 5 | @import "./defaults"; 6 | @import "./header"; 7 | @import "./nav"; 8 | @import "./main"; 9 | @import "./footer"; -------------------------------------------------------------------------------- /docs/assets/js/base.ts: -------------------------------------------------------------------------------- 1 | import Plausible from "plausible-tracker"; 2 | 3 | const plausible = Plausible({ 4 | domain: "akte.js.org", 5 | trackLocalhost: true, 6 | apiHost: "https://akte.js.org/p7e", 7 | }); 8 | 9 | type Event< 10 | TType = string, 11 | TProps extends Record | void = void, 12 | > = TProps extends void 13 | ? { 14 | event: TType; 15 | props?: Record; 16 | data?: Record; 17 | } 18 | : { 19 | event: TType; 20 | props: TProps; 21 | data?: Record; 22 | }; 23 | 24 | type PageViewEvent = Event<"pageView">; 25 | type OutboundLinkClickEvent = Event<"outboundLink:click", { url: string }>; 26 | 27 | type TrackEventArgs = PageViewEvent | OutboundLinkClickEvent; 28 | 29 | const MachineToHumanEventTypes: Record = { 30 | "pageView": "pageview", 31 | "outboundLink:click": "Outbound Link: Click", 32 | }; 33 | 34 | const trackEvent = (args: TrackEventArgs): Promise => { 35 | return new Promise((resolve) => { 36 | plausible.trackEvent( 37 | MachineToHumanEventTypes[args.event], 38 | { 39 | callback: resolve, 40 | props: args.props, 41 | }, 42 | args.data, 43 | ); 44 | }); 45 | }; 46 | 47 | // Page view 48 | if (location.host !== "akte.js.org") { 49 | // Welcome page 50 | if (document.title.toLowerCase().includes("welcome")) { 51 | trackEvent({ 52 | event: "pageView", 53 | data: { 54 | url: "https://akte.js.org/welcome", 55 | domain: "akte.js.org", 56 | }, 57 | }); 58 | } 59 | } else { 60 | // Documentation 61 | trackEvent({ event: "pageView" }); 62 | } 63 | 64 | // Outbound links (using custom solution because Plausible implementation has issues) 65 | document.querySelectorAll("a").forEach((node) => { 66 | if (node.host !== location.host) { 67 | const trackOutboundLink = (event: MouseEvent) => { 68 | trackEvent({ 69 | event: "outboundLink:click", 70 | props: { url: node.href }, 71 | }); 72 | 73 | if (!node.target) { 74 | event.preventDefault(); 75 | setTimeout(() => { 76 | location.href = node.href; 77 | }, 150); 78 | } 79 | }; 80 | node.addEventListener("click", trackOutboundLink); 81 | node.addEventListener("auxclick", (event) => { 82 | if (event.button === 1) { 83 | trackOutboundLink(event); 84 | } 85 | }); 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /docs/content/404.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 404 Not Found 3 | toc: false 4 | --- 5 | 6 | # 404 Not Found 7 | 8 | This page does not exists. 9 | 10 | ![Sad Pablo](/sad-pablo.gif) 11 | -------------------------------------------------------------------------------- /docs/content/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API References 3 | --- 4 | 5 | # API References 6 | 7 | Exhaustive but simplified Akte API references. 8 | 9 | ## Classes 10 | 11 | ### `AkteApp` 12 | 13 | :::callout{icon=🈂 title="Type export only"} 14 | Only the type is exported, to create an instance of `AkteApp`, see [`defineAkteApp`](#defineakteapp). 15 | ::: 16 | 17 | An Akte app, ready to be interacted with. 18 | 19 | #### `files` 20 | 21 | Readonly array of Akte files registered within the app. 22 | 23 | ```typescript 24 | AkteFiles[]; 25 | ``` 26 | 27 | #### `lookup` 28 | 29 | Looks up the Akte file responsible for rendering the path. 30 | 31 | ```typescript 32 | (path: string) => Match; 33 | ``` 34 | 35 | Throws `NotFoundError` when path could not be looked up. 36 | 37 | #### `render` 38 | 39 | Renders a match from `lookup`. 40 | 41 | ```typescript 42 | (match: Match) => Promise; 43 | ``` 44 | 45 | Throws `NotFoundError` when match could not be rendered. 46 | 47 | #### `renderAll` 48 | 49 | Renders all Akte files. 50 | 51 | ```typescript 52 | () => Promise>; // Record 53 | ``` 54 | 55 | #### `writeAll` 56 | 57 | Writes a map of rendered Akte files. 58 | 59 | ```typescript 60 | (args: { 61 | outDir?: string; // Defaults to the app configured one, or `"dist"` 62 | files: Record; 63 | }) => Promise; 64 | ``` 65 | 66 | #### `buildAll` 67 | 68 | Builds (renders and writes) all Akte files. 69 | 70 | ```typescript 71 | (args: { 72 | outDir?: string; // Defaults to the app configured one, or `"dist"` 73 | }) => Promise; // Built files 74 | ``` 75 | 76 | #### `clearCache` 77 | 78 | Akte caches all `globalData`, `bulkData`, `data` calls for performance. The `clearCache` method allows you to clear these caches. 79 | 80 | ```typescript 81 | // Only clears global data cache unless `true` 82 | (alsoClearFileCache?: boolean) => void; 83 | ``` 84 | 85 | #### `globalDataCache` 86 | 87 | Readonly cache of the app's definition `globalData` method. 88 | 89 | ```typescript 90 | Awaitable | undefined; 91 | ``` 92 | 93 | #### `getGlobalData` 94 | 95 | Retrieves data from the app's definition `globalData` method. 96 | 97 | ```typescript 98 | () => Awaitable; 99 | ``` 100 | 101 | ### `AkteFiles` 102 | 103 | :::callout{icon=🈂 title="Type export only"} 104 | Only the type is exported, to create an instance of `AkteFiles`, see [`defineAkteFile`](#defineaktefile) and [`defineAkteFiles`](#defineaktefiles). 105 | ::: 106 | 107 | An Akte files, managing its data cascade and rendering process. 108 | 109 | :::callout{icon=🤫 title="Internal API (for now)"} 110 | Methods and properties from the `AkteFiles` class are not documented for now and to be considered internal. They are the ones that are the most likely to evolve. 111 | 112 | If you're looking to render a single file, don't be afraid to spin up and `AkteApp`, even if it's just for it. 113 | 114 |
115 | 116 | ```typescript 117 | import { defineAkteApp } from "akte"; 118 | import { foo } from "./foo"; // An `AkteFiles` instance 119 | 120 | const app = defineAkteApp({ files: [foo] }); 121 | 122 | const file = await app.render(app.lookup("/foo")); 123 | ``` 124 | ::: 125 | 126 | ### `NotFoundError` 127 | 128 | Creates a 404 error. To be used within Akte files definition `data` function. 129 | 130 | ```typescript 131 | import { NotFoundError } from "akte"; 132 | 133 | new NotFoundError(path); // Pure 404 134 | new NotFoundError(path, { cause }); // Maybe 500 135 | ``` 136 | 137 | ## Factories 138 | 139 | ### `defineAkteApp` 140 | 141 | Creates an Akte app from given configuration. 142 | 143 | ```typescript 144 | import { defineAkteApp } from "akte"; 145 | 146 | defineAkteApp(config); 147 | defineAkteApp(config); 148 | ``` 149 | 150 | #### Config 151 | 152 | *[Simplified from sources~](https://github.com/lihbr/akte/blob/master/src/AkteApp.ts#L25-L63)* 153 | 154 | ```typescript 155 | type Config = { 156 | // Akte files this config is responsible for. 157 | files: AkteFiles[]; 158 | 159 | // Required when global data type is defined or inferred. 160 | globalData?: () => Awaitable; 161 | 162 | // Configuration related to Akte build process. 163 | build?: { 164 | // Only used by the CLI build command, defaults to `"dist"` 165 | outDir?: string; 166 | }; 167 | }; 168 | ``` 169 | 170 | ### `defineAkteFile` 171 | 172 | Creates an Akte files instance for a single file from a definition. 173 | 174 | ```typescript 175 | import { defineAkteFile } from "akte"; 176 | 177 | defineAkteFile().from(definition); 178 | defineAkteFile().from(definition); 179 | defineAkteFile().from(definition); 180 | ``` 181 | 182 | #### Definition 183 | 184 | *[Simplified from sources~](https://github.com/lihbr/akte/blob/master/src/defineAkteFile.ts#L9-L12)* 185 | 186 | ```typescript 187 | type Definition = { 188 | // Path for the Akte file, e.g. `/about` or `/sitemap.xml` 189 | path: string; 190 | 191 | // Required when data type is defined. 192 | data?: (context: { 193 | path: string; 194 | globalData: TGlobalData; 195 | }) => Awaitable; 196 | 197 | // Function to render the file. 198 | render: (context: { 199 | path: string; 200 | globalData: TGlobalData; 201 | data: TData; 202 | }) => Awaitable; 203 | }; 204 | ``` 205 | 206 | ### `defineAkteFiles` 207 | 208 | Creates an Akte files instance from a definition. 209 | 210 | ```typescript 211 | import { defineAkteFiles } from "akte"; 212 | 213 | defineAkteFiles().from(definition); 214 | defineAkteFiles().from(definition); 215 | defineAkteFiles().from(definition); 216 | defineAkteFiles().from(definition); 217 | ``` 218 | 219 | #### Definiton 220 | 221 | *[Simplified from sources~](https://github.com/lihbr/akte/blob/master/src/AkteFiles.ts#L50-L94)* 222 | 223 | ```typescript 224 | type Definition = { 225 | // Path pattern for the Akte files, e.g. `/posts/:slug` or `/**` 226 | path: string; 227 | 228 | // Inferred from `bulkData` when not provided. Used for 229 | // optimization when rendering only one file (e.g. for serverless) 230 | data?: (context: { 231 | path: string; 232 | params: Record; 233 | globalData: TGlobalData; 234 | }) => Awaitable; 235 | 236 | // Required when data type is defined. 237 | bulkData?: (context: { 238 | globalData: TGlobalData; 239 | }) => Awaitable>; 240 | 241 | // Function to render each file. 242 | render: (context: { 243 | path: string; 244 | globalData: TGlobalData; 245 | data: TData; 246 | }) => Awaitable; 247 | }; 248 | ``` 249 | 250 | ## Vite 251 | 252 | ### `akte` (plugin) 253 | 254 | Akte Vite plugin factory. 255 | 256 | ```typescript 257 | import { defineConfig } from "vite"; 258 | import akte from "akte/vite"; 259 | import { app } from "./akte.app"; 260 | 261 | export default defineConfig({ 262 | plugins: [akte({ app, ...options })], 263 | }); 264 | ``` 265 | 266 | #### Options 267 | 268 | *[Simplified from sources~](https://github.com/lihbr/akte/blob/master/src/vite/types.ts#L6-L33)* 269 | 270 | ```typescript 271 | type Options = { 272 | app: AkteApp; 273 | 274 | // Has to be a children of Vite `root` directory. 275 | // Defaults to `".akte"` 276 | cacheDir?: string; 277 | 278 | // On by defaults, requires `html-minifier-terser` to be installed. 279 | minifyHTML?: boolean : MinifyHTMLOptions; 280 | } 281 | ``` 282 | 283 | *[See `html-minifier-terser` documentation](https://github.com/terser/html-minifier-terser#options-quick-reference) for available options.* 284 | 285 | ## CLI 286 | 287 | Akte integrates a small CLI for minimal use cases allowing you to build a given app without processing it through [Vite](#vite). The CLI can be run by executing your Akte app configuration. 288 | 289 | ### Usage 290 | 291 | ```bash 292 | node akte.app.js 293 | npx tsx akte.app.ts 294 | ``` 295 | 296 | ### Commands 297 | 298 | | Command | Description | 299 | | ------- | -------------------------- | 300 | | `build` | Build the current Akte app | 301 | 302 | ### Flags 303 | 304 | | Flag | Description | 305 | | ----------------- | --------------- | 306 | | `--silent`, `-s` | Silence output | 307 | | `--help`, `-h` | Display help | 308 | | `--version`, `-v` | Display version | 309 | -------------------------------------------------------------------------------- /docs/content/comparisons.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Comparisons 3 | --- 4 | 5 | # Comparisons 6 | 7 | Overall, the main difference between Akte and the tools listed below is that Akte has a much smaller scope than any of them, and is much lower level. 8 | 9 | These comparisons exist to help share a better picture of where Akte stands next to other tools. 10 | 11 | :::callout{icon=❔ title="Noticed an error?"} 12 | If you noticed any error, or think a statement is unclear or wrong, [feel free to open a PR](https://github.com/lihbr/akte/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc)~ 13 | ::: 14 | 15 | ## 11ty 16 | 17 | [11ty](https://11ty.dev) is an awesome static site generator which I had the opportunity to work with a lot[^1]. Among the other tools listed here, 11ty might be the closest one to Akte. 18 | 19 | 11ty and Akte both can: 20 | - Generate various kinds of files 21 | - Be used programmatically 22 | - Fully or partially run in serverless environments 23 | - Provide integration with Vite 24 | 25 | Unlike 11ty, Akte: 26 | - Is TypeScript first and allows you to type all your data[^2] 27 | - Exports [ESM](https://nodejs.org/api/esm.html) code ([CJS](https://nodejs.org/api/modules.html) exports are also available) 28 | - Does not natively integrate with any template language 29 | - Does not have (yet?) a dedicated plugin API 30 | 31 | ## Astro 32 | 33 | [Astro](https://astro.build) is an all-in-one web framework embracing the [island architecture](https://jasonformat.com/islands-architecture) and providing integrations with most JavaScript frameworks. 34 | 35 | Overall, Astro is **much more** featureful than Akte but lacks some flexibility coming to its serverless integrations[^3], but it's improving fast. Astro also does not seem to offer a programmatic API. 36 | 37 | [^1]: I [created and maintain 11ty plugins](https://github.com/prismicio-community/eleventy-plugin-prismic), had the opportunity to [give talks about 11ty](https://lihbr.com/talks/11ties/integrating-11ty-with-a-cms-and-making-it-cool-to-use), and [my website](https://lihbr.com), among others, was built with 11ty 38 | [^2]: [It's fair to note 11ty can run TypeScript with 3rd party tools](https://gist.github.com/zachleat/b274ee939759b032bc320be1a03704a2) 39 | [^3]: Namely, Astro is not able to have a same page used both at build time and in a serverless environment, which has been a dealbreaker for me on previous occasions, this might be worked around by leveraging [Astro integration API](https://docs.astro.build/en/reference/integrations-reference) 40 | -------------------------------------------------------------------------------- /docs/content/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | # Examples 6 | 7 | Here are some examples showcasing what Akte enables. 8 | 9 | ## Common 10 | 11 | :::callout{icon=👋 title="Basic" level=3} 12 | This example shows basic usage of Akte with JavaScript or TypeScript. When executed, an Akte app configuration behaves as a minimal CLI. This is helpful for simple use cases. 13 | 14 |
15 | 16 | [Check it out on GitHub ›](https://github.com/lihbr/akte/tree/master/examples/common/basic) 17 | ::: 18 | 19 | :::callout{icon=⚾ title="Catch-all" level=3} 20 | This example shows usage of catch-all routes. This is helpful for rendering similar files that aren't living under the same hierarchy. 21 | 22 |
23 | 24 | [Check it out on GitHub ›](https://github.com/lihbr/akte/tree/master/examples/common/catch-all) 25 | ::: 26 | 27 | :::callout{icon=🆔 title="Non-HTML" level=3} 28 | This example shows usage of Akte to render non-HTML files. This is helpful for rendering any kind of asset, XML, JSON, etc. 29 | 30 |
31 | 32 | [Check it out on GitHub ›](https://github.com/lihbr/akte/tree/master/examples/common/non-html) 33 | ::: 34 | 35 | ## Vite 36 | 37 | :::callout{icon=⚡ title="Basic" level=3} 38 | This example shows usage of Akte as a [Vite](https://vitejs.dev) plugin. This is helpful for processing assets of any sort as well as taking advantage of Vite great developer experience while developing. 39 | 40 |
41 | 42 | [Open in Stackblitz ›](https://stackblitz.com/github/lihbr/akte/tree/master/examples/vite/basic?file=files%2Findex.ts&theme=dark)
43 | [Check it out on GitHub ›](https://github.com/lihbr/akte/tree/master/examples/vite/basic) 44 | ::: 45 | 46 | ## Programmatic 47 | 48 | :::callout{icon=🎹 title="Basic" level=3} 49 | This example shows programmatic usage of Akte. This is helpful for running Akte in various environments, including serverless. 50 | 51 |
52 | 53 | [Check it out on GitHub ›](https://github.com/lihbr/akte/tree/master/examples/programmatic/basic) 54 | ::: 55 | -------------------------------------------------------------------------------- /docs/content/get-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Get Started 3 | --- 4 | 5 | # Get Started 6 | 7 | Getting started with Akte takes a few minutes and consists of two things: 8 | 1. Setting up Akte 9 | 2. Picking up a development flavor 10 | 11 | :::callout{icon=🎠 title="Don't want to commit yet?"} 12 | Have a look at [the examples](/examples) to get a feeling of what Akte looks like, [some are even available on Stackblitz](/examples#vite). 13 | ::: 14 | 15 | ## Akte setup 16 | 17 | 1. Install Akte 18 | 19 | ```bash 20 | npm install --save-dev akte 21 | ``` 22 | 23 | 2. Create an `akte.app.ts`[^1] file 24 | 25 | ```typescript 26 | import { defineAkteApp } from "akte"; 27 | 28 | export const app = defineAkteApp({ 29 | files: [], 30 | }); 31 | ``` 32 | 33 | :::callout{icon=⚙ title="Configuration"} 34 | Discover more configuration options in the [app configuration ›](/api#defineakteapp) 35 | ::: 36 | 37 | ## Development Flavor 38 | 39 | Don't be afraid to make a wrong choice here as you can easily switch between development flavors. 40 | 41 | ### Akte CLI 42 | 43 | Akte CLI is minimal and only allows you to build your Akte app. Use it over Vite, when you don't need Vite or just want to experiment with Akte. 44 | 45 | To run Akte CLI and build your app, just execute your `akte.app.ts` file. 46 | 47 | ```bash 48 | node akte.app.js build 49 | npx tsx akte.app.ts build 50 | ``` 51 | 52 | Your built app will be available in the `dist` directory. 53 | 54 | :::callout{icon=🕹 title="Usage"} 55 | Discover more about the CLI usage in the [CLI references ›](/api#cli) 56 | ::: 57 | 58 | ### Vite 59 | 60 | Akte integrates with [Vite](https://vitejs.dev), this allows you to leverage Vite development server and assets processing pipeline to enrich your app. 61 | 62 | 1. Install additional dependencies[^2] 63 | 64 | ```bash 65 | npm install --save-dev vite html-minifier-terser 66 | ``` 67 | 68 | 2. Create or update your `vite.config.ts` file 69 | 70 | ```typescript 71 | import { defineConfig } from "vite"; 72 | import akte from "akte/vite"; 73 | import { app } from "./akte.app"; 74 | 75 | export default defineConfig({ 76 | plugins: [akte({ app })], 77 | }); 78 | ``` 79 | 80 | 3. Add `.akte` to your `.gitignore` file 81 | 82 | ```ignore 83 | .akte 84 | ``` 85 | 86 | You're ready to start developing your Akte app through Vite. 87 | 88 | ```bash 89 | npx vite 90 | npx vite build 91 | ``` 92 | 93 | 94 | :::callout{icon=⚙ title="Configuration"} 95 | Discover more configuration options in the [Vite plugin configuration ›](/api#akte-plugin) 96 | ::: 97 | 98 | ## Next steps 99 | 100 | Well done! Whether you opted for the CLI or Vite, you're ready to start developing with Akte. 101 | 102 | - [Check out the guide to learn more about Akte usage ›](/guide) 103 | - [Check out the examples to get inspiration ›](/examples) 104 | 105 | [^1]: Akte also works with plain JavaScript! While most of the snippets in the documentation are TypeScript, don't be afraid of them. Everything works the same way or is indicated so when otherwise~ 106 | [^2]: You can omit `html-minifier-terser` if you don't want HTML to be minified by Akte. You will need to disable explicitly the `minifyHTML` option of the Vite plugin, [see its configuration](/api#akte-plugin). 107 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: A minimal static site (and file) generator 3 | toc: false 4 | --- 5 | 6 | # Akte 7 | 8 | :::callout{icon=⚠ title="Pre-major"} 9 | Akte is still pre-major (pre-1.0.0), it's usable (this website is built with Akte), however, consider pinning the patch version before using. 10 | ::: 11 | 12 | A minimal static site (and file) generator. 13 | 14 |
15 | Get started › 16 | GitHub › 17 |
18 | 19 | ## Overview 20 | 21 | Akte offers a lightweight API to generate files (e.g. `/about`, `/sitemap.xml`, `/post/:slug`, etc.) that is portable and capable to run in various environments. 22 | 23 | :::callout{icon=🚕 title="Minimal and flexible"} 24 | Akte template literals-based rendering lets you perform any kind of string manipulation when rendering files. 25 | ::: 26 | 27 | :::callout{icon=⚡ title="Vite integration"} 28 | Take advantage of Vite development server and assets processing pipeline to enrich your app. 29 | ::: 30 | 31 | :::callout{icon=🛰 title="Serverless ready"} 32 | Import your Akte app on your serverless handlers and run it intuitively. 33 | ::: 34 | 35 | :::callout{icon=🎹 title="Programmatic API"} 36 | Manipulate your app with fine-grain control through Akte programmatic API. 37 | ::: 38 | 39 | :::callout{icon=🌊 title="Controllable data cascade"} 40 | Control data globally and on a per-file basis with single-file optimization available. 41 | ::: 42 | 43 | :::callout{icon=🈂 title="TypeScript supercharged"} 44 | Types are inferred in most cases and controllable easily to prevent errors. 45 | ::: 46 | 47 | :::callout{icon=🗜 title="Tiny install, 600kB"} 48 | Ensures fast installs and setup, see on [Package Phobia](https://packagephobia.com/result?p=akte). 49 | ::: 50 | 51 | :::callout{icon=💼 title="Portable, 6kB bundle size"} 52 | Akte is fully tree-shakeable allowing for smooth runs in constrained environments, see on [Bundle Phobia](https://bundlephobia.com/package/akte@0.1.0). 53 | ::: 54 | 55 | ### Akte? 56 | 57 | Akte is German for "file", it is pronounced [`[ˈaktə]`](https://upload.wikimedia.org/wikipedia/commons/b/bf/De-Akte.ogg). While "Datei" is more commonly used in German when referring to a computer file, I preferred "Akte". _It ain't a democracy!_ 58 | -------------------------------------------------------------------------------- /docs/content/welcome.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome! 3 | toc: false 4 | --- 5 | 6 | # Welcome to Akte! 7 | 8 | ## Get started 9 | 10 | Remove this welcome page by adding your first Akte file to your `akte.app.ts` configuration[^1]. 11 | 12 | ```typescript 13 | import { defineAkteFile, defineAkteApp } from "akte"; 14 | 15 | const myFirstFile = defineAkteFile().from({ 16 | path: "/", 17 | render() { 18 | return "Hello World!"; 19 | }, 20 | }); 21 | 22 | export const app = defineAkteApp({ 23 | files: [myFirstFile], 24 | }); 25 | ``` 26 | 27 | ## Documentation 28 | 29 | Learn more about Akte, browse references, and find examples on [Akte documentation](https://akte.js.org?source=welcome). 30 | 31 | [^1]: This file is only shown in development when no other Akte files are registered within your `akte.app.ts` configuration. When bundling Akte (e.g. for serverless usage), this file gets tree shaken out of the bundle. 32 | -------------------------------------------------------------------------------- /docs/files/pages.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import * as fs from "node:fs/promises"; 3 | 4 | import { defineAkteFiles } from "akte"; 5 | import { globby } from "globby"; 6 | 7 | import { markdownToHTML } from "../akte/markdownToHTML"; 8 | import { base } from "../layouts/base"; 9 | 10 | const contentDir = path.resolve(__dirname, "../content"); 11 | 12 | const readFile = (file: string): Promise => { 13 | return fs.readFile(path.resolve(contentDir, file), "utf-8"); 14 | }; 15 | 16 | export const pages = defineAkteFiles<{ version: string }>().from({ 17 | path: "/**", 18 | async bulkData() { 19 | const pagePaths = await globby("**/*.md", { cwd: contentDir }); 20 | 21 | type Matter = { 22 | title?: string; 23 | toc?: boolean; 24 | }; 25 | 26 | const pages: Record< 27 | string, 28 | { 29 | html: string; 30 | matter: Matter; 31 | } 32 | > = {}; 33 | 34 | for (const pagePath of pagePaths) { 35 | const path = `/${pagePath.replace(/(index)?\.md/, "")}`; 36 | pages[path] = await markdownToHTML(await readFile(pagePath)); 37 | } 38 | 39 | pages["/changelog"] = await markdownToHTML( 40 | await readFile("../../CHANGELOG.md"), 41 | ); 42 | 43 | return pages; 44 | }, 45 | render(context) { 46 | const navigation = Object.entries({ 47 | "/get-started": "Get started", 48 | "/guide": "Guide", 49 | "/api": "API", 50 | "/examples": "Examples", 51 | "/comparisons": "Comparisons", 52 | }).map(([path, label]) => { 53 | return /* html */ `
  • 54 | ${label} 55 |
  • `; 56 | }); 57 | 58 | const slot = /* html */ ` 59 |
    60 |
    61 | Akte Logo 62 | v${ 63 | context.globalData.version 64 | } - GitHub 65 |
    66 | 69 |
    70 | ${context.data.html} 71 | 80 | `; 81 | 82 | return base(slot, { path: context.path, title: context.data.matter.title }); 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /docs/files/sitemap.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | 3 | import { defineAkteFile } from "akte"; 4 | import { globby } from "globby"; 5 | 6 | const contentDir = path.resolve(__dirname, "../content"); 7 | 8 | export const sitemap = defineAkteFile().from({ 9 | path: "/sitemap.xml", 10 | async data() { 11 | const pagePaths = await globby("**/*.md", { cwd: contentDir }); 12 | const pages: string[] = []; 13 | 14 | for (const pagePath of pagePaths) { 15 | pages.push(`/${pagePath.replace(/(index)?\.md/, "")}`); 16 | } 17 | 18 | pages.push("/changelog"); 19 | 20 | return pages.filter((page) => page !== "/404"); 21 | }, 22 | render(context) { 23 | const now = new Date().toISOString().replace(/\..+/, "+0000"); 24 | 25 | const urls = context.data 26 | .map((page) => { 27 | return /* xml */ ` 28 | https://akte.js.org${page} 29 | ${now} 30 | `; 31 | }) 32 | .join("\n"); 33 | 34 | const slot = /* xml */ ` 35 | 36 | ${urls} 37 | 38 | `; 39 | 40 | return slot; 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /docs/layouts/base.ts: -------------------------------------------------------------------------------- 1 | export const base = ( 2 | slot: string, 3 | args: { 4 | path: string; 5 | title?: string; 6 | }, 7 | ): string => { 8 | const docURL = "https://akte.js.org"; 9 | const title = args.title ? `Akte - ${args.title}` : "Akte"; 10 | const description = 11 | "Akte is a minimal file generator to make websites with an integrated data cascade. It integrates with Vite, has serverless capabilities, and is all nicely typed~"; 12 | 13 | return /* html */ ` 14 | 15 | 16 | 17 | 18 | 19 | ${title} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ${slot} 49 | 50 | 51 | `; 52 | }; 53 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "cross-env DEBUG=akte:* vite", 8 | "build": "cross-env DEBUG=akte:* vite build", 9 | "preview": "cross-env DEBUG=akte:* vite preview" 10 | }, 11 | "dependencies": { 12 | "@fontsource/ibm-plex-sans": "^5.0.20", 13 | "@wooorm/starry-night": "^3.3.0", 14 | "globby": "^13.2.2", 15 | "hast": "^1.0.0", 16 | "hast-util-to-string": "^3.0.0", 17 | "hastscript": "^8.0.0", 18 | "mdast": "^3.0.0", 19 | "plausible-tracker": "^0.3.8", 20 | "rehype-autolink-headings": "^7.1.0", 21 | "rehype-slug": "^6.0.0", 22 | "rehype-stringify": "^10.0.0", 23 | "rehype-toc": "^3.0.2", 24 | "remark-directive": "^3.0.0", 25 | "remark-frontmatter": "^5.0.0", 26 | "remark-gfm": "^4.0.0", 27 | "remark-parse": "^11.0.0", 28 | "remark-rehype": "^11.1.0", 29 | "unified": "^11.0.4", 30 | "unist-util-visit": "^5.0.0", 31 | "vfile": "^6.0.1", 32 | "vfile-matter": "^5.0.0" 33 | }, 34 | "devDependencies": { 35 | "akte": "latest", 36 | "autoprefixer": "^10.4.19", 37 | "cross-env": "^7.0.3", 38 | "cssnano": "^6.1.2", 39 | "html-minifier-terser": "^7.2.0", 40 | "postcss-import": "^16.1.0", 41 | "postcss-nesting": "^12.1.2", 42 | "vite": "^5.2.11" 43 | } 44 | } -------------------------------------------------------------------------------- /docs/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "postcss-nesting": {}, 5 | "autoprefixer": process.env.NODE_ENV === "production" ? {} : false, 6 | "cssnano": process.env.NODE_ENV === "production" ? {} : false, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/favicon-48x48.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/icon.png -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/public/meta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/meta.png -------------------------------------------------------------------------------- /docs/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/mstile-150x150.png -------------------------------------------------------------------------------- /docs/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | 3 | Allow: / 4 | 5 | Disallow: /404 6 | 7 | Sitemap: https://lihbr.com/sitemap.xml 8 | -------------------------------------------------------------------------------- /docs/public/sad-pablo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lihbr/akte/3757adb38dba3fdcd953bd1bcc006594666d28a1/docs/public/sad-pablo.gif -------------------------------------------------------------------------------- /docs/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 55 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Akte", 3 | "short_name": "Akte", 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 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import akte from "akte/vite"; 3 | 4 | import { app } from "./akte.app"; 5 | 6 | export default defineConfig({ 7 | build: { 8 | cssCodeSplit: false, 9 | emptyOutDir: true, 10 | rollupOptions: { 11 | output: { 12 | entryFileNames: "assets/js/[name].js", 13 | chunkFileNames: "assets/js/[name].js", 14 | assetFileNames: (assetInfo) => { 15 | const extension = assetInfo.name?.split(".").pop(); 16 | 17 | switch (extension) { 18 | case "css": 19 | return "assets/css/[name][extname]"; 20 | 21 | case "woff": 22 | case "woff2": 23 | return "assets/fonts/[name][extname]"; 24 | 25 | default: 26 | return "assets/[name][extname]"; 27 | } 28 | }, 29 | }, 30 | }, 31 | }, 32 | plugins: [ 33 | akte({ app }), 34 | { 35 | name: "markdown:watch", 36 | configureServer(server) { 37 | // Hot reload on Markdown updates 38 | server.watcher.add("content"); 39 | server.watcher.on("change", (path) => { 40 | if (path.endsWith(".md")) { 41 | app.clearCache(true); 42 | server.ws.send({ 43 | type: "full-reload", 44 | }); 45 | } 46 | }); 47 | }, 48 | }, 49 | ], 50 | }); 51 | -------------------------------------------------------------------------------- /examples/common/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic 2 | 3 | This example shows basic usage of Akte with JavaScript or TypeScript. When executed, an Akte app configuration behaves as a minimal CLI. This is helpful for simple use cases. 4 | 5 | Running either of the `akte.app` file build command results in the Akte project being built under the `dist` folder. 6 | 7 | ```bash 8 | # JavaScript 9 | node akte.app.js # Displays help 10 | node akte.app.js build # Build project 11 | 12 | # TypeScript 13 | npx tsx akte.app.ts # Displays help 14 | npx tsx akte.app.ts build # Build project 15 | ``` 16 | 17 | You can also display debug logs prefixing any of the above command with `npx cross-env DEBUG=akte:*`, e.g. 18 | 19 | ```bash 20 | npx cross-env DEBUG=akte:* npx tsx akte.app.ts build 21 | ``` 22 | -------------------------------------------------------------------------------- /examples/common/basic/akte.app.js: -------------------------------------------------------------------------------- 1 | import { defineAkteApp, defineAkteFile, defineAkteFiles } from "akte"; 2 | 3 | // Unique file 4 | const index = defineAkteFile().from({ 5 | path: "/", 6 | data() { 7 | // We assume those are sourced one way or another 8 | const posts = { 9 | "/posts/foo": "foo", 10 | "/posts/bar": "bar", 11 | "/posts/baz": "bar", 12 | }; 13 | 14 | return { posts }; 15 | }, 16 | render(context) { 17 | const posts = Object.entries(context.data.posts).map( 18 | ([href, title]) => /* html */ `
  • ${title}
  • `, 19 | ); 20 | 21 | return /* html */ `
    22 |

    basic javascript

    23 |

    ${context.globalData.siteDescription}

    24 |
      25 | ${posts.join("\n")} 26 |
    27 |
    28 | `; 29 | }, 30 | }); 31 | 32 | // Multiple files 33 | const posts = defineAkteFiles().from({ 34 | path: "/posts/:slug", 35 | bulkData() { 36 | // We assume those are sourced one way or another 37 | const posts = { 38 | "/posts/foo": { 39 | title: "foo", 40 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 41 | }, 42 | "/posts/bar": { 43 | title: "bar", 44 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 45 | }, 46 | "/posts/baz": { 47 | title: "baz", 48 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 49 | }, 50 | }; 51 | 52 | return posts; 53 | }, 54 | render(context) { 55 | return /* html */ `
    56 | index 57 |

    ${context.data.title}

    58 |

    ${context.data.body}

    59 |
    `; 60 | }, 61 | }); 62 | 63 | export const app = defineAkteApp({ 64 | files: [index, posts], 65 | globalData: () => { 66 | return { 67 | siteDescription: "A really simple website", 68 | }; 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /examples/common/basic/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp, defineAkteFile, defineAkteFiles } from "akte"; 2 | 3 | type GlobalData = { siteDescription: string }; 4 | 5 | // Unique file 6 | const index = defineAkteFile().from({ 7 | path: "/", 8 | data() { 9 | // We assume those are sourced one way or another 10 | const posts = { 11 | "/posts/foo": "foo", 12 | "/posts/bar": "bar", 13 | "/posts/baz": "bar", 14 | }; 15 | 16 | return { posts }; 17 | }, 18 | render(context) { 19 | const posts = Object.entries(context.data.posts).map( 20 | ([href, title]) => /* html */ `
  • ${title}
  • `, 21 | ); 22 | 23 | return /* html */ `
    24 |

    basic typescript

    25 |

    ${context.globalData.siteDescription}

    26 |
      27 | ${posts.join("\n")} 28 |
    29 |
    30 | `; 31 | }, 32 | }); 33 | 34 | // Multiple files 35 | const posts = defineAkteFiles().from({ 36 | path: "/posts/:slug", 37 | bulkData() { 38 | // We assume those are sourced one way or another 39 | const posts = { 40 | "/posts/foo": { 41 | title: "foo", 42 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 43 | }, 44 | "/posts/bar": { 45 | title: "bar", 46 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 47 | }, 48 | "/posts/baz": { 49 | title: "baz", 50 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 51 | }, 52 | }; 53 | 54 | return posts; 55 | }, 56 | render(context) { 57 | return /* html */ `
    58 | index 59 |

    ${context.data.title}

    60 |

    ${context.data.body}

    61 |
    `; 62 | }, 63 | }); 64 | 65 | export const app = defineAkteApp({ 66 | files: [index, posts], 67 | globalData: () => { 68 | return { 69 | siteDescription: "A really simple website", 70 | }; 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /examples/common/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.examples.basic", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "devDependencies": { 7 | "akte": "latest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/common/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/common/catch-all/README.md: -------------------------------------------------------------------------------- 1 | # Catch-All 2 | 3 | This example shows usage of catch-all routes. This is helpful for rendering similarly files that aren't living under the same hierarchy. 4 | 5 | Running the `akte.app` file build command results in the Akte project being built under the `dist` folder. 6 | 7 | ```bash 8 | npx tsx akte.app.ts build # Build project 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/common/catch-all/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp, defineAkteFiles } from "akte"; 2 | 3 | const pages = defineAkteFiles().from({ 4 | path: "/**", 5 | bulkData() { 6 | // We assume those are sourced one way or another 7 | const pages = { 8 | "/foo": "foo", 9 | "/foo/bar": "bar", 10 | "/foo/bar/baz": "bar", 11 | }; 12 | 13 | return pages; 14 | }, 15 | render(context) { 16 | return /* html */ `
    17 |

    ${context.data}

    18 |
    `; 19 | }, 20 | }); 21 | 22 | export const app = defineAkteApp({ files: [pages] }); 23 | -------------------------------------------------------------------------------- /examples/common/catch-all/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.examples.catch-all", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "devDependencies": { 7 | "akte": "latest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/common/catch-all/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/common/non-html/README.md: -------------------------------------------------------------------------------- 1 | # Non-HTML 2 | 3 | This example shows usage of Akte to render non-HTML files. This is helpful for rendering any kind of asset, XML, JSON, etc. 4 | 5 | Running the `akte.app` file build command results in the Akte project being built under the `dist` folder. 6 | 7 | ```bash 8 | npx tsx akte.app.ts build # Build project 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/common/non-html/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp, defineAkteFiles } from "akte"; 2 | 3 | const jsons = defineAkteFiles().from({ 4 | path: "/:slug.json", 5 | bulkData() { 6 | // We assume those are sourced one way or another 7 | const jsons = { 8 | "/foo.json": "foo", 9 | "/bar.json": "bar", 10 | "/baz.json": "bar", 11 | }; 12 | 13 | return jsons; 14 | }, 15 | render(context) { 16 | return JSON.stringify(context); 17 | }, 18 | }); 19 | 20 | export const app = defineAkteApp({ files: [jsons] }); 21 | -------------------------------------------------------------------------------- /examples/common/non-html/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.examples.non-html", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "devDependencies": { 7 | "akte": "latest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/common/non-html/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/programmatic/basic/README.md: -------------------------------------------------------------------------------- 1 | # Programmatic 2 | 3 | This example shows programmatic usage of Akte. This is helpful for running Akte in various environments, including serverless. 4 | 5 | Peek inside `programmatic.ts` to see some example usage of Akte API. 6 | -------------------------------------------------------------------------------- /examples/programmatic/basic/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp, defineAkteFile, defineAkteFiles } from "akte"; 2 | 3 | type GlobalData = { siteDescription: string }; 4 | 5 | // Unique file 6 | const index = defineAkteFile().from({ 7 | path: "/", 8 | data() { 9 | // We assume those are sourced one way or another 10 | const posts = { 11 | "/posts/foo": "foo", 12 | "/posts/bar": "bar", 13 | "/posts/baz": "bar", 14 | }; 15 | 16 | return { posts }; 17 | }, 18 | render(context) { 19 | const posts = Object.entries(context.data.posts).map( 20 | ([href, title]) => /* html */ `
  • ${title}
  • `, 21 | ); 22 | 23 | return /* html */ `
    24 |

    basic typescript

    25 |

    ${context.globalData.siteDescription}

    26 |
      27 | ${posts.join("\n")} 28 |
    29 |
    30 | `; 31 | }, 32 | }); 33 | 34 | // Multiple files 35 | const posts = defineAkteFiles().from({ 36 | path: "/posts/:slug", 37 | bulkData() { 38 | // We assume those are sourced one way or another 39 | const posts = { 40 | "/posts/foo": { 41 | title: "foo", 42 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 43 | }, 44 | "/posts/bar": { 45 | title: "bar", 46 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 47 | }, 48 | "/posts/baz": { 49 | title: "baz", 50 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 51 | }, 52 | }; 53 | 54 | return posts; 55 | }, 56 | render(context) { 57 | return /* html */ `
    58 | index 59 |

    ${context.data.title}

    60 |

    ${context.data.body}

    61 |
    `; 62 | }, 63 | }); 64 | 65 | export const app = defineAkteApp({ 66 | files: [index, posts], 67 | globalData: () => { 68 | return { 69 | siteDescription: "A really simple website", 70 | }; 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /examples/programmatic/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.examples.programmatic", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "devDependencies": { 7 | "akte": "latest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/programmatic/basic/programmatic.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { app } from "./akte.app"; 4 | 5 | // Renders all files and returns them. 6 | const files = await app.renderAll(); 7 | 8 | // Renders all files and returns them. 9 | await app.writeAll({ files, outDir: "my-out-dir" }); 10 | 11 | // Renders and writes all files to the config output directory. 12 | await app.buildAll(); 13 | 14 | // Looks up the Akte file responsible for rendering the given path. 15 | const match = app.lookup("/foo"); 16 | 17 | // Renders a match from `app.lookup()` 18 | const file = await app.render(match); 19 | -------------------------------------------------------------------------------- /examples/programmatic/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/vite/basic/.gitignore: -------------------------------------------------------------------------------- 1 | .akte 2 | -------------------------------------------------------------------------------- /examples/vite/basic/README.md: -------------------------------------------------------------------------------- 1 | # Vite  [![Open in StackBlitz][stackblitz-src]][stackblitz-href] 2 | 3 | This example shows usage of Akte as a [Vite][vite] plugin. This is helpful for processing assets of any sort as well as taking advantage of Vite great developer experience while developing. 4 | 5 | Running the `akte.app` file build command results in the Akte project being built under the `dist` folder. 6 | 7 | Using Vite CLI results in the Akte project being served or built accordingly and processed by Vite. 8 | 9 | ```bash 10 | npm run dev # Dev project 11 | npm run build # Build project 12 | ``` 13 | 14 | To work with Vite, Akte relies on a `.akte` cache folder (configurable). This folder is meant to be gitignored. 15 | 16 | ```ignore 17 | .akte 18 | ``` 19 | 20 | [vite]: https://vitejs.dev 21 | [stackblitz-src]: https://developer.stackblitz.com/img/open_in_stackblitz_small.svg 22 | [stackblitz-href]: https://stackblitz.com/github/lihbr/akte/tree/master/examples/vite/basic?file=files%2Findex.ts&theme=dark 23 | -------------------------------------------------------------------------------- /examples/vite/basic/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp } from "akte"; 2 | 3 | import { index } from "./files"; 4 | import { jsons } from "./files/jsons"; 5 | import { pages } from "./files/pages"; 6 | import { posts } from "./files/posts"; 7 | 8 | export const app = defineAkteApp({ 9 | files: [index, pages, posts, jsons], 10 | globalData: () => { 11 | return { 12 | siteDescription: "A really simple website", 13 | }; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /examples/vite/basic/assets/main.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-console 2 | console.log("Hello Akte + Vite"); 3 | -------------------------------------------------------------------------------- /examples/vite/basic/files/index.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFile } from "akte"; 2 | 3 | import { basic } from "../layouts/basic"; 4 | 5 | export const index = defineAkteFile<{ siteDescription: string }>().from({ 6 | path: "/", 7 | data() { 8 | // We assume those are sourced one way or another 9 | const posts = { 10 | "/posts/foo": "foo", 11 | "/posts/bar": "bar", 12 | "/posts/baz": "bar", 13 | }; 14 | 15 | return { posts }; 16 | }, 17 | render(context) { 18 | const posts = Object.entries(context.data.posts).map( 19 | ([href, title]) => /* html */ `
  • ${title}
  • `, 20 | ); 21 | 22 | const slot = /* html */ `
    23 |

    basic typescript

    24 |

    ${context.globalData.siteDescription}

    25 |

    posts

    26 |
      27 | ${posts.join("\n")} 28 |
    29 |

    pages

    30 | 35 |

    jsons

    36 | 41 |
    42 | `; 43 | 44 | return basic(slot); 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /examples/vite/basic/files/jsons.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "akte"; 2 | 3 | export const jsons = defineAkteFiles().from({ 4 | path: "/:slug.json", 5 | bulkData() { 6 | // We assume those are sourced one way or another 7 | const jsons = { 8 | "/foo.json": "foo", 9 | "/bar.json": "bar", 10 | "/baz.json": "bar", 11 | }; 12 | 13 | return jsons; 14 | }, 15 | render(context) { 16 | return JSON.stringify(context); 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /examples/vite/basic/files/pages.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "akte"; 2 | 3 | import { basic } from "../layouts/basic"; 4 | 5 | export const pages = defineAkteFiles().from({ 6 | path: "/**", 7 | bulkData() { 8 | // We assume those are sourced one way or another 9 | const pages = { 10 | "/foo": "foo", 11 | "/foo/bar": "foo bar", 12 | "/foo/bar/baz": "foo bar baz", 13 | }; 14 | 15 | return pages; 16 | }, 17 | render(context) { 18 | const slot = /* html */ `
    19 |

    ${context.data}

    20 |
    `; 21 | 22 | return basic(slot); 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /examples/vite/basic/files/posts.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "akte"; 2 | 3 | import { basic } from "../layouts/basic"; 4 | 5 | export const posts = defineAkteFiles().from({ 6 | path: "/posts/:slug", 7 | bulkData() { 8 | // We assume those are sourced one way or another 9 | const posts = { 10 | "/posts/foo": { 11 | title: "foo", 12 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 13 | }, 14 | "/posts/bar": { 15 | title: "bar", 16 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 17 | }, 18 | "/posts/baz": { 19 | title: "baz", 20 | body: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod, dignissimos enim qui cupiditate provident cumque distinctio id reiciendis quia consectetur fugiat dolorem mollitia laborum libero natus et, vero voluptatibus dolorum?", 21 | }, 22 | }; 23 | 24 | return posts; 25 | }, 26 | render(context) { 27 | const slot = /* html */ `
    28 |

    ${context.data.title}

    29 |

    ${context.data.body}

    30 |
    `; 31 | 32 | return basic(slot); 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /examples/vite/basic/layouts/basic.ts: -------------------------------------------------------------------------------- 1 | export const basic = (slot: string): string => { 2 | return /* html */ ` 3 | 4 | 5 | 6 | 7 | Akte + Vite 8 | 9 | 10 | index 11 | ${slot} 12 | 13 | 14 | `; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/vite/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.examples.vite", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build" 9 | }, 10 | "devDependencies": { 11 | "akte": "latest", 12 | "html-minifier-terser": "^7.2.0", 13 | "vite": "^5.2.11" 14 | } 15 | } -------------------------------------------------------------------------------- /examples/vite/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/vite/basic/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import akte from "akte/vite"; 3 | 4 | import { app } from "./akte.app"; 5 | 6 | export default defineConfig({ plugins: [akte({ app })] }); 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build --workspace docs" 3 | publish = "docs/dist" 4 | ignore = "git diff --quiet HEAD^ HEAD ./docs ./package.json ./CHANGELOG.md ./netlify.toml" 5 | 6 | [[headers]] 7 | for = "/assets/*" 8 | [headers.values] 9 | access-control-allow-origin = "*" 10 | 11 | # Netlify domain 12 | [[redirects]] 13 | from = "https://akte.netlify.app/*" 14 | to = "https://akte.js.org/:splat" 15 | status = 301 16 | force = true 17 | 18 | # Analytics 19 | [[redirects]] 20 | from = "/p7e/api/event" 21 | to = "https://plausible.io/api/event" 22 | status = 202 23 | force = true 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte", 3 | "type": "module", 4 | "version": "0.4.2", 5 | "description": "A minimal static site (and file) generator", 6 | "author": "Lucie Haberer (https://lihbr.com)", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "ssh://git@github.com/lihbr/akte.git" 11 | }, 12 | "keywords": [ 13 | "typescript", 14 | "akte" 15 | ], 16 | "sideEffects": false, 17 | "exports": { 18 | ".": { 19 | "types": "./dist/index.d.ts", 20 | "import": "./dist/index.js", 21 | "require": "./dist/index.cjs" 22 | }, 23 | "./vite": { 24 | "types": "./dist/vite/index.d.ts", 25 | "import": "./dist/vite.js", 26 | "require": "./dist/vite.cjs" 27 | }, 28 | "./package.json": "./package.json" 29 | }, 30 | "main": "dist/index.cjs", 31 | "module": "dist/index.js", 32 | "types": "dist/index.d.ts", 33 | "typesVersions": { 34 | "*": { 35 | "*": [ 36 | "dist/index.d.ts" 37 | ], 38 | "vite": [ 39 | "dist/vite/index.d.ts" 40 | ] 41 | } 42 | }, 43 | "files": [ 44 | "dist", 45 | "src" 46 | ], 47 | "engines": { 48 | "node": ">=16.13.0" 49 | }, 50 | "scripts": { 51 | "build": "vite build", 52 | "dev": "vite build --watch", 53 | "format": "prettier --write .", 54 | "prepare": "npm run build", 55 | "release": "npm run test && standard-version && git push --follow-tags && npm run build && npm publish", 56 | "release:dry": "standard-version --dry-run", 57 | "release:alpha": "npm run test && standard-version --release-as major --prerelease alpha && git push --follow-tags && npm run build && npm publish --tag alpha", 58 | "release:alpha:dry": "standard-version --release-as major --prerelease alpha --dry-run", 59 | "lint": "eslint --ext .js,.ts .", 60 | "types": "tsc --noEmit", 61 | "unit": "vitest run --coverage", 62 | "unit:watch": "vitest watch", 63 | "size": "size-limit", 64 | "test": "npm run lint && npm run types && npm run unit && npm run build && npm run size" 65 | }, 66 | "peerDependencies": { 67 | "html-minifier-terser": "^7.0.0", 68 | "vite": ">=4.0.0" 69 | }, 70 | "peerDependenciesMeta": { 71 | "html-minifier-terser": { 72 | "optional": true 73 | }, 74 | "vite": { 75 | "optional": true 76 | } 77 | }, 78 | "dependencies": { 79 | "debug": "^4.3.4", 80 | "http-proxy": "^1.18.1", 81 | "radix3": "^1.1.2" 82 | }, 83 | "devDependencies": { 84 | "@antfu/eslint-config": "^0.42.1", 85 | "@size-limit/preset-small-lib": "^11.1.2", 86 | "@types/html-minifier-terser": "^7.0.2", 87 | "@types/http-proxy": "^1.17.14", 88 | "@vitest/coverage-v8": "^1.6.0", 89 | "eslint": "^8.57.0", 90 | "eslint-config-prettier": "^9.1.0", 91 | "eslint-plugin-prettier": "^5.1.3", 92 | "eslint-plugin-tsdoc": "^0.2.17", 93 | "html-minifier-terser": "^7.2.0", 94 | "memfs": "^4.9.2", 95 | "prettier": "^3.2.5", 96 | "prettier-plugin-jsdoc": "^1.3.0", 97 | "size-limit": "^11.1.2", 98 | "standard-version": "^9.5.0", 99 | "typescript": "^5.4.5", 100 | "vite": "^5.2.11", 101 | "vite-plugin-sdk": "^0.1.2", 102 | "vitest": "^1.6.0" 103 | }, 104 | "workspaces": [ 105 | ".", 106 | "docs", 107 | "playground", 108 | "examples/*/*" 109 | ], 110 | "publishConfig": { 111 | "access": "public" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /playground/akte.app.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteApp } from "akte"; 2 | 3 | import { index } from "./src/pages/index"; 4 | import { sitemap } from "./src/pages/sitemap"; 5 | import { postsSlug } from "./src/pages/posts/slug"; 6 | import { catchAll } from "./src/pages/catchAll"; 7 | 8 | export const app = defineAkteApp({ 9 | // files: [], 10 | files: [index, sitemap, postsSlug, catchAll], 11 | globalData: () => { 12 | return 1; 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akte.playground", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "cross-env DEBUG=akte:* vite", 8 | "build": "cross-env DEBUG=akte:* vite build", 9 | "preview": "cross-env DEBUG=akte:* vite preview" 10 | }, 11 | "dependencies": { 12 | "@prismicio/client": "^7.5.0", 13 | "node-fetch": "^3.3.2" 14 | }, 15 | "devDependencies": { 16 | "akte": "file:../", 17 | "cross-env": "^7.0.3", 18 | "html-minifier-terser": "^7.2.0", 19 | "tsx": "^4.9.3", 20 | "vite": "^5.2.11" 21 | } 22 | } -------------------------------------------------------------------------------- /playground/src/assets/main.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-console 2 | console.log("hello world!!"); 3 | -------------------------------------------------------------------------------- /playground/src/layouts/basic.ts: -------------------------------------------------------------------------------- 1 | export const basic = (slot: string): string => { 2 | return /* html */ ` 3 | 4 | 5 | 6 | 7 | 8 | Vite + TS 9 | 10 | 11 | ${slot} 12 | 13 | 14 | `; 15 | }; 16 | -------------------------------------------------------------------------------- /playground/src/pages/catchAll/index.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "akte"; 2 | import { basic } from "../../layouts/basic"; 3 | 4 | export const catchAll = defineAkteFiles().from({ 5 | path: "/catch-all/**", 6 | bulkData() { 7 | return { 8 | "/catch-all": {}, 9 | "/catch-all/foo": {}, 10 | "/catch-all/foo/bar": {}, 11 | "/catch-all/foo/bar/baz": {}, 12 | }; 13 | }, 14 | render: (context) => { 15 | const slot = /* html */ `
    ${context.path}
    `; 16 | 17 | return basic(slot); 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /playground/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFile } from "akte"; 2 | import { basic } from "../layouts/basic"; 3 | 4 | export const index = defineAkteFile().from({ 5 | path: "/", 6 | async data() { 7 | await new Promise((resolve) => setTimeout(resolve, 2000)); 8 | 9 | return 1; 10 | }, 11 | render: (context) => { 12 | const slot = /* html */ `
    index ${JSON.stringify(context)}
    `; 13 | 14 | return basic(slot); 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /playground/src/pages/posts/slug.ts: -------------------------------------------------------------------------------- 1 | import { type PrismicDocument, createClient } from "@prismicio/client"; 2 | import fetch from "node-fetch"; 3 | 4 | import { defineAkteFiles } from "akte"; 5 | import { basic } from "../../layouts/basic"; 6 | 7 | const client = createClient("lihbr", { 8 | routes: [ 9 | { 10 | path: "/posts/:uid", 11 | type: "post__blog", 12 | }, 13 | ], 14 | fetch, 15 | }); 16 | 17 | export const postsSlug = defineAkteFiles().from({ 18 | path: "/posts/:slug", 19 | bulkData: async () => { 20 | const posts = await client.getAllByType("post__blog"); 21 | 22 | const records: Record = {}; 23 | for (const post of posts) { 24 | if (post.url) { 25 | records[post.url] = post; 26 | } 27 | } 28 | 29 | return records; 30 | }, 31 | render: (context) => { 32 | const slot = /* html */ `
    ${context.data.uid}
    `; 33 | 34 | return basic(slot); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /playground/src/pages/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFile } from "akte"; 2 | 3 | export const sitemap = defineAkteFile().from({ 4 | path: "/foo/sitemap.xml", 5 | render: () => { 6 | const slot = /* xml */ ` 7 | 8 | 9 | https://lihbr.com/404 10 | 2023-01-04T14:24:46.082Z 11 | 12 | 13 | `; 14 | 15 | return slot; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import akte from "akte/vite"; 3 | 4 | import { app } from "./akte.app"; 5 | 6 | export default defineConfig({ 7 | root: "src", 8 | build: { 9 | outDir: "../dist", 10 | emptyOutDir: true, 11 | }, 12 | // TypeScript appears to be drunk here with npm workspaces 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | plugins: [akte({ app: app as any })], 15 | }); 16 | -------------------------------------------------------------------------------- /src/AkteApp.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from "node:path"; 2 | import { mkdir, writeFile } from "node:fs/promises"; 3 | 4 | import { type MatchedRoute, type RadixRouter, createRouter } from "radix3"; 5 | 6 | import type { AkteFiles } from "./AkteFiles"; 7 | import type { Awaitable, GlobalDataFn } from "./types"; 8 | import { NotFoundError } from "./errors"; 9 | import { runCLI } from "./runCLI"; 10 | import { akteWelcome } from "./akteWelcome"; 11 | 12 | import { __PRODUCTION__ } from "./lib/__PRODUCTION__"; 13 | import { createDebugger } from "./lib/createDebugger"; 14 | import { pathToRouterPath } from "./lib/pathToRouterPath"; 15 | import { isCLI } from "./lib/isCLI"; 16 | 17 | /* eslint-disable @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports */ 18 | 19 | import type { defineAkteFile } from "./defineAkteFile"; 20 | import type { defineAkteFiles } from "./defineAkteFiles"; 21 | 22 | /* eslint-enable @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports */ 23 | 24 | /** Akte app configuration object. */ 25 | export type Config = { 26 | /** 27 | * Akte files this config is responsible for. 28 | * 29 | * Create them with {@link defineAkteFile} and {@link defineAkteFiles}. 30 | */ 31 | files: AkteFiles[]; 32 | 33 | /** Configuration related to Akte build process. */ 34 | build?: { 35 | /** 36 | * Output directory for Akte build command. 37 | * 38 | * @remarks 39 | * This directory is overriden by the Akte Vite plugin when running Akte 40 | * through Vite. 41 | * @defaultValue `"dist"` for Akte build command, `".akte"` for Akte Vite plugin. 42 | */ 43 | outDir?: string; 44 | }; 45 | // Most global data will eventually be objects we use this 46 | // assumption to make mandatory or not the `globalData` method 47 | } & (TGlobalData extends Record 48 | ? { 49 | /** 50 | * Global data retrieval function. 51 | * 52 | * The return value of this function is then shared with each Akte file. 53 | */ 54 | globalData: GlobalDataFn; 55 | } 56 | : { 57 | /** 58 | * Global data retrieval function. 59 | * 60 | * The return value of this function is then shared with each Akte file. 61 | */ 62 | globalData?: GlobalDataFn; 63 | }); 64 | 65 | const debug = createDebugger("akte:app"); 66 | const debugWrite = createDebugger("akte:app:write"); 67 | const debugRender = createDebugger("akte:app:render"); 68 | const debugRouter = createDebugger("akte:app:router"); 69 | const debugCache = createDebugger("akte:app:cache"); 70 | 71 | /** An Akte app, ready to be interacted with. */ 72 | export class AkteApp { 73 | protected config: Config; 74 | 75 | /** 76 | * Readonly array of {@link AkteFiles} registered within the app. 77 | * 78 | * @experimental Programmatic API might still change not following SemVer. 79 | */ 80 | get files(): AkteFiles[] { 81 | return this.config.files; 82 | } 83 | 84 | constructor(config: Config) { 85 | if (!__PRODUCTION__) { 86 | if (config.files.length === 0 && akteWelcome) { 87 | config.files.push(akteWelcome); 88 | } 89 | } 90 | 91 | this.config = config; 92 | 93 | debug("defined with %o files", this.config.files.length); 94 | 95 | if (isCLI) { 96 | runCLI(this as AkteApp); 97 | } 98 | } 99 | 100 | /** 101 | * Looks up the Akte file responsible for rendering the path. 102 | * 103 | * @param path - Path to lookup, e.g. "/foo" 104 | * @returns A match featuring the path, the path parameters if any, and the 105 | * Akte file. 106 | * @throws a {@link NotFoundError} when no Akte file is found for handling 107 | * looked up path. 108 | * @experimental Programmatic API might still change not following SemVer. 109 | */ 110 | lookup(path: string): MatchedRoute<{ 111 | file: AkteFiles; 112 | }> & { path: string } { 113 | const pathWithExtension = pathToRouterPath(path); 114 | debugRouter("looking up %o (%o)", path, pathWithExtension); 115 | 116 | const maybeMatch = this.getRouter().lookup(pathWithExtension); 117 | 118 | if (!maybeMatch || !maybeMatch.file) { 119 | debugRouter("not found %o", path); 120 | throw new NotFoundError(path); 121 | } 122 | 123 | return { 124 | ...maybeMatch, 125 | path, 126 | }; 127 | } 128 | 129 | /** 130 | * Renders a match from {@link lookup}. 131 | * 132 | * @param match - Match to render. 133 | * @returns Rendered file. 134 | * @throws a {@link NotFoundError} when the Akte file could not render the match 135 | * (404), with an optional `cause` attached to it for uncaught errors (500) 136 | * @experimental Programmatic API might still change not following SemVer. 137 | */ 138 | async render( 139 | match: MatchedRoute<{ 140 | file: AkteFiles; 141 | }> & { path: string; globalData?: TGlobalData; data?: unknown }, 142 | ): Promise { 143 | debugRender("rendering %o...", match.path); 144 | 145 | const params: Record = match.params || {}; 146 | const globalData = match.globalData || (await this.getGlobalData()); 147 | 148 | try { 149 | const content = await match.file.render({ 150 | path: match.path, 151 | params, 152 | globalData, 153 | data: match.data, 154 | }); 155 | 156 | debugRender("rendered %o", match.path); 157 | 158 | return content; 159 | } catch (error) { 160 | if (error instanceof NotFoundError) { 161 | throw error; 162 | } 163 | 164 | debugRender("could not render %o", match.path); 165 | 166 | throw new NotFoundError(match.path, { cause: error }); 167 | } 168 | } 169 | 170 | /** 171 | * Renders all Akte files. 172 | * 173 | * @returns Rendered files map. 174 | * @experimental Programmatic API might still change not following SemVer. 175 | */ 176 | async renderAll(): Promise> { 177 | debugRender("rendering all files..."); 178 | 179 | const globalData = await this.getGlobalData(); 180 | 181 | const renderAll = async ( 182 | akteFiles: AkteFiles, 183 | ): Promise> => { 184 | try { 185 | const files = await akteFiles.renderAll({ globalData }); 186 | 187 | return files; 188 | } catch (error) { 189 | debug.error("Akte → Failed to build %o\n", akteFiles.path); 190 | 191 | throw error; 192 | } 193 | }; 194 | 195 | const promises: Promise>[] = []; 196 | for (const akteFiles of this.config.files) { 197 | promises.push(renderAll(akteFiles)); 198 | } 199 | 200 | const rawFilesArray = await Promise.all(promises); 201 | 202 | const files: Record = {}; 203 | for (const rawFiles of rawFilesArray) { 204 | for (const path in rawFiles) { 205 | if (path in files) { 206 | debug.warn( 207 | " Multiple files built %o, only the first one is preserved", 208 | path, 209 | ); 210 | continue; 211 | } 212 | 213 | files[path] = rawFiles[path]; 214 | } 215 | } 216 | 217 | const rendered = Object.keys(files).length; 218 | debugRender( 219 | `done, %o ${rendered > 1 ? "files" : "file"} rendered`, 220 | rendered, 221 | ); 222 | 223 | return files; 224 | } 225 | 226 | /** 227 | * Writes a map of rendered Akte files to the specified `outDir`, or the app 228 | * specified one (defaults to `"dist"`). 229 | * 230 | * @param args 231 | * @param args.files - A map of rendered Akte files 232 | * @param args.outDir - An optional `outDir` 233 | * @experimental Programmatic API might still change not following SemVer. 234 | */ 235 | async writeAll(args: { 236 | outDir?: string; 237 | files: Record; 238 | }): Promise { 239 | debugWrite("writing all files..."); 240 | const outDir = args.outDir ?? this.config.build?.outDir ?? "dist"; 241 | const outDirPath = resolve(outDir); 242 | 243 | const controller = new AbortController(); 244 | 245 | const write = async (path: string, content: string): Promise => { 246 | const filePath = join(outDirPath, path); 247 | const fileDir = dirname(filePath); 248 | 249 | try { 250 | await mkdir(fileDir, { recursive: true }); 251 | await writeFile(filePath, content, { 252 | encoding: "utf-8", 253 | signal: controller.signal, 254 | }); 255 | } catch (error) { 256 | if (controller.signal.aborted) { 257 | return; 258 | } 259 | 260 | controller.abort(); 261 | 262 | debug.error("Akte → Failed to write %o\n", path); 263 | 264 | throw error; 265 | } 266 | 267 | debugWrite("%o", path); 268 | debugWrite.log(" %o", path); 269 | }; 270 | 271 | const promises: Promise[] = []; 272 | for (const path in args.files) { 273 | promises.push(write(path, args.files[path])); 274 | } 275 | 276 | await Promise.all(promises); 277 | 278 | debugWrite( 279 | `done, %o ${promises.length > 1 ? "files" : "file"} written`, 280 | promises.length, 281 | ); 282 | } 283 | 284 | /** 285 | * Build (renders and write) all Akte files to the specified `outDir`, or the 286 | * app specified one (defaults to `"dist"`). 287 | * 288 | * @param args 289 | * @param args.outDir - An optional `outDir` 290 | * @returns Built files array. 291 | * @experimental Programmatic API might still change not following SemVer. 292 | */ 293 | async buildAll(args?: { outDir?: string }): Promise { 294 | const files = await this.renderAll(); 295 | await this.writeAll({ ...args, files }); 296 | 297 | return Object.keys(files); 298 | } 299 | 300 | /** 301 | * Akte caches all `globalData`, `data`, `bulkData` calls for performance. 302 | * This method can be used to clear the cache. 303 | * 304 | * @param alsoClearFileCache - Also clear cache on all registered Akte files. 305 | * @experimental Programmatic API might still change not following SemVer. 306 | */ 307 | clearCache(alsoClearFileCache = false): void { 308 | debugCache("clearing..."); 309 | 310 | this._globalDataCache = undefined; 311 | this._router = undefined; 312 | 313 | if (alsoClearFileCache) { 314 | for (const file of this.config.files) { 315 | file.clearCache(); 316 | } 317 | } 318 | 319 | debugCache("cleared"); 320 | } 321 | 322 | /** 323 | * Readonly cache of the app's definition `globalData` method. 324 | * 325 | * @experimental Programmatic API might still change not following SemVer. 326 | */ 327 | get globalDataCache(): Awaitable | undefined { 328 | return this._globalDataCache; 329 | } 330 | 331 | private _globalDataCache: Awaitable | undefined; 332 | 333 | /** 334 | * Retrieves data from the app's definition `globalData` method. 335 | * 336 | * @returns Retrieved global data. 337 | * @remark Returned global data may come from cache. 338 | * @experimental Programmatic API might still change not following SemVer. 339 | */ 340 | getGlobalData(): Awaitable { 341 | if (!this._globalDataCache) { 342 | debugCache("retrieving global data..."); 343 | const globalDataPromise = 344 | this.config.globalData?.() ?? (undefined as TGlobalData); 345 | 346 | if (globalDataPromise instanceof Promise) { 347 | globalDataPromise.then(() => { 348 | debugCache("retrieved global data"); 349 | }); 350 | } else { 351 | debugCache("retrieved global data"); 352 | } 353 | 354 | this._globalDataCache = globalDataPromise; 355 | } else { 356 | debugCache("using cached global data"); 357 | } 358 | 359 | return this._globalDataCache; 360 | } 361 | 362 | private _router: 363 | | RadixRouter<{ 364 | file: AkteFiles; 365 | }> 366 | | undefined; 367 | 368 | protected getRouter(): RadixRouter<{ 369 | file: AkteFiles; 370 | }> { 371 | if (!this._router) { 372 | debugCache("creating router..."); 373 | const router = createRouter<{ file: AkteFiles }>(); 374 | 375 | for (const file of this.config.files) { 376 | const path = pathToRouterPath(file.path); 377 | router.insert(pathToRouterPath(file.path), { file }); 378 | debugRouter("registered %o", path); 379 | if (file.path.endsWith("/**")) { 380 | const catchAllPath = pathToRouterPath( 381 | file.path.replace(/\/\*\*$/, ""), 382 | ); 383 | router.insert(catchAllPath, { 384 | file, 385 | }); 386 | debugRouter("registered %o", catchAllPath); 387 | debugCache(pathToRouterPath(file.path.replace(/\/\*\*$/, ""))); 388 | } 389 | } 390 | 391 | this._router = router; 392 | debugCache("created router"); 393 | } else { 394 | debugCache("using cached router"); 395 | } 396 | 397 | return this._router; 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/AkteFiles.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError } from "./errors"; 2 | import { type Awaitable } from "./types"; 3 | 4 | import { createDebugger } from "./lib/createDebugger"; 5 | import { pathToFilePath } from "./lib/pathToFilePath"; 6 | import { toReadonlyMap } from "./lib/toReadonlyMap"; 7 | 8 | /* eslint-disable @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports */ 9 | 10 | import type { AkteApp } from "./AkteApp"; 11 | 12 | /* eslint-enable @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports */ 13 | 14 | type Path< 15 | TParams extends string[], 16 | TPrefix extends string = string, 17 | > = TParams extends [] 18 | ? "" 19 | : TParams extends [string] 20 | ? `${TPrefix}:${TParams[0]}${string}` 21 | : TParams extends readonly [string, ...infer Rest extends string[]] 22 | ? Path 23 | : string; 24 | 25 | /** 26 | * A function responsible for fetching the data required to render a given file 27 | * at the provided path. Used for optimization like server side rendering or 28 | * serverless. 29 | */ 30 | export type FilesDataFn< 31 | TGlobalData, 32 | TParams extends string[], 33 | TData, 34 | > = (context: { 35 | /** Path to get data for. */ 36 | path: string; 37 | 38 | /** Path parameters if any. */ 39 | params: Record; 40 | 41 | /** Akte app global data. */ 42 | globalData: TGlobalData; 43 | }) => Awaitable; 44 | 45 | /** A function responsible for fetching all the data required to render files. */ 46 | export type FilesBulkDataFn = (context: { 47 | /** Akte app global data. */ 48 | globalData: TGlobalData; 49 | }) => Awaitable>; 50 | 51 | export type FilesDefinition = { 52 | /** 53 | * Path pattern for the Akte files. 54 | * 55 | * @example 56 | * "/"; 57 | * "/foo"; 58 | * "/bar.json"; 59 | * "/posts/:slug"; 60 | * "/posts/:taxonomy/:slug"; 61 | * "/pages/**"; 62 | * "/assets/**.json"; 63 | */ 64 | path: Path; 65 | 66 | /** 67 | * A function responsible for fetching the data required to render a given 68 | * file. Used for optimization like server side rendering or serverless. 69 | * 70 | * Throwing a {@link NotFoundError} makes the file at path to be treated as a 71 | * 404, any other error makes it treated as a 500. 72 | */ 73 | data?: FilesDataFn; 74 | 75 | /** A function responsible for fetching all the data required to render files. */ 76 | bulkData?: FilesBulkDataFn; 77 | 78 | /** 79 | * A function responsible for rendering the file. 80 | * 81 | * @param context - Resolved file path, app global data, and data to render 82 | * the file. 83 | * @returns Rendered file. 84 | */ 85 | render: (context: { 86 | /** Path to render. */ 87 | path: string; 88 | 89 | /** Akte app global data. */ 90 | globalData: TGlobalData; 91 | 92 | /** File data for path. */ 93 | data: TData; 94 | }) => Awaitable; 95 | }; 96 | 97 | const debug = createDebugger("akte:files"); 98 | const debugRender = createDebugger("akte:files:render"); 99 | const debugCache = createDebugger("akte:files:cache"); 100 | 101 | /** An Akte files, managing its data cascade and rendering process. */ 102 | export class AkteFiles< 103 | TGlobalData = unknown, 104 | TParams extends string[] = string[], 105 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 106 | TData = any, 107 | > { 108 | protected definition: FilesDefinition; 109 | 110 | /** Path pattern of this Akte files. */ 111 | get path(): string { 112 | return this.definition.path; 113 | } 114 | 115 | constructor(definition: FilesDefinition) { 116 | this.definition = definition; 117 | 118 | debug("defined %o", this.path); 119 | } 120 | 121 | /** 122 | * Prefer {@link AkteApp.render} or use at your own risks. 123 | * 124 | * @internal 125 | */ 126 | async render(args: { 127 | path: string; 128 | params: Record; 129 | globalData: TGlobalData; 130 | data?: TData; 131 | }): Promise { 132 | const data = args.data || (await this.getData(args)); 133 | 134 | return this.definition.render({ 135 | path: args.path, 136 | globalData: args.globalData, 137 | data, 138 | }); 139 | } 140 | 141 | /** 142 | * Prefer {@link AkteApp.renderAll} or use at your own risks. 143 | * 144 | * @internal 145 | */ 146 | async renderAll(args: { 147 | globalData: TGlobalData; 148 | }): Promise> { 149 | if (!this.definition.bulkData) { 150 | debugRender("no files to render %o", this.path); 151 | 152 | return {}; 153 | } 154 | 155 | debugRender("rendering files... %o", this.path); 156 | 157 | const bulkData = await this.getBulkData(args); 158 | 159 | const render = async ( 160 | path: string, 161 | data: TData, 162 | ): Promise<[string, string]> => { 163 | const content = await this.definition.render({ 164 | path, 165 | globalData: args.globalData, 166 | data, 167 | }); 168 | 169 | debugRender("rendered %o", path); 170 | 171 | return [pathToFilePath(path), content]; 172 | }; 173 | 174 | const promises: Awaitable<[string, string]>[] = []; 175 | for (const path in bulkData) { 176 | const data = bulkData[path]; 177 | 178 | promises.push(render(path, data)); 179 | } 180 | 181 | const fileEntries = await Promise.all(Object.values(promises)); 182 | 183 | debugRender( 184 | `rendered %o ${fileEntries.length > 1 ? "files" : "file"} %o`, 185 | fileEntries.length, 186 | this.path, 187 | ); 188 | 189 | return Object.fromEntries(fileEntries); 190 | } 191 | 192 | /** 193 | * Prefer {@link AkteApp.clearCache} or use at your own risks. 194 | * 195 | * @internal 196 | */ 197 | clearCache(): void { 198 | this._dataMapCache.clear(); 199 | this._bulkDataCache = undefined; 200 | } 201 | 202 | /** 203 | * Readonly cache of files' definition `data` method. 204 | * 205 | * @experimental Programmatic API might still change not following SemVer. 206 | */ 207 | get dataMapCache(): ReadonlyMap> { 208 | return toReadonlyMap(this._dataMapCache); 209 | } 210 | 211 | private _dataMapCache: Map> = new Map(); 212 | 213 | /** 214 | * Retrieves data from files' definition `data` method with given context. 215 | * 216 | * @param context - Context to get data with. 217 | * @returns Retrieved data. 218 | * @remark Returned data may come from cache. 219 | * @experimental Programmatic API might still change not following SemVer. 220 | */ 221 | getData: FilesDataFn = (context) => { 222 | const maybePromise = this._dataMapCache.get(context.path); 223 | if (maybePromise) { 224 | debugCache("using cached data %o", context.path); 225 | 226 | return maybePromise; 227 | } 228 | 229 | debugCache("retrieving data... %o", context.path); 230 | 231 | let promise: Awaitable; 232 | if (this.definition.data) { 233 | promise = this.definition.data(context); 234 | } else if (this.definition.bulkData) { 235 | const dataFromBulkData = async (path: string): Promise => { 236 | const bulkData = await this.getBulkData({ 237 | globalData: context.globalData, 238 | }); 239 | 240 | if (path in bulkData) { 241 | return bulkData[path]; 242 | } 243 | 244 | throw new NotFoundError(path); 245 | }; 246 | 247 | promise = dataFromBulkData(context.path); 248 | } else { 249 | throw new Error( 250 | `Cannot render file for path \`${context.path}\`, no \`data\` or \`bulkData\` function available`, 251 | ); 252 | } 253 | 254 | if (promise instanceof Promise) { 255 | promise 256 | .then(() => { 257 | debugCache("retrieved data %o", context.path); 258 | }) 259 | .catch(() => {}); 260 | } else { 261 | debugCache("retrieved data %o", context.path); 262 | } 263 | 264 | this._dataMapCache.set(context.path, promise); 265 | 266 | return promise; 267 | }; 268 | 269 | /** 270 | * Readonly cache of files' definition `bulkData` method. 271 | * 272 | * @experimental Programmatic API might still change not following SemVer. 273 | */ 274 | get bulkDataCache(): Awaitable> | undefined { 275 | return this._bulkDataCache; 276 | } 277 | 278 | private _bulkDataCache: Awaitable> | undefined; 279 | 280 | /** 281 | * Retrieves data from files' definition `bulkData` method with given context. 282 | * 283 | * @param context - Context to get bulk data with. 284 | * @returns Retrieved bulk data. 285 | * @remark Returned bulk data may come from cache. 286 | * @experimental Programmatic API might still change not following SemVer. 287 | */ 288 | getBulkData: FilesBulkDataFn = (context) => { 289 | if (!this._bulkDataCache) { 290 | debugCache("retrieving bulk data... %o", this.path); 291 | 292 | const bulkDataPromise = 293 | this.definition.bulkData?.(context) || ({} as Record); 294 | 295 | if (bulkDataPromise instanceof Promise) { 296 | bulkDataPromise.then(() => { 297 | debugCache("retrieved bulk data %o", this.path); 298 | }); 299 | } else { 300 | debugCache("retrieved bulk data %o", this.path); 301 | } 302 | 303 | this._bulkDataCache = bulkDataPromise; 304 | } else { 305 | debugCache("using cached bulk data %o", this.path); 306 | } 307 | 308 | return this._bulkDataCache; 309 | }; 310 | } 311 | -------------------------------------------------------------------------------- /src/akteWelcome.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs/promises"; 2 | import * as path from "node:path"; 3 | import { createRequire } from "node:module"; 4 | 5 | import { __PRODUCTION__ } from "./lib/__PRODUCTION__"; 6 | import { defineAkteFiles } from "./defineAkteFiles"; 7 | import { NotFoundError } from "./errors"; 8 | 9 | /** 10 | * Akte welcome page shown in development when the Akte app does not have any 11 | * othe Akte files registered. 12 | * 13 | * @remarks 14 | * The HTML code below is highlighted and uglified manually to prevent the 15 | * introduction of extra dependencies just for the sake of having a welcome 16 | * page. 17 | */ 18 | export const akteWelcome = __PRODUCTION__ 19 | ? null 20 | : defineAkteFiles().from({ 21 | path: "/", 22 | async data() { 23 | try { 24 | const require = createRequire(path.resolve("index.js")); 25 | const aktePath = require.resolve("akte/package.json"); 26 | const htmlPath = path.resolve(aktePath, "../dist/akteWelcome.html"); 27 | 28 | return { 29 | html: await fs.readFile(htmlPath, "utf-8"), 30 | }; 31 | } catch (error) { 32 | throw new NotFoundError("/"); 33 | } 34 | }, 35 | bulkData() { 36 | // Never build the file 37 | return {}; 38 | }, 39 | render(context) { 40 | return context.data.html; 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /src/defineAkteApp.ts: -------------------------------------------------------------------------------- 1 | import { AkteApp, type Config } from "./AkteApp"; 2 | 3 | /** 4 | * Creates an Akte app from given configuration. 5 | * 6 | * @typeParam TGlobalData - Global data type the app should be configured with 7 | * (inferred by default) 8 | * @param config - Configuration to create the Akte app with. 9 | * @returns The created Akte app. 10 | */ 11 | export const defineAkteApp = ( 12 | config: Config, 13 | ): AkteApp => { 14 | return new AkteApp(config); 15 | }; 16 | -------------------------------------------------------------------------------- /src/defineAkteFile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AkteFiles, 3 | type FilesBulkDataFn, 4 | type FilesDataFn, 5 | type FilesDefinition, 6 | } from "./AkteFiles"; 7 | import type { Empty } from "./types"; 8 | 9 | type FileDefinition = Omit< 10 | FilesDefinition, 11 | "bulkData" 12 | >; 13 | 14 | /** 15 | * Creates an Akte files instance for a single file. 16 | * 17 | * @example 18 | * const posts = defineAkteFile().from({ 19 | * path: "/about", 20 | * data() { 21 | * return {}; 22 | * }, 23 | * render(context) { 24 | * return "..."; 25 | * }, 26 | * }); 27 | * 28 | * @typeParam TGlobalData - Global data the Akte files expects. 29 | * @typeParam TData - Data the Akte files expects (inferred by default) 30 | * @returns A factory to create the Akte files from. 31 | */ 32 | export const defineAkteFile = (): { 33 | /** 34 | * Creates an Akte files instance for a single file from a definition. 35 | * 36 | * @param definition - The definition to create the instance from. 37 | * @returns The created Akte files. 38 | */ 39 | from: < 40 | _TGlobalData extends TGlobalData, 41 | _TData extends TData extends Empty 42 | ? _TDataFn extends FilesDataFn<_TGlobalData, never[], unknown> 43 | ? Awaited> 44 | : undefined 45 | : TData, 46 | _TDataFn extends FilesDataFn<_TGlobalData, never[], unknown> | undefined, 47 | >( 48 | definition: FileDefinition<_TGlobalData, _TData>, 49 | ) => AkteFiles<_TGlobalData, never[], _TData>; 50 | } => { 51 | return { 52 | from: (definition) => { 53 | type _FileDataFn = Required["data"]; 54 | 55 | // Allows single file to still get build without any data function 56 | const data = (() => {}) as unknown as _FileDataFn; 57 | 58 | const bulkData: FilesBulkDataFn< 59 | Parameters<_FileDataFn>[0]["globalData"], 60 | Awaited> 61 | > = async (args) => { 62 | if (definition.data) { 63 | return { 64 | [definition.path]: await definition.data({ 65 | path: definition.path, 66 | params: {}, 67 | globalData: args.globalData, 68 | }), 69 | }; 70 | } 71 | 72 | return { [definition.path]: {} as Awaited> }; 73 | }; 74 | 75 | return new AkteFiles({ 76 | data, 77 | ...definition, 78 | bulkData, 79 | }); 80 | }, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /src/defineAkteFiles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AkteFiles, 3 | type FilesBulkDataFn, 4 | type FilesDataFn, 5 | type FilesDefinition, 6 | } from "./AkteFiles"; 7 | 8 | import type { Empty } from "./types"; 9 | 10 | /** 11 | * Creates an Akte files instance. 12 | * 13 | * @example 14 | * const posts = defineAkteFiles().from({ 15 | * path: "/posts/:slug", 16 | * bulkData() { 17 | * return { 18 | * "/posts/foo": {}, 19 | * "/posts/bar": {}, 20 | * "/posts/baz": {}, 21 | * }; 22 | * }, 23 | * render(context) { 24 | * return "..."; 25 | * }, 26 | * }); 27 | * 28 | * @typeParam TGlobalData - Global data the Akte files expects. 29 | * @typeParam TParams - Parameters the Akte files expects. 30 | * @typeParam TData - Data the Akte files expects (inferred by default) 31 | * @returns A factory to create the Akte files from. 32 | */ 33 | export const defineAkteFiles = < 34 | TGlobalData, 35 | TParams extends string[] | Empty = Empty, 36 | TData = Empty, 37 | >(): { 38 | /** 39 | * Creates an Akte files instance from a definition. 40 | * 41 | * @param definition - The definition to create the instance from. 42 | * @returns The created Akte files. 43 | */ 44 | from: < 45 | _TGlobalData extends TGlobalData, 46 | _TParams extends TParams extends Empty 47 | ? _TDataFn extends FilesDataFn<_TGlobalData, string[], unknown> 48 | ? Exclude[0]["params"], symbol | number>[] 49 | : string[] 50 | : TParams, 51 | _TData extends TData extends Empty 52 | ? _TDataFn extends FilesDataFn<_TGlobalData, string[], unknown> 53 | ? Awaited> 54 | : _TBulkDataFn extends FilesBulkDataFn<_TGlobalData, unknown> 55 | ? Awaited>[keyof Awaited< 56 | ReturnType<_TBulkDataFn> 57 | >] 58 | : undefined 59 | : TData, 60 | _TDataFn extends FilesDataFn<_TGlobalData, string[], unknown> | undefined, 61 | _TBulkDataFn extends _TDataFn extends FilesDataFn< 62 | _TGlobalData, 63 | string[], 64 | unknown 65 | > 66 | ? FilesBulkDataFn<_TGlobalData, Awaited>> | undefined 67 | : FilesBulkDataFn<_TGlobalData, unknown> | undefined, 68 | >( 69 | definition: FilesDefinition<_TGlobalData, _TParams, _TData>, 70 | ) => AkteFiles<_TGlobalData, _TParams, _TData>; 71 | } => { 72 | return { 73 | from: (definition) => { 74 | return new AkteFiles(definition); 75 | }, 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Indicates that the file could not be rendered. If the `cause` property is 3 | * undefined, this error can be considered as a pure 404, otherwise it can be a 4 | * 500. 5 | */ 6 | export class NotFoundError extends Error { 7 | path: string; 8 | 9 | constructor( 10 | path: string, 11 | options?: { 12 | cause?: unknown; 13 | }, 14 | ) { 15 | if (!options?.cause) { 16 | super(`Could lookup file for path \`${path}\``, options); 17 | } else { 18 | super( 19 | `Could lookup file for path \`${path}\`\n\n${options.cause.toString()}`, 20 | options, 21 | ); 22 | } 23 | 24 | this.path = path; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Config, AkteApp } from "./AkteApp"; 2 | export type { AkteFiles } from "./AkteFiles"; 3 | 4 | export { defineAkteApp } from "./defineAkteApp"; 5 | export { defineAkteFile } from "./defineAkteFile"; 6 | export { defineAkteFiles } from "./defineAkteFiles"; 7 | 8 | export { NotFoundError } from "./errors"; 9 | -------------------------------------------------------------------------------- /src/lib/__PRODUCTION__.ts: -------------------------------------------------------------------------------- 1 | // We need to polyfill process if it doesn't exist, such as in the browser. 2 | if (typeof process === "undefined") { 3 | globalThis.process = { env: {} } as typeof process; 4 | } 5 | 6 | /** 7 | * `true` if in the production environment, `false` otherwise. 8 | * 9 | * This boolean can be used to perform actions only in development environments, 10 | * such as logging. 11 | */ 12 | export const __PRODUCTION__ = process.env.NODE_ENV === "production"; 13 | -------------------------------------------------------------------------------- /src/lib/commandsAndFlags.ts: -------------------------------------------------------------------------------- 1 | export const commandsAndFlags = (): string[] => { 2 | const _commandsAndFlags = process.argv.slice(2); 3 | if (_commandsAndFlags[0] === "--") { 4 | _commandsAndFlags.shift(); 5 | } 6 | 7 | return _commandsAndFlags; 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/createDebugger.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | import { hasHelp, hasSilent, hasVersion } from "./hasFlag"; 3 | import { isCLI } from "./isCLI"; 4 | 5 | type DebuggerFn = (msg: unknown, ...args: unknown[]) => void; 6 | type Debugger = DebuggerFn & { 7 | log: DebuggerFn; 8 | warn: DebuggerFn; 9 | error: DebuggerFn; 10 | }; 11 | 12 | const _canLog = isCLI && (!hasSilent() || hasHelp() || hasVersion()); 13 | 14 | export const createDebugger = (scope: string, canLog = _canLog): Debugger => { 15 | const _debug = debug(scope); 16 | 17 | const _debugger: Debugger = (msg, ...args) => { 18 | return _debug(msg, ...args); 19 | }; 20 | 21 | _debugger.log = (msg, ...args) => { 22 | // eslint-disable-next-line no-console 23 | canLog && console.log(msg, ...args); 24 | }; 25 | 26 | _debugger.warn = (msg, ...args) => { 27 | canLog && console.warn(msg, ...args); 28 | }; 29 | 30 | _debugger.error = (msg, ...args) => { 31 | console.error(msg, ...args); 32 | }; 33 | 34 | return _debugger; 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/hasFlag.ts: -------------------------------------------------------------------------------- 1 | import { commandsAndFlags } from "./commandsAndFlags"; 2 | 3 | const hasFlag = (...flags: string[]): boolean => { 4 | for (const flag of flags) { 5 | if (commandsAndFlags().includes(flag)) { 6 | return true; 7 | } 8 | } 9 | 10 | return false; 11 | }; 12 | 13 | export const hasSilent = (): boolean => hasFlag("--silent", "-s"); 14 | 15 | export const hasHelp = (): boolean => { 16 | return ( 17 | hasFlag("--help", "-h") || 18 | commandsAndFlags().filter( 19 | (commandOrFlag) => !["--silent", "-s"].includes(commandOrFlag), 20 | ).length === 0 21 | ); 22 | }; 23 | 24 | export const hasVersion = (): boolean => hasFlag("--version", "-v"); 25 | -------------------------------------------------------------------------------- /src/lib/isCLI.ts: -------------------------------------------------------------------------------- 1 | const filePath = process.argv[1] || ""; 2 | const file = filePath.replaceAll("\\", "/").split("/").pop() || ""; 3 | 4 | export const isCLI = file.includes("akte.app") || file.includes("akte.config"); 5 | -------------------------------------------------------------------------------- /src/lib/pathToFilePath.ts: -------------------------------------------------------------------------------- 1 | export const pathToFilePath = (path: string): string => { 2 | if (/\.(.*)$/.test(path)) { 3 | return path; 4 | } 5 | 6 | return path.endsWith("/") ? `${path}index.html` : `${path}.html`; 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/pathToRouterPath.ts: -------------------------------------------------------------------------------- 1 | import { pathToFilePath } from "./pathToFilePath"; 2 | 3 | export const pathToRouterPath = (path: string): string => { 4 | const filePath = pathToFilePath(path); 5 | 6 | return filePath.replace(/^(.*?)\.(.*)$/, "/.akte/$2$1").replaceAll("//", "/"); 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/pkg.ts: -------------------------------------------------------------------------------- 1 | import { name as pkgName, version as pkgVersion } from "../../package.json"; 2 | 3 | export const pkg = { 4 | name: pkgName, 5 | version: pkgVersion, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/toReadonlyMap.ts: -------------------------------------------------------------------------------- 1 | export const toReadonlyMap = (map: Map): ReadonlyMap => { 2 | return { 3 | has: map.has.bind(map), 4 | get: map.get.bind(map), 5 | keys: map.keys.bind(map), 6 | values: map.values.bind(map), 7 | entries: map.entries.bind(map), 8 | forEach: map.forEach.bind(map), 9 | size: map.size, 10 | [Symbol.iterator]: map[Symbol.iterator].bind(map), 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/runCLI.ts: -------------------------------------------------------------------------------- 1 | import { type AkteApp } from "./AkteApp"; 2 | 3 | import { commandsAndFlags } from "./lib/commandsAndFlags"; 4 | import { createDebugger } from "./lib/createDebugger"; 5 | import { hasHelp, hasVersion } from "./lib/hasFlag"; 6 | import { pkg } from "./lib/pkg"; 7 | 8 | const debugCLI = createDebugger("akte:cli"); 9 | 10 | const exit = (code: number): void => { 11 | debugCLI("done"); 12 | 13 | process.exit(code); 14 | }; 15 | 16 | const displayHelp = (): void => { 17 | debugCLI.log(` 18 | Akte CLI 19 | 20 | DOCUMENTATION 21 | https://akte.js.org 22 | 23 | VERSION 24 | ${pkg.name}@${pkg.version} 25 | 26 | USAGE 27 | $ node akte.app.js 28 | $ npx tsx akte.app.ts 29 | 30 | COMMANDS 31 | build Build Akte to file system 32 | 33 | OPTIONS 34 | --silent, -s Silence output 35 | 36 | --help, -h Display CLI help 37 | --version, -v Display CLI version 38 | `); 39 | 40 | exit(0); 41 | }; 42 | 43 | const displayVersion = (): void => { 44 | debugCLI.log(`${pkg.name}@${pkg.version}`); 45 | 46 | exit(0); 47 | }; 48 | 49 | const build = async (app: AkteApp): Promise => { 50 | debugCLI.log("\nAkte → Beginning build...\n"); 51 | 52 | await app.buildAll(); 53 | 54 | const buildTime = `${Math.ceil(performance.now())}ms`; 55 | debugCLI.log("\nAkte → Done in %o", buildTime); 56 | 57 | return exit(0); 58 | }; 59 | 60 | export const runCLI = async (app: AkteApp): Promise => { 61 | debugCLI("started"); 62 | 63 | process.title = "Akte CLI"; 64 | 65 | // Global flags 66 | if (hasHelp()) { 67 | debugCLI("displaying help"); 68 | 69 | return displayHelp(); 70 | } else if (hasVersion()) { 71 | debugCLI("displaying version"); 72 | 73 | return displayVersion(); 74 | } 75 | 76 | // Commands 77 | const [command] = commandsAndFlags(); 78 | switch (command) { 79 | case "build": 80 | debugCLI("running %o command", command); 81 | 82 | return build(app); 83 | 84 | default: 85 | debugCLI.log( 86 | `Akte → Unknown command \`${command}\`, use \`--help\` flag for manual`, 87 | ); 88 | 89 | exit(2); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type GlobalDataFn = () => Awaitable; 2 | 3 | export type Awaitable = T | Promise; 4 | 5 | declare const tag: unique symbol; 6 | export type Empty = { 7 | readonly [tag]: unknown; 8 | }; 9 | -------------------------------------------------------------------------------- /src/vite/AkteViteCache.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "node:path"; 2 | import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; 3 | 4 | import type { AkteFiles } from "../AkteFiles"; 5 | import { type Awaitable } from "../types"; 6 | import { pathToFilePath } from "../lib/pathToFilePath"; 7 | 8 | const GLOBAL_DATA = "app.globalData"; 9 | const DATA = "file.data"; 10 | 11 | export class AkteViteCache { 12 | get dir(): { root: string; data: string; render: string } { 13 | return this._dir; 14 | } 15 | 16 | private _dir: { root: string; data: string; render: string }; 17 | 18 | constructor(root: string) { 19 | this._dir = { 20 | root, 21 | data: resolve(root, "data"), 22 | render: resolve(root, "render"), 23 | }; 24 | } 25 | 26 | async getAppGlobalData(): Promise { 27 | const globalDataRaw = await this.get("data", GLOBAL_DATA); 28 | 29 | return JSON.parse(globalDataRaw).globalData; 30 | } 31 | 32 | async setAppGlobalData(globalData: unknown): Promise { 33 | // Updating global data invalidates all cache 34 | await rm(this._dir.data, { recursive: true, force: true }); 35 | 36 | const globalDataRaw = JSON.stringify({ globalData }); 37 | 38 | return this.set("data", GLOBAL_DATA, globalDataRaw); 39 | } 40 | 41 | async getFileData(path: string): Promise { 42 | const dataRaw = await this.get("data", `${pathToFilePath(path)}.${DATA}`); 43 | 44 | return JSON.parse(dataRaw).data; 45 | } 46 | 47 | async setFileData(path: string, data: unknown): Promise { 48 | const dataRaw = JSON.stringify({ data }); 49 | 50 | return this.set("data", `${pathToFilePath(path)}.${DATA}`, dataRaw); 51 | } 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 | async setFileDataMap(file: AkteFiles): Promise { 55 | if (file.dataMapCache.size === 0 && !file.bulkDataCache) { 56 | return; 57 | } 58 | 59 | const set = async ( 60 | dataCache: Awaitable, 61 | path: string, 62 | ): Promise => { 63 | const data = await dataCache; 64 | const dataRaw = JSON.stringify({ data }); 65 | 66 | this.set("data", `${pathToFilePath(path)}.${DATA}`, dataRaw); 67 | }; 68 | 69 | const promises: Promise[] = []; 70 | 71 | if (file.bulkDataCache) { 72 | const bulkData = await file.bulkDataCache; 73 | Object.entries(bulkData).forEach(([path, dataCache]) => { 74 | promises.push(set(dataCache, path)); 75 | }); 76 | } else { 77 | file.dataMapCache.forEach((dataCache, path) => { 78 | promises.push(set(dataCache, path)); 79 | }); 80 | } 81 | 82 | await Promise.all(promises); 83 | } 84 | 85 | protected delete(type: "data" | "render", id: string): Promise { 86 | return rm(resolve(this._dir[type], `./${id}`)); 87 | } 88 | 89 | protected get(type: "data" | "render", id: string): Promise { 90 | return readFile(resolve(this._dir[type], `./${id}`), "utf-8"); 91 | } 92 | 93 | protected async set( 94 | type: "data" | "render", 95 | id: string, 96 | data: string, 97 | ): Promise { 98 | const path = resolve(this._dir[type], `./${id}`); 99 | const dir = dirname(path); 100 | 101 | await mkdir(dir, { recursive: true }); 102 | 103 | return writeFile(path, data, "utf-8"); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/vite/aktePlugin.ts: -------------------------------------------------------------------------------- 1 | import { type PluginOption } from "vite"; 2 | 3 | import { createDebugger } from "../lib/createDebugger"; 4 | import { serverPlugin } from "./plugins/serverPlugin"; 5 | import { buildPlugin } from "./plugins/buildPlugin"; 6 | import type { Options, ResolvedOptions } from "./types"; 7 | 8 | const MINIFY_HTML_DEFAULT_OPTIONS = { 9 | collapseBooleanAttributes: true, 10 | collapseWhitespace: true, 11 | keepClosingSlash: true, 12 | minifyCSS: true, 13 | removeComments: true, 14 | removeRedundantAttributes: true, 15 | removeScriptTypeAttributes: true, 16 | removeStyleLinkTypeAttributes: true, 17 | useShortDoctype: true, 18 | }; 19 | 20 | const DEFAULT_OPTIONS: Omit, "app" | "minifyHTML"> = { 21 | cacheDir: ".akte", 22 | }; 23 | 24 | const debug = createDebugger("akte:vite", true); 25 | 26 | /** 27 | * Akte Vite plugin factory. 28 | * 29 | * @param rawOptions - Plugin options. 30 | */ 31 | export const aktePlugin = ( 32 | rawOptions: Options, 33 | ): PluginOption[] => { 34 | debug("plugin registered"); 35 | 36 | const options: ResolvedOptions = { 37 | ...DEFAULT_OPTIONS, 38 | ...rawOptions, 39 | minifyHTML: false, // Gets overriden right after based on user's options 40 | }; 41 | 42 | if (rawOptions.minifyHTML === false) { 43 | // Explicit false 44 | options.minifyHTML = false; 45 | } else if (rawOptions.minifyHTML === true) { 46 | // Explicit true 47 | options.minifyHTML = MINIFY_HTML_DEFAULT_OPTIONS; 48 | } else { 49 | // Implicit undefined or object 50 | options.minifyHTML = { 51 | ...rawOptions.minifyHTML, 52 | ...MINIFY_HTML_DEFAULT_OPTIONS, 53 | }; 54 | } 55 | 56 | return [serverPlugin(options), buildPlugin(options)]; 57 | }; 58 | -------------------------------------------------------------------------------- /src/vite/createAkteViteCache.ts: -------------------------------------------------------------------------------- 1 | import { AkteViteCache } from "./AkteViteCache"; 2 | 3 | export const createAkteViteCache = (root: string): AkteViteCache => { 4 | return new AkteViteCache(root); 5 | }; 6 | -------------------------------------------------------------------------------- /src/vite/index.ts: -------------------------------------------------------------------------------- 1 | import { aktePlugin } from "./aktePlugin"; 2 | 3 | export default aktePlugin; 4 | export type { Options } from "./types"; 5 | -------------------------------------------------------------------------------- /src/vite/plugins/buildPlugin.ts: -------------------------------------------------------------------------------- 1 | import { performance } from "node:perf_hooks"; 2 | import { dirname, posix, resolve } from "node:path"; 3 | import { copyFile, mkdir } from "node:fs/promises"; 4 | import { existsSync } from "node:fs"; 5 | 6 | import type { Plugin } from "vite"; 7 | 8 | import type { ResolvedOptions } from "../types"; 9 | import { createAkteViteCache } from "../createAkteViteCache"; 10 | import { pkg } from "../../lib/pkg"; 11 | import { createDebugger } from "../../lib/createDebugger"; 12 | 13 | let isServerRestart = false; 14 | 15 | const debug = createDebugger("akte:vite:build", true); 16 | 17 | export const buildPlugin = ( 18 | options: ResolvedOptions, 19 | ): Plugin | null => { 20 | debug("plugin registered"); 21 | 22 | let cache = createAkteViteCache(resolve(options.cacheDir)); 23 | let relativeFilePaths: string[] = []; 24 | let outDir = "dist"; 25 | 26 | return { 27 | name: "akte:build", 28 | enforce: "post", 29 | config: async (userConfig, env) => { 30 | if (env.mode === "test") { 31 | debug("mode %o detected, skipping rollup config update", env.mode); 32 | 33 | return; 34 | } else if (env.mode === "production" && env.command === "serve") { 35 | debug("mode %o detected, skipping rollup config update", "preview"); 36 | 37 | return; 38 | } 39 | 40 | debug("updating rollup config..."); 41 | 42 | userConfig.build ||= {}; 43 | userConfig.build.rollupOptions ||= {}; 44 | 45 | cache = createAkteViteCache( 46 | resolve(userConfig.root || ".", options.cacheDir), 47 | ); 48 | 49 | // Don't build full app directly in dev mode 50 | const indexHTMLPath = resolve(cache.dir.render, "index.html"); 51 | if ( 52 | env.mode === "development" && 53 | env.command === "serve" && 54 | existsSync(indexHTMLPath) && 55 | isServerRestart 56 | ) { 57 | debug("server restart detected, skipping full build"); 58 | userConfig.build.rollupOptions.input = { 59 | "index.html": indexHTMLPath, 60 | }; 61 | 62 | debug("updated rollup config"); 63 | 64 | return; 65 | } 66 | 67 | const then = performance.now(); 68 | const filePaths = await options.app.buildAll({ 69 | outDir: cache.dir.render, 70 | }); 71 | const buildTime = Math.ceil(performance.now() - then); 72 | 73 | debug.log(`akte/vite v${pkg.version} built in ${buildTime}ms`); 74 | 75 | relativeFilePaths = filePaths.map((filePath) => 76 | filePath.replace(/^\.?\//, ""), 77 | ); 78 | 79 | const input: Record = {}; 80 | for (const filePath of relativeFilePaths) { 81 | if (filePath.endsWith(".html")) { 82 | input[filePath] = resolve(cache.dir.render, filePath); 83 | debug( 84 | "registered %o as rollup input", 85 | posix.join( 86 | userConfig.root?.replaceAll("\\", "/") || ".", 87 | options.cacheDir, 88 | "render", 89 | filePath, 90 | ), 91 | ); 92 | } 93 | } 94 | 95 | userConfig.build.rollupOptions.input = input; 96 | 97 | debug("updated rollup config"); 98 | 99 | if (env.mode === "development") { 100 | debug("caching globalData, bulkData, and data..."); 101 | 102 | const globalData = await options.app.globalDataCache; 103 | await cache.setAppGlobalData(globalData); 104 | 105 | const cachingPromises: Promise[] = []; 106 | 107 | for (const file of options.app.files) { 108 | cachingPromises.push(cache.setFileDataMap(file)); 109 | } 110 | 111 | await Promise.all(cachingPromises); 112 | 113 | debug("cached globalData, bulkData, and data"); 114 | } 115 | 116 | isServerRestart = true; 117 | 118 | return userConfig; 119 | }, 120 | configResolved(config) { 121 | outDir = resolve(config.root, config.build.outDir); 122 | }, 123 | async generateBundle(_, outputBundle) { 124 | debug("updating akte bundle..."); 125 | 126 | const operations = ["fixed html file paths"]; 127 | if (options.minifyHTML) { 128 | operations.push("minified html"); 129 | } 130 | 131 | let _minify = ((str: string) => 132 | Promise.resolve(str)) as typeof import("html-minifier-terser").minify; 133 | if (options.minifyHTML) { 134 | try { 135 | _minify = (await import("html-minifier-terser")).minify; 136 | } catch (error) { 137 | debug.error( 138 | "\nAkte → %o is required to minify HTML, install it or disable the %o option on the Vite plugin\n", 139 | "html-minifier-terser", 140 | "minifyHTML", 141 | ); 142 | throw error; 143 | } 144 | } 145 | 146 | const minify = async (partialBundle: { 147 | source: string | Uint8Array; 148 | }): Promise => { 149 | partialBundle.source = await _minify( 150 | partialBundle.source as string, 151 | options.minifyHTML || {}, 152 | ); 153 | }; 154 | 155 | const promises: Promise[] = []; 156 | for (const bundle of Object.values(outputBundle)) { 157 | if ( 158 | bundle.type === "asset" && 159 | typeof bundle.source === "string" && 160 | relativeFilePaths.find((relativeFilePath) => 161 | bundle.fileName.endsWith(relativeFilePath), 162 | ) 163 | ) { 164 | // Rewrite filename to be neither relative or absolute 165 | bundle.fileName = bundle.fileName.replace( 166 | new RegExp(`^${options.cacheDir}\\/render\\/?`), 167 | "", 168 | ); 169 | 170 | if (options.minifyHTML) { 171 | promises.push(minify(bundle)); 172 | } 173 | } 174 | } 175 | debug(`updated akte bundle: ${operations.join(", ")}`); 176 | }, 177 | async writeBundle() { 178 | const filePaths = relativeFilePaths.filter( 179 | (filePath) => !filePath.endsWith(".html"), 180 | ); 181 | 182 | if (!filePaths.length) { 183 | debug("no non-html files to copy"); 184 | } 185 | 186 | debug("copying non-html files to output directory..."); 187 | 188 | const copy = async (filePath: string): Promise => { 189 | const src = resolve(cache.dir.render, filePath); 190 | const dest = resolve(outDir, filePath); 191 | const destDir = dirname(dest); 192 | 193 | await mkdir(destDir, { recursive: true }); 194 | await copyFile(src, dest); 195 | debug("copied %o to output directory", filePath); 196 | }; 197 | 198 | const promises: Promise[] = []; 199 | for (const filePath of filePaths) { 200 | promises.push(copy(filePath)); 201 | } 202 | 203 | await Promise.all(promises); 204 | 205 | debug( 206 | `copied %o non-html ${ 207 | promises.length > 1 ? "files" : "file" 208 | } to output directory`, 209 | promises.length, 210 | ); 211 | }, 212 | }; 213 | }; 214 | -------------------------------------------------------------------------------- /src/vite/plugins/serverPlugin.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from "node:path"; 2 | import { mkdir, writeFile } from "node:fs/promises"; 3 | 4 | import type { Plugin } from "vite"; 5 | import httpProxy from "http-proxy"; 6 | 7 | import type { ResolvedOptions } from "../types"; 8 | import { createAkteViteCache } from "../createAkteViteCache"; 9 | import { NotFoundError } from "../../errors"; 10 | import { pathToFilePath } from "../../lib/pathToFilePath"; 11 | import { createDebugger } from "../../lib/createDebugger"; 12 | 13 | const debug = createDebugger("akte:vite:server", true); 14 | 15 | export const serverPlugin = ( 16 | options: ResolvedOptions, 17 | ): Plugin | null => { 18 | debug("plugin registered"); 19 | 20 | let cache = createAkteViteCache(resolve(options.cacheDir)); 21 | 22 | return { 23 | name: "akte:server", 24 | configResolved(config) { 25 | cache = createAkteViteCache(resolve(config.root, options.cacheDir)); 26 | }, 27 | configureServer(server) { 28 | const proxy = httpProxy.createProxyServer(); 29 | 30 | type Match = Parameters[0] & { 31 | filePath: string; 32 | }; 33 | 34 | const build = async (match: Match): Promise => { 35 | const file = await options.app.render(match); 36 | 37 | const filePath = join(cache.dir.render, match.filePath); 38 | const fileDir = dirname(filePath); 39 | 40 | await mkdir(fileDir, { recursive: true }); 41 | await writeFile(filePath, file); 42 | 43 | // Cache global data if cache wasn't hit 44 | if (!match.globalData) { 45 | const globalData = await options.app.globalDataCache; 46 | cache.setAppGlobalData(globalData); 47 | } 48 | 49 | // Cache data if cache wasn't hit 50 | if (!match.data) { 51 | const data = await match.file.dataMapCache.get(match.path); 52 | cache.setFileData(match.path, data); 53 | } 54 | }; 55 | 56 | const revalidateCache = async (match: Match): Promise => { 57 | // Current global data is needed for both revalidation 58 | const currentGlobalData = await options.app.getGlobalData(); 59 | 60 | const fullReload = () => { 61 | server.ws.off("connection", fullReload); 62 | server.ws.send({ type: "full-reload" }); 63 | }; 64 | 65 | // Revalidate global data if cache was used 66 | if (match.globalData) { 67 | const previousGlobalDataString = JSON.stringify(match.globalData); 68 | const currentGlobalDataString = JSON.stringify(currentGlobalData); 69 | 70 | if (previousGlobalDataString !== currentGlobalDataString) { 71 | debug("app %o changed, reloading page...", "globalData"); 72 | 73 | await cache.setAppGlobalData(currentGlobalData); 74 | 75 | server.ws.on("connection", fullReload); 76 | 77 | return; 78 | } 79 | } 80 | 81 | // Revalidate data if cache was used 82 | if (match.data) { 83 | const previousDataString = JSON.stringify(match.data); 84 | const currentData = await match.file.getData({ 85 | path: match.path, 86 | params: match.params || {}, 87 | globalData: currentGlobalData, 88 | }); 89 | const currentDataString = JSON.stringify(currentData); 90 | 91 | if (previousDataString !== currentDataString) { 92 | // TODO: Investigate why this is ran twice 93 | debug("file %o changed, reloading page...", "data"); 94 | 95 | await cache.setFileData(match.path, currentData); 96 | 97 | server.ws.on("connection", fullReload); 98 | } 99 | } 100 | }; 101 | 102 | server.middlewares.use(async (req, res, next) => { 103 | const path = req.url?.split("?").shift() || ""; 104 | 105 | // Skipping obvious unrelated paths 106 | if ( 107 | path.startsWith("/.akte") || 108 | path.startsWith("/@vite") || 109 | path.startsWith("/@fs") 110 | ) { 111 | return next(); 112 | } 113 | 114 | let match: Match; 115 | try { 116 | match = { 117 | ...options.app.lookup(path), 118 | filePath: pathToFilePath(path), 119 | }; 120 | 121 | try { 122 | match.globalData = 123 | (await cache.getAppGlobalData()) as typeof match.globalData; 124 | } catch (error) { 125 | // noop 126 | } 127 | 128 | try { 129 | match.data = await cache.getFileData(path); 130 | } catch (error) { 131 | // noop 132 | } 133 | 134 | await build(match); 135 | } catch (error) { 136 | if (error instanceof NotFoundError) { 137 | return next(); 138 | } 139 | 140 | throw error; 141 | } 142 | 143 | // Rewrite URL 144 | if (req.url) { 145 | req.url = match.filePath; 146 | } 147 | 148 | proxy.web(req, res, { 149 | target: `http://${req.headers.host}/${options.cacheDir}/render`, 150 | }); 151 | 152 | // Revalidate cache on non-fetch requests if cache was used 153 | if ( 154 | req.headers["sec-fetch-dest"] === "document" && 155 | (match.globalData || match.data) 156 | ) { 157 | revalidateCache(match); 158 | } 159 | }); 160 | }, 161 | }; 162 | }; 163 | -------------------------------------------------------------------------------- /src/vite/types.ts: -------------------------------------------------------------------------------- 1 | import type { Options as MinifyHTMLOptions } from "html-minifier-terser"; 2 | 3 | import { type AkteApp } from "../AkteApp"; 4 | 5 | /** Akte Vite plugin options. */ 6 | export type Options = { 7 | /** Akte app to run the plugin with. */ 8 | app: AkteApp; 9 | 10 | /** 11 | * Cache file used by Akte during Vite dev and build process. 12 | * 13 | * @remarks 14 | * This file _has_ to be a child directory of Vite's root directory. 15 | * @defaultValue `".akte"` 16 | */ 17 | cacheDir?: string; 18 | 19 | /** 20 | * By default Akte Vite plugin will minify Akte generated HTML upon Vite build 21 | * using `html-minifier-terser`. 22 | * {@link https://github.com/lihbr/akte/blob/master/src/vite/aktePlugin.ts#L8-L18 Sensible defaults are used by default}. 23 | * 24 | * You can use this option to provide additional parameters to 25 | * `html-minifier-terser`, 26 | * {@link https://github.com/terser/html-minifier-terser#options-quick-reference see its documentation}. 27 | * 28 | * @remarks 29 | * When enabled, `html-minifier-terser` needs to be installed separately as a 30 | * development dependency for the build process to succeed. 31 | */ 32 | minifyHTML?: boolean | MinifyHTMLOptions; 33 | }; 34 | 35 | /** @internal */ 36 | export type ResolvedOptions = { 37 | app: AkteApp; 38 | cacheDir: string; 39 | minifyHTML: false | MinifyHTMLOptions; 40 | }; 41 | -------------------------------------------------------------------------------- /test/AkteApp-buildAll.test.ts: -------------------------------------------------------------------------------- 1 | import { posix } from "node:path"; 2 | import { expect, it } from "vitest"; 3 | import { vol } from "memfs"; 4 | 5 | import { defineAkteApp } from "../src"; 6 | 7 | import { index } from "./__fixtures__"; 8 | import { about } from "./__fixtures__/about"; 9 | import { pages } from "./__fixtures__/pages"; 10 | import { posts } from "./__fixtures__/posts"; 11 | import { jsons } from "./__fixtures__/jsons"; 12 | 13 | it("builds all files at default output directory", async () => { 14 | const app = defineAkteApp({ 15 | files: [index, about, pages, posts, jsons], 16 | }); 17 | 18 | await app.buildAll(); 19 | 20 | const volSnapshot = Object.fromEntries( 21 | Object.entries(vol.toJSON()).map(([key, value]) => [ 22 | `/${posix.relative( 23 | // Windows has some issues with `posix.relative()`... 24 | process.platform === "win32" 25 | ? posix.join(process.cwd(), "../") 26 | : process.cwd(), 27 | key, 28 | )}`, 29 | value, 30 | ]), 31 | ); 32 | expect(volSnapshot).toMatchInlineSnapshot(` 33 | { 34 | "/dist/about.html": "Rendered: {"path":"/about","data":{}}", 35 | "/dist/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 36 | "/dist/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 37 | "/dist/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 38 | "/dist/index.html": "Rendered: {"path":"/","data":"index"}", 39 | "/dist/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 40 | "/dist/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 41 | "/dist/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 42 | "/dist/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 43 | "/dist/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 44 | "/dist/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 45 | } 46 | `); 47 | }); 48 | 49 | it("builds all files at config-provided output directory", async () => { 50 | const app = defineAkteApp({ 51 | files: [index, about, pages, posts, jsons], 52 | build: { 53 | outDir: "/foo", 54 | }, 55 | }); 56 | 57 | await app.buildAll(); 58 | 59 | const volSnapshot = vol.toJSON(); 60 | expect(Object.keys(volSnapshot).every((key) => key.startsWith("/foo"))).toBe( 61 | true, 62 | ); 63 | expect(volSnapshot).toMatchInlineSnapshot(` 64 | { 65 | "/foo/about.html": "Rendered: {"path":"/about","data":{}}", 66 | "/foo/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 67 | "/foo/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 68 | "/foo/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 69 | "/foo/index.html": "Rendered: {"path":"/","data":"index"}", 70 | "/foo/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 71 | "/foo/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 72 | "/foo/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 73 | "/foo/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 74 | "/foo/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 75 | "/foo/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 76 | } 77 | `); 78 | }); 79 | 80 | it("builds all files at function-provided output directory", async () => { 81 | const app = defineAkteApp({ 82 | files: [index, about, pages, posts, jsons], 83 | build: { 84 | outDir: "/foo", 85 | }, 86 | }); 87 | 88 | await app.buildAll({ outDir: "/bar" }); 89 | 90 | const volSnapshot = vol.toJSON(); 91 | expect(Object.keys(volSnapshot).every((key) => key.startsWith("/bar"))).toBe( 92 | true, 93 | ); 94 | expect(volSnapshot).toMatchInlineSnapshot(` 95 | { 96 | "/bar/about.html": "Rendered: {"path":"/about","data":{}}", 97 | "/bar/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 98 | "/bar/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 99 | "/bar/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 100 | "/bar/index.html": "Rendered: {"path":"/","data":"index"}", 101 | "/bar/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 102 | "/bar/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 103 | "/bar/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 104 | "/bar/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 105 | "/bar/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 106 | "/bar/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 107 | } 108 | `); 109 | }); 110 | 111 | it("returns built files", async () => { 112 | const app = defineAkteApp({ 113 | files: [index, about, pages, posts, jsons], 114 | }); 115 | 116 | await expect(app.buildAll()).resolves.toMatchInlineSnapshot(` 117 | [ 118 | "/index.html", 119 | "/about.html", 120 | "/pages/foo.html", 121 | "/pages/foo/bar.html", 122 | "/pages/foo/bar/baz.html", 123 | "/posts/foo.html", 124 | "/posts/bar.html", 125 | "/posts/baz.html", 126 | "/foo.json", 127 | "/bar.json", 128 | "/baz.json", 129 | ] 130 | `); 131 | }); 132 | -------------------------------------------------------------------------------- /test/AkteApp-getGlobalData.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from "vitest"; 2 | 3 | import { defineAkteApp } from "../src"; 4 | 5 | import { index } from "./__fixtures__"; 6 | import { about } from "./__fixtures__/about"; 7 | import { pages } from "./__fixtures__/pages"; 8 | import { posts } from "./__fixtures__/posts"; 9 | import { jsons } from "./__fixtures__/jsons"; 10 | 11 | it("caches global data", async () => { 12 | const globalDataFn = vi.fn().mockImplementation(() => true); 13 | 14 | const app = defineAkteApp({ 15 | files: [index, about, pages, posts, jsons], 16 | globalData: globalDataFn, 17 | }); 18 | 19 | app.getGlobalData(); 20 | app.getGlobalData(); 21 | 22 | expect(globalDataFn).toHaveBeenCalledOnce(); 23 | }); 24 | 25 | it("caches global data promise", async () => { 26 | const globalDataFn = vi.fn().mockImplementation(() => Promise.resolve(true)); 27 | 28 | const app = defineAkteApp({ 29 | files: [index, about, pages, posts, jsons], 30 | globalData: globalDataFn, 31 | }); 32 | 33 | app.getGlobalData(); 34 | app.getGlobalData(); 35 | 36 | expect(globalDataFn).toHaveBeenCalledOnce(); 37 | }); 38 | -------------------------------------------------------------------------------- /test/AkteApp-getRouter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from "vitest"; 2 | 3 | import { createRouter } from "radix3"; 4 | 5 | import { defineAkteApp } from "../src"; 6 | 7 | import { index } from "./__fixtures__"; 8 | import { about } from "./__fixtures__/about"; 9 | import { pages } from "./__fixtures__/pages"; 10 | import { posts } from "./__fixtures__/posts"; 11 | import { jsons } from "./__fixtures__/jsons"; 12 | 13 | vi.mock("radix3", () => { 14 | return { 15 | createRouter: vi.fn().mockImplementation(() => { 16 | return { 17 | insert: vi.fn(), 18 | }; 19 | }), 20 | }; 21 | }); 22 | 23 | it("fixes catch-all path", () => { 24 | const app = defineAkteApp({ 25 | files: [pages], 26 | }); 27 | 28 | // @ts-expect-error - Accessing protected method 29 | const router = app.getRouter(); 30 | 31 | // One for `/**`, one for `/` 32 | expect(router.insert).toHaveBeenCalledTimes(2); 33 | }); 34 | 35 | it("caches router", () => { 36 | const app = defineAkteApp({ 37 | files: [index, about, pages, posts, jsons], 38 | }); 39 | 40 | // @ts-expect-error - Accessing protected method 41 | app.getRouter(); 42 | // @ts-expect-error - Accessing protected method 43 | app.getRouter(); 44 | 45 | expect(createRouter).toHaveBeenCalledOnce(); 46 | }); 47 | -------------------------------------------------------------------------------- /test/AkteApp-lookup.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import { NotFoundError, defineAkteApp } from "../src"; 4 | 5 | import { index } from "./__fixtures__"; 6 | import { about } from "./__fixtures__/about"; 7 | import { pages } from "./__fixtures__/pages"; 8 | import { posts } from "./__fixtures__/posts"; 9 | import { jsons } from "./__fixtures__/jsons"; 10 | 11 | const app = defineAkteApp({ files: [index, about, pages, posts, jsons] }); 12 | 13 | it("looks up regular paths", () => { 14 | expect(app.lookup("/")).toStrictEqual( 15 | expect.objectContaining({ 16 | file: index, 17 | path: "/", 18 | }), 19 | ); 20 | expect(app.lookup("/about")).toStrictEqual( 21 | expect.objectContaining({ 22 | file: about, 23 | path: "/about", 24 | }), 25 | ); 26 | }); 27 | 28 | it("looks up regular paths with parameters", () => { 29 | expect(app.lookup("/posts/foo")).toStrictEqual( 30 | expect.objectContaining({ 31 | file: posts, 32 | params: { 33 | slug: "foo", 34 | }, 35 | path: "/posts/foo", 36 | }), 37 | ); 38 | expect(app.lookup("/posts/akte")).toStrictEqual( 39 | expect.objectContaining({ 40 | file: posts, 41 | params: { 42 | slug: "akte", 43 | }, 44 | path: "/posts/akte", 45 | }), 46 | ); 47 | }); 48 | 49 | it("looks up catch-all paths", () => { 50 | expect(app.lookup("/pages")).toStrictEqual( 51 | expect.objectContaining({ 52 | file: pages, 53 | path: "/pages", 54 | }), 55 | ); 56 | expect(app.lookup("/pages/foo")).toStrictEqual( 57 | expect.objectContaining({ 58 | file: pages, 59 | path: "/pages/foo", 60 | }), 61 | ); 62 | expect(app.lookup("/pages/foo/bar")).toStrictEqual( 63 | expect.objectContaining({ 64 | file: pages, 65 | path: "/pages/foo/bar", 66 | }), 67 | ); 68 | expect(app.lookup("/pages/foo/bar/baz")).toStrictEqual( 69 | expect.objectContaining({ 70 | file: pages, 71 | path: "/pages/foo/bar/baz", 72 | }), 73 | ); 74 | expect(app.lookup("/pages/foo/bar/baz/akte")).toStrictEqual( 75 | expect.objectContaining({ 76 | file: pages, 77 | path: "/pages/foo/bar/baz/akte", 78 | }), 79 | ); 80 | }); 81 | 82 | it("looks up non-html paths", () => { 83 | expect(app.lookup("/foo.json")).toStrictEqual( 84 | expect.objectContaining({ 85 | file: jsons, 86 | params: { 87 | slug: "foo", 88 | }, 89 | path: "/foo.json", 90 | }), 91 | ); 92 | expect(app.lookup("/akte.json")).toStrictEqual( 93 | expect.objectContaining({ 94 | file: jsons, 95 | params: { 96 | slug: "akte", 97 | }, 98 | path: "/akte.json", 99 | }), 100 | ); 101 | }); 102 | 103 | it("throws `NotFoundError` on unknown path", () => { 104 | try { 105 | app.lookup("/foo"); 106 | } catch (error) { 107 | expect(error).toBeInstanceOf(NotFoundError); 108 | } 109 | 110 | expect(() => app.lookup("/foo")).toThrowErrorMatchingInlineSnapshot( 111 | `[Error: Could lookup file for path \`/foo\`]`, 112 | ); 113 | expect(() => app.lookup("/foo.png")).toThrowErrorMatchingInlineSnapshot( 114 | `[Error: Could lookup file for path \`/foo.png\`]`, 115 | ); 116 | expect(() => app.lookup("/posts/foo.png")).toThrowErrorMatchingInlineSnapshot( 117 | `[Error: Could lookup file for path \`/posts/foo.png\`]`, 118 | ); 119 | expect(() => app.lookup("/posts/foo/bar")).toThrowErrorMatchingInlineSnapshot( 120 | `[Error: Could lookup file for path \`/posts/foo/bar\`]`, 121 | ); 122 | expect(() => 123 | app.lookup("/pages/foo/bar.png"), 124 | ).toThrowErrorMatchingInlineSnapshot( 125 | `[Error: Could lookup file for path \`/pages/foo/bar.png\`]`, 126 | ); 127 | 128 | expect.assertions(6); 129 | }); 130 | -------------------------------------------------------------------------------- /test/AkteApp-render.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import { NotFoundError, defineAkteApp } from "../src"; 4 | 5 | import { index } from "./__fixtures__"; 6 | import { about } from "./__fixtures__/about"; 7 | import { pages } from "./__fixtures__/pages"; 8 | import { posts } from "./__fixtures__/posts"; 9 | import { jsons } from "./__fixtures__/jsons"; 10 | import { renderError } from "./__fixtures__/renderError"; 11 | 12 | const app = defineAkteApp({ 13 | files: [index, about, pages, posts, jsons, renderError], 14 | }); 15 | 16 | it("renders matched path", async () => { 17 | await expect(app.render(app.lookup("/"))).resolves.toMatchInlineSnapshot( 18 | `"Rendered: {"path":"/","data":"index"}"`, 19 | ); 20 | await expect(app.render(app.lookup("/about"))).resolves.toMatchInlineSnapshot( 21 | `"Rendered: {"path":"/about"}"`, 22 | ); 23 | await expect( 24 | app.render(app.lookup("/posts/foo")), 25 | ).resolves.toMatchInlineSnapshot( 26 | `"Rendered: {"path":"/posts/foo","data":"foo"}"`, 27 | ); 28 | await expect( 29 | app.render(app.lookup("/pages/foo/bar")), 30 | ).resolves.toMatchInlineSnapshot( 31 | `"Rendered: {"path":"/pages/foo/bar","data":"foo bar"}"`, 32 | ); 33 | await expect( 34 | app.render(app.lookup("/foo.json")), 35 | ).resolves.toMatchInlineSnapshot( 36 | `"Rendered: {"path":"/foo.json","data":"foo"}"`, 37 | ); 38 | }); 39 | 40 | it("throws `NotFoundError` when render data function throws a `NotFoundError`", async () => { 41 | try { 42 | await app.render(app.lookup("/posts/akte")); 43 | } catch (error) { 44 | expect(error).toBeInstanceOf(NotFoundError); 45 | expect(error).toMatchInlineSnapshot( 46 | "[Error: Could lookup file for path `/posts/akte`]", 47 | ); 48 | expect((error as NotFoundError).cause).toBeUndefined(); 49 | } 50 | 51 | expect.assertions(3); 52 | }); 53 | 54 | it("throws `NotFoundError` when render data function throws any error and forward original error", async () => { 55 | try { 56 | await app.render(app.lookup("/render-error/foo")); 57 | } catch (error) { 58 | expect(error).toBeInstanceOf(NotFoundError); 59 | expect(error).toMatchInlineSnapshot(` 60 | [Error: Could lookup file for path \`/render-error/foo\` 61 | 62 | Error: render error] 63 | `); 64 | expect((error as NotFoundError).cause).toBeDefined(); 65 | } 66 | 67 | expect.assertions(3); 68 | }); 69 | -------------------------------------------------------------------------------- /test/AkteApp-renderAll.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from "vitest"; 2 | 3 | import { defineAkteApp } from "../src"; 4 | 5 | import { index } from "./__fixtures__"; 6 | import { about } from "./__fixtures__/about"; 7 | import { pages } from "./__fixtures__/pages"; 8 | import { posts } from "./__fixtures__/posts"; 9 | import { jsons } from "./__fixtures__/jsons"; 10 | import { renderError } from "./__fixtures__/renderError"; 11 | import { noGlobalData } from "./__fixtures__/noGlobalData"; 12 | 13 | it("renders all files", async () => { 14 | const app = defineAkteApp({ 15 | files: [index, about, pages, posts, jsons], 16 | }); 17 | 18 | await expect(app.renderAll()).resolves.toMatchInlineSnapshot(` 19 | { 20 | "/about.html": "Rendered: {"path":"/about","data":{}}", 21 | "/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 22 | "/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 23 | "/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 24 | "/index.html": "Rendered: {"path":"/","data":"index"}", 25 | "/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 26 | "/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 27 | "/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 28 | "/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 29 | "/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 30 | "/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 31 | } 32 | `); 33 | }); 34 | 35 | it("does not render files with no global data methods", async () => { 36 | const app = defineAkteApp({ 37 | files: [noGlobalData], 38 | }); 39 | 40 | await expect(app.renderAll()).resolves.toMatchInlineSnapshot("{}"); 41 | }); 42 | 43 | it("throws on any render issue", async () => { 44 | const app = defineAkteApp({ 45 | files: [index, about, pages, posts, jsons, renderError], 46 | }); 47 | 48 | vi.stubGlobal("console", { error: vi.fn() }); 49 | 50 | await expect(app.renderAll()).rejects.toMatchInlineSnapshot( 51 | "[Error: render error]", 52 | ); 53 | expect(console.error).toHaveBeenCalledOnce(); 54 | 55 | vi.unstubAllGlobals(); 56 | }); 57 | 58 | it("deduplicates and warns about duplicate files", async () => { 59 | const app = defineAkteApp({ 60 | files: [index, index], 61 | }); 62 | 63 | await expect(app.renderAll()).resolves.toMatchInlineSnapshot(` 64 | { 65 | "/index.html": "Rendered: {"path":"/","data":"index"}", 66 | } 67 | `); 68 | }); 69 | -------------------------------------------------------------------------------- /test/AkteApp-writeAll.test.ts: -------------------------------------------------------------------------------- 1 | import { posix } from "node:path"; 2 | import { expect, it, vi } from "vitest"; 3 | import { vol } from "memfs"; 4 | 5 | import { defineAkteApp } from "../src"; 6 | 7 | import { index } from "./__fixtures__"; 8 | import { about } from "./__fixtures__/about"; 9 | import { pages } from "./__fixtures__/pages"; 10 | import { posts } from "./__fixtures__/posts"; 11 | import { jsons } from "./__fixtures__/jsons"; 12 | 13 | it("writes all files at default output directory", async () => { 14 | const app = defineAkteApp({ 15 | files: [index, about, pages, posts, jsons], 16 | }); 17 | 18 | const files = await app.renderAll(); 19 | await app.writeAll({ files }); 20 | 21 | const volSnapshot = Object.fromEntries( 22 | Object.entries(vol.toJSON()).map(([key, value]) => [ 23 | `/${posix.relative( 24 | // Windows has some issues with `posix.relative()`... 25 | process.platform === "win32" 26 | ? posix.join(process.cwd(), "../") 27 | : process.cwd(), 28 | key, 29 | )}`, 30 | value, 31 | ]), 32 | ); 33 | expect(volSnapshot).toMatchInlineSnapshot(` 34 | { 35 | "/dist/about.html": "Rendered: {"path":"/about","data":{}}", 36 | "/dist/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 37 | "/dist/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 38 | "/dist/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 39 | "/dist/index.html": "Rendered: {"path":"/","data":"index"}", 40 | "/dist/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 41 | "/dist/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 42 | "/dist/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 43 | "/dist/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 44 | "/dist/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 45 | "/dist/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 46 | } 47 | `); 48 | }); 49 | 50 | it("writes all files at config-provided output directory", async () => { 51 | const app = defineAkteApp({ 52 | files: [index, about, pages, posts, jsons], 53 | build: { 54 | outDir: "/foo", 55 | }, 56 | }); 57 | 58 | const files = await app.renderAll(); 59 | await app.writeAll({ files }); 60 | 61 | const volSnapshot = vol.toJSON(); 62 | expect(Object.keys(volSnapshot).every((key) => key.startsWith("/foo"))).toBe( 63 | true, 64 | ); 65 | expect(volSnapshot).toMatchInlineSnapshot(` 66 | { 67 | "/foo/about.html": "Rendered: {"path":"/about","data":{}}", 68 | "/foo/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 69 | "/foo/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 70 | "/foo/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 71 | "/foo/index.html": "Rendered: {"path":"/","data":"index"}", 72 | "/foo/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 73 | "/foo/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 74 | "/foo/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 75 | "/foo/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 76 | "/foo/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 77 | "/foo/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 78 | } 79 | `); 80 | }); 81 | 82 | it("writes all files at function-provided output directory", async () => { 83 | const app = defineAkteApp({ 84 | files: [index, about, pages, posts, jsons], 85 | build: { 86 | outDir: "/foo", 87 | }, 88 | }); 89 | 90 | const files = await app.renderAll(); 91 | await app.writeAll({ files, outDir: "/bar" }); 92 | 93 | const volSnapshot = vol.toJSON(); 94 | expect(Object.keys(volSnapshot).every((key) => key.startsWith("/bar"))).toBe( 95 | true, 96 | ); 97 | expect(volSnapshot).toMatchInlineSnapshot(` 98 | { 99 | "/bar/about.html": "Rendered: {"path":"/about","data":{}}", 100 | "/bar/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 101 | "/bar/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 102 | "/bar/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 103 | "/bar/index.html": "Rendered: {"path":"/","data":"index"}", 104 | "/bar/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 105 | "/bar/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 106 | "/bar/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 107 | "/bar/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 108 | "/bar/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 109 | "/bar/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 110 | } 111 | `); 112 | }); 113 | 114 | it("throws on any write issue", async () => { 115 | const app = defineAkteApp({ 116 | files: [index, about, pages, posts, jsons], 117 | build: { 118 | outDir: "/foo", 119 | }, 120 | }); 121 | 122 | const files = await app.renderAll(); 123 | 124 | // Purposefully writing a file that cannot be written 125 | files.error = { 126 | toString() { 127 | throw new Error("write error"); 128 | }, 129 | } as unknown as string; 130 | 131 | vi.stubGlobal("console", { error: vi.fn() }); 132 | 133 | await expect(app.writeAll({ files })).rejects.toMatchInlineSnapshot( 134 | "[Error: write error]", 135 | ); 136 | expect(console.error).toHaveBeenCalledOnce(); 137 | 138 | vi.unstubAllGlobals(); 139 | }); 140 | -------------------------------------------------------------------------------- /test/AkteFile-getBulkData.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from "vitest"; 2 | 3 | import { defineAkteFile, defineAkteFiles } from "../src"; 4 | 5 | it("caches bulk data", () => { 6 | const bulkDataFn = vi.fn().mockImplementation(() => ({ 7 | "/foo": true, 8 | })); 9 | 10 | const files = defineAkteFiles().from({ 11 | path: "/:slug", 12 | bulkData: bulkDataFn, 13 | render(context) { 14 | return `Rendered: ${JSON.stringify(context)}`; 15 | }, 16 | }); 17 | 18 | files.getBulkData({ globalData: {} }); 19 | files.getBulkData({ globalData: {} }); 20 | 21 | expect(bulkDataFn).toHaveBeenCalledOnce(); 22 | }); 23 | 24 | it("caches bulk data promise", async () => { 25 | const bulkDataFn = vi.fn().mockImplementation(() => 26 | Promise.resolve({ 27 | "/foo": true, 28 | }), 29 | ); 30 | 31 | const files = defineAkteFiles().from({ 32 | path: "/:slug", 33 | bulkData: bulkDataFn, 34 | render(context) { 35 | return `Rendered: ${JSON.stringify(context)}`; 36 | }, 37 | }); 38 | 39 | await files.getBulkData({ globalData: {} }); 40 | await files.getBulkData({ globalData: {} }); 41 | 42 | expect(bulkDataFn).toHaveBeenCalledOnce(); 43 | }); 44 | 45 | it("infers bulk data from data on single file", async () => { 46 | const dataFn = vi.fn().mockImplementation(() => true); 47 | 48 | const files = defineAkteFile().from({ 49 | path: "/", 50 | data: dataFn, 51 | render(context) { 52 | return `Rendered: ${JSON.stringify(context)}`; 53 | }, 54 | }); 55 | 56 | await files.getBulkData({ globalData: {} }); 57 | 58 | expect(dataFn).toHaveBeenCalledOnce(); 59 | }); 60 | -------------------------------------------------------------------------------- /test/AkteFile-getData.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from "vitest"; 2 | 3 | import { defineAkteFiles } from "../src"; 4 | 5 | it("caches data", () => { 6 | const dataFn = vi.fn().mockImplementation(() => true); 7 | 8 | const files = defineAkteFiles().from({ 9 | path: "/:slug", 10 | data: dataFn, 11 | render(context) { 12 | return `Rendered: ${JSON.stringify(context)}`; 13 | }, 14 | }); 15 | 16 | files.getData({ path: "/foo", params: {}, globalData: {} }); 17 | files.getData({ path: "/foo", params: {}, globalData: {} }); 18 | 19 | expect(dataFn).toHaveBeenCalledOnce(); 20 | }); 21 | 22 | it("caches data promise", async () => { 23 | const dataFn = vi.fn().mockImplementation(() => Promise.resolve(true)); 24 | 25 | const files = defineAkteFiles().from({ 26 | path: "/:slug", 27 | data: dataFn, 28 | render(context) { 29 | return `Rendered: ${JSON.stringify(context)}`; 30 | }, 31 | }); 32 | 33 | await files.getData({ path: "/foo", params: {}, globalData: {} }); 34 | await files.getData({ path: "/foo", params: {}, globalData: {} }); 35 | 36 | expect(dataFn).toHaveBeenCalledOnce(); 37 | }); 38 | 39 | it("infers data from bulk data when data is not implemented", async () => { 40 | const bulkDataFn = vi.fn().mockImplementation(() => ({ 41 | "/foo": true, 42 | })); 43 | 44 | const files = defineAkteFiles().from({ 45 | path: "/:slug", 46 | bulkData: bulkDataFn, 47 | render(context) { 48 | return `Rendered: ${JSON.stringify(context)}`; 49 | }, 50 | }); 51 | 52 | await files.getData({ path: "/foo", params: {}, globalData: {} }); 53 | 54 | expect(bulkDataFn).toHaveBeenCalledOnce(); 55 | }); 56 | 57 | it("throws when neither data and bulk data are implemented", () => { 58 | const files = defineAkteFiles().from({ 59 | path: "/:slug", 60 | render(context) { 61 | return `Rendered: ${JSON.stringify(context)}`; 62 | }, 63 | }); 64 | 65 | expect(() => 66 | files.getData({ path: "/foo", params: {}, globalData: {} }), 67 | ).toThrowErrorMatchingInlineSnapshot( 68 | `[Error: Cannot render file for path \`/foo\`, no \`data\` or \`bulkData\` function available]`, 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /test/__fixtures__/about.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFile } from "../../src"; 2 | 3 | export const about = defineAkteFile().from({ 4 | path: "/about", 5 | render(context) { 6 | return `Rendered: ${JSON.stringify(context)}`; 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /test/__fixtures__/index.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFile } from "../../src"; 2 | 3 | export const index = defineAkteFile().from({ 4 | path: "/", 5 | data() { 6 | return "index"; 7 | }, 8 | render(context) { 9 | return `Rendered: ${JSON.stringify(context)}`; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /test/__fixtures__/jsons.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "../../src"; 2 | 3 | export const jsons = defineAkteFiles().from({ 4 | path: "/:slug.json", 5 | bulkData() { 6 | const jsons = { 7 | "/foo.json": "foo", 8 | "/bar.json": "bar", 9 | "/baz.json": "bar", 10 | }; 11 | 12 | return jsons; 13 | }, 14 | render(context) { 15 | return `Rendered: ${JSON.stringify(context)}`; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /test/__fixtures__/noGlobalData.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "../../src"; 2 | 3 | export const noGlobalData = defineAkteFiles().from({ 4 | path: "/no-global-data/:slug", 5 | render(context) { 6 | return `Rendered: ${JSON.stringify(context)}`; 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /test/__fixtures__/pages.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "../../src"; 2 | 3 | export const pages = defineAkteFiles().from({ 4 | path: "/pages/**", 5 | bulkData() { 6 | const pages = { 7 | "/pages/foo": "foo", 8 | "/pages/foo/bar": "foo bar", 9 | "/pages/foo/bar/baz": "foo bar baz", 10 | }; 11 | 12 | return pages; 13 | }, 14 | render(context) { 15 | return `Rendered: ${JSON.stringify(context)}`; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /test/__fixtures__/posts.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "../../src"; 2 | 3 | export const posts = defineAkteFiles().from({ 4 | path: "/posts/:slug", 5 | bulkData() { 6 | const posts = { 7 | "/posts/foo": "foo", 8 | "/posts/bar": "bar", 9 | "/posts/baz": "bar", 10 | }; 11 | 12 | return posts; 13 | }, 14 | render(context) { 15 | return `Rendered: ${JSON.stringify(context)}`; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /test/__fixtures__/renderError.ts: -------------------------------------------------------------------------------- 1 | import { defineAkteFiles } from "../../src"; 2 | 3 | export const renderError = defineAkteFiles().from({ 4 | path: "/render-error/:slug", 5 | bulkData() { 6 | throw new Error("render error"); 7 | }, 8 | render(context) { 9 | return `Rendered: ${JSON.stringify(context)}`; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /test/__setup__.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, vi } from "vitest"; 2 | import { vol } from "memfs"; 3 | 4 | vi.mock("fs", async () => { 5 | const memfs: typeof import("memfs") = await vi.importActual("memfs"); 6 | 7 | return { 8 | ...memfs.fs, 9 | default: memfs.fs, 10 | }; 11 | }); 12 | 13 | vi.mock("fs/promises", async () => { 14 | const memfs: typeof import("memfs") = await vi.importActual("memfs"); 15 | 16 | return { 17 | ...memfs.fs.promises, 18 | default: memfs.fs.promises, 19 | }; 20 | }); 21 | 22 | afterEach(async () => { 23 | vi.clearAllMocks(); 24 | vol.reset(); 25 | }); 26 | -------------------------------------------------------------------------------- /test/defineAkteApp.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { defineAkteApp, defineAkteFiles } from "../src"; 3 | 4 | const noGlobalData = defineAkteFiles().from({ 5 | path: "/:slug", 6 | render() { 7 | return ""; 8 | }, 9 | }); 10 | 11 | const numberGlobalData = defineAkteFiles().from({ 12 | path: "/:slug", 13 | render() { 14 | return ""; 15 | }, 16 | }); 17 | 18 | const objectGlobalData = defineAkteFiles<{ foo: number }>().from({ 19 | path: "/:slug", 20 | render() { 21 | return ""; 22 | }, 23 | }); 24 | 25 | it("infers global data from files", () => { 26 | defineAkteApp({ 27 | files: [noGlobalData, numberGlobalData], 28 | globalData() { 29 | return 1; 30 | }, 31 | }); 32 | 33 | defineAkteApp({ 34 | files: [noGlobalData, numberGlobalData], 35 | // @ts-expect-error - globalData is of type number 36 | globalData() { 37 | return ""; 38 | }, 39 | }); 40 | }); 41 | 42 | it("makes global data optional when possible", () => { 43 | defineAkteApp({ 44 | files: [noGlobalData], 45 | }); 46 | 47 | defineAkteApp({ 48 | files: [noGlobalData], 49 | globalData() { 50 | return 1; 51 | }, 52 | }); 53 | 54 | // @ts-expect-error - globalData is required 55 | defineAkteApp({ 56 | files: [noGlobalData, objectGlobalData], 57 | }); 58 | }); 59 | 60 | it("enforces global data consistency", () => { 61 | defineAkteApp({ 62 | files: [numberGlobalData], 63 | globalData() { 64 | return 1; 65 | }, 66 | }); 67 | 68 | defineAkteApp({ 69 | // @ts-expect-error - globalData is of type number 70 | files: [numberGlobalData, objectGlobalData], 71 | globalData() { 72 | return 1; 73 | }, 74 | }); 75 | }); 76 | 77 | it("supports global data generic", () => { 78 | defineAkteApp({ 79 | files: [numberGlobalData], 80 | }); 81 | 82 | defineAkteApp({ 83 | // @ts-expect-error - globalData is of type number 84 | files: [objectGlobalData], 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/defineAkteFile.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectTypeOf, it } from "vitest"; 2 | import { defineAkteFile } from "../src"; 3 | 4 | it("infers data from data", () => { 5 | defineAkteFile().from({ 6 | path: "/", 7 | render(context) { 8 | expectTypeOf(context.data).toBeUnknown(); 9 | 10 | return ""; 11 | }, 12 | }); 13 | 14 | defineAkteFile().from({ 15 | path: "/", 16 | data() { 17 | return 1; 18 | }, 19 | render(context) { 20 | expectTypeOf(context.data).toBeNumber(); 21 | // @ts-expect-error - data is of type number 22 | expectTypeOf(context.data).toBeString(); 23 | 24 | return ""; 25 | }, 26 | }); 27 | }); 28 | 29 | it("supports global data generic", () => { 30 | defineAkteFile().from({ 31 | path: "/", 32 | render(context) { 33 | expectTypeOf(context.globalData).toBeUnknown(); 34 | 35 | return ""; 36 | }, 37 | }); 38 | 39 | defineAkteFile().from({ 40 | path: "/", 41 | render(context) { 42 | expectTypeOf(context.globalData).toBeNumber(); 43 | // @ts-expect-error - globalData is of type number 44 | expectTypeOf(context.globalData).toBeString(); 45 | 46 | return ""; 47 | }, 48 | }); 49 | }); 50 | 51 | it("support data generic", () => { 52 | defineAkteFile().from({ 53 | path: "/", 54 | data() { 55 | return 1; 56 | }, 57 | render(context) { 58 | expectTypeOf(context.data).toBeNumber(); 59 | // @ts-expect-error - data is of type number 60 | expectTypeOf(context.data).toBeString(); 61 | 62 | return ""; 63 | }, 64 | }); 65 | 66 | defineAkteFile().from({ 67 | path: "/", 68 | // @ts-expect-error - data is of type number 69 | data() { 70 | return ""; 71 | }, 72 | render(context) { 73 | expectTypeOf(context.data).toBeNumber(); 74 | // @ts-expect-error - data is of type number 75 | expectTypeOf(context.data).toBeString(); 76 | 77 | return ""; 78 | }, 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/defineAkteFiles.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectTypeOf, it } from "vitest"; 2 | import { defineAkteFiles } from "../src"; 3 | 4 | it("infers data from data", () => { 5 | defineAkteFiles().from({ 6 | path: "/:slug", 7 | render(context) { 8 | expectTypeOf(context.data).toBeUnknown(); 9 | 10 | return ""; 11 | }, 12 | }); 13 | 14 | defineAkteFiles().from({ 15 | path: "/:slug", 16 | data() { 17 | return 1; 18 | }, 19 | render(context) { 20 | expectTypeOf(context.data).toBeNumber(); 21 | // @ts-expect-error - data is of type number 22 | expectTypeOf(context.data).toBeString(); 23 | 24 | return ""; 25 | }, 26 | }); 27 | }); 28 | 29 | it("infers data from bulkData", () => { 30 | defineAkteFiles().from({ 31 | path: "/:slug", 32 | render(context) { 33 | expectTypeOf(context.data).toBeUnknown(); 34 | 35 | return ""; 36 | }, 37 | }); 38 | 39 | defineAkteFiles().from({ 40 | path: "/:slug", 41 | bulkData() { 42 | return { "/foo": 1 }; 43 | }, 44 | render(context) { 45 | expectTypeOf(context.data).toBeNumber(); 46 | // @ts-expect-error - data is of type number 47 | expectTypeOf(context.data).toBeString(); 48 | 49 | return ""; 50 | }, 51 | }); 52 | }); 53 | 54 | it("forces data and bulkData to return the same type of data", () => { 55 | defineAkteFiles().from({ 56 | path: "/:slug", 57 | data() { 58 | return 1; 59 | }, 60 | bulkData() { 61 | return { "/foo": 1 }; 62 | }, 63 | render(context) { 64 | expectTypeOf(context.data).toBeNumber(); 65 | // @ts-expect-error - data is of type number 66 | expectTypeOf(context.data).toBeString(); 67 | 68 | return ""; 69 | }, 70 | }); 71 | 72 | defineAkteFiles().from({ 73 | path: "/:slug", 74 | data() { 75 | return ""; 76 | }, 77 | // @ts-expect-error - data is of type string 78 | bulkData() { 79 | return { "/foo": 1 }; 80 | }, 81 | render(context) { 82 | // @ts-expect-error - data is of type string 83 | expectTypeOf(context.data).toBeNumber(); 84 | expectTypeOf(context.data).toBeString(); 85 | 86 | return ""; 87 | }, 88 | }); 89 | 90 | defineAkteFiles().from({ 91 | path: "/:slug", 92 | data() { 93 | return 1; 94 | }, 95 | // @ts-expect-error - data is of type number 96 | bulkData() { 97 | return { "/foo": "" }; 98 | }, 99 | render(context) { 100 | expectTypeOf(context.data).toBeNumber(); 101 | // @ts-expect-error - data is of type number 102 | expectTypeOf(context.data).toBeString(); 103 | 104 | return ""; 105 | }, 106 | }); 107 | }); 108 | 109 | it("supports global data generic", () => { 110 | defineAkteFiles().from({ 111 | path: "/:slug", 112 | render(context) { 113 | expectTypeOf(context.globalData).toBeUnknown(); 114 | 115 | return ""; 116 | }, 117 | }); 118 | 119 | defineAkteFiles().from({ 120 | path: "/:slug", 121 | render(context) { 122 | expectTypeOf(context.globalData).toBeNumber(); 123 | // @ts-expect-error - globalData is of type number 124 | expectTypeOf(context.globalData).toBeString(); 125 | 126 | return ""; 127 | }, 128 | }); 129 | }); 130 | 131 | it("supports params generic", () => { 132 | defineAkteFiles().from({ 133 | path: "/:slug", 134 | render() { 135 | return ""; 136 | }, 137 | }); 138 | 139 | defineAkteFiles().from({ 140 | // @ts-expect-error - path should contain :slug 141 | path: "/:not-slug", 142 | render() { 143 | return ""; 144 | }, 145 | }); 146 | 147 | defineAkteFiles().from({ 148 | path: "/:taxonomy/:slug", 149 | render() { 150 | return ""; 151 | }, 152 | }); 153 | 154 | defineAkteFiles().from({ 155 | // @ts-expect-error - path should contain :taxonomy 156 | path: "/:slug", 157 | render() { 158 | return ""; 159 | }, 160 | }); 161 | }); 162 | 163 | it("support data generic", () => { 164 | defineAkteFiles().from({ 165 | path: "/:slug", 166 | data() { 167 | return 1; 168 | }, 169 | bulkData() { 170 | return { "/foo": 1 }; 171 | }, 172 | render(context) { 173 | expectTypeOf(context.data).toBeNumber(); 174 | // @ts-expect-error - data is of type number 175 | expectTypeOf(context.data).toBeString(); 176 | 177 | return ""; 178 | }, 179 | }); 180 | 181 | defineAkteFiles().from({ 182 | path: "/:slug", 183 | // @ts-expect-error - data is of type number 184 | data() { 185 | return ""; 186 | }, 187 | // @ts-expect-error - data is of type number 188 | bulkData() { 189 | return { "/foo": "" }; 190 | }, 191 | render(context) { 192 | expectTypeOf(context.data).toBeNumber(); 193 | // @ts-expect-error - data is of type number 194 | expectTypeOf(context.data).toBeString(); 195 | 196 | return ""; 197 | }, 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | import * as lib from "../src"; 4 | 5 | // TODO: Dummy test, meant to be removed when real tests come in 6 | it("exports something", () => { 7 | expect(lib).toBeTruthy(); 8 | }); 9 | -------------------------------------------------------------------------------- /test/runCLI.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from "vitest"; 2 | import { vol } from "memfs"; 3 | 4 | import { defineAkteApp } from "../src"; 5 | import { runCLI } from "../src/runCLI"; 6 | 7 | import { index } from "./__fixtures__"; 8 | import { about } from "./__fixtures__/about"; 9 | import { pages } from "./__fixtures__/pages"; 10 | import { posts } from "./__fixtures__/posts"; 11 | import { jsons } from "./__fixtures__/jsons"; 12 | 13 | it("builds product upon build command", async () => { 14 | const app = defineAkteApp({ 15 | files: [index, about, pages, posts, jsons], 16 | build: { outDir: "/foo" }, 17 | }); 18 | 19 | vi.stubGlobal("process", { 20 | ...process, 21 | exit: vi.fn().mockImplementation(() => Promise.resolve()), 22 | argv: ["node", "akte.app.ts", "build"], 23 | }); 24 | 25 | await runCLI(app); 26 | 27 | expect(vol.toJSON()).toMatchInlineSnapshot(` 28 | { 29 | "/foo/about.html": "Rendered: {"path":"/about","data":{}}", 30 | "/foo/bar.json": "Rendered: {"path":"/bar.json","data":"bar"}", 31 | "/foo/baz.json": "Rendered: {"path":"/baz.json","data":"bar"}", 32 | "/foo/foo.json": "Rendered: {"path":"/foo.json","data":"foo"}", 33 | "/foo/index.html": "Rendered: {"path":"/","data":"index"}", 34 | "/foo/pages/foo.html": "Rendered: {"path":"/pages/foo","data":"foo"}", 35 | "/foo/pages/foo/bar.html": "Rendered: {"path":"/pages/foo/bar","data":"foo bar"}", 36 | "/foo/pages/foo/bar/baz.html": "Rendered: {"path":"/pages/foo/bar/baz","data":"foo bar baz"}", 37 | "/foo/posts/bar.html": "Rendered: {"path":"/posts/bar","data":"bar"}", 38 | "/foo/posts/baz.html": "Rendered: {"path":"/posts/baz","data":"bar"}", 39 | "/foo/posts/foo.html": "Rendered: {"path":"/posts/foo","data":"foo"}", 40 | } 41 | `); 42 | 43 | vi.unstubAllGlobals(); 44 | }); 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | 6 | "target": "esnext", 7 | "module": "esnext", 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "preserve", 16 | "lib": ["esnext", "dom"], 17 | "types": ["node"] 18 | }, 19 | "exclude": ["node_modules", "dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import * as fs from "node:fs/promises"; 3 | 4 | import { defineConfig } from "vite"; 5 | import sdk from "vite-plugin-sdk"; 6 | import { minify } from "html-minifier-terser"; 7 | 8 | import { app } from "./docs/akte.app"; 9 | 10 | const MINIFY_HTML_OPTIONS = { 11 | collapseBooleanAttributes: true, 12 | collapseWhitespace: true, 13 | keepClosingSlash: true, 14 | minifyCSS: true, 15 | removeComments: true, 16 | removeRedundantAttributes: true, 17 | removeScriptTypeAttributes: true, 18 | removeStyleLinkTypeAttributes: true, 19 | useShortDoctype: true, 20 | }; 21 | 22 | export default defineConfig({ 23 | build: { 24 | lib: { 25 | entry: { 26 | index: "./src/index.ts", 27 | vite: "./src/vite/index.ts", 28 | }, 29 | }, 30 | }, 31 | plugins: [ 32 | sdk(), 33 | { 34 | name: "akte:welcome", 35 | async writeBundle(options) { 36 | const match = app.lookup("/welcome"); 37 | let welcomePage = await app.render(match); 38 | 39 | const docURL = "https://akte.js.org"; 40 | 41 | // Load assets from documentation 42 | welcomePage = welcomePage.replace( 43 | "", 44 | ``, 45 | ); 46 | welcomePage = welcomePage.replaceAll( 47 | `href="/assets`, 48 | `href="${docURL}/assets`, 49 | ); 50 | welcomePage = welcomePage.replace( 51 | /(src="\/assets\/js\/\w+?)\.ts/g, 52 | "$1.js", 53 | ); 54 | 55 | welcomePage = await minify(welcomePage, MINIFY_HTML_OPTIONS); 56 | 57 | await fs.writeFile( 58 | path.resolve(options.dir || "dist", "akteWelcome.html"), 59 | welcomePage, 60 | "utf-8", 61 | ); 62 | }, 63 | }, 64 | ], 65 | test: { 66 | coverage: { 67 | reporter: ["lcovonly", "text"], 68 | }, 69 | setupFiles: ["./test/__setup__.ts"], 70 | }, 71 | }); 72 | --------------------------------------------------------------------------------