215 lines
5.4 KiB
TypeScript
215 lines
5.4 KiB
TypeScript
import { asset } from 'fresh/runtime'
|
|
import { App } from 'fresh'
|
|
import { build, type Plugin } from 'esbuild'
|
|
import { extname, fromFileUrl, toFileUrl } from '@std/path'
|
|
import { exists } from '@std/fs/exists'
|
|
import { ensureDir } from '@std/fs'
|
|
import { contentType } from '@std/media-types'
|
|
import { styles } from './SmartStylesheetCommon.tsx'
|
|
|
|
const bundledFiles = new Set<string>()
|
|
|
|
const baseRoute = '__smart_css__'
|
|
|
|
/**
|
|
* Dynamic components and islands stylesheet.
|
|
*
|
|
* @param options - `pathname` set the pathname of the current route, `baseRoute` overrides default plugin baseRoute.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import type { PageProps } from 'fresh'
|
|
* import { SmartStylesheet } from './SmartStylesheet.tsx'
|
|
*
|
|
* export default function App({ Component, url }: PageProps) {
|
|
* return (
|
|
* <html>
|
|
* <head>
|
|
* <meta charset='utf-8' />
|
|
* <meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
|
* <title>My app</title>
|
|
* <link rel='stylesheet' href='/styles.css' />
|
|
* <SmartStylesheet pathname={url.pathname} />
|
|
* </head>
|
|
* <body>
|
|
* <Component />
|
|
* </body>
|
|
* </html>
|
|
* )
|
|
* }
|
|
* ```
|
|
*/
|
|
export function SmartStylesheet(
|
|
options: { pathname: string; baseRoute?: string },
|
|
) {
|
|
styles.clear()
|
|
|
|
return (
|
|
<link
|
|
href={asset(
|
|
`/${options.baseRoute ?? baseRoute}/__r__${
|
|
encodeURIComponent(options.pathname)
|
|
}`,
|
|
)}
|
|
rel='stylesheet'
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function StaticStylesheet(
|
|
options: { href: string },
|
|
) {
|
|
return <link rel='stylesheet' href={asset(`/${baseRoute}/${options.href}`)} />
|
|
}
|
|
|
|
export function smartStylesheetPlugin<T>(
|
|
app: App<T>,
|
|
options: { baseRoute?: string } = {},
|
|
) {
|
|
options.baseRoute ??= baseRoute
|
|
|
|
bundleCss(['./src/stylesheets/main.css'], app, options.baseRoute)
|
|
|
|
//resolve dynamic styles imports
|
|
app.get(`/${options.baseRoute}/:path+`, async (ctx) => {
|
|
const { path } = ctx.params
|
|
|
|
if (path.startsWith('__r__')) {
|
|
const css = styles.values().map(({ url, layer, scope }) => {
|
|
const href = asset(`/${options.baseRoute}${url}`)
|
|
const scopeParam = scope ? `&__scope=${scope}` : ''
|
|
const cssImport = `@import url("${href}${scopeParam}")`
|
|
const cssLayer = layer ? ` layer(${layer})` : ''
|
|
return `${cssImport}${cssLayer};`
|
|
}).toArray()
|
|
|
|
return new Response(
|
|
`/* auto generated using jsr:@jotsr/smart-stylesheet */\n${
|
|
css.join('\n')
|
|
}`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'text/css; charset=utf-8',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
if (path.startsWith('components') || path.startsWith('islands')) {
|
|
const scope = ctx.url.searchParams.get('__scope')
|
|
|
|
try {
|
|
const css = await Deno.readTextFile(`./${path}`)
|
|
const file = scope ? `@scope (.${scope}) {\n\n${css}\n}` : css
|
|
|
|
return new Response(file, {
|
|
headers: {
|
|
'Content-Type': 'text/css; charset=utf-8',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof Deno.errors.NotFound) {
|
|
return new Response(null, { status: 404 })
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
try {
|
|
const file = await Deno.readFile(
|
|
`${app.config.build.outDir}/static/${options.baseRoute}/${path}`,
|
|
)
|
|
const mime = contentType(extname(path)) ?? 'application/octet-stream'
|
|
return new Response(file, {
|
|
headers: { 'Content-Type': mime },
|
|
})
|
|
} catch {
|
|
return new Response(null, {
|
|
status: 400,
|
|
statusText: 'Bad Request - Invalid url',
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
async function bundleCss<T extends unknown>(
|
|
files: string[],
|
|
app: App<T>,
|
|
baseRoute?: string,
|
|
) {
|
|
const cssPlugin: Plugin = {
|
|
name: 'smart-stylesheet-bundler',
|
|
setup(build) {
|
|
build.onResolve({ filter: /.*/ }, async (args) => {
|
|
const importer = args.importer.length
|
|
? args.importer
|
|
: `${app.config.root}/`
|
|
const importerUrl = importer.match(/\w+:\/\/.+/)
|
|
? new URL(importer)
|
|
: toFileUrl(importer)
|
|
const url = new URL(args.path, importerUrl)
|
|
|
|
const external = url.protocol === 'file:' && !(await exists(url))
|
|
const local = url.protocol === 'file:'
|
|
|
|
if (!external) bundledFiles.add(url.href)
|
|
const watchFiles: string[] = []
|
|
if (local && !external) watchFiles.push(fromFileUrl(url.href))
|
|
|
|
return {
|
|
path: external ? args.path : url.href,
|
|
namespace: external ? 'external' : 'internal',
|
|
external,
|
|
watchFiles,
|
|
}
|
|
})
|
|
|
|
build.onLoad({ filter: /.*/, namespace: 'internal' }, async (args) => {
|
|
const url = new URL(args.path)
|
|
let isCss = url.pathname.match(/.css$/) !== null
|
|
const buffer = url.protocol === 'file:'
|
|
? await Deno.readFile(url)
|
|
: await fetch(url).then((r) => {
|
|
if (r.headers.get('Content-type')?.includes('text/css')) {
|
|
isCss = true
|
|
}
|
|
return r.bytes()
|
|
})
|
|
|
|
if (isCss) {
|
|
return {
|
|
contents: new TextDecoder().decode(buffer),
|
|
loader: 'css',
|
|
resolveDir: url.href,
|
|
}
|
|
}
|
|
|
|
return {
|
|
contents: buffer,
|
|
loader: url.protocol === 'data:' ? 'dataurl' : 'file',
|
|
}
|
|
})
|
|
},
|
|
}
|
|
|
|
const outdir = baseRoute
|
|
? `${app.config.build.outDir}/static/${baseRoute}`
|
|
: `${app.config.build.outDir}/static`
|
|
await ensureDir(outdir)
|
|
|
|
const result = await build({
|
|
entryPoints: files,
|
|
bundle: true,
|
|
minify: true,
|
|
sourcemap: app.config.mode === 'development' ? 'inline' : false,
|
|
plugins: [cssPlugin],
|
|
outdir,
|
|
write: false,
|
|
})
|
|
|
|
for (const file of result.outputFiles) {
|
|
const path = new URL(file.path).pathname
|
|
Deno.writeFile(path, file.contents)
|
|
}
|
|
}
|