This commit is contained in:
MasterGordon 2025-07-08 22:51:27 +02:00
parent ddb6f41eec
commit b8107c8f04
28 changed files with 857 additions and 120 deletions

1
.gitignore vendored
View File

@ -173,3 +173,4 @@ dist
# Finder (MacOS) folder config
.DS_Store
target

BIN
bun.lockb

Binary file not shown.

1
bunfig.toml Normal file
View File

@ -0,0 +1 @@
preload = ["./src/myPlugin.ts"]

View File

@ -4,17 +4,21 @@
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun run --hot src/index.tsx"
"dev": "bun run --watch --hot src/index.tsx"
},
"devDependencies": {
"@types/bun": "latest",
"csstype": "^3.1.3"
},
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.7.2"
},
"dependencies": {
"lightningcss": "^1.29.1",
"serve-static-bun": "^0.5.3"
"pokedex-promise-v2": "^4.2.1",
"serve-static-bun": "^0.5.3",
"tree-sitter-cli": "^0.25.2",
"tree-sitter-typescript": "^0.23.2",
"web-tree-sitter": "^0.25.2"
}
}

View File

View File

@ -0,0 +1,22 @@
import { createState } from "~/lib/state";
export default createState(
{ value: 0 },
{
add: (ctx) => ({
onClick: () => {
const { value } = ctx.get();
ctx.set({ value: value + 1 });
ctx.$counter.innerText = String(value + 1);
},
}),
counter: (_ctx) => ({}),
sub: (ctx) => ({
onClick: () => {
const { value } = ctx.get();
ctx.set({ value: value - 1 });
ctx.$counter.innerText = String(value - 1);
},
}),
},
);

View File

@ -0,0 +1,14 @@
import { useState } from "~/lib/hooks";
import counterState from "./Counter.state";
export const Counter = () => {
const { sub, counter, add } = useState(counterState);
return (
<div>
<button {...sub}>-</button>
<span {...counter}>0</span>
<button {...add}>+</button>
</div>
);
};

View File

@ -0,0 +1,43 @@
import type { Styles } from "~/types";
interface HeadingProps {
children: JSX.Children;
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div" | "span";
size?:
| "xs"
| "sm"
| "base"
| "lg"
| "xl"
| "2xl"
| "3xl"
| "4xl"
| "5xl"
| "6xl"
| "7xl"
| "8xl"
| "9xl";
style?: Styles;
}
const Heading = ({
children,
as = "span",
size = "base",
style,
}: HeadingProps) => {
const Tag = as;
return (
<Tag
style={{
fontSize: "$" + size,
fontWeight: "$extrabold",
...style,
}}
>
{children}
</Tag>
);
};
export default Heading;

View File

@ -1,5 +1,7 @@
import Global from "~/lib/Global";
import Navigation from "./Navigation";
import Heading from "./Heading";
import { Link } from "./Link";
interface LayoutProps {
children: JSX.Element | JSX.Element[];
@ -7,29 +9,72 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
marginX: "auto",
paddingX: "$4",
marginTop: "$2",
maxWidth: "1100px",
gap: "$4",
}}
>
<>
<Global
css={{
body: {
backgroundColor: "$gray.950",
backgroundColor: "$gray.900",
color: "$gray.50",
fontFamily: "$sans",
},
}}
/>
<header>Header</header>
<Navigation />
{children}
</div>
<div>
<header
style={{
pY: "$4",
pX: "$6",
gap: "$2",
display: "grid",
maxWidth: "1280px",
gridTemplateColumns: "auto 1fr auto",
mX: "auto",
alignItems: "center",
}}
>
<div>
<Link>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fillRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit="2"
clipRule="evenodd"
viewbox="0 0 3307 593"
style={{
height: "$7",
width: "auto",
}}
aria-hidden="true"
>
<path
style={{
color: "$white",
}}
fill="currentColor"
fill-rule="nonzero"
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
></path>
<g
fill="currentColor"
style={{
color: "$green.500",
}}
>
<path d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"></path>
<path d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"></path>
<path d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"></path>
</g>
</svg>
</Link>
</div>
<Navigation />
<div></div>
</header>
<main>{children}</main>
<footer></footer>
</div>
</>
);
}

5
src/components/Link.tsx Normal file
View File

@ -0,0 +1,5 @@
type Props = JSX.IntrinsicElements["a"];
export const Link = (props: Props) => {
return <a {...props} />;
};

View File

@ -13,13 +13,21 @@ export default function Navigation() {
<nav
style={{
display: "flex",
gap: "1rem",
alignItems: "center",
margin: "auto",
gap: "$2",
}}
>
{items.map((item) => (
<a
style={{
backgroundColor: "$gray.700",
_hover: {
backgroundColor: "$gray.900",
},
fontWeight: "$semibold",
paddingY: "$2",
paddingX: "$4",
borderRadius: "$md",
}}
href={item.href}
>

View File

@ -1,4 +1,6 @@
import { myPlugin } from "./myPlugin";
import { fillTemplate, render } from "./lib/fast-web";
import { requestLog } from "./middlewares/request-log";
import template from "./template.html" with { type: "text" };
import theme from "./theme.css" with { type: "text" };
import staticDir from "serve-static-bun";
@ -10,11 +12,11 @@ const router = new Bun.FileSystemRouter({
const staticPublic = staticDir("./public");
const glob = new Bun.Glob(__dirname + "/scripts/*");
const scriptsGlob = new Bun.Glob(__dirname + "/scripts/*");
const server = Bun.serve({
port: 8080,
fetch: async (req): Promise<Response> => {
fetch: requestLog(async (req): Promise<Response> => {
if (req.url.endsWith("ws")) {
if (server.upgrade(req)) return new Response("ok");
}
@ -23,30 +25,37 @@ const server = Bun.serve({
if (!match) return staticPublic(req);
const Comp = (await import(__dirname + "/pages/" + match?.pathname))
.default;
const renderResult = render(<Comp />, [theme]);
const renderResult = await render(<Comp />, [theme], new URL(req.url));
const systemScripts = await Bun.build({
entrypoints: [...glob.scanSync()],
entrypoints: [
...scriptsGlob.scanSync(),
"./src/components/Counter.state.ts",
],
minify: true,
plugins: [myPlugin],
});
const scripts = await Promise.all(
systemScripts.outputs.map(
async (script) => `<script>${await script.text()}</script>`,
async (script) =>
`<script type="module">${await script.text()}</script>`,
),
);
return new Response(
fillTemplate(template, {
head: renderResult.head + scripts,
body: renderResult.html,
}),
{
headers: {
"content-type": "text/html",
},
const responseData = fillTemplate(template, {
head: renderResult.head + scripts.join(""),
body: renderResult.html,
});
const gzipResponseData = Bun.gzipSync(responseData);
return new Response(gzipResponseData, {
headers: {
"content-type": "text/html",
"Content-Encoding": "gzip",
},
);
},
});
}),
websocket: {
message: () => {},
open: async (ws) => {

111
src/jsx.d.ts vendored
View File

@ -1,6 +1,14 @@
namespace JSX {
interface FC {
(props: any): JSX.Element | string | null;
(
props: any,
):
| JSX.Element
| string
| null
| Promise<JSX.Element>
| Promise<string>
| Promsie<null>;
}
type Children =
| JSX.Element
@ -8,20 +16,102 @@ namespace JSX {
| string
| null
| undefined;
type CSSTypeProperties = import("csstype").Properties;
type CSSProperties = import("csstype").Properties & {
marginX?: CSSTypeProperties["marginLeft"];
marginY?: CSSTypeProperties["marginTop"];
paddingX?: CSSTypeProperties["paddingLeft"];
paddingY?: CSSTypeProperties["paddingTop"];
};
type BaseElementPropsWithoutChildren = {
className?: string;
style?: CSSProperties;
id?: string;
tabindex?: number | string;
style?: import("./types").Styles;
[`data-${string}`]?: string | boolean;
} & Partial<ARIAMixin>;
type BaseElementProps = BaseElementPropsWithoutChildren & {
children?: JSX.Children;
};
type SvgProps = BaseElementProps & {
["xml:lang"]?: string;
["xml:space"]?: string;
xmlns?: string;
// XLink attributes
["xlink:hrefDeprecated"]?: string;
["xlink:type"]?: string;
["xlink:role"]?: string;
["xlink:arcrole"]?: string;
["xlink:title"]?: string;
["xlink:show"]?: string;
["xlink:actuate"]?: string;
// Presentation attributes
["alignment-baseline"]?: string;
["baseline-shift"]?: string;
["clip"]?: string;
["clipPath"]?: string;
["clipRule"]?: string;
["color"]?: string;
["colorInterpolation"]?: string;
["colorInterpolationFilters"]?: string;
["cursor"]?: string;
["cx"]?: string;
["cy"]?: string;
["d"]?: string;
["direction"]?: string;
["display"]?: string;
["dominantBaseline"]?: string;
["fill"]?: string;
["fillOpacity"]?: string;
["fillRule"]?: string;
["filter"]?: string;
["floodColor"]?: string;
["floodOpacity"]?: string;
["fontFamily"]?: string;
["fontSize"]?: string;
["fontSize-adjust"]?: string;
["fontStretch"]?: string;
["fontStyle"]?: string;
["fontVariant"]?: string;
["fontWeight"]?: string;
["glyphOrientation-horizontal"]?: string;
["glyphOrientation-vertical"]?: string;
["height"]?: string;
["imageRendering"]?: string;
["letterSpacing"]?: string;
["lightingColor"]?: string;
["markerEnd"]?: string;
["markerMid"]?: string;
["markerStart"]?: string;
["mask"]?: string;
["maskType"]?: string;
["opacity"]?: string;
["overflow"]?: string;
["pointerEvents"]?: string;
["r"]?: string;
["rx"]?: string;
["ry"]?: string;
["shapeRendering"]?: string;
["stopColor"]?: string;
["stopOpacity"]?: string;
["stroke"]?: string;
["strokeDasharray"]?: string;
["strokeDashoffset"]?: string;
["strokeLinecap"]?: string;
["strokeLinejoin"]?: string;
["strokeMiterlimit"]?: string;
["strokeOpacity"]?: string;
["strokeWidth"]?: string;
["textAnchor"]?: string;
["textDecoration"]?: string;
["textOverflow"]?: string;
["textRendering"]?: string;
["transform"]?: string;
["transformOrigin"]?: string;
["unicodeBidi"]?: string;
["vectorEffect"]?: string;
["visibility"]?: string;
["whiteSpace"]?: string;
["width"]?: string;
["wordSpacing"]?: string;
["writingMode"]?: string;
["x"]?: string;
["y"]?: string;
viewbox?: string;
};
interface IntrinsicElements {
a: BaseElementProps & {
href?: string;
@ -50,6 +140,9 @@ namespace JSX {
source: BaseElementPropsWithoutChildren;
track: BaseElementPropsWithoutChildren;
wbr: BaseElementPropsWithoutChildren;
svg: SvgProps;
path: SvgProps;
g: SvgProps;
[tagName: string]: BaseElementProps;
}
interface Element {

View File

@ -1,19 +1,15 @@
import { getStyleRules } from "~/system/style-rules";
import { renderStyle } from "~/system/style-rules";
import { pushStyle } from "./fast-web";
import type { Styles } from "~/types";
interface GlobalProps {
css?: Record<string, JSX.CSSProperties>;
css?: Record<string, Styles>;
}
export default function Global({ css = {} }: GlobalProps) {
const stylesArray = Object.entries(css).map<[string, string[]]>(
([key, value]) => {
return [key, getStyleRules(value)];
},
);
stylesArray.forEach(([key, value]) => {
pushStyle(`${key} {${value.join(" ")}}`);
Object.entries(css).forEach(([key, value]) => {
const style = renderStyle(value, key);
pushStyle(style.css);
});
return null;

View File

@ -1,10 +1,23 @@
import { camelToKebab, renderStyle } from "~/system/style-rules";
import { transform } from "lightningcss";
import reset from "./reset.css" with { type: "text" };
import { AsyncLocalStorage } from "async_hooks";
const renderChildren = (elements: JSX.Children): string => {
interface RenderContext {
styles: string[];
url: URL;
states: Record<string, { state: any; handlerId: string }>;
// Mapping handlerHash to file
handlers: Record<string, string>;
}
const asyncLocalStorage = new AsyncLocalStorage<RenderContext>();
const renderChildren = async (elements: JSX.Children): Promise<string> => {
if (Array.isArray(elements)) {
return elements.map((element) => renderChildren(element)).join("");
return (
await Promise.all(elements.map((element) => renderChildren(element)))
).join("");
}
if (typeof elements === "string") {
return elements;
@ -12,15 +25,18 @@ const renderChildren = (elements: JSX.Children): string => {
return renderElement(elements);
};
let styles: string[] = [];
const getStyles = () => asyncLocalStorage.getStore()!.styles;
export const getUrl = () => asyncLocalStorage.getStore()!.url;
export const getHandlers = () => asyncLocalStorage.getStore()!.handlers;
export const getStates = () => asyncLocalStorage.getStore()!.states;
export const pushStyle = (style: string) => {
styles.push(style);
export const pushStyle = (...styles: string[]) => {
getStyles().push(...styles);
};
const renderElement = (
const renderElement = async (
element: JSX.Element | string | null | undefined,
): string => {
): Promise<string> => {
if (!element) {
return "";
}
@ -34,7 +50,7 @@ const renderElement = (
let { className, ...otherProps } = element.props;
if ("style" in element.props) {
const res = renderStyle(element.props.style);
styles.push(res.css);
pushStyle(res.css);
className = className ? `${className} ${res.className}` : res.className;
}
const props = { ...otherProps, class: className };
@ -50,32 +66,54 @@ const renderElement = (
}
return ` ${camelToKebab(key)}="${value}"`;
});
return `<${element.type}${attrs.join("")}>${element.children ? renderChildren(element.children) : ""}</${element.type}>`;
return `<${element.type}${attrs.join("")}>${element.children ? await renderChildren(element.children) : ""}</${element.type}>`;
}
if (typeof element.type === "function") {
return renderElement(
element.type({ ...element.props, children: element.children }),
await element.type({ ...element.props, children: element.children }),
);
}
return "";
};
export const render = (
const render = async (
element: JSX.Element | string | null,
extraCss: string[] = [],
) => {
styles = [reset, ...extraCss];
const html = renderElement(element);
pushStyle(reset, ...extraCss);
const html = await renderElement(element);
const encoder = new TextEncoder();
const css = transform({
code: encoder.encode(styles.join("\n")),
code: encoder.encode(getStyles().join("\n")),
minify: true,
filename: "render-style.ts",
}).code;
const head = `<style>${css.toString()}</style>`;
const head = `<style>${css.toString()}</style>
<script>
window.stateManager ??= {
init: false,
states: {},
stateRegistry: new Map()
}
window.stateManager.states = {...window.stateManager.states,...${JSON.stringify(getStates())}}
</script>
`;
return { html, head };
};
const runRender = (
element: JSX.Element | string | null,
extraCss: string[] = [],
url: URL,
) => {
return asyncLocalStorage.run(
{ styles: [], url, states: {}, handlers: {} },
() => render(element, extraCss),
);
};
export { runRender as render };
export const fillTemplate = (
template: string,
data: Record<string, string>,

37
src/lib/hooks.ts Normal file
View File

@ -0,0 +1,37 @@
import { crazyHash } from "~/system/crazyHash";
import { getHandlers, getStates, getUrl } from "./fast-web";
import type { CreateStateResult } from "./state";
export const useUrl = (): URL => {
return getUrl();
};
export const useParams = (): URLSearchParams => {
return getUrl().searchParams;
};
export const usePath = (): string => {
return getUrl().pathname;
};
export const useState = <S, T extends string>(
state: CreateStateResult<S, T>,
) => {
const handlerId = state.meta.hash;
const handlers = getHandlers();
handlers[handlerId] = state.meta.path;
const stateId = crazyHash(Bun.randomUUIDv7());
const parts = Object.keys(state.handler);
getStates()[stateId] = {
handlerId,
state: state.initial,
};
return Object.fromEntries(
parts.map((part) => [
part,
{
"data-c": part + "-" + stateId,
},
]),
) as unknown as Record<T, Record<`data-${string}`, string>>;
};

3
src/lib/pokeapi.ts Normal file
View File

@ -0,0 +1,3 @@
import Pokedex from "pokedex-promise-v2";
export const pokeapi = new Pokedex();

100
src/lib/state.ts Normal file
View File

@ -0,0 +1,100 @@
type StateContext<S extends any, T extends string> = Record<
`$${T}`,
HTMLElement
> & {
get: () => S;
set: (state: Partial<S>) => void;
};
type StateHandler<S extends any, T extends string> = Record<
T,
(ctx: StateContext<S, T>) => {
onClick?: (event: MouseEvent) => void;
}
>;
interface StateMeta {
path: string;
hash: string;
}
interface StateRegistryEntry<S, T extends string> {
handler: StateHandler<S, T>;
}
interface StateManager {
init: boolean;
states: Record<string, { state: any; handlerId: string }>;
stateRegistry: Map<string, StateRegistryEntry<any, string>>;
}
interface MyWindow extends Window {
stateManager: StateManager;
}
declare let window: MyWindow;
const stateManager: StateManager =
typeof window !== "undefined" && "stateManager" in window
? window.stateManager
: {
init: false,
states: {},
stateRegistry: new Map(),
};
export type CreateStateResult<S, T extends string> = {
meta: StateMeta;
initial: S;
handler: StateHandler<S, T>;
};
export const createState = <S, T extends string>(
initialState: S,
stateHandler: StateHandler<S, T>,
meta?: StateMeta,
): CreateStateResult<S, T> => {
if (!meta) {
throw new Error("Missing meta please check if bun plugin is loaded");
}
if (!stateManager.stateRegistry.has(meta.hash)) {
stateManager.stateRegistry.set(meta.hash, {
handler: stateHandler,
});
}
return {
meta,
initial: initialState,
handler: stateHandler,
};
};
if (!stateManager.init && typeof window != "undefined") {
stateManager.init = true;
window.stateManager = stateManager;
document.addEventListener("click", (e) => {
const target = e.target;
if (target instanceof HTMLElement && target.dataset["c"]) {
const [part, stateId] = target.dataset["c"].split("-");
const { handler } = stateManager.stateRegistry.get(
stateManager.states[stateId].handlerId,
)!;
const parts = Object.keys(handler);
const { state } = stateManager.states[stateId];
const context = Object.fromEntries([
["get", () => state],
[
"set",
(newState: any) => {
stateManager.states[stateId].state = { ...state, ...newState };
},
],
...parts.map((part) => [
`$$${part}`,
document.querySelector(`[data-c=${part}-${stateId}]`),
]),
]);
handler[part](context).onClick?.(e);
}
});
}

View File

@ -0,0 +1,25 @@
const formatBytes = (bytes: number) => {
if (bytes > 1024 * 1024 * 1024) {
return Math.floor((bytes * 100) / (1024 * 1024 * 1024)) / 100 + "GB";
}
if (bytes > 1024 * 1024) {
return Math.floor((bytes * 100) / (1024 * 1024)) / 100 + "MB";
}
if (bytes > 1024) {
return Math.floor((bytes * 100) / 1024) / 100 + "KB";
}
return bytes + "B";
};
export const requestLog = (handler: (req: Request) => Promise<Response>) => {
return async (req: Request) => {
const start = performance.now();
const response = await handler(req);
const bytes = await response.clone().bytes();
const delta = performance.now() - start;
console.log(
`[${req.method}] ${req.url} -> ${response.headers.get("Content-Type")} ${formatBytes(bytes.length)} in ${delta}ms`,
);
return response;
};
};

52
src/myPlugin.ts Normal file
View File

@ -0,0 +1,52 @@
import { plugin, type BunPlugin } from "bun";
import { crazyHash } from "./system/crazyHash";
const createStateString = "createState(";
const createStateStringRegex = /createState\(/g;
export const myPlugin: BunPlugin = {
name: "Custom loader",
setup(build) {
build.onLoad(
{
filter: /\.tsx?$/,
},
async (args) => {
console.log("loading", args.path);
const path = require.resolve(args.path);
let contents = await Bun.file(path).text();
const createStateCalls = contents.matchAll(createStateStringRegex);
createStateCalls.forEach((match) => {
console.log(match);
let braces = 1;
let index = match.index + createStateString.length;
let needsComma = false;
console.log(contents[index]);
do {
index++;
if (contents[index] == "(") braces++;
else if (contents[index] == ")") braces--;
else if (contents[index] == ",") needsComma = false;
else if (contents[index].trim().length) needsComma = true;
} while (braces > 0);
console.log(contents[index]);
const meta = {
path,
hash: crazyHash(contents),
};
contents =
contents.substring(0, index) +
(needsComma ? ", " : "") +
JSON.stringify(meta) +
contents.substring(index);
console.log(contents);
});
// console.log(contents);
return {
contents,
};
},
);
},
};
plugin(myPlugin);

View File

@ -1,9 +1,30 @@
import { Counter } from "~/components/Counter";
import Layout from "~/components/Layout";
import { Link } from "~/components/Link";
import { useParams } from "~/lib/hooks";
import { pokeapi } from "~/lib/pokeapi";
export default function Index() {
export default async function Index() {
const params = useParams();
const page = Number(params.get("page") ?? 0);
const pokemon = await pokeapi.getPokemonsList({
limit: 20,
offset: page * 20,
});
return (
<Layout>
<div>Hello World</div>
<>
<div>Hello World</div>
<Counter />
<Counter />
<ul>
{pokemon.results.map((r) => (
<li>{r.name}</li>
))}
</ul>
<Link href={`/?page=${page - 1}`}>Prev</Link>
<Link href={`/?page=${page + 1}`}>Next</Link>
</>
</Layout>
);
}

14
src/scripts/navigation.ts Normal file
View File

@ -0,0 +1,14 @@
document.addEventListener("pointerdown", (e) => {
const target = e.target;
if (target instanceof HTMLElement) {
if (target.tagName == "A" && !target.getAttribute("target")) {
e.preventDefault();
e.stopPropagation();
const href = target.getAttribute("href")!;
window.history.pushState({}, "", href);
fetch(href).then(async (res) => {
window.document.documentElement.innerHTML = await res.text();
});
}
}
});

13
src/system/crazyHash.ts Normal file
View File

@ -0,0 +1,13 @@
export function crazyHash(data: string) {
let num = Bun.hash.murmur32v3(data);
// const chars =
// "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const chars =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZÀÁÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ";
let result = "";
do {
result = chars[num % chars.length] + result;
num = Math.floor(num / chars.length);
} while (num > 0);
return result;
}

View File

@ -0,0 +1,11 @@
import type { BaseCSSProperty, CSSDuration, EasingFunction } from "~/types";
type Transition =
| [BaseCSSProperty]
| [BaseCSSProperty, CSSDuration]
| [BaseCSSProperty, CSSDuration, EasingFunction]
| [BaseCSSProperty, CSSDuration, EasingFunction, CSSDuration];
export const transition = (transitions: Transition[]) => {
return transitions.map((t) => t.join(" ")).join(",");
};

View File

@ -1,55 +1,99 @@
import { pseudoClasses, type Styles } from "~/types";
import { getCategory, shorthand } from "./token-categories";
import { crazyHash } from "./crazyHash";
export const camelToKebab = (str: string) => {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2").toLowerCase();
};
export const getStyleRules = (style: JSX.CSSProperties) => {
const styles = Object.entries(style).map(([key, value]) => {
return {
key,
value,
};
});
const rules = [];
while (styles.length) {
const { key, value } = styles.shift()!;
if (key in shorthand) {
const shorthandRules = shorthand[key as keyof typeof shorthand](value);
styles.unshift(...shorthandRules);
continue;
}
const resolveVars = (key: string, value: string) => {
const match = value.match(/\$([a-zA-Z0-9\.\-]+)/g);
if (match) {
const category = getCategory(key);
const replaceValue = match.reduce((v, m) => {
return v.replace(
m,
`var(--${category}${m.replaceAll("$", "").replaceAll(".", "-")})`,
);
}, value);
return replaceValue;
}
return value;
};
const kKey = camelToKebab(key);
if (typeof value === "number") {
rules.push(`${kKey}: ${value}px;`);
} else if (typeof value === "string") {
const match = value.match(/\$([a-zA-Z0-9\.\-]+)/g);
if (match) {
const category = getCategory(key);
const replaceValue = match.reduce((v, m) => {
return v.replace(
m,
`var(--${category}${m.replaceAll("$", "").replaceAll(".", "-")})`,
);
}, value);
rules.push(`${kKey}: ${replaceValue};`);
} else {
rules.push(`${kKey}: ${value};`);
}
const renderRule = (key: string, value: string | number | string[]): string => {
const kKey = camelToKebab(key);
if (typeof value === "number") {
return `${kKey}: ${value}px;`;
}
if (typeof value === "string") {
return `${kKey}: ${resolveVars(key, value)};`;
}
return `${kKey}: ${value.join(" ")};`;
};
const partitionStyles = (style: Styles) => {
const selectorStylesMapping: Record<
string,
Record<string, string | number | string[]>
> = {
"&": {},
};
for (const [key, value] of Object.entries(style)) {
if (typeof value === "object" && !Array.isArray(value)) {
const selector =
pseudoClasses[key.substring(1) as keyof typeof pseudoClasses] ?? key;
selectorStylesMapping[selector] = value;
} else {
rules.push(`${kKey}: ${value.join(" ")};`);
selectorStylesMapping["&"][key] = value;
}
}
return selectorStylesMapping;
};
const resolveShorthand = (
key: string,
value: string | number | string[],
): { key: string; value: string | number | string[] }[] => {
if (key in shorthand) {
return shorthand[key as keyof typeof shorthand](value);
}
return [{ key, value }];
};
export const getStyleRules = (style: Styles) => {
const selectorStylesMapping = partitionStyles(style);
const rules: Record<string, string[]> = {};
for (const [selector, styles] of Object.entries(selectorStylesMapping)) {
const stylePairs = Object.entries(styles).reduce(
(acc, [key, value]) => {
const resolvedShorthand = resolveShorthand(key, value);
return [...acc, ...resolvedShorthand];
},
[] as { key: string; value: string | number | string[] }[],
);
rules[selector] = stylePairs.map(({ key, value }) => {
return renderRule(key, value);
});
}
return rules;
};
export const renderStyle = (style: JSX.CSSProperties) => {
const rules = getStyleRules(style);
const sortedRules = rules.sort();
const rulesString = sortedRules.join(" ");
const hash = Bun.hash(rulesString);
const className = `style-${hash.toString(36)}`;
const css = `.${className} {${rulesString}}`;
const getRulesClassName = (rules: Record<string, string[]>) => {
const flatRules = Object.values(rules).flat();
flatRules.push(...Object.keys(rules));
const sortedRules = flatRules.sort();
const rulesString = sortedRules.join(";");
return `f${crazyHash(rulesString)}`;
};
export const renderStyle = (style: Styles, selectorOverride?: string) => {
const rulesMapping = getStyleRules(style);
const className = selectorOverride ?? getRulesClassName(rulesMapping);
let css = "";
for (const [selector, rules] of Object.entries(rulesMapping)) {
css += `${selector.replaceAll("&", selectorOverride ?? "." + className)} {${rules.join(" ")}}`;
}
return { className, css };
};

View File

@ -1,8 +1,17 @@
const tokenTypes = ["color", "space", "font"] as const;
import type { CSSProperties } from "~/types";
const tokenTypes = [
"color",
"space",
"font",
"font-size",
"font-weight",
"border-radius",
] as const;
type TokenType = (typeof tokenTypes)[number];
export const shorthand = {
marginX: (value: string) => [
marginX: <T>(value: T) => [
{
key: "marginLeft",
value,
@ -12,7 +21,7 @@ export const shorthand = {
value,
},
],
marginY: (value: string) => [
marginY: <T>(value: T) => [
{
key: "marginTop",
value,
@ -22,7 +31,27 @@ export const shorthand = {
value,
},
],
paddingX: (value: string) => [
mX: <T>(value: T) => [
{
key: "marginLeft",
value,
},
{
key: "marginRight",
value,
},
],
mY: <T>(value: T) => [
{
key: "marginTop",
value,
},
{
key: "marginBottom",
value,
},
],
paddingX: <T>(value: T) => [
{
key: "paddingLeft",
value,
@ -32,7 +61,27 @@ export const shorthand = {
value,
},
],
paddingY: (value: string) => [
paddingY: <T>(value: T) => [
{
key: "paddingTop",
value,
},
{
key: "paddingBottom",
value,
},
],
pX: <T>(value: T) => [
{
key: "paddingLeft",
value,
},
{
key: "paddingRight",
value,
},
],
pY: <T>(value: T) => [
{
key: "paddingTop",
value,
@ -63,8 +112,13 @@ export const categoryMap = {
paddingX: "space",
paddingY: "space",
gap: "space",
height: "space",
width: "space",
fontFamily: "font",
} as const satisfies Partial<Record<keyof JSX.CSSProperties, TokenType>>;
fontSize: "font-size",
fontWeight: "font-weight",
borderRadius: "border-radius",
} as const satisfies Partial<Record<keyof CSSProperties, TokenType>>;
export const getCategory = (value: string) => {
return value in categoryMap

View File

@ -280,5 +280,38 @@
--space-72: 18rem;
--space-80: 20rem;
--font-sans: "Rubik", sans-serif;
--font-sans: sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-5xl: 3rem;
--font-size-6xl: 3.75rem;
--font-size-7xl: 4.5rem;
--font-size-8xl: 6rem;
--font-size-9xl: 8rem;
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
--border-radius-xs: 0.125rem;
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--border-radius-xl: 0.75rem;
--border-radius-2xl: 1rem;
--border-radius-3xl: 1.5rem;
--border-radius-4xl: 2rem;
}

51
src/types.ts Normal file
View File

@ -0,0 +1,51 @@
import * as CSS from "csstype";
export const pseudoClasses = {
hover: "&:hover",
focus: "&:focus",
active: "&:active",
visited: "&:visited",
first: "&:first-child",
last: "&:last-child",
odd: "&:nth-child(odd)",
even: "&:nth-child(even)",
disabled: "&:disabled",
checked: "&:checked",
selected: "&:selected",
readOnly: "&:read-only",
empty: "&:empty",
before: "&:before",
after: "&:after",
link: "&:link",
visitedLink: "&:visited",
activeLink: "&:active",
focusWithin: "&:focus-within",
} as const;
export type PseudoClassProperty = `_${keyof typeof pseudoClasses}`;
export type BaseCSSProperty = CSS.Properties;
export type CSSProperties = CSS.Properties & {
marginX?: CSS.Properties["marginLeft"];
marginY?: CSS.Properties["marginTop"];
paddingX?: CSS.Properties["paddingLeft"];
paddingY?: CSS.Properties["paddingTop"];
mX?: CSS.Properties["marginLeft"];
mY?: CSS.Properties["marginTop"];
pX?: CSS.Properties["paddingLeft"];
pY?: CSS.Properties["paddingTop"];
};
export type CSSDuration = `${number}s` | `${number}ms`;
export type EasingFunction =
| (string & {})
| "linear"
| "ease"
| "ease-in"
| "ease-in-out"
| "ease-out";
type ArbitrarySelectors = Record<`&${string}`, CSSProperties>;
type PseudoClassSelectors = Partial<Record<PseudoClassProperty, CSSProperties>>;
export type Styles = ArbitrarySelectors & PseudoClassSelectors & CSSProperties;