@burdenoff/website-sdk 0.0.0-dev

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 (119) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +357 -0
  3. package/dist/analytics/index.d.mts +27 -0
  4. package/dist/analytics/index.d.ts +27 -0
  5. package/dist/analytics/index.js +36 -0
  6. package/dist/analytics/index.js.map +1 -0
  7. package/dist/analytics/index.mjs +34 -0
  8. package/dist/analytics/index.mjs.map +1 -0
  9. package/dist/client/index.d.mts +29 -0
  10. package/dist/client/index.d.ts +29 -0
  11. package/dist/client/index.js +261 -0
  12. package/dist/client/index.js.map +1 -0
  13. package/dist/client/index.mjs +255 -0
  14. package/dist/client/index.mjs.map +1 -0
  15. package/dist/components/contact.d.mts +144 -0
  16. package/dist/components/contact.d.ts +144 -0
  17. package/dist/components/contact.js +806 -0
  18. package/dist/components/contact.js.map +1 -0
  19. package/dist/components/contact.mjs +780 -0
  20. package/dist/components/contact.mjs.map +1 -0
  21. package/dist/components/electron-download.d.mts +32 -0
  22. package/dist/components/electron-download.d.ts +32 -0
  23. package/dist/components/electron-download.js +131 -0
  24. package/dist/components/electron-download.js.map +1 -0
  25. package/dist/components/electron-download.mjs +108 -0
  26. package/dist/components/electron-download.mjs.map +1 -0
  27. package/dist/components/launch-countdown.d.mts +15 -0
  28. package/dist/components/launch-countdown.d.ts +15 -0
  29. package/dist/components/launch-countdown.js +89 -0
  30. package/dist/components/launch-countdown.js.map +1 -0
  31. package/dist/components/launch-countdown.mjs +87 -0
  32. package/dist/components/launch-countdown.mjs.map +1 -0
  33. package/dist/components/newsletter.d.mts +155 -0
  34. package/dist/components/newsletter.d.ts +155 -0
  35. package/dist/components/newsletter.js +880 -0
  36. package/dist/components/newsletter.js.map +1 -0
  37. package/dist/components/newsletter.mjs +854 -0
  38. package/dist/components/newsletter.mjs.map +1 -0
  39. package/dist/components/partners.d.mts +88 -0
  40. package/dist/components/partners.d.ts +88 -0
  41. package/dist/components/partners.js +1169 -0
  42. package/dist/components/partners.js.map +1 -0
  43. package/dist/components/partners.mjs +1143 -0
  44. package/dist/components/partners.mjs.map +1 -0
  45. package/dist/components/pricing.d.mts +194 -0
  46. package/dist/components/pricing.d.ts +194 -0
  47. package/dist/components/pricing.js +813 -0
  48. package/dist/components/pricing.js.map +1 -0
  49. package/dist/components/pricing.mjs +791 -0
  50. package/dist/components/pricing.mjs.map +1 -0
  51. package/dist/components/problems-solved.d.mts +37 -0
  52. package/dist/components/problems-solved.d.ts +37 -0
  53. package/dist/components/problems-solved.js +157 -0
  54. package/dist/components/problems-solved.js.map +1 -0
  55. package/dist/components/problems-solved.mjs +135 -0
  56. package/dist/components/problems-solved.mjs.map +1 -0
  57. package/dist/components/resource-links.d.mts +28 -0
  58. package/dist/components/resource-links.d.ts +28 -0
  59. package/dist/components/resource-links.js +229 -0
  60. package/dist/components/resource-links.js.map +1 -0
  61. package/dist/components/resource-links.mjs +227 -0
  62. package/dist/components/resource-links.mjs.map +1 -0
  63. package/dist/components/social-links.d.mts +37 -0
  64. package/dist/components/social-links.d.ts +37 -0
  65. package/dist/components/social-links.js +268 -0
  66. package/dist/components/social-links.js.map +1 -0
  67. package/dist/components/social-links.mjs +266 -0
  68. package/dist/components/social-links.mjs.map +1 -0
  69. package/dist/components/unsubscribe.d.mts +14 -0
  70. package/dist/components/unsubscribe.d.ts +14 -0
  71. package/dist/components/unsubscribe.js +538 -0
  72. package/dist/components/unsubscribe.js.map +1 -0
  73. package/dist/components/unsubscribe.mjs +517 -0
  74. package/dist/components/unsubscribe.mjs.map +1 -0
  75. package/dist/components/waitlist.d.mts +70 -0
  76. package/dist/components/waitlist.d.ts +70 -0
  77. package/dist/components/waitlist.js +864 -0
  78. package/dist/components/waitlist.js.map +1 -0
  79. package/dist/components/waitlist.mjs +841 -0
  80. package/dist/components/waitlist.mjs.map +1 -0
  81. package/dist/components/website-switcher.d.mts +27 -0
  82. package/dist/components/website-switcher.d.ts +27 -0
  83. package/dist/components/website-switcher.js +168 -0
  84. package/dist/components/website-switcher.js.map +1 -0
  85. package/dist/components/website-switcher.mjs +146 -0
  86. package/dist/components/website-switcher.mjs.map +1 -0
  87. package/dist/content/index.d.mts +247 -0
  88. package/dist/content/index.d.ts +247 -0
  89. package/dist/content/index.js +2458 -0
  90. package/dist/content/index.js.map +1 -0
  91. package/dist/content/index.mjs +2428 -0
  92. package/dist/content/index.mjs.map +1 -0
  93. package/dist/index.d.mts +72 -0
  94. package/dist/index.d.ts +72 -0
  95. package/dist/index.js +6273 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/index.mjs +6204 -0
  98. package/dist/index.mjs.map +1 -0
  99. package/dist/product/index.d.mts +94 -0
  100. package/dist/product/index.d.ts +94 -0
  101. package/dist/product/index.js +282 -0
  102. package/dist/product/index.js.map +1 -0
  103. package/dist/product/index.mjs +278 -0
  104. package/dist/product/index.mjs.map +1 -0
  105. package/dist/provider-D7Rij9UM.d.mts +86 -0
  106. package/dist/provider-D7Rij9UM.d.ts +86 -0
  107. package/dist/seo/index.d.mts +64 -0
  108. package/dist/seo/index.d.ts +64 -0
  109. package/dist/seo/index.js +61 -0
  110. package/dist/seo/index.js.map +1 -0
  111. package/dist/seo/index.mjs +59 -0
  112. package/dist/seo/index.mjs.map +1 -0
  113. package/dist/utils/index.d.mts +21 -0
  114. package/dist/utils/index.d.ts +21 -0
  115. package/dist/utils/index.js +12 -0
  116. package/dist/utils/index.js.map +1 -0
  117. package/dist/utils/index.mjs +10 -0
  118. package/dist/utils/index.mjs.map +1 -0
  119. package/package.json +168 -0
@@ -0,0 +1,28 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ type Resource = "docs" | "community" | "careers" | "team" | "companies";
4
+ interface ResourceLinksProps {
5
+ /**
6
+ * Per-resource URL overrides. `undefined` falls through to CMS/default
7
+ * resolution; explicit `null` suppresses the link entirely.
8
+ */
9
+ links?: Partial<Record<Resource, string | null>>;
10
+ /**
11
+ * Allowlist + ORDER of resources to render. Default: canonical order
12
+ * (docs, community, careers, team, companies). Pass a subset/reorder to
13
+ * customise the column on a per-site basis.
14
+ */
15
+ resources?: readonly Resource[];
16
+ /** Per-link label overrides. Falls back to canonical English labels. */
17
+ labels?: Partial<Record<Resource, string>>;
18
+ /** Outer container className. Default: `"space-y-2"` (vertical list). */
19
+ className?: string;
20
+ /**
21
+ * Per-link className. Default matches the muted-foreground / hover-foreground
22
+ * style used by the other Footer columns.
23
+ */
24
+ itemClassName?: string;
25
+ }
26
+ declare function ResourceLinks({ links: linksProp, resources, labels, className, itemClassName, }: ResourceLinksProps): react_jsx_runtime.JSX.Element | null;
27
+
28
+ export { type Resource, ResourceLinks, type ResourceLinksProps };
@@ -0,0 +1,28 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ type Resource = "docs" | "community" | "careers" | "team" | "companies";
4
+ interface ResourceLinksProps {
5
+ /**
6
+ * Per-resource URL overrides. `undefined` falls through to CMS/default
7
+ * resolution; explicit `null` suppresses the link entirely.
8
+ */
9
+ links?: Partial<Record<Resource, string | null>>;
10
+ /**
11
+ * Allowlist + ORDER of resources to render. Default: canonical order
12
+ * (docs, community, careers, team, companies). Pass a subset/reorder to
13
+ * customise the column on a per-site basis.
14
+ */
15
+ resources?: readonly Resource[];
16
+ /** Per-link label overrides. Falls back to canonical English labels. */
17
+ labels?: Partial<Record<Resource, string>>;
18
+ /** Outer container className. Default: `"space-y-2"` (vertical list). */
19
+ className?: string;
20
+ /**
21
+ * Per-link className. Default matches the muted-foreground / hover-foreground
22
+ * style used by the other Footer columns.
23
+ */
24
+ itemClassName?: string;
25
+ }
26
+ declare function ResourceLinks({ links: linksProp, resources, labels, className, itemClassName, }: ResourceLinksProps): react_jsx_runtime.JSX.Element | null;
27
+
28
+ export { type Resource, ResourceLinks, type ResourceLinksProps };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ var __defProp = Object.defineProperty;
7
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
8
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
9
+
10
+ // src/client/security.ts
11
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
12
+ var RESERVED_HEADERS = /* @__PURE__ */ new Set([
13
+ "authorization",
14
+ "cookie",
15
+ "x-actor-id",
16
+ "x-actor-type",
17
+ "x-consent-granted-by",
18
+ "x-consent-scope",
19
+ "x-consent-granted-at",
20
+ "x-consent-expires-at"
21
+ ]);
22
+ function validateGatewayUrl(value) {
23
+ let url;
24
+ try {
25
+ url = new URL(value);
26
+ } catch {
27
+ throw new Error("Gateway URL must be a valid absolute URL");
28
+ }
29
+ if (url.protocol === "https:") {
30
+ return url.toString();
31
+ }
32
+ if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname.toLowerCase())) {
33
+ return url.toString();
34
+ }
35
+ throw new Error("Gateway URL must use HTTPS unless it targets localhost");
36
+ }
37
+ function sanitizeHeaders(headers) {
38
+ if (!headers) return void 0;
39
+ return Object.fromEntries(
40
+ Object.entries(headers).filter(
41
+ ([key]) => !RESERVED_HEADERS.has(key.toLowerCase())
42
+ )
43
+ );
44
+ }
45
+
46
+ // src/client/graphql-client.ts
47
+ var _WebSDKClient = class _WebSDKClient {
48
+ constructor(config) {
49
+ __publicField(this, "config");
50
+ __publicField(this, "queryCache", /* @__PURE__ */ new Map());
51
+ __publicField(this, "inflight", /* @__PURE__ */ new Map());
52
+ this.config = {
53
+ ...config,
54
+ gatewayUrl: validateGatewayUrl(config.gatewayUrl),
55
+ headers: sanitizeHeaders(config.headers)
56
+ };
57
+ }
58
+ getConfig() {
59
+ return this.config;
60
+ }
61
+ /**
62
+ * Execute a GraphQL query (with deduplication and caching)
63
+ */
64
+ async query(query, variables) {
65
+ const cacheKey = JSON.stringify({ query, variables });
66
+ const cached = this.queryCache.get(cacheKey);
67
+ if (cached) {
68
+ if (Date.now() - cached.ts < _WebSDKClient.CACHE_TTL) {
69
+ return cached.data;
70
+ }
71
+ this.queryCache.delete(cacheKey);
72
+ }
73
+ const existing = this.inflight.get(cacheKey);
74
+ if (existing) {
75
+ return existing;
76
+ }
77
+ const promise = this.request(query, variables).then((result) => {
78
+ if (result.data && !result.errors?.length) {
79
+ if (this.queryCache.size >= _WebSDKClient.MAX_CACHE_ENTRIES) {
80
+ const oldestEntry = this.queryCache.keys().next().value;
81
+ if (oldestEntry) {
82
+ this.queryCache.delete(oldestEntry);
83
+ }
84
+ }
85
+ this.queryCache.set(cacheKey, { data: result, ts: Date.now() });
86
+ }
87
+ return result;
88
+ }).finally(() => {
89
+ this.inflight.delete(cacheKey);
90
+ });
91
+ this.inflight.set(cacheKey, promise);
92
+ return promise;
93
+ }
94
+ /**
95
+ * Execute a GraphQL mutation
96
+ */
97
+ async mutate(mutation, variables) {
98
+ return this.request(mutation, variables);
99
+ }
100
+ async request(query, variables) {
101
+ try {
102
+ const response = await fetch(this.config.gatewayUrl, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ "x-product-id": this.config.productId,
107
+ ...this.config.headers
108
+ },
109
+ body: JSON.stringify({ query, variables })
110
+ });
111
+ if (!response.ok) {
112
+ console.error(
113
+ "[WebSDK] GraphQL request failed",
114
+ response.status,
115
+ response.statusText
116
+ );
117
+ return {
118
+ errors: [
119
+ {
120
+ message: `HTTP ${response.status}: ${response.statusText}`,
121
+ extensions: { code: `HTTP_${response.status}` }
122
+ }
123
+ ]
124
+ };
125
+ }
126
+ return await response.json();
127
+ } catch (error) {
128
+ console.error(
129
+ "[WebSDK] Network error",
130
+ error instanceof Error ? error.message : "unknown error"
131
+ );
132
+ return {
133
+ errors: [
134
+ {
135
+ message: error instanceof Error ? error.message : "Network error",
136
+ extensions: { code: "NETWORK_ERROR" }
137
+ }
138
+ ]
139
+ };
140
+ }
141
+ }
142
+ };
143
+ __publicField(_WebSDKClient, "CACHE_TTL", 5 * 60 * 1e3);
144
+ // 5 minutes
145
+ __publicField(_WebSDKClient, "MAX_CACHE_ENTRIES", 100);
146
+ react.createContext(null);
147
+ var ProductContext = react.createContext({
148
+ product: null,
149
+ loading: true,
150
+ error: null,
151
+ hasProvider: false,
152
+ refetch: () => {
153
+ }
154
+ });
155
+ function useProduct() {
156
+ return react.useContext(ProductContext);
157
+ }
158
+ var DEFAULT_LABELS = {
159
+ docs: "Docs",
160
+ community: "Community",
161
+ careers: "Careers",
162
+ team: "Team",
163
+ companies: "Sister Companies"
164
+ };
165
+ var BURDENOFF_GROUP_DEFAULTS = {
166
+ docs: null,
167
+ // per-product → read from `Product.docsUrl`
168
+ community: null,
169
+ // per-product → caller supplies via prop until CMS field exists
170
+ careers: "https://burdenoff.com/careers",
171
+ team: "https://burdenoff.com/team",
172
+ companies: "https://burdenoff.com/companies"
173
+ };
174
+ var DEFAULT_ORDER = [
175
+ "docs",
176
+ "community",
177
+ "careers",
178
+ "team",
179
+ "companies"
180
+ ];
181
+ var DEFAULT_ITEM_CLASS = "text-sm text-muted-foreground hover:text-foreground transition-colors";
182
+ function isSafeUrl(value) {
183
+ const trimmed = value.trim();
184
+ if (/^[/?#]/.test(trimmed)) return true;
185
+ return /^(https?:|mailto:|tel:)/i.test(trimmed);
186
+ }
187
+ function ResourceLinks({
188
+ links: linksProp,
189
+ resources = DEFAULT_ORDER,
190
+ labels = {},
191
+ className = "space-y-2",
192
+ itemClassName = DEFAULT_ITEM_CLASS
193
+ }) {
194
+ const { product } = useProduct();
195
+ function resolve(resource) {
196
+ if (linksProp && resource in linksProp) {
197
+ const override = linksProp[resource];
198
+ if (override === void 0) ; else {
199
+ return override;
200
+ }
201
+ }
202
+ const fromCms = product?.resourceLinks?.[resource];
203
+ if (typeof fromCms === "string" && fromCms.length > 0) {
204
+ return fromCms;
205
+ }
206
+ if (resource === "docs" && product?.docsUrl) {
207
+ return product.docsUrl;
208
+ }
209
+ return BURDENOFF_GROUP_DEFAULTS[resource];
210
+ }
211
+ const items = resources.map((r) => ({ resource: r, url: resolve(r) })).filter(
212
+ (x) => !!x.url && isSafeUrl(x.url)
213
+ );
214
+ if (items.length === 0) return null;
215
+ return /* @__PURE__ */ jsxRuntime.jsx("ul", { className, children: items.map(({ resource, url }) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(
216
+ "a",
217
+ {
218
+ href: url,
219
+ target: "_blank",
220
+ rel: "noopener noreferrer",
221
+ className: itemClassName,
222
+ children: labels[resource] ?? DEFAULT_LABELS[resource]
223
+ }
224
+ ) }, resource)) });
225
+ }
226
+
227
+ exports.ResourceLinks = ResourceLinks;
228
+ //# sourceMappingURL=resource-links.js.map
229
+ //# sourceMappingURL=resource-links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/security.ts","../../src/client/graphql-client.ts","../../src/client/provider.tsx","../../src/product/product-provider.tsx","../../src/components/resource-links.tsx"],"names":["createContext","useContext","jsx"],"mappings":"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","file":"resource-links.js","sourcesContent":["const LOOPBACK_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"::1\"]);\nconst RESERVED_HEADERS = new Set([\n \"authorization\",\n \"cookie\",\n \"x-actor-id\",\n \"x-actor-type\",\n \"x-consent-granted-by\",\n \"x-consent-scope\",\n \"x-consent-granted-at\",\n \"x-consent-expires-at\",\n]);\n\nexport function validateGatewayUrl(value: string): string {\n let url: URL;\n\n try {\n url = new URL(value);\n } catch {\n throw new Error(\"Gateway URL must be a valid absolute URL\");\n }\n\n if (url.protocol === \"https:\") {\n return url.toString();\n }\n\n if (\n url.protocol === \"http:\" &&\n LOOPBACK_HOSTS.has(url.hostname.toLowerCase())\n ) {\n return url.toString();\n }\n\n throw new Error(\"Gateway URL must use HTTPS unless it targets localhost\");\n}\n\nexport function sanitizeHeaders(\n headers?: Record<string, string>,\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n\n return Object.fromEntries(\n Object.entries(headers).filter(\n ([key]) => !RESERVED_HEADERS.has(key.toLowerCase()),\n ),\n );\n}\n","/**\n * Lightweight GraphQL client for the Burdenoff Web SDK.\n * Uses native fetch - zero external dependencies.\n * All calls are unauthenticated (public gateway operations only).\n */\n\nimport { sanitizeHeaders, validateGatewayUrl } from \"./security\";\n\nexport interface WebSDKClientConfig {\n /** Global public gateway URL (e.g. \"https://api.burdenoff.com/global/graphql\") */\n gatewayUrl: string;\n /** Platform product ID for scoping operations */\n productId: string;\n /** Product slug (used for public product queries) */\n productSlug?: string;\n /** reCAPTCHA site key (if not fetched from product config) */\n recaptchaSiteKey?: string;\n /** Rybbit analytics site ID */\n rybbitSiteId?: string;\n /** Custom headers to include in all requests */\n headers?: Record<string, string>;\n}\n\nexport interface GraphQLResponse<T = Record<string, unknown>> {\n data?: T;\n errors?: Array<{\n message: string;\n locations?: Array<{ line: number; column: number }>;\n path?: Array<string | number>;\n extensions?: Record<string, unknown>;\n }>;\n}\n\nexport class WebSDKClient {\n private config: WebSDKClientConfig;\n private queryCache = new Map<\n string,\n { data: GraphQLResponse<unknown>; ts: number }\n >();\n private inflight = new Map<string, Promise<GraphQLResponse<unknown>>>();\n private static CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n private static MAX_CACHE_ENTRIES = 100;\n\n constructor(config: WebSDKClientConfig) {\n this.config = {\n ...config,\n gatewayUrl: validateGatewayUrl(config.gatewayUrl),\n headers: sanitizeHeaders(config.headers),\n };\n }\n\n getConfig(): WebSDKClientConfig {\n return this.config;\n }\n\n /**\n * Execute a GraphQL query (with deduplication and caching)\n */\n async query<T = Record<string, unknown>>(\n query: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n const cacheKey = JSON.stringify({ query, variables });\n\n // Return cached response if still valid, evict if expired\n const cached = this.queryCache.get(cacheKey);\n if (cached) {\n if (Date.now() - cached.ts < WebSDKClient.CACHE_TTL) {\n return cached.data as GraphQLResponse<T>;\n }\n this.queryCache.delete(cacheKey);\n }\n\n // Deduplicate: if same query is already in-flight, reuse the promise\n const existing = this.inflight.get(cacheKey);\n if (existing) {\n return existing as Promise<GraphQLResponse<T>>;\n }\n\n const promise = this.request<T>(query, variables)\n .then((result) => {\n // Cache successful responses only\n if (result.data && !result.errors?.length) {\n if (this.queryCache.size >= WebSDKClient.MAX_CACHE_ENTRIES) {\n const oldestEntry = this.queryCache.keys().next().value;\n if (oldestEntry) {\n this.queryCache.delete(oldestEntry);\n }\n }\n this.queryCache.set(cacheKey, { data: result, ts: Date.now() });\n }\n return result;\n })\n .finally(() => {\n this.inflight.delete(cacheKey);\n });\n\n this.inflight.set(cacheKey, promise);\n return promise;\n }\n\n /**\n * Execute a GraphQL mutation\n */\n async mutate<T = Record<string, unknown>>(\n mutation: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n return this.request<T>(mutation, variables);\n }\n\n private async request<T>(\n query: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n try {\n const response = await fetch(this.config.gatewayUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-product-id\": this.config.productId,\n ...this.config.headers,\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n console.error(\n \"[WebSDK] GraphQL request failed\",\n response.status,\n response.statusText,\n );\n return {\n errors: [\n {\n message: `HTTP ${response.status}: ${response.statusText}`,\n extensions: { code: `HTTP_${response.status}` },\n },\n ],\n };\n }\n\n return (await response.json()) as GraphQLResponse<T>;\n } catch (error) {\n console.error(\n \"[WebSDK] Network error\",\n error instanceof Error ? error.message : \"unknown error\",\n );\n return {\n errors: [\n {\n message: error instanceof Error ? error.message : \"Network error\",\n extensions: { code: \"NETWORK_ERROR\" },\n },\n ],\n };\n }\n }\n}\n","/**\n * React context provider for the Web SDK client.\n * Wraps the application with WebSDKClient configuration.\n */\n\"use client\";\n\nimport { createContext, useContext, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { resolveGatewayUrl } from \"./gateway-resolver\";\nimport { WebSDKClient, type WebSDKClientConfig } from \"./graphql-client\";\n\nconst WebSDKContext = createContext<WebSDKClient | null>(null);\n\nexport interface WebSDKProviderProps {\n /** SDK client configuration */\n config: WebSDKClientConfig;\n /** Child components */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the WebSDKClient available to all child components.\n *\n * @example\n * ```tsx\n * <WebSDKProvider config={{\n * gatewayUrl: \"https://api.burdenoff.com/global/graphql\",\n * productId: \"your-product-id\",\n * productSlug: \"your-product\",\n * }}>\n * <App />\n * </WebSDKProvider>\n * ```\n */\nexport function WebSDKProvider({ config, children }: WebSDKProviderProps) {\n const client = useMemo(() => {\n const resolvedConfig = {\n ...config,\n gatewayUrl: resolveGatewayUrl(config.gatewayUrl),\n };\n return new WebSDKClient(resolvedConfig);\n }, [config]);\n\n return (\n <WebSDKContext.Provider value={client}>{children}</WebSDKContext.Provider>\n );\n}\n\n/**\n * Hook to access the WebSDKClient instance.\n * Must be used within a WebSDKProvider.\n */\nexport function useWebSDK(): WebSDKClient {\n const client = useContext(WebSDKContext);\n if (!client) {\n throw new Error(\"useWebSDK must be used within a WebSDKProvider\");\n }\n return client;\n}\n\n/**\n * Hook to access the SDK configuration.\n * Useful for components that need config values like productId, recaptchaSiteKey, etc.\n */\nexport function useWebSDKConfig(): WebSDKClientConfig {\n const client = useWebSDK();\n return client.getConfig();\n}\n","/**\n * Product Provider\n *\n * React context and hooks for accessing product metadata from the global tenant service.\n * Fetches product info (name, logo, SEO, contact, reCAPTCHA key, etc.) on mount\n * and provides it to all child components.\n */\n\"use client\";\n\nimport {\n createContext,\n useContext,\n useState,\n useLayoutEffect,\n useCallback,\n useMemo,\n} from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useWebSDK, useWebSDKConfig } from \"../client/provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface ProductData {\n id: string;\n name: string;\n slug: string;\n description?: string | null;\n status: string;\n logoUrl?: string | null;\n website?: string | null;\n appUrl?: string | null;\n docsUrl?: string | null;\n contactEmail?: string | null;\n supportEmail?: string | null;\n contactPhone?: string | null;\n contactAddress?: string | null;\n seoTitle?: string | null;\n seoDescription?: string | null;\n seoKeywords?: string | null;\n ogImage?: string | null;\n socialLinks?: Record<string, string> | null;\n /**\n * Resource links for the website footer \"Resources\" column\n * (JSON: { docs, community, careers, team, companies, ... }).\n * Same shape as socialLinks. Free-form keys; the ResourceLinks\n * component renders known keys and silently ignores the rest.\n * Backed by Product.resourceLinks on the backend (global-tenant-svc).\n */\n resourceLinks?: Record<string, string> | null;\n recaptchaSiteKey?: string | null;\n rybbitSiteId?: string | null;\n notificationEmail?: string | null;\n metadata?: Record<string, unknown> | null;\n}\n\nexport interface ProductProviderProps {\n /** Child components */\n children: ReactNode;\n /** Optional fallback product data (used while loading) */\n fallback?: Partial<ProductData>;\n}\n\n// ============================================================================\n// GraphQL Query\n// ============================================================================\n\n/**\n * Body of fields requested for every product fetch — shared by the primary\n * query and the legacy-backend fallback below. Kept as a string fragment\n * (not a real GraphQL `fragment` block) so we can append/strip\n * `resourceLinks` without touching anything else.\n */\nconst PRODUCT_FIELDS_BASE = `\n id\n name\n slug\n description\n status\n logoUrl\n website\n appUrl\n docsUrl\n contactEmail\n supportEmail\n contactPhone\n contactAddress\n seoTitle\n seoDescription\n seoKeywords\n ogImage\n socialLinks\n recaptchaSiteKey\n rybbitSiteId\n notificationEmail\n metadata\n`;\n\n/**\n * Primary query — includes the recently-added `resourceLinks` field.\n * Backends that have run the corresponding `global-tenant-svc` migration\n * (PR #64) expose this field; older ones don't, in which case\n * GraphQL validation rejects the whole query with\n * `Cannot query field \"resourceLinks\" on type \"PublicProduct\"`. The fetch\n * helper detects that specific error and transparently retries the\n * fallback below, so the SDK works on both pre- and post-migration\n * deployments without consumer-side gating.\n */\nconst PUBLIC_PRODUCT_QUERY = `\n query PublicProduct($slug: String!) {\n publicProduct(slug: $slug) {\n ${PRODUCT_FIELDS_BASE}\n resourceLinks\n }\n }\n`;\n\n/**\n * Fallback query for older backend deployments. Identical to the primary\n * minus `resourceLinks`. Consumers see `product.resourceLinks === undefined`\n * and any consumer (e.g. `<ResourceLinks/>`) falls through to prop / default\n * resolution paths.\n */\nconst PUBLIC_PRODUCT_QUERY_FALLBACK = `\n query PublicProductFallback($slug: String!) {\n publicProduct(slug: $slug) {\n ${PRODUCT_FIELDS_BASE}\n }\n }\n`;\n\n// ============================================================================\n// Context\n// ============================================================================\n\ninterface ProductContextValue {\n product: ProductData | null;\n loading: boolean;\n error: string | null;\n hasProvider: boolean;\n refetch: () => void;\n}\n\nconst ProductContext = createContext<ProductContextValue>({\n product: null,\n loading: true,\n error: null,\n hasProvider: false,\n refetch: () => {},\n});\n\n// ============================================================================\n// Provider\n// ============================================================================\n\n/**\n * ProductProvider fetches and provides product metadata to child components.\n * Uses the product slug from WebSDKConfig to fetch from the public API.\n *\n * @example\n * ```tsx\n * <WebSDKProvider config={sdkConfig}>\n * <ProductProvider>\n * <App />\n * </ProductProvider>\n * </WebSDKProvider>\n * ```\n */\nexport function ProductProvider({ children, fallback }: ProductProviderProps) {\n const client = useWebSDK();\n const config = useWebSDKConfig();\n const [product, setProduct] = useState<ProductData | null>(\n fallback ? (fallback as ProductData) : null,\n );\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const fetchProduct = useCallback(async () => {\n if (!config.productSlug) {\n setLoading(false);\n return;\n }\n\n setLoading(true);\n setError(null);\n\n try {\n let result = await client.query<{ publicProduct: ProductData | null }>(\n PUBLIC_PRODUCT_QUERY,\n { slug: config.productSlug },\n );\n\n // Backwards-compat fallback: if the backend's `publicProduct` schema\n // doesn't yet expose `resourceLinks` (deployed before\n // `global-tenant-svc` PR #64), the primary query fails validation\n // with a \"Cannot query field …\" error and `result.data` is null.\n // Retry the fallback query that omits `resourceLinks`; consumers\n // gracefully see `product.resourceLinks === undefined` and fall\n // through their own resolution paths.\n const schemaMissingResourceLinks = result.errors?.some((e) =>\n /Cannot query field [\"']?resourceLinks[\"']?/i.test(e?.message ?? \"\"),\n );\n if (schemaMissingResourceLinks) {\n result = await client.query<{ publicProduct: ProductData | null }>(\n PUBLIC_PRODUCT_QUERY_FALLBACK,\n { slug: config.productSlug },\n );\n }\n\n if (result.errors?.length) {\n setError(result.errors[0]?.message ?? \"Unknown error\");\n } else if (result.data?.publicProduct) {\n setProduct(result.data.publicProduct);\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to fetch product\");\n } finally {\n setLoading(false);\n }\n }, [client, config.productSlug]);\n\n // Use useLayoutEffect for earliest possible fetch after commit (before paint).\n // Runs when slug or client changes. Client-side query cache + dedup ensures\n // no duplicate network requests even if React StrictMode fires this twice.\n useLayoutEffect(() => {\n fetchProduct();\n }, [fetchProduct]);\n\n const value = useMemo(\n () => ({\n product,\n loading,\n error,\n hasProvider: true,\n refetch: fetchProduct,\n }),\n [product, loading, error, fetchProduct],\n );\n\n return (\n <ProductContext.Provider value={value}>{children}</ProductContext.Provider>\n );\n}\n\n// ============================================================================\n// Hooks\n// ============================================================================\n\n/**\n * Hook to access the current product data.\n *\n * @example\n * ```tsx\n * const { product, loading } = useProduct();\n * if (loading) return <Loading />;\n * return <h1>{product?.name}</h1>;\n * ```\n */\nexport function useProduct(): ProductContextValue {\n return useContext(ProductContext);\n}\n\n/**\n * Hook to get product-specific configuration.\n * Merges SDK config with fetched product data.\n * Useful for getting reCAPTCHA keys, analytics IDs, etc.\n *\n * @example\n * ```tsx\n * const { recaptchaSiteKey, rybbitSiteId } = useProductConfig();\n * ```\n */\nexport function useProductConfig(): {\n recaptchaSiteKey: string | null;\n rybbitSiteId: string | null;\n contactEmail: string | null;\n supportEmail: string | null;\n notificationEmail: string | null;\n productName: string | null;\n logoUrl: string | null;\n} {\n const { product } = useProduct();\n const config = useWebSDKConfig();\n\n return {\n // Prefer product-level config, fall back to SDK config\n recaptchaSiteKey:\n product?.recaptchaSiteKey ?? config.recaptchaSiteKey ?? null,\n rybbitSiteId: product?.rybbitSiteId ?? config.rybbitSiteId ?? null,\n contactEmail: product?.contactEmail ?? null,\n supportEmail: product?.supportEmail ?? null,\n notificationEmail:\n product?.notificationEmail ?? product?.contactEmail ?? null,\n productName: product?.name ?? null,\n logoUrl: product?.logoUrl ?? null,\n };\n}\n","\"use client\";\n\n/**\n * ResourceLinks Component\n *\n * Renders the Footer \"Resources\" column. Hybrid data sourcing — uses the\n * product CMS where a field already exists, prop overrides where CMS doesn't\n * (yet) have a column, and Burdenoff-group-wide defaults for resources that\n * are constant across every product website.\n *\n * | Resource | Default source | Notes |\n * | ------------ | ----------------------------------------------- | ----- |\n * | `docs` | `useProduct().product.docsUrl` (existing field) | Per-product; admin can change in `microfe-product → Products → <product>` |\n * | `community` | (none — pass via `links` prop) | Per-product; needs `Product.communityUrl` in backend to become CMS-managed |\n * | `careers` | `https://burdenoff.com/careers` (group default) | Burdenoff-wide constant; overridable |\n * | `team` | `https://burdenoff.com/team` (group default) | Burdenoff-wide constant; overridable |\n * | `companies` | `https://burdenoff.com/companies` (group default)| Burdenoff-wide constant; overridable |\n *\n * Future-compat: if the CMS grows a `Product.resourceLinks` JSON field\n * mirroring `socialLinks`, the resolver below switches to read it as the\n * first preference — no consumer-side change.\n *\n * @example\n * ```tsx\n * import { ResourceLinks } from '@burdenoff/website-sdk'\n *\n * // Minimal — pass the one resource without a CMS source today\n * <ResourceLinks links={{ community: 'https://support.vibecontrols.com' }} />\n *\n * // Suppress an item (e.g. hide Sister Companies for a standalone product)\n * <ResourceLinks links={{ companies: null }} />\n *\n * // Restrict + reorder\n * <ResourceLinks resources={['docs', 'community']} />\n * ```\n */\n\nimport { useProduct } from \"../product/product-provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type Resource = \"docs\" | \"community\" | \"careers\" | \"team\" | \"companies\";\n\nexport interface ResourceLinksProps {\n /**\n * Per-resource URL overrides. `undefined` falls through to CMS/default\n * resolution; explicit `null` suppresses the link entirely.\n */\n links?: Partial<Record<Resource, string | null>>;\n /**\n * Allowlist + ORDER of resources to render. Default: canonical order\n * (docs, community, careers, team, companies). Pass a subset/reorder to\n * customise the column on a per-site basis.\n */\n resources?: readonly Resource[];\n /** Per-link label overrides. Falls back to canonical English labels. */\n labels?: Partial<Record<Resource, string>>;\n /** Outer container className. Default: `\"space-y-2\"` (vertical list). */\n className?: string;\n /**\n * Per-link className. Default matches the muted-foreground / hover-foreground\n * style used by the other Footer columns.\n */\n itemClassName?: string;\n}\n\n// ============================================================================\n// Defaults\n// ============================================================================\n\nconst DEFAULT_LABELS: Record<Resource, string> = {\n docs: \"Docs\",\n community: \"Community\",\n careers: \"Careers\",\n team: \"Team\",\n companies: \"Sister Companies\",\n};\n\n/**\n * Burdenoff-group-wide URLs — same across every product website by default.\n * `null` means \"no group default; must be supplied per-site or via CMS\".\n */\nconst BURDENOFF_GROUP_DEFAULTS: Record<Resource, string | null> = {\n docs: null, // per-product → read from `Product.docsUrl`\n community: null, // per-product → caller supplies via prop until CMS field exists\n careers: \"https://burdenoff.com/careers\",\n team: \"https://burdenoff.com/team\",\n companies: \"https://burdenoff.com/companies\",\n};\n\nconst DEFAULT_ORDER: readonly Resource[] = [\n \"docs\",\n \"community\",\n \"careers\",\n \"team\",\n \"companies\",\n];\n\nconst DEFAULT_ITEM_CLASS =\n \"text-sm text-muted-foreground hover:text-foreground transition-colors\";\n\n/**\n * Defense-in-depth XSS guard for any URL flowing into a rendered `href`.\n * Only http(s), mailto, tel, and relative URLs pass through. `javascript:`,\n * `data:`, `vbscript:`, etc. are rejected. The CMS is admin-only, but\n * treating the URL as untrusted is cheap and consistent with the wider\n * Burdenoff posture.\n */\nfunction isSafeUrl(value: string): boolean {\n const trimmed = value.trim();\n if (/^[/?#]/.test(trimmed)) return true;\n return /^(https?:|mailto:|tel:)/i.test(trimmed);\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function ResourceLinks({\n links: linksProp,\n resources = DEFAULT_ORDER,\n labels = {},\n className = \"space-y-2\",\n itemClassName = DEFAULT_ITEM_CLASS,\n}: ResourceLinksProps) {\n const { product } = useProduct();\n\n /**\n * Resolve a resource's URL. Precedence:\n * 1. Explicit prop — caller wins.\n * • `string` → use it\n * • `null` → suppress (force-omit even if CMS / defaults exist)\n * • `undefined` value (the key exists in `links` but is undefined)\n * → treat as \"fall through to CMS / defaults\" (same as\n * if the key wasn't in `links` at all)\n * 2. `product.resourceLinks[resource]` — CMS-managed JSON record on\n * `Product`; the canonical source once an admin has set values\n * via microfe-product → Products → <product> → Resource Links.\n * 3. CMS field for resources that have a dedicated column\n * (`docs` → `product.docsUrl`) — kept as a fallback for legacy\n * products that only have the older single-field set.\n * 4. Burdenoff group-wide default.\n * 5. `null` → omit the link.\n *\n * URLs returned by any path go through `isSafeUrl()` downstream; bad\n * schemes are dropped during the render pass.\n */\n function resolve(resource: Resource): string | null {\n if (linksProp && resource in linksProp) {\n const override = linksProp[resource];\n // `undefined` → fall through; `null` (explicit) → suppress\n if (override === undefined) {\n // continue to CMS / defaults below\n } else {\n return override;\n }\n }\n const fromCms = product?.resourceLinks?.[resource];\n if (typeof fromCms === \"string\" && fromCms.length > 0) {\n return fromCms;\n }\n if (resource === \"docs\" && product?.docsUrl) {\n return product.docsUrl;\n }\n return BURDENOFF_GROUP_DEFAULTS[resource];\n }\n\n const items = resources\n .map((r) => ({ resource: r, url: resolve(r) }))\n .filter(\n (x): x is { resource: Resource; url: string } =>\n !!x.url && isSafeUrl(x.url),\n );\n\n if (items.length === 0) return null;\n\n return (\n <ul className={className}>\n {items.map(({ resource, url }) => (\n <li key={resource}>\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={itemClassName}\n >\n {labels[resource] ?? DEFAULT_LABELS[resource]}\n </a>\n </li>\n ))}\n </ul>\n );\n}\n"]}
@@ -0,0 +1,227 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
3
+
4
+ var __defProp = Object.defineProperty;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
+
8
+ // src/client/security.ts
9
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
10
+ var RESERVED_HEADERS = /* @__PURE__ */ new Set([
11
+ "authorization",
12
+ "cookie",
13
+ "x-actor-id",
14
+ "x-actor-type",
15
+ "x-consent-granted-by",
16
+ "x-consent-scope",
17
+ "x-consent-granted-at",
18
+ "x-consent-expires-at"
19
+ ]);
20
+ function validateGatewayUrl(value) {
21
+ let url;
22
+ try {
23
+ url = new URL(value);
24
+ } catch {
25
+ throw new Error("Gateway URL must be a valid absolute URL");
26
+ }
27
+ if (url.protocol === "https:") {
28
+ return url.toString();
29
+ }
30
+ if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname.toLowerCase())) {
31
+ return url.toString();
32
+ }
33
+ throw new Error("Gateway URL must use HTTPS unless it targets localhost");
34
+ }
35
+ function sanitizeHeaders(headers) {
36
+ if (!headers) return void 0;
37
+ return Object.fromEntries(
38
+ Object.entries(headers).filter(
39
+ ([key]) => !RESERVED_HEADERS.has(key.toLowerCase())
40
+ )
41
+ );
42
+ }
43
+
44
+ // src/client/graphql-client.ts
45
+ var _WebSDKClient = class _WebSDKClient {
46
+ constructor(config) {
47
+ __publicField(this, "config");
48
+ __publicField(this, "queryCache", /* @__PURE__ */ new Map());
49
+ __publicField(this, "inflight", /* @__PURE__ */ new Map());
50
+ this.config = {
51
+ ...config,
52
+ gatewayUrl: validateGatewayUrl(config.gatewayUrl),
53
+ headers: sanitizeHeaders(config.headers)
54
+ };
55
+ }
56
+ getConfig() {
57
+ return this.config;
58
+ }
59
+ /**
60
+ * Execute a GraphQL query (with deduplication and caching)
61
+ */
62
+ async query(query, variables) {
63
+ const cacheKey = JSON.stringify({ query, variables });
64
+ const cached = this.queryCache.get(cacheKey);
65
+ if (cached) {
66
+ if (Date.now() - cached.ts < _WebSDKClient.CACHE_TTL) {
67
+ return cached.data;
68
+ }
69
+ this.queryCache.delete(cacheKey);
70
+ }
71
+ const existing = this.inflight.get(cacheKey);
72
+ if (existing) {
73
+ return existing;
74
+ }
75
+ const promise = this.request(query, variables).then((result) => {
76
+ if (result.data && !result.errors?.length) {
77
+ if (this.queryCache.size >= _WebSDKClient.MAX_CACHE_ENTRIES) {
78
+ const oldestEntry = this.queryCache.keys().next().value;
79
+ if (oldestEntry) {
80
+ this.queryCache.delete(oldestEntry);
81
+ }
82
+ }
83
+ this.queryCache.set(cacheKey, { data: result, ts: Date.now() });
84
+ }
85
+ return result;
86
+ }).finally(() => {
87
+ this.inflight.delete(cacheKey);
88
+ });
89
+ this.inflight.set(cacheKey, promise);
90
+ return promise;
91
+ }
92
+ /**
93
+ * Execute a GraphQL mutation
94
+ */
95
+ async mutate(mutation, variables) {
96
+ return this.request(mutation, variables);
97
+ }
98
+ async request(query, variables) {
99
+ try {
100
+ const response = await fetch(this.config.gatewayUrl, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ "x-product-id": this.config.productId,
105
+ ...this.config.headers
106
+ },
107
+ body: JSON.stringify({ query, variables })
108
+ });
109
+ if (!response.ok) {
110
+ console.error(
111
+ "[WebSDK] GraphQL request failed",
112
+ response.status,
113
+ response.statusText
114
+ );
115
+ return {
116
+ errors: [
117
+ {
118
+ message: `HTTP ${response.status}: ${response.statusText}`,
119
+ extensions: { code: `HTTP_${response.status}` }
120
+ }
121
+ ]
122
+ };
123
+ }
124
+ return await response.json();
125
+ } catch (error) {
126
+ console.error(
127
+ "[WebSDK] Network error",
128
+ error instanceof Error ? error.message : "unknown error"
129
+ );
130
+ return {
131
+ errors: [
132
+ {
133
+ message: error instanceof Error ? error.message : "Network error",
134
+ extensions: { code: "NETWORK_ERROR" }
135
+ }
136
+ ]
137
+ };
138
+ }
139
+ }
140
+ };
141
+ __publicField(_WebSDKClient, "CACHE_TTL", 5 * 60 * 1e3);
142
+ // 5 minutes
143
+ __publicField(_WebSDKClient, "MAX_CACHE_ENTRIES", 100);
144
+ createContext(null);
145
+ var ProductContext = createContext({
146
+ product: null,
147
+ loading: true,
148
+ error: null,
149
+ hasProvider: false,
150
+ refetch: () => {
151
+ }
152
+ });
153
+ function useProduct() {
154
+ return useContext(ProductContext);
155
+ }
156
+ var DEFAULT_LABELS = {
157
+ docs: "Docs",
158
+ community: "Community",
159
+ careers: "Careers",
160
+ team: "Team",
161
+ companies: "Sister Companies"
162
+ };
163
+ var BURDENOFF_GROUP_DEFAULTS = {
164
+ docs: null,
165
+ // per-product → read from `Product.docsUrl`
166
+ community: null,
167
+ // per-product → caller supplies via prop until CMS field exists
168
+ careers: "https://burdenoff.com/careers",
169
+ team: "https://burdenoff.com/team",
170
+ companies: "https://burdenoff.com/companies"
171
+ };
172
+ var DEFAULT_ORDER = [
173
+ "docs",
174
+ "community",
175
+ "careers",
176
+ "team",
177
+ "companies"
178
+ ];
179
+ var DEFAULT_ITEM_CLASS = "text-sm text-muted-foreground hover:text-foreground transition-colors";
180
+ function isSafeUrl(value) {
181
+ const trimmed = value.trim();
182
+ if (/^[/?#]/.test(trimmed)) return true;
183
+ return /^(https?:|mailto:|tel:)/i.test(trimmed);
184
+ }
185
+ function ResourceLinks({
186
+ links: linksProp,
187
+ resources = DEFAULT_ORDER,
188
+ labels = {},
189
+ className = "space-y-2",
190
+ itemClassName = DEFAULT_ITEM_CLASS
191
+ }) {
192
+ const { product } = useProduct();
193
+ function resolve(resource) {
194
+ if (linksProp && resource in linksProp) {
195
+ const override = linksProp[resource];
196
+ if (override === void 0) ; else {
197
+ return override;
198
+ }
199
+ }
200
+ const fromCms = product?.resourceLinks?.[resource];
201
+ if (typeof fromCms === "string" && fromCms.length > 0) {
202
+ return fromCms;
203
+ }
204
+ if (resource === "docs" && product?.docsUrl) {
205
+ return product.docsUrl;
206
+ }
207
+ return BURDENOFF_GROUP_DEFAULTS[resource];
208
+ }
209
+ const items = resources.map((r) => ({ resource: r, url: resolve(r) })).filter(
210
+ (x) => !!x.url && isSafeUrl(x.url)
211
+ );
212
+ if (items.length === 0) return null;
213
+ return /* @__PURE__ */ jsx("ul", { className, children: items.map(({ resource, url }) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
214
+ "a",
215
+ {
216
+ href: url,
217
+ target: "_blank",
218
+ rel: "noopener noreferrer",
219
+ className: itemClassName,
220
+ children: labels[resource] ?? DEFAULT_LABELS[resource]
221
+ }
222
+ ) }, resource)) });
223
+ }
224
+
225
+ export { ResourceLinks };
226
+ //# sourceMappingURL=resource-links.mjs.map
227
+ //# sourceMappingURL=resource-links.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/security.ts","../../src/client/graphql-client.ts","../../src/client/provider.tsx","../../src/product/product-provider.tsx","../../src/components/resource-links.tsx"],"names":["createContext","useContext","jsx"],"mappings":"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","file":"resource-links.mjs","sourcesContent":["const LOOPBACK_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"::1\"]);\nconst RESERVED_HEADERS = new Set([\n \"authorization\",\n \"cookie\",\n \"x-actor-id\",\n \"x-actor-type\",\n \"x-consent-granted-by\",\n \"x-consent-scope\",\n \"x-consent-granted-at\",\n \"x-consent-expires-at\",\n]);\n\nexport function validateGatewayUrl(value: string): string {\n let url: URL;\n\n try {\n url = new URL(value);\n } catch {\n throw new Error(\"Gateway URL must be a valid absolute URL\");\n }\n\n if (url.protocol === \"https:\") {\n return url.toString();\n }\n\n if (\n url.protocol === \"http:\" &&\n LOOPBACK_HOSTS.has(url.hostname.toLowerCase())\n ) {\n return url.toString();\n }\n\n throw new Error(\"Gateway URL must use HTTPS unless it targets localhost\");\n}\n\nexport function sanitizeHeaders(\n headers?: Record<string, string>,\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n\n return Object.fromEntries(\n Object.entries(headers).filter(\n ([key]) => !RESERVED_HEADERS.has(key.toLowerCase()),\n ),\n );\n}\n","/**\n * Lightweight GraphQL client for the Burdenoff Web SDK.\n * Uses native fetch - zero external dependencies.\n * All calls are unauthenticated (public gateway operations only).\n */\n\nimport { sanitizeHeaders, validateGatewayUrl } from \"./security\";\n\nexport interface WebSDKClientConfig {\n /** Global public gateway URL (e.g. \"https://api.burdenoff.com/global/graphql\") */\n gatewayUrl: string;\n /** Platform product ID for scoping operations */\n productId: string;\n /** Product slug (used for public product queries) */\n productSlug?: string;\n /** reCAPTCHA site key (if not fetched from product config) */\n recaptchaSiteKey?: string;\n /** Rybbit analytics site ID */\n rybbitSiteId?: string;\n /** Custom headers to include in all requests */\n headers?: Record<string, string>;\n}\n\nexport interface GraphQLResponse<T = Record<string, unknown>> {\n data?: T;\n errors?: Array<{\n message: string;\n locations?: Array<{ line: number; column: number }>;\n path?: Array<string | number>;\n extensions?: Record<string, unknown>;\n }>;\n}\n\nexport class WebSDKClient {\n private config: WebSDKClientConfig;\n private queryCache = new Map<\n string,\n { data: GraphQLResponse<unknown>; ts: number }\n >();\n private inflight = new Map<string, Promise<GraphQLResponse<unknown>>>();\n private static CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n private static MAX_CACHE_ENTRIES = 100;\n\n constructor(config: WebSDKClientConfig) {\n this.config = {\n ...config,\n gatewayUrl: validateGatewayUrl(config.gatewayUrl),\n headers: sanitizeHeaders(config.headers),\n };\n }\n\n getConfig(): WebSDKClientConfig {\n return this.config;\n }\n\n /**\n * Execute a GraphQL query (with deduplication and caching)\n */\n async query<T = Record<string, unknown>>(\n query: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n const cacheKey = JSON.stringify({ query, variables });\n\n // Return cached response if still valid, evict if expired\n const cached = this.queryCache.get(cacheKey);\n if (cached) {\n if (Date.now() - cached.ts < WebSDKClient.CACHE_TTL) {\n return cached.data as GraphQLResponse<T>;\n }\n this.queryCache.delete(cacheKey);\n }\n\n // Deduplicate: if same query is already in-flight, reuse the promise\n const existing = this.inflight.get(cacheKey);\n if (existing) {\n return existing as Promise<GraphQLResponse<T>>;\n }\n\n const promise = this.request<T>(query, variables)\n .then((result) => {\n // Cache successful responses only\n if (result.data && !result.errors?.length) {\n if (this.queryCache.size >= WebSDKClient.MAX_CACHE_ENTRIES) {\n const oldestEntry = this.queryCache.keys().next().value;\n if (oldestEntry) {\n this.queryCache.delete(oldestEntry);\n }\n }\n this.queryCache.set(cacheKey, { data: result, ts: Date.now() });\n }\n return result;\n })\n .finally(() => {\n this.inflight.delete(cacheKey);\n });\n\n this.inflight.set(cacheKey, promise);\n return promise;\n }\n\n /**\n * Execute a GraphQL mutation\n */\n async mutate<T = Record<string, unknown>>(\n mutation: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n return this.request<T>(mutation, variables);\n }\n\n private async request<T>(\n query: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n try {\n const response = await fetch(this.config.gatewayUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-product-id\": this.config.productId,\n ...this.config.headers,\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n console.error(\n \"[WebSDK] GraphQL request failed\",\n response.status,\n response.statusText,\n );\n return {\n errors: [\n {\n message: `HTTP ${response.status}: ${response.statusText}`,\n extensions: { code: `HTTP_${response.status}` },\n },\n ],\n };\n }\n\n return (await response.json()) as GraphQLResponse<T>;\n } catch (error) {\n console.error(\n \"[WebSDK] Network error\",\n error instanceof Error ? error.message : \"unknown error\",\n );\n return {\n errors: [\n {\n message: error instanceof Error ? error.message : \"Network error\",\n extensions: { code: \"NETWORK_ERROR\" },\n },\n ],\n };\n }\n }\n}\n","/**\n * React context provider for the Web SDK client.\n * Wraps the application with WebSDKClient configuration.\n */\n\"use client\";\n\nimport { createContext, useContext, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { resolveGatewayUrl } from \"./gateway-resolver\";\nimport { WebSDKClient, type WebSDKClientConfig } from \"./graphql-client\";\n\nconst WebSDKContext = createContext<WebSDKClient | null>(null);\n\nexport interface WebSDKProviderProps {\n /** SDK client configuration */\n config: WebSDKClientConfig;\n /** Child components */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the WebSDKClient available to all child components.\n *\n * @example\n * ```tsx\n * <WebSDKProvider config={{\n * gatewayUrl: \"https://api.burdenoff.com/global/graphql\",\n * productId: \"your-product-id\",\n * productSlug: \"your-product\",\n * }}>\n * <App />\n * </WebSDKProvider>\n * ```\n */\nexport function WebSDKProvider({ config, children }: WebSDKProviderProps) {\n const client = useMemo(() => {\n const resolvedConfig = {\n ...config,\n gatewayUrl: resolveGatewayUrl(config.gatewayUrl),\n };\n return new WebSDKClient(resolvedConfig);\n }, [config]);\n\n return (\n <WebSDKContext.Provider value={client}>{children}</WebSDKContext.Provider>\n );\n}\n\n/**\n * Hook to access the WebSDKClient instance.\n * Must be used within a WebSDKProvider.\n */\nexport function useWebSDK(): WebSDKClient {\n const client = useContext(WebSDKContext);\n if (!client) {\n throw new Error(\"useWebSDK must be used within a WebSDKProvider\");\n }\n return client;\n}\n\n/**\n * Hook to access the SDK configuration.\n * Useful for components that need config values like productId, recaptchaSiteKey, etc.\n */\nexport function useWebSDKConfig(): WebSDKClientConfig {\n const client = useWebSDK();\n return client.getConfig();\n}\n","/**\n * Product Provider\n *\n * React context and hooks for accessing product metadata from the global tenant service.\n * Fetches product info (name, logo, SEO, contact, reCAPTCHA key, etc.) on mount\n * and provides it to all child components.\n */\n\"use client\";\n\nimport {\n createContext,\n useContext,\n useState,\n useLayoutEffect,\n useCallback,\n useMemo,\n} from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useWebSDK, useWebSDKConfig } from \"../client/provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface ProductData {\n id: string;\n name: string;\n slug: string;\n description?: string | null;\n status: string;\n logoUrl?: string | null;\n website?: string | null;\n appUrl?: string | null;\n docsUrl?: string | null;\n contactEmail?: string | null;\n supportEmail?: string | null;\n contactPhone?: string | null;\n contactAddress?: string | null;\n seoTitle?: string | null;\n seoDescription?: string | null;\n seoKeywords?: string | null;\n ogImage?: string | null;\n socialLinks?: Record<string, string> | null;\n /**\n * Resource links for the website footer \"Resources\" column\n * (JSON: { docs, community, careers, team, companies, ... }).\n * Same shape as socialLinks. Free-form keys; the ResourceLinks\n * component renders known keys and silently ignores the rest.\n * Backed by Product.resourceLinks on the backend (global-tenant-svc).\n */\n resourceLinks?: Record<string, string> | null;\n recaptchaSiteKey?: string | null;\n rybbitSiteId?: string | null;\n notificationEmail?: string | null;\n metadata?: Record<string, unknown> | null;\n}\n\nexport interface ProductProviderProps {\n /** Child components */\n children: ReactNode;\n /** Optional fallback product data (used while loading) */\n fallback?: Partial<ProductData>;\n}\n\n// ============================================================================\n// GraphQL Query\n// ============================================================================\n\n/**\n * Body of fields requested for every product fetch — shared by the primary\n * query and the legacy-backend fallback below. Kept as a string fragment\n * (not a real GraphQL `fragment` block) so we can append/strip\n * `resourceLinks` without touching anything else.\n */\nconst PRODUCT_FIELDS_BASE = `\n id\n name\n slug\n description\n status\n logoUrl\n website\n appUrl\n docsUrl\n contactEmail\n supportEmail\n contactPhone\n contactAddress\n seoTitle\n seoDescription\n seoKeywords\n ogImage\n socialLinks\n recaptchaSiteKey\n rybbitSiteId\n notificationEmail\n metadata\n`;\n\n/**\n * Primary query — includes the recently-added `resourceLinks` field.\n * Backends that have run the corresponding `global-tenant-svc` migration\n * (PR #64) expose this field; older ones don't, in which case\n * GraphQL validation rejects the whole query with\n * `Cannot query field \"resourceLinks\" on type \"PublicProduct\"`. The fetch\n * helper detects that specific error and transparently retries the\n * fallback below, so the SDK works on both pre- and post-migration\n * deployments without consumer-side gating.\n */\nconst PUBLIC_PRODUCT_QUERY = `\n query PublicProduct($slug: String!) {\n publicProduct(slug: $slug) {\n ${PRODUCT_FIELDS_BASE}\n resourceLinks\n }\n }\n`;\n\n/**\n * Fallback query for older backend deployments. Identical to the primary\n * minus `resourceLinks`. Consumers see `product.resourceLinks === undefined`\n * and any consumer (e.g. `<ResourceLinks/>`) falls through to prop / default\n * resolution paths.\n */\nconst PUBLIC_PRODUCT_QUERY_FALLBACK = `\n query PublicProductFallback($slug: String!) {\n publicProduct(slug: $slug) {\n ${PRODUCT_FIELDS_BASE}\n }\n }\n`;\n\n// ============================================================================\n// Context\n// ============================================================================\n\ninterface ProductContextValue {\n product: ProductData | null;\n loading: boolean;\n error: string | null;\n hasProvider: boolean;\n refetch: () => void;\n}\n\nconst ProductContext = createContext<ProductContextValue>({\n product: null,\n loading: true,\n error: null,\n hasProvider: false,\n refetch: () => {},\n});\n\n// ============================================================================\n// Provider\n// ============================================================================\n\n/**\n * ProductProvider fetches and provides product metadata to child components.\n * Uses the product slug from WebSDKConfig to fetch from the public API.\n *\n * @example\n * ```tsx\n * <WebSDKProvider config={sdkConfig}>\n * <ProductProvider>\n * <App />\n * </ProductProvider>\n * </WebSDKProvider>\n * ```\n */\nexport function ProductProvider({ children, fallback }: ProductProviderProps) {\n const client = useWebSDK();\n const config = useWebSDKConfig();\n const [product, setProduct] = useState<ProductData | null>(\n fallback ? (fallback as ProductData) : null,\n );\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const fetchProduct = useCallback(async () => {\n if (!config.productSlug) {\n setLoading(false);\n return;\n }\n\n setLoading(true);\n setError(null);\n\n try {\n let result = await client.query<{ publicProduct: ProductData | null }>(\n PUBLIC_PRODUCT_QUERY,\n { slug: config.productSlug },\n );\n\n // Backwards-compat fallback: if the backend's `publicProduct` schema\n // doesn't yet expose `resourceLinks` (deployed before\n // `global-tenant-svc` PR #64), the primary query fails validation\n // with a \"Cannot query field …\" error and `result.data` is null.\n // Retry the fallback query that omits `resourceLinks`; consumers\n // gracefully see `product.resourceLinks === undefined` and fall\n // through their own resolution paths.\n const schemaMissingResourceLinks = result.errors?.some((e) =>\n /Cannot query field [\"']?resourceLinks[\"']?/i.test(e?.message ?? \"\"),\n );\n if (schemaMissingResourceLinks) {\n result = await client.query<{ publicProduct: ProductData | null }>(\n PUBLIC_PRODUCT_QUERY_FALLBACK,\n { slug: config.productSlug },\n );\n }\n\n if (result.errors?.length) {\n setError(result.errors[0]?.message ?? \"Unknown error\");\n } else if (result.data?.publicProduct) {\n setProduct(result.data.publicProduct);\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to fetch product\");\n } finally {\n setLoading(false);\n }\n }, [client, config.productSlug]);\n\n // Use useLayoutEffect for earliest possible fetch after commit (before paint).\n // Runs when slug or client changes. Client-side query cache + dedup ensures\n // no duplicate network requests even if React StrictMode fires this twice.\n useLayoutEffect(() => {\n fetchProduct();\n }, [fetchProduct]);\n\n const value = useMemo(\n () => ({\n product,\n loading,\n error,\n hasProvider: true,\n refetch: fetchProduct,\n }),\n [product, loading, error, fetchProduct],\n );\n\n return (\n <ProductContext.Provider value={value}>{children}</ProductContext.Provider>\n );\n}\n\n// ============================================================================\n// Hooks\n// ============================================================================\n\n/**\n * Hook to access the current product data.\n *\n * @example\n * ```tsx\n * const { product, loading } = useProduct();\n * if (loading) return <Loading />;\n * return <h1>{product?.name}</h1>;\n * ```\n */\nexport function useProduct(): ProductContextValue {\n return useContext(ProductContext);\n}\n\n/**\n * Hook to get product-specific configuration.\n * Merges SDK config with fetched product data.\n * Useful for getting reCAPTCHA keys, analytics IDs, etc.\n *\n * @example\n * ```tsx\n * const { recaptchaSiteKey, rybbitSiteId } = useProductConfig();\n * ```\n */\nexport function useProductConfig(): {\n recaptchaSiteKey: string | null;\n rybbitSiteId: string | null;\n contactEmail: string | null;\n supportEmail: string | null;\n notificationEmail: string | null;\n productName: string | null;\n logoUrl: string | null;\n} {\n const { product } = useProduct();\n const config = useWebSDKConfig();\n\n return {\n // Prefer product-level config, fall back to SDK config\n recaptchaSiteKey:\n product?.recaptchaSiteKey ?? config.recaptchaSiteKey ?? null,\n rybbitSiteId: product?.rybbitSiteId ?? config.rybbitSiteId ?? null,\n contactEmail: product?.contactEmail ?? null,\n supportEmail: product?.supportEmail ?? null,\n notificationEmail:\n product?.notificationEmail ?? product?.contactEmail ?? null,\n productName: product?.name ?? null,\n logoUrl: product?.logoUrl ?? null,\n };\n}\n","\"use client\";\n\n/**\n * ResourceLinks Component\n *\n * Renders the Footer \"Resources\" column. Hybrid data sourcing — uses the\n * product CMS where a field already exists, prop overrides where CMS doesn't\n * (yet) have a column, and Burdenoff-group-wide defaults for resources that\n * are constant across every product website.\n *\n * | Resource | Default source | Notes |\n * | ------------ | ----------------------------------------------- | ----- |\n * | `docs` | `useProduct().product.docsUrl` (existing field) | Per-product; admin can change in `microfe-product → Products → <product>` |\n * | `community` | (none — pass via `links` prop) | Per-product; needs `Product.communityUrl` in backend to become CMS-managed |\n * | `careers` | `https://burdenoff.com/careers` (group default) | Burdenoff-wide constant; overridable |\n * | `team` | `https://burdenoff.com/team` (group default) | Burdenoff-wide constant; overridable |\n * | `companies` | `https://burdenoff.com/companies` (group default)| Burdenoff-wide constant; overridable |\n *\n * Future-compat: if the CMS grows a `Product.resourceLinks` JSON field\n * mirroring `socialLinks`, the resolver below switches to read it as the\n * first preference — no consumer-side change.\n *\n * @example\n * ```tsx\n * import { ResourceLinks } from '@burdenoff/website-sdk'\n *\n * // Minimal — pass the one resource without a CMS source today\n * <ResourceLinks links={{ community: 'https://support.vibecontrols.com' }} />\n *\n * // Suppress an item (e.g. hide Sister Companies for a standalone product)\n * <ResourceLinks links={{ companies: null }} />\n *\n * // Restrict + reorder\n * <ResourceLinks resources={['docs', 'community']} />\n * ```\n */\n\nimport { useProduct } from \"../product/product-provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type Resource = \"docs\" | \"community\" | \"careers\" | \"team\" | \"companies\";\n\nexport interface ResourceLinksProps {\n /**\n * Per-resource URL overrides. `undefined` falls through to CMS/default\n * resolution; explicit `null` suppresses the link entirely.\n */\n links?: Partial<Record<Resource, string | null>>;\n /**\n * Allowlist + ORDER of resources to render. Default: canonical order\n * (docs, community, careers, team, companies). Pass a subset/reorder to\n * customise the column on a per-site basis.\n */\n resources?: readonly Resource[];\n /** Per-link label overrides. Falls back to canonical English labels. */\n labels?: Partial<Record<Resource, string>>;\n /** Outer container className. Default: `\"space-y-2\"` (vertical list). */\n className?: string;\n /**\n * Per-link className. Default matches the muted-foreground / hover-foreground\n * style used by the other Footer columns.\n */\n itemClassName?: string;\n}\n\n// ============================================================================\n// Defaults\n// ============================================================================\n\nconst DEFAULT_LABELS: Record<Resource, string> = {\n docs: \"Docs\",\n community: \"Community\",\n careers: \"Careers\",\n team: \"Team\",\n companies: \"Sister Companies\",\n};\n\n/**\n * Burdenoff-group-wide URLs — same across every product website by default.\n * `null` means \"no group default; must be supplied per-site or via CMS\".\n */\nconst BURDENOFF_GROUP_DEFAULTS: Record<Resource, string | null> = {\n docs: null, // per-product → read from `Product.docsUrl`\n community: null, // per-product → caller supplies via prop until CMS field exists\n careers: \"https://burdenoff.com/careers\",\n team: \"https://burdenoff.com/team\",\n companies: \"https://burdenoff.com/companies\",\n};\n\nconst DEFAULT_ORDER: readonly Resource[] = [\n \"docs\",\n \"community\",\n \"careers\",\n \"team\",\n \"companies\",\n];\n\nconst DEFAULT_ITEM_CLASS =\n \"text-sm text-muted-foreground hover:text-foreground transition-colors\";\n\n/**\n * Defense-in-depth XSS guard for any URL flowing into a rendered `href`.\n * Only http(s), mailto, tel, and relative URLs pass through. `javascript:`,\n * `data:`, `vbscript:`, etc. are rejected. The CMS is admin-only, but\n * treating the URL as untrusted is cheap and consistent with the wider\n * Burdenoff posture.\n */\nfunction isSafeUrl(value: string): boolean {\n const trimmed = value.trim();\n if (/^[/?#]/.test(trimmed)) return true;\n return /^(https?:|mailto:|tel:)/i.test(trimmed);\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function ResourceLinks({\n links: linksProp,\n resources = DEFAULT_ORDER,\n labels = {},\n className = \"space-y-2\",\n itemClassName = DEFAULT_ITEM_CLASS,\n}: ResourceLinksProps) {\n const { product } = useProduct();\n\n /**\n * Resolve a resource's URL. Precedence:\n * 1. Explicit prop — caller wins.\n * • `string` → use it\n * • `null` → suppress (force-omit even if CMS / defaults exist)\n * • `undefined` value (the key exists in `links` but is undefined)\n * → treat as \"fall through to CMS / defaults\" (same as\n * if the key wasn't in `links` at all)\n * 2. `product.resourceLinks[resource]` — CMS-managed JSON record on\n * `Product`; the canonical source once an admin has set values\n * via microfe-product → Products → <product> → Resource Links.\n * 3. CMS field for resources that have a dedicated column\n * (`docs` → `product.docsUrl`) — kept as a fallback for legacy\n * products that only have the older single-field set.\n * 4. Burdenoff group-wide default.\n * 5. `null` → omit the link.\n *\n * URLs returned by any path go through `isSafeUrl()` downstream; bad\n * schemes are dropped during the render pass.\n */\n function resolve(resource: Resource): string | null {\n if (linksProp && resource in linksProp) {\n const override = linksProp[resource];\n // `undefined` → fall through; `null` (explicit) → suppress\n if (override === undefined) {\n // continue to CMS / defaults below\n } else {\n return override;\n }\n }\n const fromCms = product?.resourceLinks?.[resource];\n if (typeof fromCms === \"string\" && fromCms.length > 0) {\n return fromCms;\n }\n if (resource === \"docs\" && product?.docsUrl) {\n return product.docsUrl;\n }\n return BURDENOFF_GROUP_DEFAULTS[resource];\n }\n\n const items = resources\n .map((r) => ({ resource: r, url: resolve(r) }))\n .filter(\n (x): x is { resource: Resource; url: string } =>\n !!x.url && isSafeUrl(x.url),\n );\n\n if (items.length === 0) return null;\n\n return (\n <ul className={className}>\n {items.map(({ resource, url }) => (\n <li key={resource}>\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={itemClassName}\n >\n {labels[resource] ?? DEFAULT_LABELS[resource]}\n </a>\n </li>\n ))}\n </ul>\n );\n}\n"]}