diff --git a/README.md b/README.md
index e463524..84b1770 100755
--- a/README.md
+++ b/README.md
@@ -391,6 +391,101 @@ property limits styles to highlighted languages.
When converting a Prism CSS theme it's mostly just necessary to use classes as
`types` and convert the declarations to object-style-syntax and put them on `style`.
+### SSR Support / Avoiding FOUC
+
+If your React app supports "light mode / dark mode" you need to do additional work to avoid a flash of unstyled content (FOUC).
+Suppose you have the following application code:
+
+```jsx
+import { useState, useEffect } from 'react';
+import Highlight from 'prism-react-renderer'
+import duotoneDark from 'prism-react-renderer/themes/duotoneDark';
+import duotoneLight from 'prism-react-renderer/themes/duotoneLight';
+
+const useTheme = () => {
+ const [theme, _setTheme] = useState(duotoneLight);
+
+ useEffect(() => {
+ const colorMode = window.localStorage.getItem('color-mode');
+ _setTheme(colorMode === 'dark' ? duotoneDark : duotoneLight);
+ }, []);
+
+ const setTheme = (themeId) => {
+ if (themeId === duotoneLight.id) {
+ window.localStorage.setItem('color-mode', 'light');
+ _setTheme(duotoneLight);
+ }
+ if (themeId === duotoneDark.id) {
+ window.localStorage.setItem('color-mode', 'dark');
+ _setTheme(duotoneDark);
+ }
+ }
+
+ return { theme, setTheme }
+}
+
+const MyComponent = () => {
+ const { setTheme, theme } = useTheme();
+
+ return (
+ <>
+
+ {tokens.map((line, i) => { + const lineProps = getLineProps({ line, key: i }); + return ( ++); + describe("+ {line.map((token, key) => { + const tokenProps = getTokenProps({ token, key }); + return ( + + ); + })} ++ ); + })} +
- {tokens.map((line, i) => ( -+- {line.map((token, key) => ( - - ))} -- ))} -
- {tokens.map((line, i) => ( -+- {line.map((token, key) => ( - - ))} -- ))} -
- {tokens.map((line, i) => ( -+- {line.map((token, key) => ( - - ))} -- ))} -
( function @@ -29,19 +30,19 @@ exports[`snapshots renders correctly 1`] = ` someDemo ( ) @@ -52,7 +53,7 @@ exports[` snapshots renders correctly 1`] = ` { @@ -62,7 +63,7 @@ exports[` snapshots renders correctly 1`] = ` snapshots renders correctly 1`] = ` var @@ -82,7 +83,7 @@ exports[`snapshots renders correctly 1`] = ` = @@ -93,13 +94,13 @@ exports[` snapshots renders correctly 1`] = ` "Hello World!" ; @@ -109,7 +110,7 @@ exports[` snapshots renders correctly 1`] = ` snapshots renders correctly 1`] = ` console . log ( @@ -146,13 +148,13 @@ exports[`snapshots renders correctly 1`] = ` ) ; @@ -162,38 +164,38 @@ exports[` snapshots renders correctly 1`] = ` } ) ( ) ; @@ -203,10 +205,11 @@ exports[`snapshots renders correctly 1`] = ` @@ -215,14 +218,14 @@ exports[`snapshots renders correctly 1`] = ` return @@ -233,13 +236,13 @@ exports[`snapshots renders correctly 1`] = ` ( ) @@ -250,7 +253,7 @@ exports[` snapshots renders correctly 1`] = ` => @@ -261,31 +264,31 @@ exports[` snapshots renders correctly 1`] = ` < App /> ; @@ -298,11 +301,12 @@ exports[` snapshots renders unsupported languages correctly 1`] = ` snapshots renders unsupported languages correctly 1`] = `snapshots renders unsupported languages correctly 1`] = `snapshots renders unsupported languages correctly 1`] = `snapshots renders unsupported languages correctly 1`] = `@@ -354,7 +359,7 @@ exports[`snapshots renders unsupported languages correctly 1`] = ` { + it("generates code to set CSS variables on the document root", () => { + const themes = [duotoneDark, duotoneLight]; + const scriptStr = generateScriptForSSR( + themes, + "() => window.PRISM_REACT_RENDERER_INITIAL_THEME_ID" + ); + expect(scriptStr).toMatchInlineSnapshot(` + "try { + const themeId = (() => window.PRISM_REACT_RENDERER_INITIAL_THEME_ID)(); + + const root = document.documentElement; + + if (themeId === 'duotoneDark') { + root.style.setProperty('--plain-backgroundColor', '#2a2734'); + root.style.setProperty('--plain-color', '#9a86fd'); + root.style.setProperty('--comment-color', '#6c6783'); + root.style.setProperty('--prolog-color', '#6c6783'); + root.style.setProperty('--doctype-color', '#6c6783'); + root.style.setProperty('--cdata-color', '#6c6783'); + root.style.setProperty('--punctuation-color', '#6c6783'); + root.style.setProperty('--namespace-opacity', '0.7'); + root.style.setProperty('--tag-color', '#e09142'); + root.style.setProperty('--operator-color', '#e09142'); + root.style.setProperty('--number-color', '#e09142'); + root.style.setProperty('--property-color', '#9a86fd'); + root.style.setProperty('--function-color', '#9a86fd'); + root.style.setProperty('--tag-id-color', '#eeebff'); + root.style.setProperty('--selector-color', '#eeebff'); + root.style.setProperty('--atrule-id-color', '#eeebff'); + root.style.setProperty('--attr-name-color', '#c4b9fe'); + root.style.setProperty('--boolean-color', '#ffcc99'); + root.style.setProperty('--string-color', '#ffcc99'); + root.style.setProperty('--entity-color', '#ffcc99'); + root.style.setProperty('--url-color', '#ffcc99'); + root.style.setProperty('--attr-value-color', '#ffcc99'); + root.style.setProperty('--keyword-color', '#ffcc99'); + root.style.setProperty('--control-color', '#ffcc99'); + root.style.setProperty('--directive-color', '#ffcc99'); + root.style.setProperty('--unit-color', '#ffcc99'); + root.style.setProperty('--statement-color', '#ffcc99'); + root.style.setProperty('--regex-color', '#ffcc99'); + root.style.setProperty('--atrule-color', '#ffcc99'); + root.style.setProperty('--placeholder-color', '#ffcc99'); + root.style.setProperty('--variable-color', '#ffcc99'); + root.style.setProperty('--deleted-textDecorationLine', 'line-through'); + root.style.setProperty('--inserted-textDecorationLine', 'underline'); + root.style.setProperty('--italic-fontStyle', 'italic'); + root.style.setProperty('--important-fontWeight', 'bold'); + root.style.setProperty('--bold-fontWeight', 'bold'); + root.style.setProperty('--important-color', '#c4b9fe'); + } + + if (themeId === 'duotoneLight') { + root.style.setProperty('--plain-backgroundColor', '#faf8f5'); + root.style.setProperty('--plain-color', '#728fcb'); + root.style.setProperty('--comment-color', '#b6ad9a'); + root.style.setProperty('--prolog-color', '#b6ad9a'); + root.style.setProperty('--doctype-color', '#b6ad9a'); + root.style.setProperty('--cdata-color', '#b6ad9a'); + root.style.setProperty('--punctuation-color', '#b6ad9a'); + root.style.setProperty('--namespace-opacity', '0.7'); + root.style.setProperty('--tag-color', '#063289'); + root.style.setProperty('--operator-color', '#063289'); + root.style.setProperty('--number-color', '#063289'); + root.style.setProperty('--property-color', '#b29762'); + root.style.setProperty('--function-color', '#b29762'); + root.style.setProperty('--tag-id-color', '#2d2006'); + root.style.setProperty('--selector-color', '#2d2006'); + root.style.setProperty('--atrule-id-color', '#2d2006'); + root.style.setProperty('--attr-name-color', '#896724'); + root.style.setProperty('--boolean-color', '#728fcb'); + root.style.setProperty('--string-color', '#728fcb'); + root.style.setProperty('--entity-color', '#728fcb'); + root.style.setProperty('--url-color', '#728fcb'); + root.style.setProperty('--attr-value-color', '#728fcb'); + root.style.setProperty('--keyword-color', '#728fcb'); + root.style.setProperty('--control-color', '#728fcb'); + root.style.setProperty('--directive-color', '#728fcb'); + root.style.setProperty('--unit-color', '#728fcb'); + root.style.setProperty('--statement-color', '#728fcb'); + root.style.setProperty('--regex-color', '#728fcb'); + root.style.setProperty('--atrule-color', '#728fcb'); + root.style.setProperty('--placeholder-color', '#93abdc'); + root.style.setProperty('--variable-color', '#93abdc'); + root.style.setProperty('--deleted-textDecorationLine', 'line-through'); + root.style.setProperty('--inserted-textDecorationLine', 'underline'); + root.style.setProperty('--italic-fontStyle', 'italic'); + root.style.setProperty('--important-fontWeight', 'bold'); + root.style.setProperty('--bold-fontWeight', 'bold'); + root.style.setProperty('--important-color', '#896724'); + } + } catch (e) { + console.error('Failed to set prism-react-renderer CSS variables'); + console.error(e); + }" + `); + }); +}); diff --git a/src/utils/__tests__/themeToDict.test.js b/src/utils/__tests__/themeToDict.test.js index 405473f..e0ffd00 100755 --- a/src/utils/__tests__/themeToDict.test.js +++ b/src/utils/__tests__/themeToDict.test.js @@ -72,4 +72,19 @@ describe("themeToDict", () => { expect(themeToDict(input, "ocaml").test).toEqual(undefined); }); + + it("passes along third argument to root styles", () => { + const input = { + plain: { + backgroundColor: "green", + }, + styles: [], + }; + const styleObj = { color: "red" }; + + expect(themeToDict(input, "js", styleObj).root).toEqual({ + backgroundColor: "green", + ...styleObj, + }); + }); }); diff --git a/src/utils/__tests__/themeWithCssVariables.test.js b/src/utils/__tests__/themeWithCssVariables.test.js new file mode 100644 index 0000000..844a274 --- /dev/null +++ b/src/utils/__tests__/themeWithCssVariables.test.js @@ -0,0 +1,46 @@ +import themeWithCssVariables from "../themeWithCssVariables"; + +describe("themeWithCssVariables", () => { + it("creates a visually equivalent theme with one type per style entry", () => { + const input = { + plain: { color: "red" }, + styles: [ + { + types: ["type1", "type2"], + style: { color: "green" }, + }, + { + types: ["type1"], + style: { fontWeight: "bold" }, + languages: "javascript", + }, + ], + }; + + const { theme, variables } = themeWithCssVariables(input); + expect(theme).toEqual({ + plain: { color: "var(--plain-color)" }, + styles: [ + { + types: ["type1"], + style: { color: "var(--type1-color)" }, + }, + { + types: ["type2"], + style: { color: "var(--type2-color)" }, + }, + { + types: ["type1"], + style: { fontWeight: "var(--type1-fontWeight)" }, + languages: "javascript", + }, + ], + }); + expect(variables).toEqual({ + "--plain-color": "red", + "--type1-color": "green", + "--type2-color": "green", + "--type1-fontWeight": "bold", + }); + }); +}); diff --git a/src/utils/generateScriptForSSR.js b/src/utils/generateScriptForSSR.js new file mode 100644 index 0000000..485d8e1 --- /dev/null +++ b/src/utils/generateScriptForSSR.js @@ -0,0 +1,36 @@ +// @flow + +import themeWithCssVariables from "./themeWithCssVariables"; +import type { PrismTheme } from "../types"; + +const generateScriptForSSR = ( + themes: PrismTheme[], + getThemeIdFuncStr: string +): string => + ` +try { + const themeId = (${getThemeIdFuncStr})(); + + const root = document.documentElement; + + ${themes + .map( + (theme) => + `if (themeId === '${theme.id || ""}') { + ${Object.entries(themeWithCssVariables(theme).variables) + .map( + ([key, value]) => + // $FlowFixMe + `root.style.setProperty('${key}', '${value || ""}');` + ) + .join("\n" + " ".repeat(4))} + }` + ) + .join("\n\n" + " ".repeat(2))} +} catch (e) { + console.error('Failed to set prism-react-renderer CSS variables'); + console.error(e); +} +`.trim(); + +export default generateScriptForSSR; diff --git a/src/utils/themeToDict.js b/src/utils/themeToDict.js index 07d463c..7d78890 100755 --- a/src/utils/themeToDict.js +++ b/src/utils/themeToDict.js @@ -8,7 +8,11 @@ export type ThemeDict = { [type: string]: StyleObj, }; -const themeToDict = (theme: PrismTheme, language: Language): ThemeDict => { +const themeToDict = ( + theme: PrismTheme, + language: Language, + rootStyles?: StyleObj +): ThemeDict => { const { plain } = theme; // $FlowFixMe @@ -22,16 +26,18 @@ const themeToDict = (theme: PrismTheme, language: Language): ThemeDict => { themeEntry.types.forEach((type) => { // $FlowFixMe - const accStyle: StyleObj = { ...acc[type], ...style }; - - acc[type] = accStyle; + acc[type] = { ...acc[type], ...style }; }); return acc; }, base); // $FlowFixMe - themeDict.root = (plain: StyleObj); + themeDict.root = ((rootStyles + ? // $FlowFixMe + { ...rootStyles, ...plain } + : // $FlowFixMe + plain): StyleObj); // $FlowFixMe themeDict.plain = ({ ...plain, backgroundColor: null }: StyleObj); diff --git a/src/utils/themeWithCssVariables.js b/src/utils/themeWithCssVariables.js new file mode 100644 index 0000000..49091ea --- /dev/null +++ b/src/utils/themeWithCssVariables.js @@ -0,0 +1,64 @@ +import { PrismTheme, StyleObj } from "../types"; + +/** + * Returns a new PrismTheme `t` that is visually equivalent + * to `theme` but `t.styles[i].types` always has length 1 + */ +const flattenThemeTypes = (theme: PrismTheme): PrismTheme => { + const { plain, styles, id } = theme; + + return { + id, + plain: { ...plain }, + styles: styles.reduce((acc, x) => { + const { types, style, ...rest } = x; + const flatStyle = types.map((type) => ({ + types: [type], + style: { ...style }, + ...rest, + })); + acc.push(...flatStyle); + return acc; + }, []), + }; +}; + +/** + * Returns a PrismTheme that is visually equivalent to `theme` + * but with CSS Variables instead of fixed values (e.g. + * `var(--plain-color)` instead of `"#F8F8F2"`). + * + * Also returns a mapping from CSS Variable to value (i.e. an + * object with key `--plain-color` and value `"#F8F8F2"`) + */ +const themeWithCssVariables = ( + theme: PrismTheme +): { theme: PrismTheme, variables: StyleObj } => { + const flatTheme = flattenThemeTypes(theme); + const variables: StyleObj = {}; + + const { plain, styles } = flatTheme; + + Object.entries(plain).forEach(([key, value]) => { + const varName = `--plain-${key}`; + variables[varName] = value; + // Will not modify `theme` because `flattenThemeTypes` + // deep clones the original `theme` object + plain[key] = `var(${varName})`; + }); + + // `types` should have length 1 + styles.forEach(({ style, types }) => { + Object.entries(style).forEach(([key, value]) => { + const varName = `--${types[0]}-${key}`; + variables[varName] = value; + // Will not modify `theme` because `flattenThemeTypes` + // deep clones the original `theme` object + style[key] = `var(${varName})`; + }); + }); + + return { theme: flatTheme, variables }; +}; + +export default themeWithCssVariables; diff --git a/tools/themeFromVsCode/README.md b/tools/themeFromVsCode/README.md index e7ce730..38804bc 100644 --- a/tools/themeFromVsCode/README.md +++ b/tools/themeFromVsCode/README.md @@ -3,3 +3,4 @@ 1. Open this directory and run `npm install` 2. Save your VSCode theme in a file called `theme.json` in this directory (`ctrl`/`cmd` + `shift` + `p` -> `Developer: Generate Color Theme From Current Settings`) 3. Run `npm start` and your theme will be created in a file called `outputTheme.js` (inside root same as the `README.md` file) +4. (SSR only) Edit the `id` attribute of the generated theme. \ No newline at end of file diff --git a/tools/themeFromVsCode/src/index.js b/tools/themeFromVsCode/src/index.js index 8e83470..0e47fae 100755 --- a/tools/themeFromVsCode/src/index.js +++ b/tools/themeFromVsCode/src/index.js @@ -10,6 +10,7 @@ const theme = JSON5.parse(themeString); const prismTheme = collectAllSettings(theme.tokenColors); const json = { + id: '@TODO', plain: { color: theme.colors['editor.foreground'], backgroundColor: theme.colors['editor.background'],