feat(plugins): use new wip css bundler plugin

This commit is contained in:
Julien Oculi 2025-06-23 11:48:33 +02:00
parent f6825a9321
commit 1b285afe75
22 changed files with 300 additions and 173 deletions

View file

@ -1,5 +1,8 @@
import { ComponentChildren, JSX } from 'preact' import { ComponentChildren, JSX } from 'preact'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
type Units = 'rem' | '%' | 'px' type Units = 'rem' | '%' | 'px'

View file

@ -1,6 +1,6 @@
import { Markdown } from ':components/Markdown.tsx' import { Markdown } from ':components/Markdown.tsx'
import { NewsFrontMatter } from ':src/blog/types.ts' import { NewsFrontMatter } from ':src/blog/types.ts'
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import { useSmartStylesheet } from ':plugins/SmartStylesheetIsland.tsx'
export type BlogProps = { export type BlogProps = {
title: string title: string

View file

@ -1,4 +1,4 @@
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import { useSmartStylesheet } from ':plugins/SmartStylesheetIsland.tsx'
export function CohabitInfoTable() { export function CohabitInfoTable() {
return ( return (

View file

@ -1,4 +1,7 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
const scope = getStyleScope(Footer) const scope = getStyleScope(Footer)

View file

@ -3,7 +3,10 @@ import AiChatBox from ':islands/AiChatBox.tsx'
import MoreBox from ':islands/MoreBox.tsx' import MoreBox from ':islands/MoreBox.tsx'
import SearchBox from ':islands/SearchBox.tsx' import SearchBox from ':islands/SearchBox.tsx'
import ThemePicker from ':islands/ThemePicker.tsx' import ThemePicker from ':islands/ThemePicker.tsx'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
const scope = getStyleScope(Header) const scope = getStyleScope(Header)

View file

@ -1,4 +1,7 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
const scope = getStyleScope(Heros) const scope = getStyleScope(Heros)

View file

@ -1,4 +1,7 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
type MachineCardProps = { type MachineCardProps = {
img: string img: string

View file

@ -1,4 +1,7 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
export type MemberCardProps = { export type MemberCardProps = {
id: string id: string

View file

@ -1,5 +1,8 @@
import { JSX } from 'preact' import { JSX } from 'preact'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
type ProjectCardProps = { type ProjectCardProps = {
id: string id: string

View file

@ -1,6 +1,9 @@
import { asset } from 'fresh/runtime' import { asset } from 'fresh/runtime'
import { Picture } from ':components/Picture.tsx' import { Picture } from ':components/Picture.tsx'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
const scope = getStyleScope(SponsorCards) const scope = getStyleScope(SponsorCards)

View file

@ -30,8 +30,10 @@
"@deno/gfm": "jsr:@deno/gfm@^0.10.0", "@deno/gfm": "jsr:@deno/gfm@^0.10.0",
"@mdxeditor/editor": "npm:@mdxeditor/editor@^3.32.3", "@mdxeditor/editor": "npm:@mdxeditor/editor@^3.32.3",
"@std/fs": "jsr:@std/fs@^1.0.6", "@std/fs": "jsr:@std/fs@^1.0.6",
"@std/media-types": "jsr:@std/media-types@^1.1.0",
"@std/path": "jsr:@std/path@^1.0.8", "@std/path": "jsr:@std/path@^1.0.8",
"fresh": "jsr:@fresh/core@^2.0.0-alpha.25", "fresh": "jsr:@fresh/core@^2.0.0-alpha.25",
"esbuild": "npm:esbuild@^0.25.4",
":components/": "./components/", ":components/": "./components/",
":islands/": "./islands/", ":islands/": "./islands/",
":src/": "./src/", ":src/": "./src/",

View file

@ -3,7 +3,7 @@ import { Markdown } from ':components/Markdown.tsx'
import { Signal, signal, useSignal } from '@preact/signals' import { Signal, signal, useSignal } from '@preact/signals'
import { JSX } from 'preact' import { JSX } from 'preact'
import { useEffect, useRef } from 'preact/hooks' import { useEffect, useRef } from 'preact/hooks'
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import { useSmartStylesheet } from ':plugins/SmartStylesheetIsland.tsx'
const systemHistory = signal<BotMessage[]>([{ const systemHistory = signal<BotMessage[]>([{
role: 'system', role: 'system',

View file

@ -1,6 +1,9 @@
import { type Signal, useComputed, useSignal } from '@preact/signals' import { type Signal, useComputed, useSignal } from '@preact/signals'
import { useEffect } from 'preact/hooks' import { useEffect } from 'preact/hooks'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
type NetworkConnection = { type NetworkConnection = {
addEventListener: ( addEventListener: (

View file

@ -7,7 +7,10 @@ import type {
} from '../routes/api/webauthn/login/[step].ts' } from '../routes/api/webauthn/login/[step].ts'
import { Button } from ':components/Button.tsx' import { Button } from ':components/Button.tsx'
import { Input } from ':components/Input.tsx' import { Input } from ':components/Input.tsx'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
const scope = getStyleScope(LoginForm) const scope = getStyleScope(LoginForm)

View file

@ -1,6 +1,6 @@
import { VNode } from 'preact' import { VNode } from 'preact'
import { useEffect, useRef } from 'preact/hooks' import { useEffect, useRef } from 'preact/hooks'
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import { useSmartStylesheet } from ':plugins/SmartStylesheetIsland.tsx'
export default function MoreBox({ children }: { children: VNode | VNode[] }) { export default function MoreBox({ children }: { children: VNode | VNode[] }) {
useSmartStylesheet(import.meta) useSmartStylesheet(import.meta)

View file

@ -1,5 +1,5 @@
import { useEffect, useRef } from 'preact/hooks' import { useEffect, useRef } from 'preact/hooks'
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import { useSmartStylesheet } from ':plugins/SmartStylesheetIsland.tsx'
export default function SearchBox() { export default function SearchBox() {
useSmartStylesheet(import.meta) useSmartStylesheet(import.meta)

View file

@ -1,6 +1,9 @@
import { useComputed, useSignal, useSignalEffect } from '@preact/signals' import { useComputed, useSignal, useSignalEffect } from '@preact/signals'
import { IS_BROWSER } from 'fresh/runtime' import { IS_BROWSER } from 'fresh/runtime'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx' import {
getStyleScope,
useSmartStylesheet,
} from ':plugins/SmartStylesheetIsland.tsx'
const scope = getStyleScope(ThemePicker) const scope = getStyleScope(ThemePicker)

26
main.ts
View file

@ -1,6 +1,5 @@
import { App, fsRoutes, staticFiles } from 'fresh' import { App, fsRoutes, staticFiles } from 'fresh'
import { type State } from './utils.ts' import { type State } from './utils.ts'
import { contentType } from 'jsr:@std/media-types@1/content-type'
import { smartStylesheetPlugin } from ':plugins/SmartStylesheet.tsx' import { smartStylesheetPlugin } from ':plugins/SmartStylesheet.tsx'
export const app = new App<State>() export const app = new App<State>()
@ -16,31 +15,6 @@ app.use(staticFiles())
smartStylesheetPlugin(app) smartStylesheetPlugin(app)
//TEMP fix before updating cssBundler middleware
app.use(async (ctx) => {
const response = await ctx.next()
if (
response.status === 404 &&
!ctx.url.pathname.match(/\/js\/[0-9a-f]+\/\S+\.js/)
) {
const ext = ctx.url.pathname.split('.').at(-1) ?? '.bin'
const mime = contentType(ext) ?? 'application/octet-stream'
try {
const file = await Deno.readFile(`./_fresh/static/${ctx.url.pathname}`)
return new Response(file, {
headers: {
'Content-Type': mime,
},
})
} catch {
//TEMP don't handle specific error for now
return response
}
}
return response
})
await fsRoutes(app, { await fsRoutes(app, {
dir: './', dir: './',
loadIsland: (path) => import(`./islands/${path}`), loadIsland: (path) => import(`./islands/${path}`),

View file

@ -1,125 +1,16 @@
import { asset, IS_BROWSER } from 'fresh/runtime' import { asset } from 'fresh/runtime'
import { App } from 'fresh' 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>()
* 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__' 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. * Dynamic components and islands stylesheet.
* *
@ -165,12 +56,20 @@ export function SmartStylesheet(
) )
} }
export function StaticStylesheet(
options: { href: string },
) {
return <link rel='stylesheet' href={asset(`/${baseRoute}/${options.href}`)} />
}
export function smartStylesheetPlugin<T>( export function smartStylesheetPlugin<T>(
app: App<T>, app: App<T>,
options: { baseRoute?: string } = {}, options: { baseRoute?: string } = {},
) { ) {
options.baseRoute ??= baseRoute options.baseRoute ??= baseRoute
bundleCss(['./src/stylesheets/main.css'], app, options.baseRoute)
//resolve dynamic styles imports //resolve dynamic styles imports
app.get(`/${options.baseRoute}/:path+`, async (ctx) => { app.get(`/${options.baseRoute}/:path+`, async (ctx) => {
const { path } = ctx.params const { path } = ctx.params
@ -199,20 +98,117 @@ export function smartStylesheetPlugin<T>(
if (path.startsWith('components') || path.startsWith('islands')) { if (path.startsWith('components') || path.startsWith('islands')) {
const scope = ctx.url.searchParams.get('__scope') const scope = ctx.url.searchParams.get('__scope')
const css = await Deno.readTextFile(`./${path}`) try {
const css = await Deno.readTextFile(`./${path}`)
const file = scope ? `@scope (.${scope}) {\n\n${css}\n}` : css
const file = scope ? `@scope (.${scope}) {\n\n${css}\n}` : css return new Response(file, {
headers: {
return new Response(file, { 'Content-Type': 'text/css; charset=utf-8',
headers: { },
'Content-Type': 'text/css; charset=utf-8', })
}, } catch (error) {
}) if (error instanceof Deno.errors.NotFound) {
return new Response(null, { status: 404 })
}
throw error
}
} }
return new Response(null, { try {
status: 400, const file = await Deno.readFile(
statusText: 'Bad Request - Invalid url', `${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)
}
}

View file

@ -0,0 +1,7 @@
/**
* List of css files imported by the current fresh route.
*/
export const styles = new Map<
string,
{ url: string; layer: string | undefined; scope: string | undefined }
>()

View file

@ -0,0 +1,111 @@
import { IS_BROWSER } from 'fresh/runtime'
import { styles } from './SmartStylesheetCommon.tsx'
/**
* 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 })
}

View file

@ -4,7 +4,7 @@ import { Footer } from ':components/Footer.tsx'
import { Header } from ':components/Header.tsx' import { Header } from ':components/Header.tsx'
import IsOnline from ':islands/IsOnline.tsx' import IsOnline from ':islands/IsOnline.tsx'
import RegisterServiceWorker from ':islands/RegisterServiceWorker.tsx' import RegisterServiceWorker from ':islands/RegisterServiceWorker.tsx'
import { SmartStylesheet } from ':plugins/SmartStylesheet.tsx' import { SmartStylesheet, StaticStylesheet } from ':plugins/SmartStylesheet.tsx'
export default function App( export default function App(
{ Component, data, url }: PageProps<{ title?: string } | undefined>, { Component, data, url }: PageProps<{ title?: string } | undefined>,
@ -42,7 +42,11 @@ export default function App(
type='image/x-icon' type='image/x-icon'
/> />
<SmartStylesheet pathname={url.pathname} /> <SmartStylesheet pathname={url.pathname} />
<link rel='stylesheet' href={asset('/main.css')} /> <link
rel='stylesheet'
href='https://cdn.jsdelivr.net/npm/@mdxeditor/editor@3.32.3/dist/style.min.css'
/>
<StaticStylesheet href='main.css' />
<link rel='stylesheet' href={asset('/imports/markdown_css')} /> <link rel='stylesheet' href={asset('/imports/markdown_css')} />
<title>{data?.title ?? 'Fablab Coh@bit'}</title> <title>{data?.title ?? 'Fablab Coh@bit'}</title>
</head> </head>