@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
@@ -0,0 +1,514 @@
1
+ "use client";
2
+ import React, {
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { Button } from "./button.js";
11
+ import { Dialog } from "./dialog.js";
12
+ import { Input } from "./input.js";
13
+
14
+ /**
15
+ * RichText - dependency-free rich text editor using contentEditable.
16
+ *
17
+ * Props:
18
+ * id (string) - id applied to the editor div for label association
19
+ * name (string) - form field name (value submitted as HTML string)
20
+ * defaultValue (string) - initial HTML content
21
+ * placeholder (string) - placeholder text when empty
22
+ * disabled (bool) - read-only mode
23
+ * required (bool) - form validation
24
+ * rows (number) - approximate visible rows (default 6)
25
+ * className (string) - merged onto the outer wrapper
26
+ * onChange (fn) - called with HTML string on content change
27
+ */
28
+
29
+ const wrapperCls =
30
+ "rounded-[--radius-default] border border-[--richtext-border] bg-[--richtext-bg] text-sm text-[--richtext-text] transition-colors duration-200 focus-within:border-[--richtext-border-focus] focus-within:ring-2 focus-within:ring-[var(--richtext-ring)] overflow-hidden";
31
+
32
+ const toolbarCls =
33
+ "flex flex-wrap items-center border-b border-[--richtext-border] bg-[--richtext-toolbar-bg]";
34
+
35
+ const btnBase =
36
+ "inline-flex items-center justify-center rounded-[--radius-default] text-[--richtext-btn-text] transition-colors cursor-pointer hover:bg-[--richtext-btn-hover-bg] hover:text-[--richtext-btn-hover-text] disabled:opacity-30 disabled:cursor-not-allowed";
37
+
38
+ const btnActive =
39
+ "bg-[--richtext-btn-active-bg] text-[--richtext-btn-active-text]";
40
+
41
+ const separatorCls = "bg-[--richtext-separator]";
42
+
43
+ const editorCls =
44
+ "outline-none px-3 py-2 overflow-y-auto prose-sm [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:my-0.5 [&_a]:text-[--richtext-link] [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-[--richtext-blockquote-border] [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-[--richtext-blockquote-text] [&_blockquote]:my-2 [&_code]:bg-[--richtext-code-bg] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono";
45
+
46
+ const placeholderCls =
47
+ "before:content-[attr(data-placeholder)] before:text-[--richtext-placeholder] before:pointer-events-none before:absolute before:left-3 before:top-2";
48
+
49
+ const TOOLBAR_ITEMS = [
50
+ { command: "bold", icon: "B", title: "Bold", style: "font-bold" },
51
+ { command: "italic", icon: "I", title: "Italic", style: "italic" },
52
+ {
53
+ command: "strikeThrough",
54
+ icon: "S",
55
+ title: "Strikethrough",
56
+ style: "line-through",
57
+ },
58
+ "separator",
59
+ {
60
+ command: "formatBlock",
61
+ arg: "h1",
62
+ icon: "H1",
63
+ title: "Heading 1",
64
+ style: "text-xs font-bold",
65
+ },
66
+ {
67
+ command: "formatBlock",
68
+ arg: "h2",
69
+ icon: "H2",
70
+ title: "Heading 2",
71
+ style: "text-xs font-bold",
72
+ },
73
+ {
74
+ command: "formatBlock",
75
+ arg: "h3",
76
+ icon: "H3",
77
+ title: "Heading 3",
78
+ style: "text-xs font-bold",
79
+ },
80
+ "separator",
81
+ {
82
+ command: "insertUnorderedList",
83
+ icon: "ul",
84
+ title: "Bullet list",
85
+ svg: true,
86
+ },
87
+ {
88
+ command: "insertOrderedList",
89
+ icon: "ol",
90
+ title: "Numbered list",
91
+ svg: true,
92
+ },
93
+ "separator",
94
+ { command: "formatBlock", arg: "blockquote", icon: "❝", title: "Quote" },
95
+ { command: "createLink", icon: "🔗", title: "Insert link", isLink: true },
96
+ ];
97
+
98
+ /**
99
+ * @param {string} command
100
+ * @returns {boolean}
101
+ */
102
+ function queryActive(command) {
103
+ try {
104
+ return document.queryCommandState(command);
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ export function RichText({
111
+ id,
112
+ name,
113
+ defaultValue = "",
114
+ placeholder = "",
115
+ disabled = false,
116
+ required = false,
117
+ rows = 6,
118
+ className = "",
119
+ onChange,
120
+ }) {
121
+ const editorRef = useRef(null);
122
+ const hiddenRef = useRef(null);
123
+ const [activeStates, setActiveStates] = useState({});
124
+ const [isEmpty, setIsEmpty] = useState(!defaultValue);
125
+ const [linkDialogOpen, setLinkDialogOpen] = useState(false);
126
+ const [linkUrl, setLinkUrl] = useState("");
127
+ const [linkError, setLinkError] = useState("");
128
+ const savedRangeRef = useRef(null);
129
+ const linkInputId = useId();
130
+
131
+ // Sync hidden input value with editor content
132
+ const syncValue = useCallback(() => {
133
+ const el = editorRef.current;
134
+ if (!el) return;
135
+ const html = el.innerHTML;
136
+ const textEmpty =
137
+ !el.textContent?.trim() && !el.querySelector("img,hr,table");
138
+ setIsEmpty(textEmpty);
139
+ if (hiddenRef.current) {
140
+ hiddenRef.current.value = textEmpty ? "" : html;
141
+ }
142
+ onChange?.(textEmpty ? "" : html);
143
+ }, [onChange]);
144
+
145
+ // Update toolbar active states
146
+ const updateToolbar = useCallback(() => {
147
+ setActiveStates({
148
+ bold: queryActive("bold"),
149
+ italic: queryActive("italic"),
150
+ strikeThrough: queryActive("strikeThrough"),
151
+ insertUnorderedList: queryActive("insertUnorderedList"),
152
+ insertOrderedList: queryActive("insertOrderedList"),
153
+ });
154
+ }, []);
155
+
156
+ // Set initial content before the browser paints to prevent a flash of
157
+ // empty editor on first mount (same fix as Dialog's showModal() call).
158
+ const initializedRef = useRef(false);
159
+ useLayoutEffect(() => {
160
+ if (!initializedRef.current && editorRef.current && defaultValue) {
161
+ initializedRef.current = true;
162
+ editorRef.current.innerHTML = sanitizeHtml(defaultValue);
163
+ syncValue();
164
+ }
165
+ }, [defaultValue, syncValue]);
166
+
167
+ const exec = useCallback(
168
+ (command, arg) => {
169
+ if (disabled) return;
170
+ editorRef.current?.focus();
171
+ if (command === "createLink") {
172
+ const sel = window.getSelection();
173
+ if (sel && sel.rangeCount > 0) {
174
+ savedRangeRef.current = sel.getRangeAt(0).cloneRange();
175
+ }
176
+ setLinkUrl("");
177
+ setLinkError("");
178
+ setLinkDialogOpen(true);
179
+ return;
180
+ } else if (command === "formatBlock") {
181
+ document.execCommand("formatBlock", false, `<${arg}>`);
182
+ } else {
183
+ document.execCommand(command, false, arg ?? null);
184
+ }
185
+ syncValue();
186
+ updateToolbar();
187
+ },
188
+ [disabled, syncValue, updateToolbar],
189
+ );
190
+
191
+ const confirmLink = useCallback(() => {
192
+ const trimmed = linkUrl.trim();
193
+ if (!trimmed) {
194
+ setLinkError("Please enter a URL.");
195
+ return;
196
+ }
197
+ if (!isSafeUrl(trimmed)) {
198
+ setLinkError("Only http:// and https:// URLs are allowed.");
199
+ return;
200
+ }
201
+ const sel = window.getSelection();
202
+ if (sel && savedRangeRef.current) {
203
+ sel.removeAllRanges();
204
+ sel.addRange(savedRangeRef.current);
205
+ }
206
+ editorRef.current?.focus();
207
+ document.execCommand("createLink", false, trimmed);
208
+ setLinkDialogOpen(false);
209
+ setLinkUrl("");
210
+ setLinkError("");
211
+ savedRangeRef.current = null;
212
+ syncValue();
213
+ updateToolbar();
214
+ }, [linkUrl, syncValue, updateToolbar]);
215
+
216
+ const cancelLink = useCallback(() => {
217
+ setLinkDialogOpen(false);
218
+ setLinkUrl("");
219
+ setLinkError("");
220
+ savedRangeRef.current = null;
221
+ }, []);
222
+
223
+ useEffect(() => {
224
+ if (!linkDialogOpen) return;
225
+ const handle = requestAnimationFrame(() => {
226
+ document.getElementById(linkInputId)?.focus();
227
+ });
228
+ return () => cancelAnimationFrame(handle);
229
+ }, [linkDialogOpen, linkInputId]);
230
+
231
+ const onInput = useCallback(() => {
232
+ syncValue();
233
+ updateToolbar();
234
+ }, [syncValue, updateToolbar]);
235
+
236
+ const onKeyUp = useCallback(() => {
237
+ updateToolbar();
238
+ }, [updateToolbar]);
239
+
240
+ const onMouseUp = useCallback(() => {
241
+ updateToolbar();
242
+ }, [updateToolbar]);
243
+
244
+ // Prevent pasting formatted HTML - paste as clean HTML but strip dangerous tags
245
+ const onPaste = useCallback(
246
+ (e) => {
247
+ e.preventDefault();
248
+ const html = e.clipboardData?.getData("text/html");
249
+ const text = e.clipboardData?.getData("text/plain") ?? "";
250
+ if (html) {
251
+ // Sanitize: strip script, style, event handlers
252
+ const clean = sanitizeHtml(html);
253
+ document.execCommand("insertHTML", false, clean);
254
+ } else {
255
+ document.execCommand("insertText", false, text);
256
+ }
257
+ syncValue();
258
+ },
259
+ [syncValue],
260
+ );
261
+
262
+ const minHeight = rows * 24;
263
+
264
+ // Toolbar
265
+ const toolbar = React.createElement(
266
+ "div",
267
+ {
268
+ className: toolbarCls,
269
+ role: "toolbar",
270
+ "aria-label": "Text formatting",
271
+ style: {
272
+ paddingLeft: "var(--richtext-toolbar-padding-x)",
273
+ paddingRight: "var(--richtext-toolbar-padding-x)",
274
+ paddingTop: "var(--richtext-toolbar-padding-y)",
275
+ paddingBottom: "var(--richtext-toolbar-padding-y)",
276
+ gap: "var(--richtext-toolbar-gap)",
277
+ },
278
+ },
279
+ ...TOOLBAR_ITEMS.map((item, i) => {
280
+ if (item === "separator") {
281
+ return React.createElement("div", {
282
+ key: `sep-${i}`,
283
+ className: separatorCls,
284
+ role: "separator",
285
+ style: {
286
+ width: "var(--richtext-separator-width)",
287
+ height: "var(--richtext-separator-height)",
288
+ marginLeft: "var(--richtext-separator-margin-x)",
289
+ marginRight: "var(--richtext-separator-margin-x)",
290
+ },
291
+ });
292
+ }
293
+ const isActive = activeStates[item.command] || false;
294
+ const btnCls = `${btnBase} ${isActive ? btnActive : ""}`;
295
+
296
+ let content;
297
+ if (item.svg) {
298
+ content =
299
+ item.icon === "ul"
300
+ ? React.createElement(
301
+ "svg",
302
+ {
303
+ "aria-hidden": "true",
304
+ className: "w-4 h-4",
305
+ fill: "none",
306
+ viewBox: "0 0 24 24",
307
+ stroke: "currentColor",
308
+ strokeWidth: 2,
309
+ },
310
+ React.createElement("path", {
311
+ strokeLinecap: "round",
312
+ strokeLinejoin: "round",
313
+ d: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
314
+ }),
315
+ )
316
+ : React.createElement(
317
+ "svg",
318
+ {
319
+ "aria-hidden": "true",
320
+ className: "w-4 h-4",
321
+ fill: "none",
322
+ viewBox: "0 0 24 24",
323
+ stroke: "currentColor",
324
+ strokeWidth: 2,
325
+ },
326
+ React.createElement("path", {
327
+ strokeLinecap: "round",
328
+ strokeLinejoin: "round",
329
+ d: "M10 6h11M10 12h11M10 18h11M4 6V4l2 2M4 12v-1l1 1 1-1v1M4 18v-2h2v1H4v1h2",
330
+ }),
331
+ );
332
+ } else {
333
+ content = React.createElement(
334
+ "span",
335
+ { className: item.style || "text-xs" },
336
+ item.icon,
337
+ );
338
+ }
339
+
340
+ return React.createElement(
341
+ "button",
342
+ {
343
+ key: item.command + (item.arg || ""),
344
+ type: "button",
345
+ title: item.title,
346
+ "aria-label": item.title,
347
+ "aria-pressed": isActive,
348
+ disabled,
349
+ className: btnCls,
350
+ style: {
351
+ width: "var(--richtext-btn-size)",
352
+ height: "var(--richtext-btn-size)",
353
+ },
354
+ onMouseDown: (e) => {
355
+ e.preventDefault(); // prevent editor blur
356
+ exec(item.command, item.arg);
357
+ },
358
+ },
359
+ content,
360
+ );
361
+ }),
362
+ );
363
+
364
+ // Editor area
365
+ const editor = React.createElement("div", {
366
+ id,
367
+ ref: editorRef,
368
+ contentEditable: !disabled,
369
+ suppressContentEditableWarning: true,
370
+ onInput,
371
+ onKeyUp,
372
+ onMouseUp,
373
+ onPaste,
374
+ role: "textbox",
375
+ "aria-multiline": "true",
376
+ "aria-label": name || "Rich text editor",
377
+ "data-placeholder": placeholder,
378
+ className: `${editorCls} relative ${isEmpty ? placeholderCls : ""}`,
379
+ style: { minHeight: `${minHeight}px` },
380
+ });
381
+
382
+ // Hidden input for form submission
383
+ const hidden = React.createElement("input", {
384
+ ref: hiddenRef,
385
+ type: "hidden",
386
+ name,
387
+ defaultValue: defaultValue || "",
388
+ required,
389
+ });
390
+
391
+ return React.createElement(
392
+ "div",
393
+ {
394
+ className:
395
+ `${wrapperCls} ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`.trim(),
396
+ },
397
+ toolbar,
398
+ editor,
399
+ hidden,
400
+ React.createElement(
401
+ Dialog,
402
+ { open: linkDialogOpen, onClose: cancelLink, title: "Insert Link" },
403
+ React.createElement(
404
+ "div",
405
+ { className: "space-y-4" },
406
+ React.createElement(
407
+ "div",
408
+ null,
409
+ React.createElement(
410
+ "label",
411
+ {
412
+ htmlFor: linkInputId,
413
+ className: "block text-sm font-medium text-text mb-1.5",
414
+ },
415
+ "Link URL",
416
+ ),
417
+ React.createElement(Input, {
418
+ id: linkInputId,
419
+ type: "url",
420
+ placeholder: "https://example.com",
421
+ value: linkUrl,
422
+ onChange: (e) => {
423
+ setLinkUrl(e.target.value);
424
+ setLinkError("");
425
+ },
426
+ onKeyDown: (e) => {
427
+ if (e.key === "Enter") {
428
+ e.preventDefault();
429
+ confirmLink();
430
+ }
431
+ },
432
+ }),
433
+ linkError
434
+ ? React.createElement(
435
+ "p",
436
+ { className: "mt-1.5 text-xs text-[--color-danger]" },
437
+ linkError,
438
+ )
439
+ : null,
440
+ ),
441
+ React.createElement(
442
+ "div",
443
+ { className: "flex justify-end gap-2 pt-1" },
444
+ React.createElement(
445
+ Button,
446
+ { variant: "primary", type: "button", onClick: confirmLink },
447
+ "Insert",
448
+ ),
449
+ React.createElement(
450
+ Button,
451
+ { variant: "secondary", type: "button", onClick: cancelLink },
452
+ "Cancel",
453
+ ),
454
+ ),
455
+ ),
456
+ ),
457
+ );
458
+ }
459
+
460
+ /**
461
+ * Sanitize HTML by stripping dangerous elements and attributes.
462
+ * @param {string} html
463
+ * @returns {string}
464
+ */
465
+ function sanitizeHtml(html) {
466
+ const parser = new DOMParser();
467
+ const doc = parser.parseFromString(html, "text/html");
468
+
469
+ // Remove dangerous elements
470
+ const dangerous = doc.querySelectorAll(
471
+ "script,style,iframe,object,embed,form,input,textarea,select,button,link,meta",
472
+ );
473
+ for (const el of dangerous) el.remove();
474
+
475
+ // Remove event handler attributes and javascript: URLs
476
+ const all = doc.body.querySelectorAll("*");
477
+ for (const el of all) {
478
+ for (const attr of [...el.attributes]) {
479
+ if (
480
+ attr.name.startsWith("on") ||
481
+ (attr.name === "href" &&
482
+ attr.value
483
+ .replace(/\s/g, "")
484
+ .toLowerCase()
485
+ .startsWith("javascript:")) ||
486
+ (attr.name === "src" &&
487
+ attr.value.replace(/\s/g, "").toLowerCase().startsWith("javascript:"))
488
+ ) {
489
+ el.removeAttribute(attr.name);
490
+ }
491
+ }
492
+ }
493
+
494
+ return doc.body.innerHTML;
495
+ }
496
+
497
+ /**
498
+ * Returns true if the URL uses an allowed protocol (http or https only).
499
+ * Prevents javascript:, vbscript:, and data: XSS vectors.
500
+ * @param {string} url
501
+ * @returns {boolean}
502
+ */
503
+ function isSafeUrl(url) {
504
+ try {
505
+ const parsed = new URL(url);
506
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
507
+ } catch {
508
+ // Relative URLs (no protocol) are safe
509
+ return !url
510
+ .replace(/\s/g, "")
511
+ .toLowerCase()
512
+ .match(/^[a-z][a-z0-9+.-]*:/);
513
+ }
514
+ }