Rework the registry

This commit is contained in:
Zoe Roux
2023-01-10 14:27:33 +09:00
parent ee5f517f67
commit 1d4d34b2ce
6 changed files with 141 additions and 133 deletions

View File

@@ -43,27 +43,15 @@ const AppName = () => {
);
};
const RootRegistry = ({ children }: { children: ReactNode }) => {
const registry = useMemo(() => createStyleRegistry(), []);
useServerInsertedHTML(() => {
return registry.flushToComponent();
});
return <StyleRegistryProvider registry={registry}>{children}</StyleRegistryProvider>;
};
const App = ({ Component, pageProps }: AppProps) => {
useMobileHover();
const auto = useAutomaticTheme("theme", theme);
return (
<RootRegistry>
<ThemeProvider theme={{ ...theme, ...auto }}>
<Component {...pageProps} />
<AppName />
</ThemeProvider>
</RootRegistry>
<ThemeProvider theme={{ ...theme, ...auto }}>
<Component {...pageProps} />
<AppName />
</ThemeProvider>
);
};

View File

@@ -0,0 +1,36 @@
//
// Copyright (c) Zoe Roux and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for details.
//
import { StyleRegistryProvider, createStyleRegistry } from "yoshiki";
import Document, { DocumentContext } from "next/document";
Document.getInitialProps = async (ctx: DocumentContext) => {
const renderPage = ctx.renderPage;
const registry = createStyleRegistry();
ctx.renderPage = () =>
renderPage({
enhanceApp: (App) => (props) => {
return (
<StyleRegistryProvider registry={registry}>
<App {...props} />
</StyleRegistryProvider>
);
},
});
const props = await ctx.defaultGetInitialProps(ctx);
return {
...props,
styles: (
<>
{props.styles}
{registry.flushToComponent()}
</>
),
};
};
export default Document;

View File

@@ -19,7 +19,7 @@ const rnwPreprocess = (block: Record<string, unknown>) => {
export const useYoshiki = () => {
const registry = useStyleRegistry();
const theme = useTheme();
const childPrefix = useClassId();
const [parentPrefix, childPrefix] = useClassId();
useInsertionEffect(() => {
registry.flushToBrowser();
@@ -37,6 +37,7 @@ export const useYoshiki = () => {
css,
[...parentKeys.map((x) => `${childPrefix}${x}`), ...(overrides?.split(" ") ?? [])],
{
parentPrefix,
registry,
theme,
preprocessBlock: rnwPreprocess,

View File

@@ -54,6 +54,6 @@ body { ${cssVariables.map((x) => `${x.name}: ${x.light}`).join(";")} }
body { ${cssVariables.map((x) => `${x.name}: ${x.dark}`).join(";")} }
}
`;
registry.addRule(`automatic-theme-${key}`, rule);
registry.addRule({ type: "user", key, state: "normal", breakpoint: "default" }, rule);
return auto;
};

View File

@@ -85,15 +85,19 @@ const generateAtomicCss = (
{
theme,
preprocessBlock,
registry,
}: {
theme: Theme;
preprocessBlock?: PreprocessBlockFunction;
registry: StyleRegistry;
},
): [string, string][] => {
): string[] => {
if (key in shorthandsFn) {
const expanded = shorthandsFn[key as keyof typeof shorthandsFn](value as any);
return Object.entries(expanded)
.map(([eKey, eValue]) => generateAtomicCss(eKey, eValue, state, { theme, preprocessBlock }))
.map(([eKey, eValue]) =>
generateAtomicCss(eKey, eValue, state, { theme, preprocessBlock, registry }),
)
.flat();
}
if (typeof value === "function") {
@@ -109,34 +113,43 @@ const generateAtomicCss = (
preprocessBlock,
);
if (!block) return [];
return [
[
className,
addBreakpointBlock(bp as BreakpointKey, `${stateMapper[state](className)} ${block}`),
],
];
registry.addRule(
{ type: "atomic", key: `${key}:${bpValue}`, state, breakpoint: bp as BreakpointKey },
`${stateMapper[state](className)} ${block}`,
);
return className;
});
}
const className = generateAtomicName(statePrefix, key, value);
const block = generateClassBlock({ [key]: value }, preprocessBlock);
if (!block) return [];
return [[className, `${stateMapper[state](className)} ${block}`]];
registry.addRule(
{ type: "atomic", key: `${key}:${value}`, state, breakpoint: "default" },
`${stateMapper[state](className)} ${block}`,
);
return [className];
};
const dedupProperties = (...classList: (string[] | undefined)[]) => {
const propMap = new Map<string, string>();
const atomicMap = new Map<string, string>();
const rest: string[] = [];
for (const classes of classList) {
if (!classes) continue;
for (const name of classes) {
if (!name) continue;
if (!name.startsWith("ys-")) {
rest.push(name);
continue;
}
// example ys-background-blue or ys-sm_background-red
const key = name.substring(3, name.lastIndexOf("-"));
propMap.set(key, name);
atomicMap.set(key, name);
}
}
return Array.from(propMap.values()).join(" ");
rest.push(...atomicMap.values())
return rest.join(" ");
};
export const yoshikiCssToClassNames = (
@@ -165,21 +178,13 @@ export const yoshikiCssToClassNames = (
if (!inlineStyle) return [];
if (preprocess) inlineStyle = preprocess(inlineStyle);
// I'm sad that traverse is not a thing in JS.
const [localClassNames, localStyle] = Object.entries(inlineStyle).reduce<[string[], string[]]>(
(acc, [key, value]) => {
const n = generateAtomicCss(key, value, state ?? "normal", {
theme,
preprocessBlock,
});
acc[0].push(...n.map((x) => x[0]));
acc[1].push(...n.map((x) => x[1]));
return acc;
},
[[], []],
return Object.entries(inlineStyle).flatMap(([key, value]) =>
generateAtomicCss(key, value, state ?? "normal", {
theme,
preprocessBlock,
registry,
}),
);
registry.addRules(localClassNames, localStyle);
return localClassNames;
};
return dedupProperties(
@@ -293,7 +298,7 @@ export const generateChildCss = (
if (!block) continue;
const cssClass = `${stateMapper[state](parentName)} .${className} ${block}`;
registry.addRule(
`${className}-${state}-${breakpoint}`,
{ type: "atomic", key: className, breakpoint: breakpoint as BreakpointKey, state },
addBreakpointBlock(breakpoint as BreakpointKey, cssClass),
);
}

View File

@@ -5,24 +5,30 @@
import { createContext, createElement, ReactNode, useContext } from "react";
import { breakpoints } from "../theme";
import { WithState } from "../type";
function findLastIndex<T>(
array: readonly T[],
predicate: (element: T, index: number) => boolean,
startIndex?: number,
): number {
for (let i = startIndex ?? array.length - 1; i >= 0; i--) {
if (predicate(array[i], i)) {
return i;
}
}
return -1;
}
type StyleKey = {
type: "atomic" | "general" | "user";
key: string;
breakpoint: keyof typeof breakpoints | "default";
state: keyof WithState<unknown> | "normal";
};
const keyToStr = ({ type, key, breakpoint, state }: StyleKey) => {
return `${type[0]}-${key}-${breakpoint}-${state}`;
};
type StyleRule = { key: StyleKey; strKey: string; css: string };
export class StyleRegistry {
private completed: string[] = [];
private rules: [string, string][] = [];
private rules: [StyleKey, string][] = [];
private styleElement: HTMLStyleElement | null = null;
private cssOutput: Record<StyleKey["state"], Record<StyleKey["breakpoint"], string[]>> =
Object.fromEntries(
["normal", "hover", "focus", "press"].map((x) => [
x,
Object.fromEntries(Object.keys({ default: 0, ...breakpoints }).map((bp) => [bp, []])),
]),
) as any;
constructor(isDefault?: true) {
if (isDefault) {
@@ -30,46 +36,36 @@ export class StyleRegistry {
"Warning: Yoshiki was used without a top level StyleRegistry. SSR won't be supported.",
);
}
if (typeof window !== "undefined") this.hydrate();
}
addRule(key: string, rule: string) {
if (this.rules.find(([eKey]) => key === eKey)) return;
addRule(key: StyleKey, rule: string) {
if (this.rules.find(([eKey]) => Object.is(key, eKey))) return;
this.rules.push([key, rule]);
}
addRules(keys: string[], rules: string[]) {
addRules(keys: StyleKey[], rules: string[]) {
// I'm sad that sequence is not a thing...
for (let i = 0; i < keys.length; i++) {
this.addRule(keys[i], rules[i]);
}
}
flush(): string[] {
const ret = this.rules.filter(([key]) => !this.completed.includes(key));
flush(): StyleRule[] {
const toFlush = this.rules
.map(([key, css]) => ({ key, strKey: keyToStr(key), css: css }))
.filter(({ strKey }) => !this.completed.includes(strKey));
this.rules = [];
this.completed.push(...ret.map(([key]) => key));
return ret.map(([, value]) => value);
this.completed.push(...toFlush.map(({ strKey }) => strKey));
return toFlush;
}
flushToBrowser() {
const toMerge: string[] = [];
if (!this.styleElement) {
const styles = document.querySelectorAll<HTMLStyleElement>("style[data-yoshiki]");
for (const style of styles) {
this.completed.push(...(style.dataset.yoshiki ?? " ").split(" "));
if (!this.styleElement) {
this.styleElement = style;
style.dataset.yoshiki = "";
} else {
if (style.textContent) toMerge.push(...style.textContent.split("\n"));
style.remove();
}
}
this.hydrate();
}
// If we have something to merge, do it before a flush.
const toFlush = toMerge.length ? toMerge : this.flush();
const toFlush = this.flush();
if (!toFlush.length) return;
if (!this.styleElement) {
@@ -78,11 +74,8 @@ export class StyleRegistry {
`<style data-yoshiki="">${this.toStyleString(toFlush)}</style>`,
);
} else {
this.styleElement.textContent = this.toStyleString(toFlush, this.styleElement.textContent);
this.styleElement.textContent = this.toStyleString(toFlush);
}
// Since we did not flush earlier to merge, we do it now.
if (toMerge.length) this.flushToBrowser();
}
flushToComponent() {
@@ -95,63 +88,48 @@ export class StyleRegistry {
});
}
toStyleString(classes: string[], existingStyle?: string | null) {
const newChunks = this.splitInChunks(classes);
if (!existingStyle) {
return newChunks
.map((x, i) => (x.length ? x.join("\n") + `\n/*${i}*/` : null))
.filter((x) => x)
.join("\n");
toStyleString(rules: StyleRule[]): string {
for (const { key, css } of rules) {
this.cssOutput[key.state][key.breakpoint].push(css);
}
const lines = existingStyle.split("\n");
const comReg = new RegExp("/\\*(\\d+)\\*/");
for (const [i, chunk] of newChunks.entries()) {
if (!chunk.length) continue;
const pos = findLastIndex(lines, (x) => {
const match = comReg.exec(x);
if (!match) return false;
return parseInt(match[1]) <= i;
});
if (pos === -1) {
// No section with a same or lower priority exists, create one.
lines.splice(0, 0, ...chunk, `/*${i}*/`);
} else if (!lines[pos].includes(i.toString())) {
// Our session does not exist, create one at the right place.
lines.splice(pos + 1, 0, ...chunk, `/*${i}*/`);
} else {
// Append in our section.
lines.splice(pos, 0, ...chunk);
}
}
return lines.join("\n");
return Object.entries(this.cssOutput)
.map(([state, bp]) =>
Object.entries(bp)
.map(([breakpoint, css]) => `/* ${state}-${breakpoint} */\n${css.join("\n")}\n`)
.join("\n"),
)
.join("\n");
}
splitInChunks(classes: string[]): string[][] {
const chunks: string[][][] = [...Array(4 /* Normal, Hover, Focus and Active*/)].map(() =>
[...Array(1 + Object.keys(breakpoints).length)].map(() => []),
);
hydrate() {
const styles = document.querySelectorAll<HTMLStyleElement>("style[data-yoshiki]");
for (const style of styles) {
this.completed.push(...(style.dataset.yoshiki ?? "").split(" "));
if (style.textContent) this.hydrateStyle(style.textContent);
if (!this.styleElement) {
this.styleElement = style;
style.dataset.yoshiki = "";
} else {
style.remove();
}
}
}
for (const cl of classes) {
const start = cl.indexOf(".ys-");
const cn = cl.substring(start, cl.indexOf("-", start + 4));
const modifier = cn.includes("_") ? cn.substring(4, cn.lastIndexOf("_")) : null;
hydrateStyle(css: string) {
const comReg = new RegExp("/\\* (\\w+):(\\w) \\*/");
let state: StyleKey["state"] = "normal";
let bp: StyleKey["breakpoint"] = "default";
if (!modifier) {
chunks[0][0].push(cl);
for (const line of css.split("\n")) {
const match = line.match(comReg);
if (!match) {
this.cssOutput[state][bp].push(line);
continue;
}
const type = ["hover", "focus", "press"].findIndex((x) => modifier.includes(x)) + 1;
const bp = Object.keys(breakpoints).findIndex((x) => modifier.includes(x)) + 1;
chunks[type][bp].push(cl);
// Not really safe but will break only if the user modifies the css manually.
state = match[1] as StyleKey["state"];
bp = match[2] as StyleKey["breakpoint"];
}
return chunks.flat();
}
}