feat: rewrite components/islands css loading with dynamic tree-shake loader

This commit is contained in:
Julien Oculi 2025-05-16 15:24:59 +02:00
parent e7bd691d53
commit d461e53a00
33 changed files with 385 additions and 100 deletions

View file

@ -1,5 +1,5 @@
.components__auto_grid { :scope {
display: grid; display: grid;
gap: var(--_gap); gap: var(--_gap);
margin-block: var(--_gap); margin-block: var(--_gap);
} }

View file

@ -1,7 +1,10 @@
import { ComponentChildren, JSX } from 'preact' import { ComponentChildren, JSX } from 'preact'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
type Units = 'rem' | '%' | 'px' type Units = 'rem' | '%' | 'px'
const scope = getStyleScope(AutoGrid)
export function AutoGrid( export function AutoGrid(
{ columnWidth, children, style }: { { columnWidth, children, style }: {
columnWidth: `${number}${Units}` columnWidth: `${number}${Units}`
@ -9,9 +12,11 @@ export function AutoGrid(
style?: JSX.CSSProperties style?: JSX.CSSProperties
}, },
) { ) {
useSmartStylesheet(import.meta, { scope })
return ( return (
<div <div
class='components__auto_grid' class={scope}
style={{ style={{
gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`, gridTemplateColumns: `repeat(auto-fit, minmax(${columnWidth}, 1fr));`,
...style, ...style,

View file

@ -1,5 +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'
export type BlogProps = { export type BlogProps = {
title: string title: string
@ -19,6 +20,8 @@ export function BlogCard(
{ title, description, author, lastUpdate, name, options, tags, publisher }: { title, description, author, lastUpdate, name, options, tags, publisher }:
BlogProps, BlogProps,
) { ) {
useSmartStylesheet(import.meta)
return ( return (
<div <div
class='components__blog_block components__blog_block--card' class='components__blog_block components__blog_block--card'
@ -55,6 +58,8 @@ export function BlogPost(
publisher, publisher,
}: BlogProps, }: BlogProps,
) { ) {
useSmartStylesheet(import.meta)
return ( return (
<div class='components__blog_block--post'> <div class='components__blog_block--post'>
<h1>{title}</h1> <h1>{title}</h1>

View file

@ -1,3 +1,5 @@
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
export function CohabitInfoTable() { export function CohabitInfoTable() {
return ( return (
<div class='components__cohabit_info_table'> <div class='components__cohabit_info_table'>
@ -8,6 +10,8 @@ export function CohabitInfoTable() {
} }
export function TechnoshopInfoTable() { export function TechnoshopInfoTable() {
useSmartStylesheet(import.meta)
return ( return (
<section class='components__cohabit_info_table__technoshop'> <section class='components__cohabit_info_table__technoshop'>
<div> <div>
@ -76,6 +80,8 @@ export function TechnoshopInfoTable() {
} }
export function FablabInfoTable() { export function FablabInfoTable() {
useSmartStylesheet(import.meta)
return ( return (
<section class='components__cohabit_info_table__fablab'> <section class='components__cohabit_info_table__fablab'>
<div> <div>

View file

@ -1,9 +1,9 @@
footer { :scope {
height: fit-content; height: fit-content;
background-color: var(--_translucent); background-color: var(--_translucent);
} }
footer div { :scope div {
max-width: var(--_wide-screen); max-width: var(--_wide-screen);
width: 100%; width: 100%;
padding: var(--_gap); padding: var(--_gap);
@ -14,13 +14,13 @@ footer div {
} }
@media (width < 800px) { @media (width < 800px) {
footer div { :scope div {
flex-wrap: wrap; flex-wrap: wrap;
text-align: center; text-align: center;
} }
} }
.footer__badges { .badges {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: var(--_gap-half); gap: var(--_gap-half);
@ -28,7 +28,7 @@ footer div {
padding-left: 0; padding-left: 0;
} }
.footer__badges__item { .badges__item {
text-decoration: none; text-decoration: none;
background-color: var(--_translucent); background-color: var(--_translucent);
color: var(--_font-color); color: var(--_font-color);

View file

@ -1,6 +1,12 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
const scope = getStyleScope(Footer)
export function Footer() { export function Footer() {
useSmartStylesheet(import.meta, { scope })
return ( return (
<footer> <footer class={scope}>
<div> <div>
<section> <section>
<h3>Nous contacter</h3> <h3>Nous contacter</h3>
@ -41,9 +47,9 @@ export function Footer() {
</section> </section>
<section> <section>
<h3>Nos réseaux</h3> <h3>Nos réseaux</h3>
<div class='footer__badges'> <div class='badges'>
<a <a
class='footer__badges__item' class='badges__item'
href='https://tube.aquilenet.fr/c/cohabit_fablab/videos' href='https://tube.aquilenet.fr/c/cohabit_fablab/videos'
target='_blank' target='_blank'
title='Peertube' title='Peertube'
@ -51,7 +57,7 @@ export function Footer() {
<i class='ri-play-line'></i> <i class='ri-play-line'></i>
</a> </a>
<a <a
class='footer__badges__item' class='badges__item'
href='https://www.facebook.com/people/Fablab-Cohabit/100069798301175/' href='https://www.facebook.com/people/Fablab-Cohabit/100069798301175/'
target='_blank' target='_blank'
title='Facebook' title='Facebook'
@ -59,7 +65,7 @@ export function Footer() {
<i class='ri-facebook-line'></i> <i class='ri-facebook-line'></i>
</a> </a>
<a <a
class='footer__badges__item' class='badges__item'
href='https://www.instagram.com/fablabcohabit/' href='https://www.instagram.com/fablabcohabit/'
target='_blank' target='_blank'
title='Instagram' title='Instagram'
@ -67,7 +73,7 @@ export function Footer() {
<i class='ri-instagram-line'></i> <i class='ri-instagram-line'></i>
</a> </a>
<a <a
class='footer__badges__item' class='badges__item'
href='https://www.linkedin.com/company/fablab-cohabit/about/' href='https://www.linkedin.com/company/fablab-cohabit/about/'
target='_blank' target='_blank'
title='LinkedIn' title='LinkedIn'
@ -75,7 +81,7 @@ export function Footer() {
<i class='ri-linkedin-line'></i> <i class='ri-linkedin-line'></i>
</a> </a>
<a <a
class='footer__badges__item' class='badges__item'
href='https://toot.aquilenet.fr/@cohabitfablab' href='https://toot.aquilenet.fr/@cohabitfablab'
target='_blank' target='_blank'
title='Mastodon' title='Mastodon'
@ -83,7 +89,7 @@ export function Footer() {
<i class='ri-mastodon-line'></i> <i class='ri-mastodon-line'></i>
</a> </a>
<a <a
class='footer__badges__item' class='badges__item'
href='https://matrix.to/#/!thtlRrlXFrbifqMNCG:matrix.org?via=matrix.org' href='https://matrix.to/#/!thtlRrlXFrbifqMNCG:matrix.org?via=matrix.org'
target='_blank' target='_blank'
title='Matrix' title='Matrix'

View file

@ -1,4 +1,4 @@
header { :scope {
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
@ -8,7 +8,7 @@ header {
--_header-width-small-screen: 960px; --_header-width-small-screen: 960px;
} }
header div { :scope div {
max-width: var(--_wide-screen); max-width: var(--_wide-screen);
margin: auto; margin: auto;
padding: var(--_gap); padding: var(--_gap);
@ -16,7 +16,7 @@ header div {
gap: var(--_gap); gap: var(--_gap);
} }
.components__header__brand { .brand {
text-wrap: nowrap; text-wrap: nowrap;
padding: var(--_gap); padding: var(--_gap);
font-size: large; font-size: large;
@ -28,7 +28,7 @@ header div {
} }
@media (width < var(--_header-width-small-screen)) { @media (width < var(--_header-width-small-screen)) {
.components__header__brand__text { .brand__text {
display: none; display: none;
} }
@ -38,7 +38,7 @@ header div {
} }
} }
header menu { :scope menu {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100%; width: 100%;
@ -66,7 +66,7 @@ header menu {
} }
} }
header details { :scope details {
cursor: pointer; cursor: pointer;
padding: var(--_gap-half); padding: var(--_gap-half);
@ -152,7 +152,7 @@ header details {
} }
} }
.components__header__menu_button { .menu_button {
color: var(--_font-color); color: var(--_font-color);
display: inline-block; display: inline-block;
text-decoration: none; text-decoration: none;

View file

@ -3,33 +3,38 @@ 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'
const scope = getStyleScope(Header)
export function Header() { export function Header() {
useSmartStylesheet(import.meta, { scope })
return ( return (
<header> <header class={scope}>
<div> <div>
<span class='components__header__brand'> <span class='brand'>
<a href='/'> <a href='/'>
<span class='components__header__brand__text'>Coh</span> <span class='brand__text'>Coh</span>
<img src={asset('/assets/bulb.svg')} alt='a' /> <img src={asset('/assets/bulb.svg')} alt='a' />
<span class='components__header__brand__text'>bit</span> <span class='brand__text'>bit</span>
</a> </a>
</span> </span>
<menu> <menu>
<li> <li>
<a href='/machines' class='components__header__menu_button'> <a href='/machines' class='menu_button'>
<i class='ri-hammer-line'></i> <i class='ri-hammer-line'></i>
<span>Machines</span> <span>Machines</span>
</a> </a>
</li> </li>
<li> <li>
<a href='/projets' class='components__header__menu_button'> <a href='/projets' class='menu_button'>
<i class='ri-organization-chart'></i> <i class='ri-organization-chart'></i>
<span>Projets</span> <span>Projets</span>
</a> </a>
</li> </li>
<li> <li>
<a href='/blog' class='components__header__menu_button'> <a href='/blog' class='menu_button'>
<i class='ri-question-answer-line'></i> <i class='ri-question-answer-line'></i>
<span>Blog</span> <span>Blog</span>
</a> </a>

View file

@ -1,4 +1,4 @@
.components__heros { :scope {
height: calc(100dvh - 6rem); height: calc(100dvh - 6rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -7,7 +7,7 @@
gap: calc(2 * var(--_gap)); gap: calc(2 * var(--_gap));
} }
.components__heros__title { .title {
font-size: 400%; font-size: 400%;
max-width: 800px; max-width: 800px;
text-wrap: balance; text-wrap: balance;
@ -15,7 +15,7 @@
margin: 0; margin: 0;
} }
.components__heros__subtitle { .subtitle {
font-size: large; font-size: large;
text-align: center; text-align: center;
text-wrap: balance; text-wrap: balance;

View file

@ -1,10 +1,16 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
const scope = getStyleScope(Heros)
export function Heros() { export function Heros() {
useSmartStylesheet(import.meta, { scope })
return ( return (
<section class='components__heros'> <section class={scope}>
<h1 class='components__heros__title'> <h1 class='title'>
L'atelier participatif libre et open source L'atelier participatif libre et open source
</h1> </h1>
<p class='components__heros__subtitle'> <p class='subtitle'>
Venez découvrir un tout nouvelle univers vous pouvez concrétiser vos Venez découvrir un tout nouvelle univers vous pouvez concrétiser vos
projet, rencontrer des passionnés apprendre et développer votre savoir projet, rencontrer des passionnés apprendre et développer votre savoir
faire, dans l'entraide et le partage. faire, dans l'entraide et le partage.

View file

@ -1,4 +1,4 @@
.components__machine_card { :scope {
min-width: 10rem; min-width: 10rem;
aspect-ratio: 1 / 2; aspect-ratio: 1 / 2;
display: flex; display: flex;
@ -19,11 +19,11 @@
} }
} }
.components__machine_card__spacer { .spacer {
height: 50%; height: 50%;
} }
.components__machine_card__tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--_gap-half); gap: var(--_gap-half);
@ -34,7 +34,7 @@
} }
} }
.components__machine_card__footer { .footer {
height: fit-content; height: fit-content;
display: flex; display: flex;
gap: var(--_gap); gap: var(--_gap);

View file

@ -1,3 +1,5 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
type MachineCardProps = { type MachineCardProps = {
img: string img: string
name: string name: string
@ -6,24 +8,28 @@ type MachineCardProps = {
free: boolean free: boolean
} }
const scope = getStyleScope(MachineCard)
export function MachineCard( export function MachineCard(
{ name, tags, img, id, free }: MachineCardProps, { name, tags, img, id, free }: MachineCardProps,
) { ) {
useSmartStylesheet(import.meta, { scope })
const stateIcon = free const stateIcon = free
? <i class='ri-checkbox-circle-line'></i> ? <i class='ri-checkbox-circle-line'></i>
: <i class='ri-indeterminate-circle-line'></i> : <i class='ri-indeterminate-circle-line'></i>
return ( return (
<div class='components__machine_card' style={{ backgroundImage: img }}> <div class={scope} style={{ backgroundImage: img }}>
<div class='components__machine_card__spacer'></div> <div class='spacer'></div>
<h3> <h3>
{stateIcon} {stateIcon}
<span>{name}</span> <span>{name}</span>
</h3> </h3>
<div class='components__machine_card__tags'> <div class='tags'>
{tags.map((tag, index) => <span key={index}>{tag}</span>)} {tags.map((tag, index) => <span key={index}>{tag}</span>)}
</div> </div>
<div class='components__machine_card__footer'> <div class='footer'>
<a href={`/machines/${id}`}>Infos</a> <a href={`/machines/${id}`}>Infos</a>
<a href={`/machines/${id}`}>Reserver</a> <a href={`/machines/${id}`}>Reserver</a>
</div> </div>

View file

@ -1,4 +1,4 @@
.components__member_card { :scope {
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
width: 16rem; width: 16rem;
@ -24,13 +24,13 @@
} }
} }
.components__member_card__icon { .icon {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
overflow: hidden; overflow: hidden;
} }
.components__member_card__content { .content {
background-color: var(--_translucent-bg); background-color: var(--_translucent-bg);
backdrop-filter: blur(var(--_blur)); backdrop-filter: blur(var(--_blur));
display: flex; display: flex;
@ -39,7 +39,7 @@
gap: var(--_gap-half); gap: var(--_gap-half);
} }
.components__member_card__groups { .groups {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--_gap-half); gap: var(--_gap-half);

View file

@ -1,3 +1,5 @@
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
export type MemberCardProps = { export type MemberCardProps = {
id: string id: string
icon: string icon: string
@ -5,17 +7,21 @@ export type MemberCardProps = {
groups: string[] groups: string[]
} }
const scope = getStyleScope(MemberCard)
export function MemberCard( export function MemberCard(
{ id, icon, name, groups }: MemberCardProps, { id, icon, name, groups }: MemberCardProps,
) { ) {
useSmartStylesheet(import.meta, { scope })
return ( return (
<div class='components__member_card' style={{ backgroundImage: icon }}> <div class={scope} style={{ backgroundImage: icon }}>
<div class='components__member_card__spacer'></div> <div class='spacer'></div>
<div class='components__member_card__content'> <div class='content'>
<h3> <h3>
<a href={`/membres/${id}`}>{name}</a> <a href={`/membres/${id}`}>{name}</a>
</h3> </h3>
<div class='components__member_card__groups'> <div class='groups'>
{groups.map((group, index) => <span key={index}>{group}</span>)} {groups.map((group, index) => <span key={index}>{group}</span>)}
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
.components__project_card { :scope {
min-width: 30rem; min-width: 30rem;
aspect-ratio: 2 / 1; aspect-ratio: 2 / 1;
display: grid; display: grid;
@ -33,23 +33,23 @@
} }
} }
.components__project_card__icon { .icon {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
overflow: hidden; overflow: hidden;
} }
.components__project_card__content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.components__project_card__state { .state {
padding: var(--_gap-half); padding: var(--_gap-half);
font-weight: bold; font-weight: bold;
} }
.components__project_card__tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--_gap-half); gap: var(--_gap-half);
@ -60,7 +60,7 @@
} }
} }
.components__project_card__summary { .summary {
text-wrap: balance; text-wrap: balance;
flex-grow: 1; flex-grow: 1;
} }

View file

@ -1,4 +1,5 @@
import { JSX } from 'preact' import { JSX } from 'preact'
import { getStyleScope, useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
type ProjectCardProps = { type ProjectCardProps = {
id: string id: string
@ -9,25 +10,29 @@ type ProjectCardProps = {
state: 'complete' | 'progress' | 'stale' | 'pending' state: 'complete' | 'progress' | 'stale' | 'pending'
} }
const scope = getStyleScope(ProjectCard)
export function ProjectCard( export function ProjectCard(
{ id, icon, title, summary, tags, state }: ProjectCardProps, { id, icon, title, summary, tags, state }: ProjectCardProps,
) { ) {
useSmartStylesheet(import.meta, { scope })
return ( return (
<div class='components__project_card'> <div class={scope}>
<img <img
alt='Icon du projet' alt='Icon du projet'
src={icon} src={icon}
class='components__project_card__icon' class='icon'
/> />
<div class='components__project_card__content'> <div class='content'>
<h3>{title}</h3> <h3>{title}</h3>
<div class='components__project_card__state'> <div class='state'>
{toStateSpan(state)} {toStateSpan(state)}
</div> </div>
<div class='components__project_card__tags'> <div class='tags'>
{tags.map((tag, index) => <span key={index}>{tag}</span>)} {tags.map((tag, index) => <span key={index}>{tag}</span>)}
</div> </div>
<div class='components__project_card__summary'> <div class='summary'>
{`${summary.slice(0, 150)} ...`} {`${summary.slice(0, 150)} ...`}
</div> </div>
<a href={`/projets/${id}`}> <a href={`/projets/${id}`}>

View file

@ -1,4 +1,4 @@
.components__sponsor_cards { :scope {
display: flex; display: flex;
gap: var(--_gap); gap: var(--_gap);
flex-wrap: wrap; flex-wrap: wrap;
@ -6,7 +6,7 @@
padding: var(--_gap); padding: var(--_gap);
} }
.components__sponsor_card { .card {
background-color: var(--_translucent); background-color: var(--_translucent);
aspect-ratio: 2/1; aspect-ratio: 2/1;
padding: var(--_gap); padding: var(--_gap);

View file

@ -1,9 +1,14 @@
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'
const scope = getStyleScope(SponsorCards)
export function SponsorCards() { export function SponsorCards() {
useSmartStylesheet(import.meta, { scope })
return ( return (
<div class='components__sponsor_cards'> <div class={scope}>
{sponsors.map(SponsorCard)} {sponsors.map(SponsorCard)}
</div> </div>
) )
@ -13,7 +18,7 @@ function SponsorCard(
{ href, src, alt }: { href: string; src: string; alt: string }, { href, src, alt }: { href: string; src: string; alt: string },
) { ) {
return ( return (
<a class='components__sponsor_card' href={href} target='_blank'> <a class='card' href={href} target='_blank'>
<Picture <Picture
src={asset(src)} src={asset(src)}
alt={alt} alt={alt}

View file

@ -35,6 +35,7 @@
":components/": "./components/", ":components/": "./components/",
":islands/": "./islands/", ":islands/": "./islands/",
":src/": "./src/", ":src/": "./src/",
":plugins/": "./plugins/",
":types": "./types.ts", ":types": "./types.ts",
"@cohabit/mailer": "jsr:@cohabit/mailer@^0.3.3", "@cohabit/mailer": "jsr:@cohabit/mailer@^0.3.3",
"@cohabit/resources-manager": "jsr:@cohabit/resources-manager@^0.2.1", "@cohabit/resources-manager": "jsr:@cohabit/resources-manager@^0.2.1",

View file

@ -3,6 +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'
const systemHistory = signal<BotMessage[]>([{ const systemHistory = signal<BotMessage[]>([{
role: 'system', role: 'system',
@ -66,6 +67,8 @@ async function aiRequest(
} }
export default function AiChatBox() { export default function AiChatBox() {
useSmartStylesheet(import.meta)
const dialog = useRef<HTMLDialogElement>(null) const dialog = useRef<HTMLDialogElement>(null)
const form = useRef<HTMLFormElement>(null) const form = useRef<HTMLFormElement>(null)
const history = useSignal<JSX.Element[]>([]) const history = useSignal<JSX.Element[]>([])

View file

@ -1,4 +1,4 @@
.island__is_online { :scope {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
width: 100%; width: 100%;

View file

@ -1,5 +1,6 @@
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'
type NetworkConnection = { type NetworkConnection = {
addEventListener: ( addEventListener: (
@ -14,18 +15,21 @@ declare global {
} }
} }
const scope = getStyleScope(IsOnline)
export default function IsOnline( export default function IsOnline(
{ online, offline }: { online?: string; offline: string }, { online, offline }: { online?: string; offline: string },
) { ) {
useSmartStylesheet(import.meta, { scope })
const status = useSignal(true) const status = useSignal(true)
const displayed = useComputed(() => { const displayed = useComputed(() => {
if (status.value && online) { if (status.value && online) {
return <span>{online}</span> return <span class={scope}>{online}</span>
} }
if (status.value) { if (status.value) {
return <span style={{ display: 'none' }}></span> return <span style={{ display: 'none' }}></span>
} }
return <span class='island__is_online'>{offline}</span> return <span class={scope}>{offline}</span>
}) })
useEffect(() => { useEffect(() => {

View file

@ -1,4 +1,4 @@
.island__login_form__form { :scope {
padding: var(--_gap); padding: var(--_gap);
background-color: var(--_translucent); background-color: var(--_translucent);

View file

@ -7,14 +7,19 @@ 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'
const scope = getStyleScope(LoginForm)
export default function LoginForm() { export default function LoginForm() {
useSmartStylesheet(import.meta, { scope })
return ( return (
<form <form
onSubmit={connect} onSubmit={connect}
method='POST' method='POST'
action='' action=''
className='island__login_form__form' class={scope}
> >
<Input <Input
label='Email' label='Email'

View file

@ -1,7 +1,10 @@
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'
export default function MoreBox({ children }: { children: VNode | VNode[] }) { export default function MoreBox({ children }: { children: VNode | VNode[] }) {
useSmartStylesheet(import.meta)
const dialog = useRef<HTMLDialogElement>(null) const dialog = useRef<HTMLDialogElement>(null)
useEffect(() => { useEffect(() => {

View file

@ -1,6 +1,9 @@
import { useEffect, useRef } from 'preact/hooks' import { useEffect, useRef } from 'preact/hooks'
import { useSmartStylesheet } from ':plugins/SmartStylesheet.tsx'
export default function SearchBox() { export default function SearchBox() {
useSmartStylesheet(import.meta)
const dialog = useRef<HTMLDialogElement>(null) const dialog = useRef<HTMLDialogElement>(null)
useEffect(() => { useEffect(() => {

View file

@ -1,4 +1,4 @@
.islands__theme_picker { :scope {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--_gap-half); gap: var(--_gap-half);

View file

@ -1,7 +1,12 @@
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'
const scope = getStyleScope(ThemePicker)
export default function ThemePicker() { export default function ThemePicker() {
useSmartStylesheet(import.meta, { scope })
const colorSchemeQuery = '(prefers-color-scheme: dark)' const colorSchemeQuery = '(prefers-color-scheme: dark)'
const colorSchemeDark = useSignal( const colorSchemeDark = useSignal(
@ -39,7 +44,7 @@ export default function ThemePicker() {
}) })
return ( return (
<label class='islands__theme_picker' title='Sélectionner un thème'> <label class={scope} title='Sélectionner un thème'>
<span>{themeIcon}</span> <span>{themeIcon}</span>
<select <select
value={theme} value={theme}

View file

@ -1,6 +1,7 @@
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 { contentType } from 'jsr:@std/media-types@1/content-type'
import { smartStylesheetPlugin } from ':plugins/SmartStylesheet.tsx'
export const app = new App<State>() export const app = new App<State>()
@ -13,6 +14,8 @@ app.use((ctx) =>
app.use(staticFiles()) app.use(staticFiles())
smartStylesheetPlugin(app)
//TEMP fix before updating cssBundler middleware //TEMP fix before updating cssBundler middleware
app.use(async (ctx) => { app.use(async (ctx) => {
const response = await ctx.next() const response = await ctx.next()

218
plugins/SmartStylesheet.tsx Normal file
View file

@ -0,0 +1,218 @@
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',
})
})
}

View file

@ -4,9 +4,10 @@ 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'
export default function App( export default function App(
{ Component, data }: PageProps<{ title?: string } | undefined>, { Component, data, url }: PageProps<{ title?: string } | undefined>,
) { ) {
return ( return (
<html lang='fr'> <html lang='fr'>
@ -40,6 +41,7 @@ export default function App(
href={asset('/assets/favicon.ico')} href={asset('/assets/favicon.ico')}
type='image/x-icon' type='image/x-icon'
/> />
<SmartStylesheet pathname={url.pathname} />
<link rel='stylesheet' href={asset('/main.css')} /> <link rel='stylesheet' href={asset('/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>

View file

@ -1,16 +0,0 @@
@import url('../../components/Header.css');
@import url('../../components/Footer.css');
@import url('../../components/Heros.css');
@import url('../../components/SponsorCards.css');
@import url('../../components/CohabitInfoTable.css');
@import url('../../components/BlogBlocks.css');
@import url('../../components/MachineCard.css');
@import url('../../components/ProjectCard.css');
@import url('../../components/AutoGrid.css');
@import url('../../components/MemberCard.css');
@import url('../../islands/ThemePicker.css');
@import url('../../islands/SearchBox.css');
@import url('../../islands/MoreBox.css');
@import url('../../islands/AiChatBox.css');
@import url('../../islands/LoginForm.css');
@import url('../../islands/IsOnline.css');

View file

@ -1,11 +1,10 @@
@import url('https://deno.land/x/univoq@0.2.0/stylesheets/reset.css') @import url('https://deno.land/x/univoq@0.2.0/stylesheets/reset.css')
layer(reset); layer(reset);
@import url('https://unpkg.com/open-props') layer(framework); @import url('https://unpkg.com/open-props') layer(framework);
@import url('https://cdn.jsdelivr.net/npm/remixicon@4.1.0/fonts/remixicon.css') @import url('https://cdn.jsdelivr.net/npm/remixicon@4.1.0/fonts/remixicon.css')
layer(framework); layer(framework);
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Hepta+Slab:wght@1..900&family=MuseoModerno:ital,wght@0,100..900;1,100..900&display=swap') @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Hepta+Slab:wght@1..900&family=MuseoModerno:ital,wght@0,100..900;1,100..900&display=swap')
layer(framework); layer(framework);
@import url('./base.css') layer(base); @import url('./base.css') layer(base);
@import url('./layout.css') layer(layout); @import url('./layout.css') layer(layout);
@import url('./components.css') layer(components); @layer reset, framework, base, layout, islands, components, utilities;
@layer reset, framework, base, layout, components, utilities;