219 lines
5.5 KiB
TypeScript
219 lines
5.5 KiB
TypeScript
import { asset, IS_BROWSER } from 'fresh/runtime'
|
|
import { App } from 'fresh'
|
|
|
|
/**
|
|
* List of css files imported by the current fresh route.
|
|
*/
|
|
const styles = new Map<
|
|
string,
|
|
{ url: string; layer: string | undefined; scope: string | undefined }
|
|
>()
|
|
|
|
const baseRoute = '__smart_css__'
|
|
|
|
/**
|
|
* Generate a css scope for the given component/island based on its name.
|
|
*
|
|
* @param component - Component or island to scope.
|
|
* @returns scope - css scope class.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // ./(components|islands)/Button.tsx
|
|
* import type { JSX } from 'preact'
|
|
* import { getStyleScope, useSmartStylesheet } from './SmartStylesheet.tsx'
|
|
*
|
|
* const scope = getStyleScope(Button)
|
|
*
|
|
* export function Button(props: JSX.ButtonHTMLAttributes) {
|
|
* useSmartStylesheet(import.meta, { scope })
|
|
*
|
|
* return <button class={scope} {...props} />
|
|
* }
|
|
* ```
|
|
*/
|
|
export function getStyleScope<T extends { name: Readonly<string> }>(
|
|
component: T,
|
|
): string {
|
|
// generate scope/class hash
|
|
return `_scope_${
|
|
btoa(component.name).slice(-10).replaceAll('=', '_').toLowerCase()
|
|
}`
|
|
}
|
|
|
|
/**
|
|
* Hook to load component/island stylesheet only when
|
|
* it is imported by the served route.
|
|
* For any `./(component|islands)/Element.tsx` it will
|
|
* load the corresponding `./(component|islands)/Element.css`
|
|
*
|
|
* @param meta - Component ImportMeta used to resolve
|
|
* stylesheet path, name and layer.
|
|
* @param options - CSS scope to use (default: none)
|
|
* and css layer ('components' or 'islands') depending of the ImportMeta.
|
|
*
|
|
* @example Basic usage
|
|
* ```ts
|
|
* // ./(components|islands)/Button.tsx
|
|
* import type { JSX } from 'preact'
|
|
* import { useSmartStylesheet } from './SmartStylesheet.tsx'
|
|
*
|
|
* export function Button(props: JSX.ButtonHTMLAttributes) {
|
|
* useSmartStylesheet(import.meta)
|
|
*
|
|
* return <button class={scope} {...props} />
|
|
* }
|
|
* ```
|
|
*
|
|
* @example Use css scope
|
|
* ```ts
|
|
* // ./(components|islands)/Button.tsx
|
|
* import type { JSX } from 'preact'
|
|
* import { getStyleScope, useSmartStylesheet } from './SmartStylesheet.tsx'
|
|
*
|
|
* const scope = getStyleScope(Button)
|
|
*
|
|
* export function Button(props: JSX.ButtonHTMLAttributes) {
|
|
* useSmartStylesheet(import.meta, { scope })
|
|
*
|
|
* return <button class={scope} {...props} />
|
|
* }
|
|
* ```
|
|
*
|
|
* @example Use custom layer
|
|
* ```ts
|
|
* // ./(components|islands)/Button.tsx
|
|
* import type { JSX } from 'preact'
|
|
* import { getStyleScope, useSmartStylesheet } from './SmartStylesheet.tsx'
|
|
*
|
|
* const scope = getStyleScope(Button)
|
|
*
|
|
* export function Button(props: JSX.ButtonHTMLAttributes) {
|
|
* useSmartStylesheet(import.meta, { scope, layer: 'custom' })
|
|
*
|
|
* return <button class={scope} {...props} />
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useSmartStylesheet(
|
|
meta: ImportMeta,
|
|
options?: { scope?: string; layer?: string },
|
|
) {
|
|
if (IS_BROWSER) return
|
|
|
|
// resolve filename
|
|
const css = meta.filename
|
|
?.replace('.tsx', '.css')
|
|
.replace(Deno.cwd(), '')
|
|
.replaceAll('\\', '/')
|
|
|
|
if (!css) return
|
|
if (styles.has(css)) return
|
|
|
|
// set css layer
|
|
const layerValue = options?.layer ?? css.includes('/components/')
|
|
? 'components'
|
|
: css.includes('/islands/')
|
|
? 'islands'
|
|
: undefined
|
|
|
|
styles.set(css, { url: css, scope: options?.scope, layer: layerValue })
|
|
}
|
|
|
|
/**
|
|
* 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 smartStylesheetPlugin<T>(
|
|
app: App<T>,
|
|
options: { baseRoute?: string } = {},
|
|
) {
|
|
options.baseRoute ??= 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')
|
|
|
|
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',
|
|
},
|
|
})
|
|
}
|
|
|
|
return new Response(null, {
|
|
status: 400,
|
|
statusText: 'Bad Request - Invalid url',
|
|
})
|
|
})
|
|
}
|