@hexclave/shared 1.0.14 → 1.0.15

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 (116) hide show
  1. package/dist/apps/apps-config.d.ts +6 -0
  2. package/dist/apps/apps-config.js +6 -0
  3. package/dist/apps/apps-config.js.map +1 -1
  4. package/dist/config/schema.d.ts +68 -68
  5. package/dist/config-authoring.d.ts.map +1 -1
  6. package/dist/config-authoring.js.map +1 -1
  7. package/dist/config-authoring.test.js +1 -1
  8. package/dist/config-authoring.test.js.map +1 -1
  9. package/dist/config-rendering.d.ts +2 -2
  10. package/dist/config-rendering.js +16 -0
  11. package/dist/config-rendering.js.map +1 -1
  12. package/dist/esm/apps/apps-config.d.ts +6 -0
  13. package/dist/esm/apps/apps-config.js +6 -0
  14. package/dist/esm/apps/apps-config.js.map +1 -1
  15. package/dist/esm/config/schema.d.ts +68 -68
  16. package/dist/esm/config-authoring.d.ts.map +1 -1
  17. package/dist/esm/config-authoring.js.map +1 -1
  18. package/dist/esm/config-authoring.test.js +1 -1
  19. package/dist/esm/config-authoring.test.js.map +1 -1
  20. package/dist/esm/config-rendering.d.ts +2 -2
  21. package/dist/esm/config-rendering.js +16 -2
  22. package/dist/esm/config-rendering.js.map +1 -1
  23. package/dist/esm/hexclave-config-file.d.ts +24 -1
  24. package/dist/esm/hexclave-config-file.d.ts.map +1 -1
  25. package/dist/esm/hexclave-config-file.js +69 -2
  26. package/dist/esm/hexclave-config-file.js.map +1 -1
  27. package/dist/esm/interface/admin-interface.d.ts +19 -1
  28. package/dist/esm/interface/admin-interface.d.ts.map +1 -1
  29. package/dist/esm/interface/admin-interface.js +14 -0
  30. package/dist/esm/interface/admin-interface.js.map +1 -1
  31. package/dist/esm/interface/admin-metrics.d.ts +101 -7
  32. package/dist/esm/interface/admin-metrics.d.ts.map +1 -1
  33. package/dist/esm/interface/admin-metrics.js +61 -1
  34. package/dist/esm/interface/admin-metrics.js.map +1 -1
  35. package/dist/esm/interface/conversations.d.ts +38 -38
  36. package/dist/esm/interface/crud/current-user.d.ts +2 -2
  37. package/dist/esm/interface/crud/email-outbox.d.ts +150 -150
  38. package/dist/esm/interface/crud/invoices.d.ts +2 -2
  39. package/dist/esm/interface/crud/products.d.ts +4 -4
  40. package/dist/esm/interface/crud/project-api-keys.d.ts +2 -2
  41. package/dist/esm/interface/crud/projects.d.ts +51 -51
  42. package/dist/esm/interface/crud/team-member-profiles.d.ts +10 -10
  43. package/dist/esm/interface/crud/transactions.d.ts +2 -2
  44. package/dist/esm/interface/crud/users.d.ts +10 -10
  45. package/dist/esm/interface/webhooks.d.ts +2 -2
  46. package/dist/esm/schema-fields.d.ts +3 -3
  47. package/dist/esm/sessions.d.ts +2 -2
  48. package/dist/esm/utils/analytics-clickmap-overlay.d.ts +18 -0
  49. package/dist/esm/utils/analytics-clickmap-overlay.d.ts.map +1 -0
  50. package/dist/esm/utils/analytics-clickmap-overlay.js +19 -0
  51. package/dist/esm/utils/analytics-clickmap-overlay.js.map +1 -0
  52. package/dist/esm/utils/dev-tool.d.ts +31 -0
  53. package/dist/esm/utils/dev-tool.d.ts.map +1 -0
  54. package/dist/esm/utils/dev-tool.js +32 -0
  55. package/dist/esm/utils/dev-tool.js.map +1 -0
  56. package/dist/esm/utils/dom.d.ts +7 -1
  57. package/dist/esm/utils/dom.d.ts.map +1 -1
  58. package/dist/esm/utils/dom.js +20 -1
  59. package/dist/esm/utils/dom.js.map +1 -1
  60. package/dist/esm/utils/elements-chain.d.ts +38 -0
  61. package/dist/esm/utils/elements-chain.d.ts.map +1 -0
  62. package/dist/esm/utils/elements-chain.js +297 -0
  63. package/dist/esm/utils/elements-chain.js.map +1 -0
  64. package/dist/hexclave-config-file.d.ts +24 -1
  65. package/dist/hexclave-config-file.d.ts.map +1 -1
  66. package/dist/hexclave-config-file.js +71 -1
  67. package/dist/hexclave-config-file.js.map +1 -1
  68. package/dist/interface/admin-interface.d.ts +19 -1
  69. package/dist/interface/admin-interface.d.ts.map +1 -1
  70. package/dist/interface/admin-interface.js +14 -0
  71. package/dist/interface/admin-interface.js.map +1 -1
  72. package/dist/interface/admin-metrics.d.ts +101 -7
  73. package/dist/interface/admin-metrics.d.ts.map +1 -1
  74. package/dist/interface/admin-metrics.js +65 -0
  75. package/dist/interface/admin-metrics.js.map +1 -1
  76. package/dist/interface/conversations.d.ts +38 -38
  77. package/dist/interface/crud/current-user.d.ts +2 -2
  78. package/dist/interface/crud/email-outbox.d.ts +150 -150
  79. package/dist/interface/crud/invoices.d.ts +2 -2
  80. package/dist/interface/crud/products.d.ts +4 -4
  81. package/dist/interface/crud/project-api-keys.d.ts +2 -2
  82. package/dist/interface/crud/projects.d.ts +51 -51
  83. package/dist/interface/crud/team-member-profiles.d.ts +10 -10
  84. package/dist/interface/crud/transactions.d.ts +2 -2
  85. package/dist/interface/crud/users.d.ts +10 -10
  86. package/dist/interface/webhooks.d.ts +2 -2
  87. package/dist/schema-fields.d.ts +3 -3
  88. package/dist/sessions.d.ts +2 -2
  89. package/dist/utils/analytics-clickmap-overlay.d.ts +18 -0
  90. package/dist/utils/analytics-clickmap-overlay.d.ts.map +1 -0
  91. package/dist/utils/analytics-clickmap-overlay.js +23 -0
  92. package/dist/utils/analytics-clickmap-overlay.js.map +1 -0
  93. package/dist/utils/dev-tool.d.ts +31 -0
  94. package/dist/utils/dev-tool.d.ts.map +1 -0
  95. package/dist/utils/dev-tool.js +37 -0
  96. package/dist/utils/dev-tool.js.map +1 -0
  97. package/dist/utils/dom.d.ts +7 -1
  98. package/dist/utils/dom.d.ts.map +1 -1
  99. package/dist/utils/dom.js +20 -0
  100. package/dist/utils/dom.js.map +1 -1
  101. package/dist/utils/elements-chain.d.ts +38 -0
  102. package/dist/utils/elements-chain.d.ts.map +1 -0
  103. package/dist/utils/elements-chain.js +304 -0
  104. package/dist/utils/elements-chain.js.map +1 -0
  105. package/package.json +1 -1
  106. package/src/apps/apps-config.ts +6 -0
  107. package/src/config-authoring.test.ts +1 -1
  108. package/src/config-authoring.ts +6 -6
  109. package/src/config-rendering.ts +24 -2
  110. package/src/hexclave-config-file.ts +90 -1
  111. package/src/interface/admin-interface.ts +43 -1
  112. package/src/interface/admin-metrics.ts +92 -4
  113. package/src/utils/analytics-clickmap-overlay.tsx +15 -0
  114. package/src/utils/dev-tool.tsx +30 -0
  115. package/src/utils/dom.tsx +42 -0
  116. package/src/utils/elements-chain.tsx +349 -0
@@ -100,11 +100,100 @@ function evaluateStaticConfigExpression(expression: t.Expression): unknown {
100
100
  throw new Error(`Unsupported config expression: ${unwrapped.type}`);
101
101
  }
102
102
 
103
+ /**
104
+ * Like {@link parseHexclaveConfigFileContent}, but returns `null` instead of
105
+ * throwing when the file is not a plain static config (e.g. it wraps the config
106
+ * in a helper call, references imported values, or has a syntax error). Useful
107
+ * for deciding whether a config file can be safely regenerated deterministically
108
+ * or whether it has custom structure that must be preserved.
109
+ */
110
+ export function tryParseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig | null {
111
+ try {
112
+ return parseHexclaveConfigFileContent(content, filePath);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Returns whether `content` parses as a module that exports a `config` binding.
120
+ * Used as a lightweight structural sanity check after editing config files whose
121
+ * values can't be evaluated by our loader (e.g. they import external text
122
+ * files), where a full semantic comparison isn't possible.
123
+ */
124
+ export function hexclaveConfigFileExportsConfig(content: string, filePath: string): boolean {
125
+ let ast: parser.ParseResult<t.File>;
126
+ try {
127
+ ast = parser.parse(content, {
128
+ sourceType: "module",
129
+ sourceFilename: filePath,
130
+ plugins: ["typescript", "importAttributes"],
131
+ });
132
+ } catch {
133
+ return false;
134
+ }
135
+ for (const statement of ast.program.body) {
136
+ if (!t.isExportNamedDeclaration(statement)) {
137
+ continue;
138
+ }
139
+ // Ignore type-only exports (`export type { config }`): they don't produce a
140
+ // runtime `config` value, so they must not satisfy the structural check.
141
+ if (statement.exportKind === "type") {
142
+ continue;
143
+ }
144
+ if (t.isVariableDeclaration(statement.declaration)) {
145
+ for (const declaration of statement.declaration.declarations) {
146
+ if (t.isIdentifier(declaration.id) && declaration.id.name === "config" && declaration.init != null) {
147
+ return true;
148
+ }
149
+ }
150
+ }
151
+ for (const specifier of statement.specifiers) {
152
+ if (t.isExportSpecifier(specifier) && specifier.exportKind !== "type") {
153
+ const exportedName = t.isIdentifier(specifier.exported) ? specifier.exported.name : specifier.exported.value;
154
+ if (exportedName === "config") {
155
+ return true;
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * Returns the relative import sources (those starting with `./` or `../`)
165
+ * declared in `content`. Used to discover the external files a config update may
166
+ * touch — e.g. `import x from "./welcome-email.tsx" with { type: "text" }` — so
167
+ * they can be snapshotted and rolled back if an in-place update fails. Returns
168
+ * an empty array if the file can't be parsed.
169
+ */
170
+ export function getRelativeImportSpecifiers(content: string): string[] {
171
+ let ast: parser.ParseResult<t.File>;
172
+ try {
173
+ ast = parser.parse(content, {
174
+ sourceType: "module",
175
+ plugins: ["typescript", "importAttributes"],
176
+ });
177
+ } catch {
178
+ return [];
179
+ }
180
+ const sources: string[] = [];
181
+ for (const statement of ast.program.body) {
182
+ if (t.isImportDeclaration(statement)) {
183
+ const source = statement.source.value;
184
+ if (source.startsWith("./") || source.startsWith("../")) {
185
+ sources.push(source);
186
+ }
187
+ }
188
+ }
189
+ return sources;
190
+ }
191
+
103
192
  export function parseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig {
104
193
  if (content.trim() === "") return {};
105
194
  const ast = parser.parse(content, {
106
195
  sourceType: "module",
107
- plugins: ["typescript"],
196
+ plugins: ["typescript", "importAttributes"],
108
197
  });
109
198
 
110
199
  for (const statement of ast.program.body) {
@@ -6,7 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants";
6
6
  import type { Json } from "../utils/json";
7
7
  import { Result } from "../utils/results";
8
8
  import { urlString } from "../utils/urls";
9
- import type { MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
9
+ import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
10
10
  import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics";
11
11
  import { EmailOutboxCrud } from "./crud/email-outbox";
12
12
  import { InternalEmailsCrud } from "./crud/emails";
@@ -435,6 +435,48 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
435
435
  return (await response.json()) as UserActivityResponse;
436
436
  }
437
437
 
438
+ async getAnalyticsClickmap(options: {
439
+ kind: AnalyticsClickmapKind,
440
+ member_user_ids?: string[],
441
+ route_path?: string,
442
+ route_regex?: string,
443
+ url_pattern?: string,
444
+ user_id?: string,
445
+ replay_id?: string,
446
+ device?: AnalyticsClickmapDevice,
447
+ viewport_width_min?: number,
448
+ viewport_width_max?: number,
449
+ sampling?: number,
450
+ since: string,
451
+ until: string,
452
+ }): Promise<AnalyticsClickmapResponse> {
453
+ const response = await this.sendAdminRequest(
454
+ "/internal/analytics/clickmap",
455
+ {
456
+ method: "POST",
457
+ headers: { "content-type": "application/json" },
458
+ body: JSON.stringify(options),
459
+ },
460
+ null,
461
+ );
462
+ return (await response.json()) as AnalyticsClickmapResponse;
463
+ }
464
+
465
+ async createAnalyticsClickmapToken(options: {
466
+ origin: string,
467
+ }): Promise<AnalyticsClickmapTokenResponse> {
468
+ const response = await this.sendAdminRequest(
469
+ "/internal/analytics/clickmap-token",
470
+ {
471
+ method: "POST",
472
+ headers: { "content-type": "application/json" },
473
+ body: JSON.stringify(options),
474
+ },
475
+ null,
476
+ );
477
+ return (await response.json()) as AnalyticsClickmapTokenResponse;
478
+ }
479
+
438
480
  async getMetricsUserCounts(): Promise<MetricsUserCounts> {
439
481
  const response = await this.sendAdminRequest(
440
482
  "/internal/metrics/user-counts",
@@ -140,9 +140,9 @@ export const MetricsAnalyticsOverviewSchema = yupObject({
140
140
  daily_bounce_rate: yupArray(MetricsDataPointSchema).optional().default([]),
141
141
  daily_avg_session_seconds: yupArray(MetricsDataPointSchema).optional().default([]),
142
142
  // User-Agent-derived breakdowns for the analytics overview. Computed from the
143
- // `data.user_agent` blob on `$page-view` events (captured client-side only,
144
- // no server-side fallback). Optional + default-[] for one release cycle
145
- // so older clients / servers without UA capture don't fail validation.
143
+ // `data.user_agent` blob on `$page-view` events, captured client-side.
144
+ // Optional + default-[] for one release cycle so older clients / servers
145
+ // without UA capture don't fail validation.
146
146
  top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]),
147
147
  top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]),
148
148
  top_devices: yupArray(MetricsNamedCountSchema).optional().default([]),
@@ -172,13 +172,77 @@ export const MetricsRecentUserSchema = yupObject({
172
172
  last_active_at_millis: yupNumber().nullable().defined(),
173
173
  }).noUnknown(false).defined();
174
174
 
175
- // Per-user activity heatmap — a simple list of daily event counts for a single
175
+ // Per-user activity clickmap — a simple list of daily event counts for a single
176
176
  // user. Backed by ClickHouse `analytics_internal.events` filtered by user_id,
177
177
  // project_id, and branch_id. See `/internal/user-activity` on the backend.
178
178
  export const UserActivityResponseBodySchema = yupObject({
179
179
  data_points: MetricsDataPointsSchema,
180
180
  }).defined();
181
181
 
182
+ export const AnalyticsClickmapKindSchema = yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined();
183
+ export const AnalyticsClickmapDeviceSchema = yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).defined();
184
+
185
+ export const AnalyticsClickmapTokenResponseBodySchema = yupObject({
186
+ token: yupString().defined(),
187
+ origin: yupString().defined(),
188
+ expires_at_millis: yupNumber().integer().defined(),
189
+ }).defined();
190
+
191
+ export const AnalyticsClickmapCellSchema = yupObject({
192
+ weekday: yupNumber().integer().min(1).max(7).defined(),
193
+ hour: yupNumber().integer().min(0).max(23).defined(),
194
+ value: yupNumber().integer().defined(),
195
+ }).defined();
196
+
197
+ export const AnalyticsClickmapResponseBodySchema = yupObject({
198
+ kind: AnalyticsClickmapKindSchema,
199
+ cells: yupArray(AnalyticsClickmapCellSchema).defined(),
200
+ // Fraction of source rows the result was computed from (1 = full scan).
201
+ // Returned counts are pre-scaled by 1/sampling.
202
+ sampling: yupNumber().min(0).max(1).optional().default(1),
203
+ routes: yupArray(yupObject({
204
+ path: yupString().defined(),
205
+ clicks: yupNumber().integer().defined(),
206
+ users: yupNumber().integer().defined(),
207
+ replays: yupNumber().integer().defined(),
208
+ }).defined()).optional().default([]),
209
+ users: yupArray(yupObject({
210
+ id: yupString().defined(),
211
+ display_name: yupString().nullable().defined(),
212
+ primary_email: yupString().nullable().defined(),
213
+ profile_image_url: yupString().nullable().defined(),
214
+ clicks: yupNumber().integer().defined(),
215
+ replays: yupNumber().integer().defined(),
216
+ last_event_at_millis: yupNumber().defined(),
217
+ }).defined()).optional().default([]),
218
+ replays: yupArray(yupObject({
219
+ id: yupString().defined(),
220
+ user_id: yupString().nullable().defined(),
221
+ route_path: yupString().nullable().defined(),
222
+ viewport_width: yupNumber().integer().nullable().defined(),
223
+ viewport_height: yupNumber().integer().nullable().defined(),
224
+ clicks: yupNumber().integer().defined(),
225
+ last_event_at_millis: yupNumber().defined(),
226
+ }).defined()).optional().default([]),
227
+ selectors: yupArray(yupObject({
228
+ selector: yupString().defined(),
229
+ clicks: yupNumber().integer().defined(),
230
+ }).defined()).optional().default([]),
231
+ // PostHog-style aggregated element identities. Resilient to DOM drift
232
+ // because the chain encodes ancestor tags/classes/attrs/text rather than
233
+ // a positional CSS selector.
234
+ elements: yupArray(yupObject({
235
+ elements_chain: yupString().defined(),
236
+ elements_text: yupString().defined(),
237
+ tag_name: yupString().defined(),
238
+ href: yupString().nullable().defined(),
239
+ clicks: yupNumber().integer().defined(),
240
+ // Optional for one release cycle so new overlay clients can validate
241
+ // responses from older servers that only returned aggregate click counts.
242
+ dead_clicks: yupNumber().integer().optional().default(0),
243
+ }).defined()).optional().default([]),
244
+ }).defined();
245
+
182
246
  // Recent "currently live" users keyed by ISO country code. Populated by
183
247
  // joining a bounded ClickHouse selection from the live `$token-refresh` window
184
248
  // with the corresponding Prisma profile rows, so the overview globe can render
@@ -243,3 +307,27 @@ export type MetricsRecentUser = yup.InferType<typeof MetricsRecentUserSchema>;
243
307
  export type MetricsResponse = yup.InferType<typeof MetricsResponseBodySchema>;
244
308
  export type MetricsUserCounts = yup.InferType<typeof MetricsUserCountsSchema>;
245
309
  export type UserActivityResponse = yup.InferType<typeof UserActivityResponseBodySchema>;
310
+ export type AnalyticsClickmapKind = yup.InferType<typeof AnalyticsClickmapKindSchema>;
311
+ export type AnalyticsClickmapDevice = yup.InferType<typeof AnalyticsClickmapDeviceSchema>;
312
+ export type AnalyticsClickmapCell = yup.InferType<typeof AnalyticsClickmapCellSchema>;
313
+ export type AnalyticsClickmapResponse = yup.InferType<typeof AnalyticsClickmapResponseBodySchema>;
314
+ export type AnalyticsClickmapTokenResponse = yup.InferType<typeof AnalyticsClickmapTokenResponseBodySchema>;
315
+
316
+ // Single (camelCase) options shape for the clickmap SDK surface — shared by the
317
+ // StackAdminApp interface and its implementation so the two can't drift. The
318
+ // HexclaveAdminInterface transport layer maps these to the snake_case request body.
319
+ export type AnalyticsClickmapOptions = {
320
+ kind: AnalyticsClickmapKind,
321
+ memberUserIds?: string[],
322
+ routePath?: string,
323
+ routeRegex?: string,
324
+ urlPattern?: string,
325
+ userId?: string,
326
+ replayId?: string,
327
+ device?: AnalyticsClickmapDevice,
328
+ viewportWidthMin?: number,
329
+ viewportWidthMax?: number,
330
+ sampling?: number,
331
+ since: string,
332
+ until: string,
333
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Wire protocol for handing a clickmap overlay token from the dashboard to the
3
+ * in-page dev tool via `sessionStorage` + a window event.
4
+ *
5
+ * The token is a self-describing JWT: its payload already carries the
6
+ * `project_id` and `origin` it was minted for, so the reader derives both from
7
+ * the token itself and the writer only has to hand over a single value. The
8
+ * dashboard (writer) and the dev tool (reader) live in different packages but
9
+ * must agree on these exact names — this module is the single source of truth so
10
+ * they can never silently desync.
11
+ */
12
+
13
+ export const CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY = "hexclave-clickmap-token";
14
+ export const CLICKMAP_OVERLAY_RESUME_STORAGE_KEY = "hexclave-clickmap-resume";
15
+ export const CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT = "hexclave:clickmap-token-updated";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared identity of the Hexclave in-page dev tool / clickmap overlay.
3
+ *
4
+ * These constants are the single source of truth for "is this DOM / event /
5
+ * stored click part of the dev tool itself?". They are consumed across package
6
+ * boundaries:
7
+ * - the dev tool mounts its root element with {@link DEV_TOOL_ROOT_ID} and
8
+ * prefixes every generated class with {@link DEV_TOOL_CLASS_PREFIX};
9
+ * - the event tracker uses them to skip self-clicks at ingest;
10
+ * - the backend clickmap query uses them to filter dev-tool clicks out of
11
+ * aggregate clickmaps server-side.
12
+ *
13
+ * Keep them here so a rename can never silently desync the SQL filter from the
14
+ * actual DOM identity.
15
+ */
16
+ export const DEV_TOOL_ROOT_ID = "__hexclave-dev-tool-root";
17
+
18
+ /**
19
+ * Root element id of the standalone clickmap overlay. The clickmap is an
20
+ * independent feature with its own mount (it must survive the dev tool being
21
+ * removed), so it gets its own root — but its self-clicks need the exact same
22
+ * ingest/query exclusions as the dev tool's.
23
+ */
24
+ export const CLICKMAP_ROOT_ID = "__hexclave-clickmap-root";
25
+
26
+ /** Prefix applied to every class/generated id the dev tool renders. */
27
+ export const DEV_TOOL_CLASS_PREFIX = "sdt-";
28
+
29
+ /** Legacy class marker still present on older dev-tool builds. */
30
+ export const DEV_TOOL_LEGACY_CLASS = "stack-devtool";
package/src/utils/dom.tsx CHANGED
@@ -5,3 +5,45 @@ export function hasClickableParent(element: HTMLElement): boolean {
5
5
 
6
6
  return hasClickableParent(element.parentElement);
7
7
  }
8
+
9
+ /**
10
+ * Escape a string so it is safe to use as a CSS identifier (id/class) inside a selector.
11
+ * Prefers the native `CSS.escape` when available, falling back to a conservative
12
+ * backslash-escape for non-DOM environments (SSR, tests, older runtimes).
13
+ */
14
+ export function cssEscapeIdent(value: string): string {
15
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
16
+ return CSS.escape(value);
17
+ }
18
+
19
+ let escaped = "";
20
+ for (let i = 0; i < value.length; i += 1) {
21
+ const char = value.charAt(i);
22
+ const codeUnit = value.charCodeAt(i);
23
+
24
+ if (codeUnit === 0x0000) {
25
+ escaped += "\uFFFD";
26
+ } else if (
27
+ (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
28
+ codeUnit === 0x007f ||
29
+ (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
30
+ (i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && value.charCodeAt(0) === 0x002d)
31
+ ) {
32
+ escaped += `\\${codeUnit.toString(16)} `;
33
+ } else if (i === 0 && codeUnit === 0x002d && value.length === 1) {
34
+ escaped += "\\-";
35
+ } else if (
36
+ codeUnit >= 0x0080 ||
37
+ codeUnit === 0x002d ||
38
+ codeUnit === 0x005f ||
39
+ (codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
40
+ (codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
41
+ (codeUnit >= 0x0061 && codeUnit <= 0x007a)
42
+ ) {
43
+ escaped += char;
44
+ } else {
45
+ escaped += `\\${char}`;
46
+ }
47
+ }
48
+ return escaped;
49
+ }