(
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------