From b4e122b61769e9ef90742bd77383edb0b60f9172 Mon Sep 17 00:00:00 2001 From: Avior Date: Sat, 11 Nov 2023 14:09:56 +0100 Subject: [PATCH] feat: Add typed routing Signed-off-by: Avior --- .gitignore | 60 +++++++------- astro.config.mjs | 75 +++++++++--------- hooks/routing.ts | 199 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 66 deletions(-) create mode 100644 hooks/routing.ts diff --git a/.gitignore b/.gitignore index 94c42a9..06a9f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,31 @@ -# build output -dist/ - -# generated types -.astro/ - -# dependencies -node_modules/ - -# logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# environment variables -.env -.env.production - -# macOS-specific files -.DS_Store - -slicers/* - -# Coverage -coverage/ - -# Playwright -/playwright/ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +slicers/* + +# Coverage +coverage/ + +# Playwright +/playwright/ + +/src/route.ts diff --git a/astro.config.mjs b/astro.config.mjs index 6c8bdab..8d4c568 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,33 +1,34 @@ -import { defineConfig } from 'astro/config' -import tailwind from "@astrojs/tailwind" -import node from "@astrojs/node" - -// const faviconHook = { -// name: 'Favicon', -// hooks: { -// "astro:build:setup": async () => { -// await Manifest.create('./src/assets/favicon.png', { -// name: 'Template' -// }) -// } -// } -// } - -// https://astro.build/config -export default defineConfig({ +import { defineConfig } from 'astro/config' +import tailwind from "@astrojs/tailwind" +import node from "@astrojs/node" +import routing from './hooks/routing' + +// const faviconHook = { +// name: 'Favicon', +// hooks: { +// "astro:build:setup": async () => { +// await Manifest.create('./src/assets/favicon.png', { +// name: 'Template' +// }) +// } +// } +// } + +// https://astro.build/config +export default defineConfig({ // Use the NodeJS adapter adapter: node({ mode: "standalone" }), // some settings to the build output - build: { + build: { // the asset path - assets: 'assets', + assets: 'assets', // inline the stylesheet if it is small enough - inlineStylesheets: 'auto' - }, + inlineStylesheets: 'auto' + }, // Compress the HTML output compressHTML: true, @@ -36,7 +37,7 @@ export default defineConfig({ output: 'server', // Add TailwindCSS - integrations: [tailwind()], + integrations: [tailwind(), routing()], // prefetch links prefetch: { @@ -47,25 +48,25 @@ export default defineConfig({ site: 'https://example.com', // the Output server - server: { - host: true, - port: 3000 - }, + server: { + host: true, + port: 3000 + }, // Remove the trailing slash by default - trailingSlash: 'never', + trailingSlash: 'never', // Dev Server - vite: { - server: { - watch: { + vite: { + server: { + watch: { // Ignore some paths ignored: [], - // support WSL strange things - usePolling: !!process.env.WSL_DISTRO_NAME - } - } - }, - + // support WSL strange things + usePolling: !!process.env.WSL_DISTRO_NAME + } + } + }, - }) + + }) diff --git a/hooks/routing.ts b/hooks/routing.ts new file mode 100644 index 0000000..3a6c7a2 --- /dev/null +++ b/hooks/routing.ts @@ -0,0 +1,199 @@ +import type { AstroIntegration } from 'astro' +import fs from 'node:fs/promises' + +const baseFile = ` +import { objectLoop } from '@dzeio/object-util' + +/** + * Format a Route with \`[param]\` elements with data in them + * + * limits: currently does not support \`[...param]\` + * + * @param url {string} the url to format + * @param params {Record} parameters to add to the URL (in [] first else in the query) + * + * @returns the URL formatted with the params + */ +export function formatRoute(url: T, params?: Record): string { + let result: string = url + + // early return if there are no params + if (!params) { + return result + } + + // external queries for the URL + let externalQueries = '' + + // loop through the parameters + objectLoop(params, (value, key) => { + const search = \`[\${key}]\` + value = encodeURI(value.toString()) + if (!result.includes(search)) { + externalQueries += \`\${encodeURI(key)}=\${value}&\` + } else { + result = result.replace(search, value) + } + }) + + // add items to the external queries if they are set + if (externalQueries) { + externalQueries = '?' + externalQueries.slice(0, externalQueries.length - 1) + } + + return result + externalQueries +}`.trim() + +/** + * Generate the file that contains every routes + * + * @param output the output file location + */ +async function updateRoutes(output: string, routes: Array) { + let file = baseFile + file += `\n\nexport type Routes = ${routes.map((it) => `'${it}'`).join(' | ')}` + + file += '\n\nexport default function route(route: Routes, query?: Record) {' + file += '\n\treturn formatRoute(route, query)' + file += '\n}\n' + + await fs.writeFile(output, file) +} + + +/** + * format the path back to an url usable by the app + * + * @param path the path to format + * @returns the path formatted as a URL + */ +function formatPath(basePath: string, path: string, removeExtension = true): string { + // remove the base path + path = path.replace(basePath, '') + + // remove the extension if asked + if (removeExtension) { + const lastDot = path.lastIndexOf('.') + path = path.slice(0, lastDot) + } + + // remove the index from the element + if (path.endsWith('/index')) { + path = path.replace('/index', '') + } + + // handle the `/` endpoint + if (path === '') { + path = '/' + } + + return path +} + +/** + * get every files recursivelly in a specific directory + * + * @param path the path to search + * @returns the list of files recursivelly in the specific directory + */ +async function getFiles(path: string): Promise> { + const dir = await fs.readdir(path) + let files: Array = [] + for (const file of dir) { + if (file.startsWith('_')) continue + const filePath = path + '/' + file + if ((await fs.stat(filePath)).isDirectory()) { + files = files.concat(await getFiles(filePath)) + } else { + files.push(filePath) + } + } + return files +} + +let publicFolder!: string +let srcFolder!: string +let pagesFolder!: string +let outputFile!: string + +/** + * launch the integration + * @returns the routng integration + */ +const integration: () => AstroIntegration = () => ({ + name: 'Routing', + hooks: { + 'astro:config:setup': async ({ config }) => { + publicFolder = config.publicDir.toString().replace('file:///', '') + srcFolder = config.srcDir.toString().replace('file:///', '') + if (process.platform !== 'win32') { + srcFolder = '/' + srcFolder + publicFolder = '/' + publicFolder + } + pagesFolder = srcFolder + 'pages' + outputFile = srcFolder + 'route.ts' + + // get the files list + const files = (await Promise.all([ + await getFiles(pagesFolder).then((ev) => ev.map((it) => formatPath(pagesFolder, it))), + await getFiles(publicFolder).then((ev) => ev.map((it) => formatPath(publicFolder, it, false))) + ])).flat() + await updateRoutes(outputFile, files) + }, + 'astro:server:setup': async ({ server }) => { + + // get the files list + const files = (await Promise.all([ + await getFiles(pagesFolder).then((ev) => ev.map((it) => formatPath(pagesFolder, it))), + await getFiles(publicFolder).then((ev) => ev.map((it) => formatPath(publicFolder, it, false))) + ])).flat() + + // watch FS changes for new files to add them to the route list + server.watcher.on('add', (path) => { + path = path.replace(/\\/g, '/') + + // ignore files starting with '_' + const filename = path.slice(path.lastIndexOf('/') + 1) + if (filename.startsWith('_')) return + + let removeExtension = true + let folder = pagesFolder + if(path.startsWith(publicFolder)) { + removeExtension = false + folder = publicFolder + } else if (!path.startsWith(folder)) { + return + } + + // format the path + path = formatPath(folder, path, removeExtension) + + // update the router + files.push(path) + updateRoutes(outputFile, files) + }) + + // watch FS changes for removed files to remove them from the list + server.watcher.on('unlink', (path) => { + path = path.replace(/\\/g, '/') + let removeExtension = true + let folder = pagesFolder + if(path.startsWith(publicFolder)) { + removeExtension = false + folder = publicFolder + } + path = formatPath(folder, path, removeExtension) + + const index = files.indexOf(path) + + files.splice(index, 1) + updateRoutes(outputFile, files) + }) + + // run the script once + await updateRoutes(outputFile, files) + } + } +}) + +export default integration