@open-cloud-initiative/editor-x 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.devcontainer/Dockerfile +13 -0
  2. package/.devcontainer/devcontainer.json +52 -0
  3. package/.github/dependabot.yml +10 -0
  4. package/.github/workflows/pages.yml +42 -0
  5. package/.github/workflows/publish.yml +41 -0
  6. package/.vscode/settings.json +7 -0
  7. package/LICENSE +9 -0
  8. package/README.md +9 -0
  9. package/app/_component/editor.tsx +383 -0
  10. package/app/layout.tsx +46 -0
  11. package/app/page.tsx +11 -0
  12. package/app/r/registry.json/route.ts +22 -0
  13. package/components/editorx/editor.tsx +1794 -0
  14. package/components/editorx/extensions/floating-menu.tsx +376 -0
  15. package/components/editorx/extensions/floating-toolbar.tsx +97 -0
  16. package/components/editorx/extensions/image-placeholder.tsx +316 -0
  17. package/components/editorx/extensions/image.tsx +462 -0
  18. package/components/editorx/extensions/search-and-replace.tsx +438 -0
  19. package/components/editorx/rich-text-editor.tsx +383 -0
  20. package/components/editorx/tiptap.css +421 -0
  21. package/components/editorx/toolbars/alignment.tsx +126 -0
  22. package/components/editorx/toolbars/blockquote.tsx +47 -0
  23. package/components/editorx/toolbars/bold.tsx +48 -0
  24. package/components/editorx/toolbars/bullet-list.tsx +48 -0
  25. package/components/editorx/toolbars/code-block.tsx +47 -0
  26. package/components/editorx/toolbars/code.tsx +43 -0
  27. package/components/editorx/toolbars/color-and-highlight.tsx +215 -0
  28. package/components/editorx/toolbars/editor-toolbar.tsx +77 -0
  29. package/components/editorx/toolbars/hard-break.tsx +46 -0
  30. package/components/editorx/toolbars/headings.tsx +97 -0
  31. package/components/editorx/toolbars/horizontal-rule.tsx +42 -0
  32. package/components/editorx/toolbars/image-placeholder-toolbar.tsx +47 -0
  33. package/components/editorx/toolbars/italic.tsx +48 -0
  34. package/components/editorx/toolbars/link.tsx +130 -0
  35. package/components/editorx/toolbars/mobile-toolbar-group.tsx +76 -0
  36. package/components/editorx/toolbars/ordered-list.tsx +47 -0
  37. package/components/editorx/toolbars/redo.tsx +44 -0
  38. package/components/editorx/toolbars/strikethrough.tsx +48 -0
  39. package/components/editorx/toolbars/toolbar-provider.tsx +29 -0
  40. package/components/editorx/toolbars/underline.tsx +48 -0
  41. package/components/editorx/toolbars/undo.tsx +43 -0
  42. package/components/layout/theme-switcher.tsx +26 -0
  43. package/components/main-nav.tsx +24 -0
  44. package/components/mobile-nav.tsx +46 -0
  45. package/components/open-in-v0-button.tsx +38 -0
  46. package/components/page-header.tsx +30 -0
  47. package/components/site-footer.tsx +41 -0
  48. package/components/site-header.tsx +32 -0
  49. package/components/theme-provider.tsx +8 -0
  50. package/components/ui/button.tsx +57 -0
  51. package/components/ui/checkbox.tsx +30 -0
  52. package/components/ui/collapsible.tsx +11 -0
  53. package/components/ui/command.tsx +148 -0
  54. package/components/ui/dialog.tsx +122 -0
  55. package/components/ui/drawer.tsx +118 -0
  56. package/components/ui/dropdown-menu.tsx +201 -0
  57. package/components/ui/input.tsx +22 -0
  58. package/components/ui/label.tsx +26 -0
  59. package/components/ui/popover.tsx +33 -0
  60. package/components/ui/resizable.tsx +40 -0
  61. package/components/ui/scroll-area.tsx +42 -0
  62. package/components/ui/separator.tsx +31 -0
  63. package/components/ui/sheet.tsx +140 -0
  64. package/components/ui/sidebar.tsx +763 -0
  65. package/components/ui/skeleton.tsx +15 -0
  66. package/components/ui/spinner.tsx +29 -0
  67. package/components/ui/tabs.tsx +55 -0
  68. package/components/ui/toggle-group.tsx +61 -0
  69. package/components/ui/toggle.tsx +45 -0
  70. package/components/ui/tooltip.tsx +32 -0
  71. package/components.json +21 -0
  72. package/config/site.ts +15 -0
  73. package/eslint.config.mjs +20 -0
  74. package/hooks/use-character-limit.ts +28 -0
  75. package/hooks/use-copy-to-clipboard.ts +16 -0
  76. package/hooks/use-debounce.ts +17 -0
  77. package/hooks/use-image-upload.ts +97 -0
  78. package/hooks/use-media-querry.ts +18 -0
  79. package/hooks/use-mobile.tsx +19 -0
  80. package/images/editor.png +0 -0
  81. package/lib/content.ts +39 -0
  82. package/lib/cookie-client.ts +19 -0
  83. package/lib/localstorage-client.ts +19 -0
  84. package/lib/package.ts +144 -0
  85. package/lib/preferences-config.ts +72 -0
  86. package/lib/preferences-storage.ts +20 -0
  87. package/lib/theme-utils.ts +12 -0
  88. package/lib/theme.ts +50 -0
  89. package/lib/tiptap-utils.ts +45 -0
  90. package/lib/utils.ts +11 -0
  91. package/next-env.d.ts +6 -0
  92. package/next.config.mjs +11 -0
  93. package/package.json +92 -0
  94. package/postcss.config.mjs +8 -0
  95. package/prettier.config.mjs +15 -0
  96. package/public/android-chrome-192x192.png +0 -0
  97. package/public/android-chrome-512x512.png +0 -0
  98. package/public/apple-touch-icon.png +0 -0
  99. package/public/favicon-16x16.png +0 -0
  100. package/public/favicon-32x32.png +0 -0
  101. package/public/favicon.ico +0 -0
  102. package/public/file.svg +1 -0
  103. package/public/globe.svg +1 -0
  104. package/public/next.svg +1 -0
  105. package/public/og.webp +0 -0
  106. package/public/r/editor-x.json +85 -0
  107. package/public/r/registry.json +93 -0
  108. package/public/site.webmanifest +19 -0
  109. package/public/vercel.svg +1 -0
  110. package/public/window.svg +1 -0
  111. package/registry/editor/components/editor.tsx +1794 -0
  112. package/registry/editor/components/extensions/floating-menu.tsx +376 -0
  113. package/registry/editor/components/extensions/floating-toolbar.tsx +97 -0
  114. package/registry/editor/components/extensions/image-placeholder.tsx +316 -0
  115. package/registry/editor/components/extensions/image.tsx +462 -0
  116. package/registry/editor/components/extensions/search-and-replace.tsx +438 -0
  117. package/registry/editor/components/rich-text-editor.tsx +383 -0
  118. package/registry/editor/components/tiptap.css +421 -0
  119. package/registry/editor/components/toolbars/alignment.tsx +126 -0
  120. package/registry/editor/components/toolbars/blockquote.tsx +47 -0
  121. package/registry/editor/components/toolbars/bold.tsx +48 -0
  122. package/registry/editor/components/toolbars/bullet-list.tsx +48 -0
  123. package/registry/editor/components/toolbars/code-block.tsx +47 -0
  124. package/registry/editor/components/toolbars/code.tsx +43 -0
  125. package/registry/editor/components/toolbars/color-and-highlight.tsx +215 -0
  126. package/registry/editor/components/toolbars/editor-toolbar.tsx +77 -0
  127. package/registry/editor/components/toolbars/hard-break.tsx +46 -0
  128. package/registry/editor/components/toolbars/headings.tsx +97 -0
  129. package/registry/editor/components/toolbars/horizontal-rule.tsx +42 -0
  130. package/registry/editor/components/toolbars/image-placeholder-toolbar.tsx +47 -0
  131. package/registry/editor/components/toolbars/italic.tsx +48 -0
  132. package/registry/editor/components/toolbars/link.tsx +130 -0
  133. package/registry/editor/components/toolbars/mobile-toolbar-group.tsx +76 -0
  134. package/registry/editor/components/toolbars/ordered-list.tsx +47 -0
  135. package/registry/editor/components/toolbars/redo.tsx +44 -0
  136. package/registry/editor/components/toolbars/strikethrough.tsx +48 -0
  137. package/registry/editor/components/toolbars/toolbar-provider.tsx +29 -0
  138. package/registry/editor/components/toolbars/underline.tsx +48 -0
  139. package/registry/editor/components/toolbars/undo.tsx +43 -0
  140. package/registry/editor/components/ui/button.tsx +57 -0
  141. package/registry/editor/components/ui/checkbox.tsx +30 -0
  142. package/registry/editor/components/ui/collapsible.tsx +11 -0
  143. package/registry/editor/components/ui/command.tsx +148 -0
  144. package/registry/editor/components/ui/dialog.tsx +122 -0
  145. package/registry/editor/components/ui/drawer.tsx +118 -0
  146. package/registry/editor/components/ui/dropdown-menu.tsx +201 -0
  147. package/registry/editor/components/ui/input.tsx +22 -0
  148. package/registry/editor/components/ui/label.tsx +26 -0
  149. package/registry/editor/components/ui/popover.tsx +33 -0
  150. package/registry/editor/components/ui/resizable.tsx +40 -0
  151. package/registry/editor/components/ui/scroll-area.tsx +42 -0
  152. package/registry/editor/components/ui/separator.tsx +31 -0
  153. package/registry/editor/components/ui/sheet.tsx +140 -0
  154. package/registry/editor/components/ui/sidebar.tsx +763 -0
  155. package/registry/editor/components/ui/skeleton.tsx +15 -0
  156. package/registry/editor/components/ui/spinner.tsx +29 -0
  157. package/registry/editor/components/ui/tabs.tsx +55 -0
  158. package/registry/editor/components/ui/toggle-group.tsx +61 -0
  159. package/registry/editor/components/ui/toggle.tsx +45 -0
  160. package/registry/editor/components/ui/tooltip.tsx +32 -0
  161. package/registry/editor/hooks/use-character-limit.ts +28 -0
  162. package/registry/editor/hooks/use-copy-to-clipboard.ts +16 -0
  163. package/registry/editor/hooks/use-debounce.ts +17 -0
  164. package/registry/editor/hooks/use-image-upload.ts +97 -0
  165. package/registry/editor/hooks/use-media-querry.ts +18 -0
  166. package/registry/editor/hooks/use-mobile.tsx +19 -0
  167. package/registry/editor/lib/content.ts +39 -0
  168. package/registry/editor/lib/cookie-client.ts +19 -0
  169. package/registry/editor/lib/localstorage-client.ts +19 -0
  170. package/registry/editor/lib/package.ts +144 -0
  171. package/registry/editor/lib/preferences-config.ts +72 -0
  172. package/registry/editor/lib/preferences-storage.ts +20 -0
  173. package/registry/editor/lib/theme-utils.ts +12 -0
  174. package/registry/editor/lib/theme.ts +50 -0
  175. package/registry/editor/lib/tiptap-utils.ts +45 -0
  176. package/registry/editor/lib/utils.ts +11 -0
  177. package/registry/editor/page.tsx +9 -0
  178. package/registry.json +93 -0
  179. package/reset.d.ts +1 -0
  180. package/scripts/generate-theme-presets.ts +128 -0
  181. package/scripts/postCreateCommand.sh +0 -0
  182. package/scripts/theme-boot.tsx +105 -0
  183. package/server/server-actions.ts +27 -0
  184. package/stores/preferences/preferences-provider.tsx +55 -0
  185. package/stores/preferences/preferences-store.ts +23 -0
  186. package/styles/globals.css +288 -0
  187. package/styles/presets/brutalist.css +89 -0
  188. package/styles/presets/soft-pop.css +89 -0
  189. package/styles/presets/tangerine.css +89 -0
  190. package/tsconfig.json +50 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How each preference should be saved.
3
+ *
4
+ * "client-cookie" → write cookie on the browser only.
5
+ * "server-cookie" → write cookie through a Server Action.
6
+ * "localStorage" → save only on the client (non-layout stuff).
7
+ * "none" → no saving, resets on reload.
8
+ *
9
+ * Layout-critical prefs (sidebar_variant / sidebar_collapsible)
10
+ * must stay consistent during SSR → so they can’t use localStorage.
11
+ * Others are flexible and can use any persistence.
12
+ */
13
+
14
+ import type { ThemeMode, ThemePreset } from './theme'
15
+
16
+ export type PreferencePersistence = 'none' | 'client-cookie' | 'server-cookie' | 'localStorage'
17
+
18
+ /**
19
+ * All available preference keys and their value types.
20
+ */
21
+ export type PreferenceValueMap = {
22
+ theme_mode: ThemeMode
23
+ theme_preset: ThemePreset
24
+ }
25
+
26
+ export type PreferenceKey = keyof PreferenceValueMap
27
+
28
+ /**
29
+ * Layout-critical keys → these affect SSR UI (sidebar shape)
30
+ * so they must be accessible on the server.
31
+ */
32
+ export const LAYOUT_CRITICAL_KEYS = [] as const
33
+ export type LayoutCriticalKey = (typeof LAYOUT_CRITICAL_KEYS)[number]
34
+
35
+ /**
36
+ * Everything else is non-critical and can be read from the client.
37
+ */
38
+ export type NonCriticalKey = Exclude<PreferenceKey, LayoutCriticalKey>
39
+
40
+ /**
41
+ * Layout-critical cannot use "localStorage" because SSR needs the value.
42
+ * So remove it from allowed persistence types for those keys.
43
+ */
44
+ type LayoutCriticalPersistence = Exclude<PreferencePersistence, 'localStorage'>
45
+
46
+ /**
47
+ * Final config:
48
+ * - layout-critical keys → restricted persistence
49
+ * - non-critical keys → can use any persistence
50
+ */
51
+ type PreferencePersistenceConfig = {
52
+ [K in LayoutCriticalKey]: LayoutCriticalPersistence
53
+ } & {
54
+ [K in NonCriticalKey]: PreferencePersistence
55
+ }
56
+
57
+ /**
58
+ * Default preference values on first load.
59
+ */
60
+ export const PREFERENCE_DEFAULTS: PreferenceValueMap = {
61
+ theme_mode: 'light',
62
+ theme_preset: 'default',
63
+ }
64
+
65
+ /**
66
+ * How each preference is persisted.
67
+ * You can change these per-key.
68
+ */
69
+ export const PREFERENCE_PERSISTENCE: PreferencePersistenceConfig = {
70
+ theme_mode: 'client-cookie',
71
+ theme_preset: 'client-cookie',
72
+ }
@@ -0,0 +1,20 @@
1
+ 'use client'
2
+
3
+ import { setLocalStorageValue } from './localstorage-client'
4
+ import { PREFERENCE_PERSISTENCE, type PreferenceKey } from './preferences-config'
5
+
6
+ export async function persistPreference(key: PreferenceKey, value: string) {
7
+ const mode = PREFERENCE_PERSISTENCE[key]
8
+
9
+ switch (mode) {
10
+ case 'none':
11
+ return
12
+
13
+ case 'localStorage':
14
+ setLocalStorageValue(key, value)
15
+ return
16
+
17
+ default:
18
+ return
19
+ }
20
+ }
@@ -0,0 +1,12 @@
1
+ export function applyThemeMode(value: 'light' | 'dark') {
2
+ const doc = document.documentElement
3
+ doc.classList.add('disable-transitions')
4
+ doc.classList.toggle('dark', value === 'dark')
5
+ requestAnimationFrame(() => {
6
+ doc.classList.remove('disable-transitions')
7
+ })
8
+ }
9
+
10
+ export function applyThemePreset(value: string) {
11
+ document.documentElement.setAttribute('data-theme-preset', value)
12
+ }
@@ -0,0 +1,50 @@
1
+ export const THEME_MODE_OPTIONS = [
2
+ { label: 'Light', value: 'light' },
3
+ { label: 'Dark', value: 'dark' },
4
+ ] as const
5
+
6
+ export const THEME_MODE_VALUES = THEME_MODE_OPTIONS.map((o) => o.value)
7
+ export type ThemeMode = (typeof THEME_MODE_VALUES)[number]
8
+
9
+ // --- generated:themePresets:start ---
10
+
11
+ export const THEME_PRESET_OPTIONS = [
12
+ {
13
+ label: 'Default',
14
+ value: 'default',
15
+ primary: {
16
+ light: 'oklch(0.205 0 0)',
17
+ dark: 'oklch(0.922 0 0)',
18
+ },
19
+ },
20
+ {
21
+ label: 'Brutalist',
22
+ value: 'brutalist',
23
+ primary: {
24
+ light: 'oklch(0.6489 0.237 26.9728)',
25
+ dark: 'oklch(0.7044 0.1872 23.1858)',
26
+ },
27
+ },
28
+ {
29
+ label: 'Soft Pop',
30
+ value: 'soft-pop',
31
+ primary: {
32
+ light: 'oklch(0.5106 0.2301 276.9656)',
33
+ dark: 'oklch(0.6801 0.1583 276.9349)',
34
+ },
35
+ },
36
+ {
37
+ label: 'Tangerine',
38
+ value: 'tangerine',
39
+ primary: {
40
+ light: 'oklch(0.64 0.17 36.44)',
41
+ dark: 'oklch(0.64 0.17 36.44)',
42
+ },
43
+ },
44
+ ] as const
45
+
46
+ export const THEME_PRESET_VALUES = THEME_PRESET_OPTIONS.map((p) => p.value)
47
+
48
+ export type ThemePreset = (typeof THEME_PRESET_OPTIONS)[number]['value']
49
+
50
+ // --- generated:themePresets:end ---
@@ -0,0 +1,45 @@
1
+ import { type Editor } from "@tiptap/core";
2
+
3
+ export const NODE_HANDLES_SELECTED_STYLE_CLASSNAME =
4
+ "node-handles-selected-style";
5
+
6
+ export function isValidUrl(url: string) {
7
+ return /^https?:\/\/\S+$/.test(url);
8
+ }
9
+
10
+ export const duplicateContent = (editor: Editor) => {
11
+ const { view } = editor;
12
+ const { state } = view;
13
+ const { selection } = state;
14
+
15
+ editor
16
+ .chain()
17
+ .insertContentAt(
18
+ selection.to,
19
+ /* eslint-disable */
20
+ // @ts-nocheck
21
+ selection.content().content.firstChild?.toJSON(),
22
+ {
23
+ updateSelection: true,
24
+ }
25
+ )
26
+ .focus(selection.to)
27
+ .run();
28
+ };
29
+
30
+ export function getUrlFromString(str: string) {
31
+ if (isValidUrl(str)) {
32
+ return str;
33
+ }
34
+ try {
35
+ if (str.includes(".") && !str.includes(" ")) {
36
+ return new URL(`https://${str}`).toString();
37
+ }
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function absoluteUrl(path: string) {
44
+ return `${process.env.NEXT_PUBLIC_APP_URL}${path}`;
45
+ }
@@ -0,0 +1,11 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ // pnpx shadcn add dropdown-menu tooltip scroll-area popover separator dropdown-menu label input drawer checkbox separator command tabs dropdown-menu
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }
8
+
9
+ export function absoluteUrl(path: string) {
10
+ return `${process.env.NEXT_PUBLIC_APP_URL}${path}`
11
+ }
@@ -0,0 +1,9 @@
1
+ import ExampleEditor from './components/rich-text-editor'
2
+
3
+ export default function Page() {
4
+ return (
5
+ <div className="mx-auto w-full container flex flex-col justify-center items-center py-5">
6
+ <ExampleEditor />
7
+ </div>
8
+ )
9
+ }
package/registry.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "Editor X",
3
+ "homepage": "https://open-cloud-initiative.github.io/editor-x",
4
+ "items": [
5
+ {
6
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
7
+ "name": "editor-x",
8
+ "type": "registry:ui",
9
+ "title": "editor-x",
10
+ "author": "Sebastian Döll (@katallaxie)",
11
+ "dependencies": [
12
+ "@floating-ui/dom",
13
+ "@radix-ui/react-checkbox",
14
+ "@radix-ui/react-collapsible",
15
+ "@radix-ui/react-dialog",
16
+ "@radix-ui/react-dropdown-menu",
17
+ "@radix-ui/react-label",
18
+ "@radix-ui/react-popover",
19
+ "@radix-ui/react-scroll-area",
20
+ "@radix-ui/react-separator",
21
+ "@radix-ui/react-slot",
22
+ "@radix-ui/react-tabs",
23
+ "@radix-ui/react-toggle",
24
+ "@radix-ui/react-toggle-group",
25
+ "@radix-ui/react-tooltip",
26
+ "@tiptap/core",
27
+ "@tiptap/extension-code-block-lowlight",
28
+ "@tiptap/extension-color",
29
+ "@tiptap/extension-floating-menu",
30
+ "@tiptap/extension-heading",
31
+ "@tiptap/extension-image",
32
+ "@tiptap/extension-link",
33
+ "@tiptap/extension-list",
34
+ "@tiptap/extension-placeholder",
35
+ "@tiptap/extension-subscript",
36
+ "@tiptap/extension-superscript",
37
+ "@tiptap/extension-table",
38
+ "@tiptap/extension-text-align",
39
+ "@tiptap/extension-text-style",
40
+ "@tiptap/extension-typography",
41
+ "@tiptap/extension-underline",
42
+ "@tiptap/markdown",
43
+ "@tiptap/pm",
44
+ "@tiptap/react",
45
+ "@tiptap/starter-kit",
46
+ "@tiptap/suggestion",
47
+ "class-variance-authority",
48
+ "clsx",
49
+ "cmdk",
50
+ "fuse.js",
51
+ "lowlight",
52
+ "lucide-react",
53
+ "next",
54
+ "next-themes",
55
+ "postcss-nested",
56
+ "react-resizable-panels",
57
+ "shadcn",
58
+ "tailwind-merge",
59
+ "tailwindcss-animate",
60
+ "tippy.js",
61
+ "vaul",
62
+ "zustand"
63
+ ],
64
+ "devDependencies": [
65
+ "@eslint/eslintrc",
66
+ "@tailwindcss/postcss",
67
+ "@tiptap/extension-highlight",
68
+ "@total-typescript/ts-reset",
69
+ "@types/node",
70
+ "@typescript-eslint/parser",
71
+ "babel-plugin-react-compiler",
72
+ "eslint",
73
+ "eslint-config-google",
74
+ "eslint-config-next",
75
+ "eslint-config-prettier",
76
+ "eslint-import-resolver-typescript",
77
+ "eslint-plugin-import",
78
+ "eslint-plugin-prettier",
79
+ "postcss",
80
+ "prettier",
81
+ "prettier-eslint",
82
+ "prettier-plugin-tailwindcss",
83
+ "tailwindcss",
84
+ "tw-animate-css"
85
+ ],
86
+ "registryDependencies": [],
87
+ "files": [],
88
+ "css": {
89
+
90
+ }
91
+ }
92
+ ]
93
+ }
package/reset.d.ts ADDED
@@ -0,0 +1 @@
1
+ import "@total-typescript/ts-reset";
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Script: generate-theme-presets.ts
3
+ *
4
+ * This script scans the /styles/presets directory for CSS files containing theme definitions.
5
+ * It extracts `label:`, `value:`, and primary color definitions (`--primary`) for both light and dark modes.
6
+ * These primary colors are used to visually represent each theme in the UI (e.g., colored dots or theme previews).
7
+ * Default theme colors are fetched from /app/globals.css.
8
+ * All extracted metadata is injected into a marked section of the /lib/preferences/theme.ts file.
9
+ *
10
+ * Usage:
11
+ * - During local development, run manually after adding any new theme preset:
12
+ * npm run generate:presets
13
+ * - Ensure that each new CSS preset includes `label:` and `value:` comments.
14
+ * - This generation step is currently automated using a Husky pre-push hook.
15
+ * - You may optionally integrate it directly into a build step if preferred.
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process'
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+
22
+ const presetDir = path.resolve(__dirname, '../styles/presets')
23
+
24
+ if (!fs.existsSync(presetDir)) {
25
+ console.error(`❌ Preset directory not found at: ${presetDir}`)
26
+ process.exit(1)
27
+ }
28
+
29
+ const outputPath = path.resolve(__dirname, '../lib/preferences/theme.ts')
30
+
31
+ const files = fs.readdirSync(presetDir).filter((file) => file.endsWith('.css'))
32
+
33
+ if (files.length === 0) {
34
+ console.warn('⚠️ No preset CSS files found. Only default preset will be included.')
35
+ }
36
+
37
+ const presets = files.map((file) => {
38
+ const filePath = path.join(presetDir, file)
39
+ const content = fs.readFileSync(filePath, 'utf8')
40
+
41
+ const labelMatch = content.match(/label:\s*(.+)/)
42
+ const valueMatch = content.match(/value:\s*(.+)/)
43
+
44
+ if (!labelMatch) {
45
+ console.warn(`⚠️ No 'label:' found in ${file}, using filename as fallback.`)
46
+ }
47
+ if (!valueMatch) {
48
+ console.warn(`⚠️ No 'value:' found in ${file}, using filename as fallback.`)
49
+ }
50
+
51
+ const label = labelMatch?.[1]?.trim() ?? file.replace('.css', '')
52
+ const value = valueMatch?.[1]?.trim() ?? file.replace('.css', '')
53
+
54
+ const lightPrimaryMatch = content.match(/:root\[data-theme-preset="[^"]*"\][\s\S]*?--primary:\s*([^;]+);/)
55
+ const darkPrimaryMatch = content.match(/\.dark:root\[data-theme-preset="[^"]*"\][\s\S]*?--primary:\s*([^;]+);/)
56
+
57
+ const primary = {
58
+ light: lightPrimaryMatch?.[1]?.trim() ?? '',
59
+ dark: darkPrimaryMatch?.[1]?.trim() ?? '',
60
+ }
61
+
62
+ if (!lightPrimaryMatch || !darkPrimaryMatch) {
63
+ console.warn(`⚠️ Missing --primary for ${file} (light or dark). Check CSS syntax.`)
64
+ }
65
+
66
+ return { label, value, primary }
67
+ })
68
+
69
+ const globalStylesPath = path.resolve(__dirname, '../app/globals.css')
70
+
71
+ let globalContent = ''
72
+ try {
73
+ globalContent = fs.readFileSync(globalStylesPath, 'utf8')
74
+ } catch (err) {
75
+ console.error(`❌ Could not read globals.css at ${globalStylesPath}`)
76
+ console.error(err)
77
+ process.exit(1)
78
+ }
79
+
80
+ const defaultLightPrimaryRegex = /:root\s*{[^}]*--primary:\s*([^;]+);/
81
+ const defaultDarkPrimaryRegex = /\.dark\s*{[^}]*--primary:\s*([^;]+);/
82
+
83
+ const defaultLightPrimaryMatch = defaultLightPrimaryRegex.exec(globalContent)
84
+ const defaultDarkPrimaryMatch = defaultDarkPrimaryRegex.exec(globalContent)
85
+
86
+ const defaultPrimary = {
87
+ light: defaultLightPrimaryMatch?.[1]?.trim() ?? '',
88
+ dark: defaultDarkPrimaryMatch?.[1]?.trim() ?? '',
89
+ }
90
+
91
+ presets.unshift({ label: 'Default', value: 'default', primary: defaultPrimary })
92
+
93
+ const generatedBlock = `// --- generated:themePresets:start ---
94
+
95
+ export const THEME_PRESET_OPTIONS = ${JSON.stringify(presets, null, 2)} as const;
96
+
97
+ export const THEME_PRESET_VALUES = THEME_PRESET_OPTIONS.map((p) => p.value);
98
+
99
+ export type ThemePreset = (typeof THEME_PRESET_OPTIONS)[number]["value"];
100
+
101
+ // --- generated:themePresets:end ---`
102
+
103
+ const fileContent = fs.readFileSync(outputPath, 'utf8')
104
+
105
+ const updated = fileContent.replace(
106
+ /\/\/ --- generated:themePresets:start ---[\s\S]*?\/\/ --- generated:themePresets:end ---/,
107
+ generatedBlock,
108
+ )
109
+
110
+ async function main() {
111
+ const formatted = execFileSync('npx', ['@biomejs/biome', 'format', '--stdin-file-path', outputPath], {
112
+ input: updated,
113
+ encoding: 'utf8',
114
+ })
115
+
116
+ if (formatted === fileContent) {
117
+ console.log('ℹ️ No changes in theme.ts')
118
+ return
119
+ }
120
+
121
+ fs.writeFileSync(outputPath, formatted)
122
+ console.log('✅ theme.ts updated with new theme presets')
123
+ }
124
+
125
+ main().catch((err) => {
126
+ console.error('❌ Unexpected error while generating theme presets:', err)
127
+ process.exit(1)
128
+ })
File without changes
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Boot script that reads user preference values (theme mode, theme preset,
3
+ * content layout, navbar style) from cookies or localStorage based on the
4
+ * configured persistence mode.
5
+ *
6
+ * Runs early in <head> to apply the correct data attributes before hydration,
7
+ * preventing layout or theme flicker and keeping RootLayout fully static.
8
+ */
9
+ import { PREFERENCE_DEFAULTS, PREFERENCE_PERSISTENCE } from '@/lib/preferences-config'
10
+
11
+ export function ThemeBootScript() {
12
+ const persistence = JSON.stringify({
13
+ theme_mode: PREFERENCE_PERSISTENCE.theme_mode,
14
+ theme_preset: PREFERENCE_PERSISTENCE.theme_preset,
15
+ })
16
+
17
+ const defaults = JSON.stringify({
18
+ theme_mode: PREFERENCE_DEFAULTS.theme_mode,
19
+ theme_preset: PREFERENCE_DEFAULTS.theme_preset,
20
+ })
21
+
22
+ const code = `
23
+ (function () {
24
+ try {
25
+ var root = document.documentElement;
26
+ var PERSISTENCE = ${persistence};
27
+ var DEFAULTS = ${defaults};
28
+
29
+ function readCookie(name) {
30
+ var match = document.cookie.split("; ").find(function(c) {
31
+ return c.indexOf(name + "=") === 0;
32
+ });
33
+ return match ? match.split("=")[1] : null;
34
+ }
35
+
36
+ function readLocal(name) {
37
+ try {
38
+ return window.localStorage.getItem(name);
39
+ } catch (e) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function readPreference(key, fallback) {
45
+ var mode = PERSISTENCE[key];
46
+ var value = null;
47
+
48
+ if (mode === "localStorage") {
49
+ value = readLocal(key);
50
+ }
51
+
52
+ if (!value && (mode === "client-cookie" || mode === "server-cookie")) {
53
+ value = readCookie(key);
54
+ }
55
+
56
+ if (!value || typeof value !== "string") {
57
+ return fallback;
58
+ }
59
+
60
+ return value;
61
+ }
62
+
63
+ var rawMode = readPreference("theme_mode", DEFAULTS.theme_mode);
64
+ var rawPreset = readPreference("theme_preset", DEFAULTS.theme_preset);
65
+ var rawContentLayout = readPreference("content_layout", DEFAULTS.content_layout);
66
+ var rawNavbarStyle = readPreference("navbar_style", DEFAULTS.navbar_style);
67
+ var rawSidebarVariant = readPreference("sidebar_variant", DEFAULTS.sidebar_variant);
68
+ var rawSidebarCollapsible = readPreference("sidebar_collapsible", DEFAULTS.sidebar_collapsible);
69
+
70
+ var mode = (rawMode === "dark" || rawMode === "light") ? rawMode : "light";
71
+ var preset = rawPreset || DEFAULTS.theme_preset;
72
+ var contentLayout = rawContentLayout || DEFAULTS.content_layout;
73
+ var navbarStyle = rawNavbarStyle || DEFAULTS.navbar_style;
74
+ var sidebarVariant = rawSidebarVariant || DEFAULTS.sidebar_variant;
75
+ var sidebarCollapsible = rawSidebarCollapsible || DEFAULTS.sidebar_collapsible;
76
+
77
+ root.classList.remove("light", "dark");
78
+ root.classList.add(mode);
79
+ root.setAttribute("data-theme-preset", preset);
80
+ root.setAttribute("data-content-layout", contentLayout);
81
+ root.setAttribute("data-navbar-style", navbarStyle);
82
+ root.setAttribute("data-sidebar-variant", sidebarVariant);
83
+ root.setAttribute("data-sidebar-collapsible", sidebarCollapsible);
84
+
85
+ root.style.colorScheme = mode === "dark" ? "dark" : "light";
86
+
87
+ var prefs = {
88
+ themeMode: mode,
89
+ themePreset: preset,
90
+ contentLayout: contentLayout,
91
+ navbarStyle: navbarStyle,
92
+ sidebarVariant: sidebarVariant,
93
+ sidebarCollapsible: sidebarCollapsible,
94
+ };
95
+
96
+ window.__PREFERENCES__ = prefs;
97
+ } catch (e) {
98
+ console.warn("ThemeBootScript error:", e);
99
+ }
100
+ })();
101
+ `
102
+
103
+ /* biome-ignore lint/security/noDangerouslySetInnerHtml: required for pre-hydration boot script */
104
+ return <script dangerouslySetInnerHTML={{ __html: code }} />
105
+ }
@@ -0,0 +1,27 @@
1
+ 'use server'
2
+
3
+ import { cookies } from 'next/headers';
4
+
5
+ export async function getValueFromCookie(key: string): Promise<string | undefined> {
6
+ const cookieStore = await cookies()
7
+ return cookieStore.get(key)?.value
8
+ }
9
+
10
+ export async function setValueToCookie(
11
+ key: string,
12
+ value: string,
13
+ options: { path?: string; maxAge?: number } = {},
14
+ ): Promise<void> {
15
+ const cookieStore = await cookies()
16
+ cookieStore.set(key, value, {
17
+ path: options.path ?? '/',
18
+ maxAge: options.maxAge ?? 60 * 60 * 24 * 7, // default: 7 days
19
+ })
20
+ }
21
+
22
+ export async function getPreference<T extends string>(key: string, allowed: readonly T[], fallback: T): Promise<T> {
23
+ const cookieStore = await cookies()
24
+ const cookie = cookieStore.get(key)
25
+ const value = cookie ? cookie.value.trim() : undefined
26
+ return allowed.includes(value as T) ? (value as T) : fallback
27
+ }
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { THEME_PRESET_VALUES } from '@/lib/theme'
4
+ import { createContext, useContext, useEffect, useState } from 'react'
5
+ import { type StoreApi, useStore } from 'zustand'
6
+
7
+ import { createPreferencesStore, type PreferencesState } from './preferences-store'
8
+
9
+ const PreferencesStoreContext = createContext<StoreApi<PreferencesState> | null>(null)
10
+
11
+ function getSafeValue<T extends string>(raw: string | null, allowed: readonly T[]): T | undefined {
12
+ if (!raw) return undefined
13
+ return allowed.includes(raw as T) ? (raw as T) : undefined
14
+ }
15
+
16
+ function readDomState(): Partial<PreferencesState> {
17
+ const root = document.documentElement
18
+
19
+ const mode = root.classList.contains('dark') ? 'dark' : 'light'
20
+
21
+ return {
22
+ themeMode: mode,
23
+ themePreset: getSafeValue(root.getAttribute('data-theme-preset'), THEME_PRESET_VALUES),
24
+ }
25
+ }
26
+
27
+ export const PreferencesStoreProvider = ({
28
+ children,
29
+ themeMode,
30
+ themePreset,
31
+ }: {
32
+ children: React.ReactNode
33
+ themeMode: PreferencesState['themeMode']
34
+ themePreset: PreferencesState['themePreset']
35
+ }) => {
36
+ const [store] = useState<StoreApi<PreferencesState>>(() => createPreferencesStore({ themeMode, themePreset }))
37
+
38
+ useEffect(() => {
39
+ const domState = readDomState()
40
+
41
+ store.setState((prev) => ({
42
+ ...prev,
43
+ ...domState,
44
+ isSynced: true,
45
+ }))
46
+ }, [store])
47
+
48
+ return <PreferencesStoreContext.Provider value={store}>{children}</PreferencesStoreContext.Provider>
49
+ }
50
+
51
+ export const usePreferencesStore = <T,>(selector: (state: PreferencesState) => T): T => {
52
+ const store = useContext(PreferencesStoreContext)
53
+ if (!store) throw new Error('Missing PreferencesStoreProvider')
54
+ return useStore(store, selector)
55
+ }
@@ -0,0 +1,23 @@
1
+ import { createStore } from 'zustand/vanilla'
2
+
3
+ import { PREFERENCE_DEFAULTS } from '@/lib/preferences-config'
4
+ import type { ThemeMode, ThemePreset } from '@/lib/theme'
5
+
6
+ export type PreferencesState = {
7
+ themeMode: ThemeMode
8
+ themePreset: ThemePreset
9
+ setThemeMode: (mode: ThemeMode) => void
10
+ setThemePreset: (preset: ThemePreset) => void
11
+ isSynced: boolean
12
+ setIsSynced: (val: boolean) => void
13
+ }
14
+
15
+ export const createPreferencesStore = (init?: Partial<PreferencesState>) =>
16
+ createStore<PreferencesState>()((set) => ({
17
+ themeMode: init?.themeMode ?? PREFERENCE_DEFAULTS.theme_mode,
18
+ themePreset: init?.themePreset ?? PREFERENCE_DEFAULTS.theme_preset,
19
+ setThemeMode: (mode) => set({ themeMode: mode }),
20
+ setThemePreset: (preset) => set({ themePreset: preset }),
21
+ isSynced: init?.isSynced ?? false,
22
+ setIsSynced: (val) => set({ isSynced: val }),
23
+ }))