@ably/ui 18.1.0 → 18.2.0

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.
package/AGENTS.md CHANGED
@@ -25,13 +25,13 @@
25
25
  & tailwind-merge)
26
26
  - **Formatting**: Prettier defaults (no config = defaults), 2-space indent
27
27
  - **Error Handling**: Wrap external service calls in try-catch, log with logger module
28
- - **Comments**: JSDoc for props, inline comments for complex logic
28
+ - **Comments**: JSDoc for props, brief inline comments only for genuinely complex logic — keep them terse per the [root AGENTS.md](../../AGENTS.md) comment rule
29
29
 
30
30
  ### Custom Hooks
31
31
 
32
32
  - **Naming**: `use` prefix with descriptive name (e.g., `useContentHeight`, `useThemedScrollpoints`)
33
33
  - **JSDoc**: Always include for custom hooks, especially performance-related ones — document `@param` with types/defaults and `@returns` with type and semantic meaning
34
- - **Performance rationale**: Include "why" in JSDoc when optimizing (e.g., "eliminates forced reflows")
34
+ - **Performance rationale**: Note the "why" in JSDoc when optimizing — a short phrase, not a paragraph (e.g., "eliminates forced reflows")
35
35
  - **Cleanup**: Always return cleanup function to prevent memory leaks
36
36
  - **Shared constants**: Import from `src/core/utils/heights.ts` instead of duplicating
37
37
 
package/core/Code.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/Code.tsx"],"sourcesContent":["import React from \"react\";\nimport {\n highlightSnippet,\n LINE_HIGHLIGHT_CLASSES,\n registerDefaultLanguages,\n splitHtmlLines,\n} from \"./utils/syntax-highlighter\";\nimport languagesRegistry from \"./utils/syntax-highlighter-registry\";\nimport cn from \"./utils/cn\";\n\nregisterDefaultLanguages(languagesRegistry);\n\nexport type LineHighlightType = \"addition\" | \"removal\" | \"highlight\";\n\ntype CodeProps = {\n language: string;\n snippet: string;\n textSize?: string;\n padding?: string;\n additionalCSS?: string;\n showLines?: boolean;\n lineCSS?: string;\n wrap?: boolean;\n lineHighlights?: Record<number, LineHighlightType>;\n};\n\nconst Code = ({\n language,\n snippet,\n textSize = \"ui-text-code\",\n padding = \"p-8\",\n additionalCSS = \"\",\n showLines,\n lineCSS,\n wrap = false,\n lineHighlights,\n}: CodeProps) => {\n // Trim the snippet and remove trailing empty lines\n const trimmedSnippet = snippet.trimEnd();\n const HTMLraw = highlightSnippet(language, trimmedSnippet) ?? \"\";\n const className = `language-${language} ${textSize}`;\n\n // Calculate line count after removing trailing empty lines\n const lines = trimmedSnippet.split(/\\r\\n|\\r|\\n/);\n const lineCount = lines.length;\n\n const hasHighlights =\n lineHighlights && Object.keys(lineHighlights).length > 0;\n\n // Per-line rendering when highlights are present\n if (hasHighlights) {\n const htmlLines = splitHtmlLines(HTMLraw);\n\n return (\n <div\n className={cn(\"hljs overflow-y-auto\", padding, additionalCSS)}\n data-id=\"code\"\n >\n <pre\n lang={language}\n className={cn(\n \"h-full flex-1 text-p4 leading-normal\",\n wrap ? \"whitespace-pre-wrap break-words\" : \"overflow-x-auto\",\n )}\n >\n <code className={className}>\n {htmlLines.map((lineHtml, i) => {\n const lineNum = i + 1;\n const highlightType = lineHighlights[lineNum];\n const highlightClass = highlightType\n ? LINE_HIGHLIGHT_CLASSES[highlightType]\n : undefined;\n\n return (\n <span\n key={i}\n className={cn(\"flex min-w-full pl-2\", highlightClass)}\n >\n {showLines && (\n <span\n className={cn(\n \"mr-4 font-mono text-right text-neutral-800 select-none shrink-0 inline-block leading-normal\",\n lineCSS,\n )}\n style={{ minWidth: `${String(lineCount).length}ch` }}\n >\n {lineNum}\n </span>\n )}\n <span\n className=\"flex-1 !leading-normal\"\n dangerouslySetInnerHTML={{\n __html: lineHtml || \"&nbsp;\",\n }}\n />\n </span>\n );\n })}\n </code>\n </pre>\n </div>\n );\n }\n\n // Default: single-block rendering (no highlights)\n return (\n <div\n className={cn(\"hljs overflow-y-auto flex\", padding, additionalCSS)}\n data-id=\"code\"\n >\n {showLines ? (\n <div className=\"text-p4 leading-normal pt-px\">\n {[...Array(lineCount)].map((_, i) => (\n <p\n className={cn(\n \"mr-4 font-mono text-right text-neutral-800\",\n lineCSS,\n )}\n key={i}\n >\n {i + 1}\n </p>\n ))}\n </div>\n ) : null}\n <pre\n lang={language}\n className={cn(\n \"h-full flex-1 text-p4 leading-normal\",\n wrap ? \"whitespace-pre-wrap break-words\" : \"overflow-x-auto\",\n )}\n >\n <code\n className={className}\n dangerouslySetInnerHTML={{ __html: HTMLraw }}\n />\n </pre>\n </div>\n );\n};\n\nexport default Code;\n"],"names":["React","highlightSnippet","LINE_HIGHLIGHT_CLASSES","registerDefaultLanguages","splitHtmlLines","languagesRegistry","cn","Code","language","snippet","textSize","padding","additionalCSS","showLines","lineCSS","wrap","lineHighlights","trimmedSnippet","trimEnd","HTMLraw","className","lines","split","lineCount","length","hasHighlights","Object","keys","htmlLines","div","data-id","pre","lang","code","map","lineHtml","i","lineNum","highlightType","highlightClass","undefined","span","key","style","minWidth","String","dangerouslySetInnerHTML","__html","Array","_","p"],"mappings":"AAAA,OAAOA,UAAW,OAAQ,AAC1B,QACEC,gBAAgB,CAChBC,sBAAsB,CACtBC,wBAAwB,CACxBC,cAAc,KACT,4BAA6B,AACpC,QAAOC,sBAAuB,qCAAsC,AACpE,QAAOC,OAAQ,YAAa,CAE5BH,yBAAyBE,mBAgBzB,MAAME,KAAO,CAAC,CACZC,QAAQ,CACRC,OAAO,CACPC,SAAW,cAAc,CACzBC,QAAU,KAAK,CACfC,cAAgB,EAAE,CAClBC,SAAS,CACTC,OAAO,CACPC,KAAO,KAAK,CACZC,cAAc,CACJ,IAEV,MAAMC,eAAiBR,QAAQS,OAAO,GACtC,MAAMC,QAAUlB,iBAAiBO,SAAUS,iBAAmB,GAC9D,MAAMG,UAAY,CAAC,SAAS,EAAEZ,SAAS,CAAC,EAAEE,SAAS,CAAC,CAGpD,MAAMW,MAAQJ,eAAeK,KAAK,CAAC,cACnC,MAAMC,UAAYF,MAAMG,MAAM,CAE9B,MAAMC,cACJT,gBAAkBU,OAAOC,IAAI,CAACX,gBAAgBQ,MAAM,CAAG,EAGzD,GAAIC,cAAe,CACjB,MAAMG,UAAYxB,eAAee,SAEjC,OACE,oBAACU,OACCT,UAAWd,GAAG,uBAAwBK,QAASC,eAC/CkB,UAAQ,QAER,oBAACC,OACCC,KAAMxB,SACNY,UAAWd,GACT,uCACAS,KAAO,kCAAoC,oBAG7C,oBAACkB,QAAKb,UAAWA,WACdQ,UAAUM,GAAG,CAAC,CAACC,SAAUC,KACxB,MAAMC,QAAUD,EAAI,EACpB,MAAME,cAAgBtB,cAAc,CAACqB,QAAQ,CAC7C,MAAME,eAAiBD,cACnBpC,sBAAsB,CAACoC,cAAc,CACrCE,UAEJ,OACE,oBAACC,QACCC,IAAKN,EACLhB,UAAWd,GAAG,uBAAwBiC,iBAErC1B,WACC,oBAAC4B,QACCrB,UAAWd,GACT,8FACAQ,SAEF6B,MAAO,CAAEC,SAAU,CAAC,EAAEC,OAAOtB,WAAWC,MAAM,CAAC,EAAE,CAAC,AAAC,GAElDa,SAGL,oBAACI,QACCrB,UAAU,yBACV0B,wBAAyB,CACvBC,OAAQZ,UAAY,QACtB,IAIR,KAKV,CAGA,OACE,oBAACN,OACCT,UAAWd,GAAG,4BAA6BK,QAASC,eACpDkB,UAAQ,QAEPjB,UACC,oBAACgB,OAAIT,UAAU,gCACZ,IAAI4B,MAAMzB,WAAW,CAACW,GAAG,CAAC,CAACe,EAAGb,IAC7B,oBAACc,KACC9B,UAAWd,GACT,6CACAQ,SAEF4B,IAAKN,GAEJA,EAAI,KAIT,KACJ,oBAACL,OACCC,KAAMxB,SACNY,UAAWd,GACT,uCACAS,KAAO,kCAAoC,oBAG7C,oBAACkB,QACCb,UAAWA,UACX0B,wBAAyB,CAAEC,OAAQ5B,OAAQ,KAKrD,CAEA,gBAAeZ,IAAK"}
1
+ {"version":3,"sources":["../../src/core/Code.tsx"],"sourcesContent":["import React from \"react\";\nimport {\n highlightSnippet,\n LINE_HIGHLIGHT_CLASSES,\n registerDefaultLanguages,\n splitHtmlLines,\n} from \"./utils/syntax-highlighter\";\nimport languagesRegistry from \"./utils/syntax-highlighter-registry\";\nimport cn from \"./utils/cn\";\n\nregisterDefaultLanguages(languagesRegistry);\n\nexport type LineHighlightType = \"addition\" | \"removal\" | \"highlight\";\n\ntype CodeProps = {\n language: string;\n snippet: string;\n textSize?: string;\n padding?: string;\n additionalCSS?: string;\n showLines?: boolean;\n lineCSS?: string;\n wrap?: boolean;\n lineHighlights?: Record<number, LineHighlightType>;\n};\n\nconst Code = ({\n language,\n snippet,\n textSize = \"ui-text-code\",\n padding = \"p-8\",\n additionalCSS = \"\",\n showLines,\n lineCSS,\n wrap = false,\n lineHighlights,\n}: CodeProps) => {\n // Trim the snippet and remove trailing empty lines\n const trimmedSnippet = snippet.trimEnd();\n const HTMLraw = highlightSnippet(language, trimmedSnippet) ?? \"\";\n const className = `language-${language} ${textSize}`;\n\n // Calculate line count after removing trailing empty lines\n const lines = trimmedSnippet.split(/\\r\\n|\\r|\\n/);\n const lineCount = lines.length;\n\n const hasHighlights =\n lineHighlights && Object.keys(lineHighlights).length > 0;\n\n // Per-line rendering when highlights are present\n if (hasHighlights) {\n const htmlLines = splitHtmlLines(HTMLraw);\n\n return (\n <div\n className={cn(\"hljs overflow-y-auto\", padding, additionalCSS)}\n data-id=\"code\"\n >\n <pre\n lang={language}\n className={cn(\n \"h-full flex-1 text-p4 leading-normal\",\n wrap ? \"whitespace-pre-wrap break-words\" : \"overflow-x-auto\",\n )}\n >\n <code className={className}>\n {htmlLines.map((lineHtml, i) => {\n const lineNum = i + 1;\n const highlightType = lineHighlights[lineNum];\n const highlightClass = highlightType\n ? LINE_HIGHLIGHT_CLASSES[highlightType]\n : undefined;\n\n return (\n <span\n key={i}\n className={cn(\"flex min-w-full pl-2\", highlightClass)}\n >\n {showLines && (\n <span\n className={cn(\n \"mr-4 font-mono text-right text-neutral-800 select-none shrink-0 inline-block leading-normal\",\n lineCSS,\n )}\n style={{ minWidth: `${String(lineCount).length}ch` }}\n >\n {lineNum}\n </span>\n )}\n <span\n className=\"flex-1 !leading-normal\"\n // lineHtml comes from highlightSnippet() which HTML-escapes\n // its input before emitting tokens. The fallback is the\n // literal \"&nbsp;\" entity. No caller-controlled HTML reaches\n // this sink.\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{\n __html: lineHtml || \"&nbsp;\",\n }}\n />\n </span>\n );\n })}\n </code>\n </pre>\n </div>\n );\n }\n\n // Default: single-block rendering (no highlights)\n return (\n <div\n className={cn(\"hljs overflow-y-auto flex\", padding, additionalCSS)}\n data-id=\"code\"\n >\n {showLines ? (\n <div className=\"text-p4 leading-normal pt-px\">\n {[...Array(lineCount)].map((_, i) => (\n <p\n className={cn(\n \"mr-4 font-mono text-right text-neutral-800\",\n lineCSS,\n )}\n key={i}\n >\n {i + 1}\n </p>\n ))}\n </div>\n ) : null}\n <pre\n lang={language}\n className={cn(\n \"h-full flex-1 text-p4 leading-normal\",\n wrap ? \"whitespace-pre-wrap break-words\" : \"overflow-x-auto\",\n )}\n >\n <code\n className={className}\n // HTMLraw is produced by highlightSnippet(); same trust analysis as\n // the lineHtml site above — the highlighter escapes input before\n // emitting tokens.\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{ __html: HTMLraw }}\n />\n </pre>\n </div>\n );\n};\n\nexport default Code;\n"],"names":["React","highlightSnippet","LINE_HIGHLIGHT_CLASSES","registerDefaultLanguages","splitHtmlLines","languagesRegistry","cn","Code","language","snippet","textSize","padding","additionalCSS","showLines","lineCSS","wrap","lineHighlights","trimmedSnippet","trimEnd","HTMLraw","className","lines","split","lineCount","length","hasHighlights","Object","keys","htmlLines","div","data-id","pre","lang","code","map","lineHtml","i","lineNum","highlightType","highlightClass","undefined","span","key","style","minWidth","String","dangerouslySetInnerHTML","__html","Array","_","p"],"mappings":"AAAA,OAAOA,UAAW,OAAQ,AAC1B,QACEC,gBAAgB,CAChBC,sBAAsB,CACtBC,wBAAwB,CACxBC,cAAc,KACT,4BAA6B,AACpC,QAAOC,sBAAuB,qCAAsC,AACpE,QAAOC,OAAQ,YAAa,CAE5BH,yBAAyBE,mBAgBzB,MAAME,KAAO,CAAC,CACZC,QAAQ,CACRC,OAAO,CACPC,SAAW,cAAc,CACzBC,QAAU,KAAK,CACfC,cAAgB,EAAE,CAClBC,SAAS,CACTC,OAAO,CACPC,KAAO,KAAK,CACZC,cAAc,CACJ,IAEV,MAAMC,eAAiBR,QAAQS,OAAO,GACtC,MAAMC,QAAUlB,iBAAiBO,SAAUS,iBAAmB,GAC9D,MAAMG,UAAY,CAAC,SAAS,EAAEZ,SAAS,CAAC,EAAEE,SAAS,CAAC,CAGpD,MAAMW,MAAQJ,eAAeK,KAAK,CAAC,cACnC,MAAMC,UAAYF,MAAMG,MAAM,CAE9B,MAAMC,cACJT,gBAAkBU,OAAOC,IAAI,CAACX,gBAAgBQ,MAAM,CAAG,EAGzD,GAAIC,cAAe,CACjB,MAAMG,UAAYxB,eAAee,SAEjC,OACE,oBAACU,OACCT,UAAWd,GAAG,uBAAwBK,QAASC,eAC/CkB,UAAQ,QAER,oBAACC,OACCC,KAAMxB,SACNY,UAAWd,GACT,uCACAS,KAAO,kCAAoC,oBAG7C,oBAACkB,QAAKb,UAAWA,WACdQ,UAAUM,GAAG,CAAC,CAACC,SAAUC,KACxB,MAAMC,QAAUD,EAAI,EACpB,MAAME,cAAgBtB,cAAc,CAACqB,QAAQ,CAC7C,MAAME,eAAiBD,cACnBpC,sBAAsB,CAACoC,cAAc,CACrCE,UAEJ,OACE,oBAACC,QACCC,IAAKN,EACLhB,UAAWd,GAAG,uBAAwBiC,iBAErC1B,WACC,oBAAC4B,QACCrB,UAAWd,GACT,8FACAQ,SAEF6B,MAAO,CAAEC,SAAU,CAAC,EAAEC,OAAOtB,WAAWC,MAAM,CAAC,EAAE,CAAC,AAAC,GAElDa,SAGL,oBAACI,QACCrB,UAAU,yBAMV0B,wBAAyB,CACvBC,OAAQZ,UAAY,QACtB,IAIR,KAKV,CAGA,OACE,oBAACN,OACCT,UAAWd,GAAG,4BAA6BK,QAASC,eACpDkB,UAAQ,QAEPjB,UACC,oBAACgB,OAAIT,UAAU,gCACZ,IAAI4B,MAAMzB,WAAW,CAACW,GAAG,CAAC,CAACe,EAAGb,IAC7B,oBAACc,KACC9B,UAAWd,GACT,6CACAQ,SAEF4B,IAAKN,GAEJA,EAAI,KAIT,KACJ,oBAACL,OACCC,KAAMxB,SACNY,UAAWd,GACT,uCACAS,KAAO,kCAAoC,oBAG7C,oBAACkB,QACCb,UAAWA,UAKX0B,wBAAyB,CAAEC,OAAQ5B,OAAQ,KAKrD,CAEA,gBAAeZ,IAAK"}
package/core/Flash.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/Flash.tsx"],"sourcesContent":["import React, {\n useEffect,\n useState,\n useRef,\n createContext,\n useContext,\n useCallback,\n useMemo,\n PropsWithChildren,\n} from \"react\";\nimport DOMPurify from \"dompurify\";\nimport Icon from \"./Icon\";\nimport { ColorClass } from \"./styles/colors/types\";\nimport { IconName } from \"./Icon/types\";\n\ntype FlashPropsType = \"error\" | \"success\" | \"notice\" | \"info\" | \"alert\";\n\ntype FlashProps = {\n id: string;\n removed: boolean;\n type: FlashPropsType;\n content: string;\n removeFlash: (id: string) => void;\n};\n\ntype BackendFlashesProps = {\n flashes: string[][];\n};\n\nconst FLASH_DATA_ID = \"ui-flashes\";\n\ntype FlashContextType = {\n flashes: FlashProps[];\n addFlashes: (flashes: Pick<FlashProps, \"type\" | \"content\">[]) => void;\n removeFlash: (id: string) => void;\n};\n\nconst FlashContext = createContext<FlashContextType | undefined>(undefined);\n\ntype FlashProviderProps = PropsWithChildren;\n\n/**\n * FlashProvider - Context provider for managing flash messages throughout the application.\n *\n * Maintains a global list of flash messages and provides methods to add/remove them.\n * Automatically deduplicates messages with the same type and content to prevent duplicates.\n * Use this at the app root level and access via useFlashContext() in child components.\n */\nconst FlashProvider = ({ children }: FlashProviderProps) => {\n const [flashes, setFlashes] = useState<FlashProps[]>([]);\n\n const removeFlash = useCallback((flashId: string) => {\n setFlashes((prev) => prev.filter((item) => item.id !== flashId));\n }, []);\n\n const addFlashes = useCallback(\n (newFlashes: Pick<FlashProps, \"type\" | \"content\">[]) => {\n setFlashes((prev) => {\n const withIds = newFlashes\n .filter(\n (flash) =>\n !prev.some(\n (existing) =>\n existing.content === flash.content &&\n existing.type === flash.type,\n ),\n )\n .map((flash) => ({\n ...flash,\n id: Math.random().toString(36).slice(2),\n removed: false,\n removeFlash,\n }));\n\n return [...prev, ...withIds];\n });\n },\n [removeFlash],\n );\n\n const contextValue = useMemo(\n () => ({ flashes, addFlashes, removeFlash }),\n [flashes, addFlashes, removeFlash],\n );\n\n return (\n <FlashContext.Provider value={contextValue}>\n {children}\n </FlashContext.Provider>\n );\n};\n\nconst useFlashContext = () => {\n const context = useContext(FlashContext);\n if (context === undefined) {\n throw new Error(\"useFlashContext must be used within FlashProvider\");\n }\n return context;\n};\n\nconst FLASH_BG_COLOR = {\n error: \"bg-gui-error\",\n success: \"bg-zingy-green\",\n notice: \"bg-electric-cyan\",\n info: \"bg-electric-cyan\",\n alert: \"bg-active-orange\",\n};\n\nconst FLASH_TEXT_COLOR = {\n error: \"text-white\",\n success: \"text-cool-black\",\n notice: \"text-cool-black\",\n info: \"text-cool-black\",\n alert: \"text-white\",\n};\n\nconst AUTO_HIDE = [\"success\", \"info\", \"notice\"];\nconst AUTO_HIDE_TIME = 8000;\n\nconst useAutoHide = (type: string, closeFlash: () => void) => {\n const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n useEffect(() => {\n if (AUTO_HIDE.includes(type)) {\n timeoutId.current = setTimeout(() => {\n closeFlash();\n }, AUTO_HIDE_TIME);\n }\n\n return () => {\n if (timeoutId.current) {\n clearTimeout(timeoutId.current);\n }\n };\n }, [type, closeFlash]);\n};\n\n/**\n * Flash - Individual flash message component with animations and auto-dismiss.\n *\n * Displays a colored notification banner with an icon, message content, and close button.\n * Success/info/notice messages auto-hide after 8 seconds. Error/alert messages require\n * manual dismissal. Uses CSS animations for smooth entry/exit and height transitions\n * on close. Content is sanitized with DOMPurify to allow safe HTML links from backend.\n */\nconst Flash = ({ id, type, content, removeFlash }: FlashProps) => {\n const ref = useRef<HTMLDivElement>(null);\n const [closed, setClosed] = useState(false);\n const [flashHeight, setFlashHeight] = useState(0);\n\n const closeFlash = () => {\n if (ref.current) {\n setFlashHeight(ref.current.getBoundingClientRect().height);\n }\n\n setClosed(true);\n\n setTimeout(() => {\n if (id) {\n removeFlash(id);\n }\n }, 100);\n };\n\n useAutoHide(type, closeFlash);\n\n const animateEntry = !closed;\n\n let style;\n\n if (flashHeight && !closed) {\n style = { height: `${flashHeight}px` };\n } else if (closed) {\n style = { height: 0, marginTop: 0, zIndex: -1 };\n } else {\n style = {};\n }\n\n const safeContent = DOMPurify.sanitize(content, {\n ALLOWED_TAGS: [\"a\"],\n ALLOWED_ATTR: [\"href\", \"data-method\"],\n ALLOWED_URI_REGEXP: /^\\/[^/]/,\n });\n\n const withIcons: Record<FlashPropsType, IconName | \"\"> = {\n notice: \"icon-gui-ably-badge\",\n success: \"icon-gui-check-outline\",\n error: \"icon-gui-exclamation-triangle-outline\",\n alert: \"icon-gui-exclamation-triangle-outline\",\n info: \"\",\n };\n\n const iconColor: Record<FlashPropsType, ColorClass | \"\"> = {\n notice: \"text-cool-black\",\n success: \"text-cool-black\",\n error: \"text-white\",\n alert: \"text-white\",\n info: \"\",\n };\n\n return (\n <div\n className={`ui-flash-message ui-grid-px ${\n animateEntry ? \"ui-flash-message-enter\" : \"\"\n }`}\n style={style}\n ref={ref}\n data-id=\"ui-flash\"\n data-testid=\"ui-flash\"\n >\n <div\n className={`${FLASH_BG_COLOR[type]} p-8 flex align-center rounded shadow-container-subtle`}\n >\n {withIcons[type] && iconColor[type] && (\n <Icon\n name={withIcons[type]}\n color={iconColor[type]}\n size=\"1.5rem\"\n additionalCSS=\"mr-4 self-baseline\"\n />\n )}\n <p\n className={`ui-text-p1 mr-4 ${FLASH_TEXT_COLOR[type]}`}\n dangerouslySetInnerHTML={{ __html: safeContent }}\n />\n <button\n type=\"button\"\n className=\"p-0 ml-auto self-start focus:outline-none focus-base\"\n onClick={closeFlash}\n >\n {iconColor[type] && (\n <Icon\n name=\"icon-gui-x-mark-outline\"\n color={iconColor[type]}\n size=\"1.5rem\"\n additionalCSS=\"transition-colors\"\n />\n )}\n </button>\n </div>\n </div>\n );\n};\n\n/**\n * Flashes - Container component that renders all active flash messages.\n *\n * Reads from FlashContext and displays each flash message in order.\n * Use this component where you want flash messages to appear in your layout\n * (typically near the top of the page). Filters out removed messages.\n */\nconst Flashes = () => {\n const { flashes } = useFlashContext();\n\n return (\n <div className=\"ui-flash\" data-id={FLASH_DATA_ID}>\n {flashes\n .filter((item) => !item.removed)\n .map((flash) => (\n <Flash key={flash.id} {...flash} />\n ))}\n </div>\n );\n};\n\n/**\n * BackendFlashes - Integration component for server-side flash messages (default export).\n *\n * Receives flash messages from backend as an array of [type, content] tuples and adds\n * them to the FlashContext on mount. Primary use case is hydrating flash messages from\n * Rails or other backend frameworks. Renders the Flashes component to display messages.\n * Must be used within FlashProvider.\n */\nconst BackendFlashes = ({ flashes }: BackendFlashesProps) => {\n const context = useContext(FlashContext);\n const addFlashes = context?.addFlashes;\n\n useEffect(() => {\n if (!addFlashes) {\n console.warn(\"BackendFlashes must be used within FlashProvider\");\n return;\n }\n\n const transformedFlashes = flashes.map((flash) => {\n const [type, content] = flash;\n return { type: type as FlashPropsType, content };\n });\n\n if (transformedFlashes.length > 0) {\n addFlashes(transformedFlashes);\n }\n }, [flashes, addFlashes]);\n\n if (!context) return null;\n\n return <Flashes />;\n};\n\nexport { FLASH_DATA_ID, Flashes, FlashProvider, useFlashContext };\nexport default BackendFlashes;\n"],"names":["React","useEffect","useState","useRef","createContext","useContext","useCallback","useMemo","DOMPurify","Icon","FLASH_DATA_ID","FlashContext","undefined","FlashProvider","children","flashes","setFlashes","removeFlash","flashId","prev","filter","item","id","addFlashes","newFlashes","withIds","flash","some","existing","content","type","map","Math","random","toString","slice","removed","contextValue","Provider","value","useFlashContext","context","Error","FLASH_BG_COLOR","error","success","notice","info","alert","FLASH_TEXT_COLOR","AUTO_HIDE","AUTO_HIDE_TIME","useAutoHide","closeFlash","timeoutId","includes","current","setTimeout","clearTimeout","Flash","ref","closed","setClosed","flashHeight","setFlashHeight","getBoundingClientRect","height","animateEntry","style","marginTop","zIndex","safeContent","sanitize","ALLOWED_TAGS","ALLOWED_ATTR","ALLOWED_URI_REGEXP","withIcons","iconColor","div","className","data-id","data-testid","name","color","size","additionalCSS","p","dangerouslySetInnerHTML","__html","button","onClick","Flashes","key","BackendFlashes","console","warn","transformedFlashes","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/core/Flash.tsx"],"sourcesContent":["import React, {\n useEffect,\n useState,\n useRef,\n createContext,\n useContext,\n useCallback,\n useMemo,\n PropsWithChildren,\n} from \"react\";\nimport DOMPurify from \"dompurify\";\nimport Icon from \"./Icon\";\nimport { ColorClass } from \"./styles/colors/types\";\nimport { IconName } from \"./Icon/types\";\n\ntype FlashPropsType = \"error\" | \"success\" | \"notice\" | \"info\" | \"alert\";\n\ntype FlashProps = {\n id: string;\n removed: boolean;\n type: FlashPropsType;\n content: string;\n removeFlash: (id: string) => void;\n};\n\ntype BackendFlashesProps = {\n flashes: string[][];\n};\n\nconst FLASH_DATA_ID = \"ui-flashes\";\n\ntype FlashContextType = {\n flashes: FlashProps[];\n addFlashes: (flashes: Pick<FlashProps, \"type\" | \"content\">[]) => void;\n removeFlash: (id: string) => void;\n};\n\nconst FlashContext = createContext<FlashContextType | undefined>(undefined);\n\ntype FlashProviderProps = PropsWithChildren;\n\n/**\n * FlashProvider - Context provider for managing flash messages throughout the application.\n *\n * Maintains a global list of flash messages and provides methods to add/remove them.\n * Automatically deduplicates messages with the same type and content to prevent duplicates.\n * Use this at the app root level and access via useFlashContext() in child components.\n */\nconst FlashProvider = ({ children }: FlashProviderProps) => {\n const [flashes, setFlashes] = useState<FlashProps[]>([]);\n\n const removeFlash = useCallback((flashId: string) => {\n setFlashes((prev) => prev.filter((item) => item.id !== flashId));\n }, []);\n\n const addFlashes = useCallback(\n (newFlashes: Pick<FlashProps, \"type\" | \"content\">[]) => {\n setFlashes((prev) => {\n const withIds = newFlashes\n .filter(\n (flash) =>\n !prev.some(\n (existing) =>\n existing.content === flash.content &&\n existing.type === flash.type,\n ),\n )\n .map((flash) => ({\n ...flash,\n id: Math.random().toString(36).slice(2),\n removed: false,\n removeFlash,\n }));\n\n return [...prev, ...withIds];\n });\n },\n [removeFlash],\n );\n\n const contextValue = useMemo(\n () => ({ flashes, addFlashes, removeFlash }),\n [flashes, addFlashes, removeFlash],\n );\n\n return (\n <FlashContext.Provider value={contextValue}>\n {children}\n </FlashContext.Provider>\n );\n};\n\nconst useFlashContext = () => {\n const context = useContext(FlashContext);\n if (context === undefined) {\n throw new Error(\"useFlashContext must be used within FlashProvider\");\n }\n return context;\n};\n\nconst FLASH_BG_COLOR = {\n error: \"bg-gui-error\",\n success: \"bg-zingy-green\",\n notice: \"bg-electric-cyan\",\n info: \"bg-electric-cyan\",\n alert: \"bg-active-orange\",\n};\n\nconst FLASH_TEXT_COLOR = {\n error: \"text-white\",\n success: \"text-cool-black\",\n notice: \"text-cool-black\",\n info: \"text-cool-black\",\n alert: \"text-white\",\n};\n\nconst AUTO_HIDE = [\"success\", \"info\", \"notice\"];\nconst AUTO_HIDE_TIME = 8000;\n\nconst useAutoHide = (type: string, closeFlash: () => void) => {\n const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n useEffect(() => {\n if (AUTO_HIDE.includes(type)) {\n timeoutId.current = setTimeout(() => {\n closeFlash();\n }, AUTO_HIDE_TIME);\n }\n\n return () => {\n if (timeoutId.current) {\n clearTimeout(timeoutId.current);\n }\n };\n }, [type, closeFlash]);\n};\n\n/**\n * Flash - Individual flash message component with animations and auto-dismiss.\n *\n * Displays a colored notification banner with an icon, message content, and close button.\n * Success/info/notice messages auto-hide after 8 seconds. Error/alert messages require\n * manual dismissal. Uses CSS animations for smooth entry/exit and height transitions\n * on close. Content is sanitized with DOMPurify to allow safe HTML links from backend.\n */\nconst Flash = ({ id, type, content, removeFlash }: FlashProps) => {\n const ref = useRef<HTMLDivElement>(null);\n const [closed, setClosed] = useState(false);\n const [flashHeight, setFlashHeight] = useState(0);\n\n const closeFlash = () => {\n if (ref.current) {\n setFlashHeight(ref.current.getBoundingClientRect().height);\n }\n\n setClosed(true);\n\n setTimeout(() => {\n if (id) {\n removeFlash(id);\n }\n }, 100);\n };\n\n useAutoHide(type, closeFlash);\n\n const animateEntry = !closed;\n\n let style;\n\n if (flashHeight && !closed) {\n style = { height: `${flashHeight}px` };\n } else if (closed) {\n style = { height: 0, marginTop: 0, zIndex: -1 };\n } else {\n style = {};\n }\n\n const safeContent = DOMPurify.sanitize(content, {\n ALLOWED_TAGS: [\"a\"],\n ALLOWED_ATTR: [\"href\", \"data-method\"],\n ALLOWED_URI_REGEXP: /^\\/[^/]/,\n });\n\n const withIcons: Record<FlashPropsType, IconName | \"\"> = {\n notice: \"icon-gui-ably-badge\",\n success: \"icon-gui-check-outline\",\n error: \"icon-gui-exclamation-triangle-outline\",\n alert: \"icon-gui-exclamation-triangle-outline\",\n info: \"\",\n };\n\n const iconColor: Record<FlashPropsType, ColorClass | \"\"> = {\n notice: \"text-cool-black\",\n success: \"text-cool-black\",\n error: \"text-white\",\n alert: \"text-white\",\n info: \"\",\n };\n\n return (\n <div\n className={`ui-flash-message ui-grid-px ${\n animateEntry ? \"ui-flash-message-enter\" : \"\"\n }`}\n style={style}\n ref={ref}\n data-id=\"ui-flash\"\n data-testid=\"ui-flash\"\n >\n <div\n className={`${FLASH_BG_COLOR[type]} p-8 flex align-center rounded shadow-container-subtle`}\n >\n {withIcons[type] && iconColor[type] && (\n <Icon\n name={withIcons[type]}\n color={iconColor[type]}\n size=\"1.5rem\"\n additionalCSS=\"mr-4 self-baseline\"\n />\n )}\n <p\n className={`ui-text-p1 mr-4 ${FLASH_TEXT_COLOR[type]}`}\n // Sanitised inline-markup allowlist (a + relative href) applied above\n // via DOMPurify. Slice 3a replaces the local config with the shared\n // sanitizeInlineMarkup helper.\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{ __html: safeContent }}\n />\n <button\n type=\"button\"\n className=\"p-0 ml-auto self-start focus:outline-none focus-base\"\n onClick={closeFlash}\n >\n {iconColor[type] && (\n <Icon\n name=\"icon-gui-x-mark-outline\"\n color={iconColor[type]}\n size=\"1.5rem\"\n additionalCSS=\"transition-colors\"\n />\n )}\n </button>\n </div>\n </div>\n );\n};\n\n/**\n * Flashes - Container component that renders all active flash messages.\n *\n * Reads from FlashContext and displays each flash message in order.\n * Use this component where you want flash messages to appear in your layout\n * (typically near the top of the page). Filters out removed messages.\n */\nconst Flashes = () => {\n const { flashes } = useFlashContext();\n\n return (\n <div className=\"ui-flash\" data-id={FLASH_DATA_ID}>\n {flashes\n .filter((item) => !item.removed)\n .map((flash) => (\n <Flash key={flash.id} {...flash} />\n ))}\n </div>\n );\n};\n\n/**\n * BackendFlashes - Integration component for server-side flash messages (default export).\n *\n * Receives flash messages from backend as an array of [type, content] tuples and adds\n * them to the FlashContext on mount. Primary use case is hydrating flash messages from\n * Rails or other backend frameworks. Renders the Flashes component to display messages.\n * Must be used within FlashProvider.\n */\nconst BackendFlashes = ({ flashes }: BackendFlashesProps) => {\n const context = useContext(FlashContext);\n const addFlashes = context?.addFlashes;\n\n useEffect(() => {\n if (!addFlashes) {\n console.warn(\"BackendFlashes must be used within FlashProvider\");\n return;\n }\n\n const transformedFlashes = flashes.map((flash) => {\n const [type, content] = flash;\n return { type: type as FlashPropsType, content };\n });\n\n if (transformedFlashes.length > 0) {\n addFlashes(transformedFlashes);\n }\n }, [flashes, addFlashes]);\n\n if (!context) return null;\n\n return <Flashes />;\n};\n\nexport { FLASH_DATA_ID, Flashes, FlashProvider, useFlashContext };\nexport default BackendFlashes;\n"],"names":["React","useEffect","useState","useRef","createContext","useContext","useCallback","useMemo","DOMPurify","Icon","FLASH_DATA_ID","FlashContext","undefined","FlashProvider","children","flashes","setFlashes","removeFlash","flashId","prev","filter","item","id","addFlashes","newFlashes","withIds","flash","some","existing","content","type","map","Math","random","toString","slice","removed","contextValue","Provider","value","useFlashContext","context","Error","FLASH_BG_COLOR","error","success","notice","info","alert","FLASH_TEXT_COLOR","AUTO_HIDE","AUTO_HIDE_TIME","useAutoHide","closeFlash","timeoutId","includes","current","setTimeout","clearTimeout","Flash","ref","closed","setClosed","flashHeight","setFlashHeight","getBoundingClientRect","height","animateEntry","style","marginTop","zIndex","safeContent","sanitize","ALLOWED_TAGS","ALLOWED_ATTR","ALLOWED_URI_REGEXP","withIcons","iconColor","div","className","data-id","data-testid","name","color","size","additionalCSS","p","dangerouslySetInnerHTML","__html","button","onClick","Flashes","key","BackendFlashes","console","warn","transformedFlashes","length"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/Loader.tsx"],"sourcesContent":["import React from \"react\";\n\ntype LoaderProps = {\n size?: string;\n ringColor?: string;\n additionalCSS?: string;\n};\n\nconst Loader = ({\n ringColor = \"text-dark-grey\",\n size = \"1.5rem\",\n additionalCSS = \"\",\n}: LoaderProps) => (\n <svg\n className={`${ringColor} ${additionalCSS}`}\n style={{ width: size, height: size }}\n height=\"24\"\n viewBox=\"0 0 24 24\"\n width=\"24\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <style\n dangerouslySetInnerHTML={{\n __html: `\n @keyframes chunk-animation {\n to {\n transform: rotate(360deg);\n }\n }\n\n .chunk {\n transform: rotate(0deg);\n transform-origin: center;\n animation: chunk-animation 0.6s cubic-bezier(.44,.15,.66,.98) forwards infinite;\n }\n `,\n }}\n ></style>\n <path\n fill=\"currentColor\"\n d=\"m12 1.99976c-1.9778 0-3.91121.58649-5.5557 1.6853s-2.92621 2.6606-3.68309 4.48786c-.75688 1.82728-.95491 3.83788-.56906 5.77778.38585 1.9398 1.33826 3.7216 2.73679 5.1201 1.39852 1.3985 3.18035 2.351 5.12016 2.7368 1.9398.3859 3.9505.1878 5.7777-.569 1.8273-.7569 3.3891-2.0387 4.4879-3.6831 1.0988-1.6445 1.6853-3.5779 1.6853-5.5557 0-1.3133-.2587-2.61362-.7612-3.82688-.5025-1.21325-1.2391-2.31565-2.1677-3.24423-.9286-.92859-2.031-1.66518-3.2443-2.16773-1.2132-.50255-2.5136-.7612-3.8268-.7612zm0 18.00004c-1.5822 0-3.12896-.4692-4.44456-1.3483-1.31559-.879-2.34097-2.1285-2.94647-3.5903s-.76393-3.0703-.45525-4.6222c.30868-1.55181 1.07061-2.97728 2.18943-4.0961s2.54428-1.88074 4.09615-2.18943c1.5518-.30868 3.1604-.15025 4.6222.45525s2.7112 1.63088 3.5903 2.94647c.879 1.3156 1.3482 2.86231 1.3482 4.44461 0 2.1217-.8428 4.1565-2.3431 5.6568s-3.5352 2.3432-5.6569 2.3432z\"\n opacity=\".5\"\n />\n <path\n className=\"chunk\"\n d=\"m20 11.9998h2c0-1.3133-.2587-2.61362-.7612-3.82688-.5026-1.21325-1.2391-2.31565-2.1677-3.24423-.9286-.92859-2.031-1.66518-3.2443-2.16773-1.2132-.50255-2.5136-.7612-3.8268-.7612v2c2.1217 0 4.1566.84285 5.6569 2.34314 1.5002 1.50029 2.3431 3.53512 2.3431 5.6569z\"\n fill=\"#ff5416\"\n />\n </svg>\n);\n\nexport default Loader;\n"],"names":["React","Loader","ringColor","size","additionalCSS","svg","className","style","width","height","viewBox","xmlns","dangerouslySetInnerHTML","__html","path","fill","d","opacity"],"mappings":"AAAA,OAAOA,UAAW,OAAQ,CAQ1B,MAAMC,OAAS,CAAC,CACdC,UAAY,gBAAgB,CAC5BC,KAAO,QAAQ,CACfC,cAAgB,EAAE,CACN,GACZ,oBAACC,OACCC,UAAW,CAAC,EAAEJ,UAAU,CAAC,EAAEE,cAAc,CAAC,CAC1CG,MAAO,CAAEC,MAAOL,KAAMM,OAAQN,IAAK,EACnCM,OAAO,KACPC,QAAQ,YACRF,MAAM,KACNG,MAAM,8BAEN,oBAACJ,SACCK,wBAAyB,CACvBC,OAAQ;AAChB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,IAAI,CAAC,AACC,IAEF,oBAACC,QACCC,KAAK,eACLC,EAAE,+2BACFC,QAAQ,OAEV,oBAACH,QACCR,UAAU,QACVU,EAAE,uQACFD,KAAK,YAKX,gBAAed,MAAO"}
1
+ {"version":3,"sources":["../../src/core/Loader.tsx"],"sourcesContent":["import React from \"react\";\n\ntype LoaderProps = {\n size?: string;\n ringColor?: string;\n additionalCSS?: string;\n};\n\nconst Loader = ({\n ringColor = \"text-dark-grey\",\n size = \"1.5rem\",\n additionalCSS = \"\",\n}: LoaderProps) => (\n <svg\n className={`${ringColor} ${additionalCSS}`}\n style={{ width: size, height: size }}\n height=\"24\"\n viewBox=\"0 0 24 24\"\n width=\"24\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <style\n // Hardcoded CSS keyframes — no caller-supplied input. Required because\n // styled-components / CSS-in-JS would force a build step on consumers.\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{\n __html: `\n @keyframes chunk-animation {\n to {\n transform: rotate(360deg);\n }\n }\n\n .chunk {\n transform: rotate(0deg);\n transform-origin: center;\n animation: chunk-animation 0.6s cubic-bezier(.44,.15,.66,.98) forwards infinite;\n }\n `,\n }}\n ></style>\n <path\n fill=\"currentColor\"\n d=\"m12 1.99976c-1.9778 0-3.91121.58649-5.5557 1.6853s-2.92621 2.6606-3.68309 4.48786c-.75688 1.82728-.95491 3.83788-.56906 5.77778.38585 1.9398 1.33826 3.7216 2.73679 5.1201 1.39852 1.3985 3.18035 2.351 5.12016 2.7368 1.9398.3859 3.9505.1878 5.7777-.569 1.8273-.7569 3.3891-2.0387 4.4879-3.6831 1.0988-1.6445 1.6853-3.5779 1.6853-5.5557 0-1.3133-.2587-2.61362-.7612-3.82688-.5025-1.21325-1.2391-2.31565-2.1677-3.24423-.9286-.92859-2.031-1.66518-3.2443-2.16773-1.2132-.50255-2.5136-.7612-3.8268-.7612zm0 18.00004c-1.5822 0-3.12896-.4692-4.44456-1.3483-1.31559-.879-2.34097-2.1285-2.94647-3.5903s-.76393-3.0703-.45525-4.6222c.30868-1.55181 1.07061-2.97728 2.18943-4.0961s2.54428-1.88074 4.09615-2.18943c1.5518-.30868 3.1604-.15025 4.6222.45525s2.7112 1.63088 3.5903 2.94647c.879 1.3156 1.3482 2.86231 1.3482 4.44461 0 2.1217-.8428 4.1565-2.3431 5.6568s-3.5352 2.3432-5.6569 2.3432z\"\n opacity=\".5\"\n />\n <path\n className=\"chunk\"\n d=\"m20 11.9998h2c0-1.3133-.2587-2.61362-.7612-3.82688-.5026-1.21325-1.2391-2.31565-2.1677-3.24423-.9286-.92859-2.031-1.66518-3.2443-2.16773-1.2132-.50255-2.5136-.7612-3.8268-.7612v2c2.1217 0 4.1566.84285 5.6569 2.34314 1.5002 1.50029 2.3431 3.53512 2.3431 5.6569z\"\n fill=\"#ff5416\"\n />\n </svg>\n);\n\nexport default Loader;\n"],"names":["React","Loader","ringColor","size","additionalCSS","svg","className","style","width","height","viewBox","xmlns","dangerouslySetInnerHTML","__html","path","fill","d","opacity"],"mappings":"AAAA,OAAOA,UAAW,OAAQ,CAQ1B,MAAMC,OAAS,CAAC,CACdC,UAAY,gBAAgB,CAC5BC,KAAO,QAAQ,CACfC,cAAgB,EAAE,CACN,GACZ,oBAACC,OACCC,UAAW,CAAC,EAAEJ,UAAU,CAAC,EAAEE,cAAc,CAAC,CAC1CG,MAAO,CAAEC,MAAOL,KAAMM,OAAQN,IAAK,EACnCM,OAAO,KACPC,QAAQ,YACRF,MAAM,KACNG,MAAM,8BAEN,oBAACJ,SAICK,wBAAyB,CACvBC,OAAQ;AAChB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,IAAI,CAAC,AACC,IAEF,oBAACC,QACCC,KAAK,eACLC,EAAE,+2BACFC,QAAQ,OAEV,oBAACH,QACCR,UAAU,QACVU,EAAE,uQACFD,KAAK,YAKX,gBAAed,MAAO"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/Notice.tsx"],"sourcesContent":["import React, { useEffect, useRef, useState } from \"react\";\nimport DOMPurify from \"dompurify\";\n\nimport { ColorClass, ColorThemeSet } from \"./styles/colors/types\";\nimport Icon from \"./Icon\";\nimport cn from \"./utils/cn.js\";\nimport NoticeScripts from \"./Notice/component.js\";\nimport useRailsUjsLinks from \"./hooks/use-rails-ujs-hooks\";\n\n// TODO(jamiehenson):\n// This type is a bit messed up currently due to the NoticeScripts import being interpreted as NoticeProps.\n// Plan is to TS-ify the JS assets too, so this can be rectified then. The NoticeScripts-oriented props are\n// the ones after the line break.\nexport type NoticeProps = {\n buttonLink?: string;\n buttonLabel?: string;\n bodyText?: string;\n title?: string;\n closeBtn?: boolean;\n config?: {\n options: {\n collapse: boolean;\n };\n noticeId: string | number;\n cookieId: string;\n };\n bgColor?: string;\n textColor?: ColorClass | ColorThemeSet;\n onClose?: () => void;\n\n bannerContainer?: Element | null;\n cookieId?: string;\n noticeId?: string;\n options?: { collapse: boolean };\n};\n\nconst defaultTextColor = \"text-neutral-1300 dark:text-neutral-000\";\n\nconst contentWrapperClasses = \"w-full pr-2 ui-text-p4 self-center\";\n\nconst Notice = ({\n buttonLink,\n buttonLabel,\n bodyText,\n title,\n config,\n closeBtn,\n bgColor = \"bg-orange-100 dark:bg-orange-1100\",\n textColor = defaultTextColor,\n onClose,\n}: NoticeProps) => {\n const contentRef = useRef<HTMLSpanElement>(null);\n const [isClosing, setIsClosing] = useState(false);\n useRailsUjsLinks(contentRef);\n\n useEffect(() => {\n const cleanup = NoticeScripts({\n bannerContainer: document.querySelector('[data-id=\"ui-notice\"]'),\n cookieId: config?.cookieId,\n noticeId: config?.noticeId,\n options: {\n collapse: config?.options?.collapse || false,\n },\n });\n\n return cleanup;\n }, [config?.cookieId, config?.noticeId, config?.options?.collapse]);\n\n const safeContent = DOMPurify.sanitize(bodyText ?? \"\", {\n ALLOWED_TAGS: [\"a\"],\n ALLOWED_ATTR: [\"href\", \"data-method\"],\n ALLOWED_URI_REGEXP: /^\\/[^/]/,\n });\n\n const isSafeButtonLink =\n typeof buttonLink === \"string\" &&\n (/^\\/(?!\\/)/.test(buttonLink) || /^https?:\\/\\//.test(buttonLink));\n\n // have to add the style classes here as src/core/Notice/component.css is not being properly imported or distributed when ably-ui is used as a package.\n return (\n <div\n className={cn(\n \"ui-announcement relative z-[60]\",\n isClosing\n ? \"ui-announcement-hidden max-h-0 -translate-y-full opacity-0 overflow-hidden\"\n : \"ui-announcement-visible\",\n bgColor,\n textColor,\n )}\n data-id=\"ui-notice\"\n >\n <div className=\"ui-grid-px py-4 max-w-screen-xl mx-auto flex items-start\">\n <div className={cn(contentWrapperClasses, textColor)}>\n <strong className=\"font-bold whitespace-nowrap pr-1\">{title}</strong>\n <span\n ref={contentRef}\n className=\"pr-1\"\n dangerouslySetInnerHTML={{\n __html: safeContent,\n }}\n ></span>\n {buttonLabel &&\n (isSafeButtonLink ? (\n <a\n href={buttonLink}\n className=\"focus-base transition-colors cursor-pointer whitespace-nowrap text-gui-blue-default-light dark:text-gui-blue-default-dark\"\n >\n {buttonLabel}\n </a>\n ) : (\n <span className=\"focus-base transition-colors cursor-pointer whitespace-nowrap text-gui-blue-default-light dark:text-gui-blue-default-dark\">\n {buttonLabel}\n </span>\n ))}\n </div>\n\n {closeBtn && (\n <button\n type=\"button\"\n className=\"ml-auto h-5 w-5 border-none bg-none self-baseline outline-none focus:outline-none focus:ring-0 focus:border-transparent\"\n onClick={() => {\n setIsClosing(true);\n setTimeout(() => {\n document.dispatchEvent(new CustomEvent(\"notice-closed\"));\n onClose?.();\n }, 300);\n }}\n >\n <Icon\n name=\"icon-gui-x-mark-outline\"\n size=\"1.25rem\"\n color={textColor}\n />\n </button>\n )}\n </div>\n </div>\n );\n};\n\nexport default Notice;\n"],"names":["React","useEffect","useRef","useState","DOMPurify","Icon","cn","NoticeScripts","useRailsUjsLinks","defaultTextColor","contentWrapperClasses","Notice","buttonLink","buttonLabel","bodyText","title","config","closeBtn","bgColor","textColor","onClose","contentRef","isClosing","setIsClosing","cleanup","bannerContainer","document","querySelector","cookieId","noticeId","options","collapse","safeContent","sanitize","ALLOWED_TAGS","ALLOWED_ATTR","ALLOWED_URI_REGEXP","isSafeButtonLink","test","div","className","data-id","strong","span","ref","dangerouslySetInnerHTML","__html","a","href","button","type","onClick","setTimeout","dispatchEvent","CustomEvent","name","size","color"],"mappings":"AAAA,OAAOA,OAASC,SAAS,CAAEC,MAAM,CAAEC,QAAQ,KAAQ,OAAQ,AAC3D,QAAOC,cAAe,WAAY,AAGlC,QAAOC,SAAU,QAAS,AAC1B,QAAOC,OAAQ,eAAgB,AAC/B,QAAOC,kBAAmB,uBAAwB,AAClD,QAAOC,qBAAsB,6BAA8B,CA6B3D,MAAMC,iBAAmB,0CAEzB,MAAMC,sBAAwB,qCAE9B,MAAMC,OAAS,CAAC,CACdC,UAAU,CACVC,WAAW,CACXC,QAAQ,CACRC,KAAK,CACLC,MAAM,CACNC,QAAQ,CACRC,QAAU,mCAAmC,CAC7CC,UAAYV,gBAAgB,CAC5BW,OAAO,CACK,IACZ,MAAMC,WAAanB,OAAwB,MAC3C,KAAM,CAACoB,UAAWC,aAAa,CAAGpB,SAAS,OAC3CK,iBAAiBa,YAEjBpB,UAAU,KACR,MAAMuB,QAAUjB,cAAc,CAC5BkB,gBAAiBC,SAASC,aAAa,CAAC,yBACxCC,SAAUZ,QAAQY,SAClBC,SAAUb,QAAQa,SAClBC,QAAS,CACPC,SAAUf,QAAQc,SAASC,UAAY,KACzC,CACF,GAEA,OAAOP,OACT,EAAG,CAACR,QAAQY,SAAUZ,QAAQa,SAAUb,QAAQc,SAASC,SAAS,EAElE,MAAMC,YAAc5B,UAAU6B,QAAQ,CAACnB,UAAY,GAAI,CACrDoB,aAAc,CAAC,IAAI,CACnBC,aAAc,CAAC,OAAQ,cAAc,CACrCC,mBAAoB,SACtB,GAEA,MAAMC,iBACJ,OAAOzB,aAAe,UACrB,CAAA,YAAY0B,IAAI,CAAC1B,aAAe,eAAe0B,IAAI,CAAC1B,WAAU,EAGjE,OACE,oBAAC2B,OACCC,UAAWlC,GACT,kCACAgB,UACI,6EACA,0BACJJ,QACAC,WAEFsB,UAAQ,aAER,oBAACF,OAAIC,UAAU,4DACb,oBAACD,OAAIC,UAAWlC,GAAGI,sBAAuBS,YACxC,oBAACuB,UAAOF,UAAU,oCAAoCzB,OACtD,oBAAC4B,QACCC,IAAKvB,WACLmB,UAAU,OACVK,wBAAyB,CACvBC,OAAQd,WACV,IAEDnB,aACEwB,CAAAA,iBACC,oBAACU,KACCC,KAAMpC,WACN4B,UAAU,6HAET3B,aAGH,oBAAC8B,QAAKH,UAAU,6HACb3B,YAEL,GAGHI,UACC,oBAACgC,UACCC,KAAK,SACLV,UAAU,0HACVW,QAAS,KACP5B,aAAa,MACb6B,WAAW,KACT1B,SAAS2B,aAAa,CAAC,IAAIC,YAAY,kBACvClC,WACF,EAAG,IACL,GAEA,oBAACf,MACCkD,KAAK,0BACLC,KAAK,UACLC,MAAOtC,cAOrB,CAEA,gBAAeR,MAAO"}
1
+ {"version":3,"sources":["../../src/core/Notice.tsx"],"sourcesContent":["import React, { useEffect, useRef, useState } from \"react\";\nimport DOMPurify from \"dompurify\";\n\nimport { ColorClass, ColorThemeSet } from \"./styles/colors/types\";\nimport Icon from \"./Icon\";\nimport cn from \"./utils/cn.js\";\nimport NoticeScripts from \"./Notice/component.js\";\nimport useRailsUjsLinks from \"./hooks/use-rails-ujs-hooks\";\n\n// TODO(jamiehenson):\n// This type is a bit messed up currently due to the NoticeScripts import being interpreted as NoticeProps.\n// Plan is to TS-ify the JS assets too, so this can be rectified then. The NoticeScripts-oriented props are\n// the ones after the line break.\nexport type NoticeProps = {\n buttonLink?: string;\n buttonLabel?: string;\n bodyText?: string;\n title?: string;\n closeBtn?: boolean;\n config?: {\n options: {\n collapse: boolean;\n };\n noticeId: string | number;\n cookieId: string;\n };\n bgColor?: string;\n textColor?: ColorClass | ColorThemeSet;\n onClose?: () => void;\n\n bannerContainer?: Element | null;\n cookieId?: string;\n noticeId?: string;\n options?: { collapse: boolean };\n};\n\nconst defaultTextColor = \"text-neutral-1300 dark:text-neutral-000\";\n\nconst contentWrapperClasses = \"w-full pr-2 ui-text-p4 self-center\";\n\nconst Notice = ({\n buttonLink,\n buttonLabel,\n bodyText,\n title,\n config,\n closeBtn,\n bgColor = \"bg-orange-100 dark:bg-orange-1100\",\n textColor = defaultTextColor,\n onClose,\n}: NoticeProps) => {\n const contentRef = useRef<HTMLSpanElement>(null);\n const [isClosing, setIsClosing] = useState(false);\n useRailsUjsLinks(contentRef);\n\n useEffect(() => {\n const cleanup = NoticeScripts({\n bannerContainer: document.querySelector('[data-id=\"ui-notice\"]'),\n cookieId: config?.cookieId,\n noticeId: config?.noticeId,\n options: {\n collapse: config?.options?.collapse || false,\n },\n });\n\n return cleanup;\n }, [config?.cookieId, config?.noticeId, config?.options?.collapse]);\n\n const safeContent = DOMPurify.sanitize(bodyText ?? \"\", {\n ALLOWED_TAGS: [\"a\"],\n ALLOWED_ATTR: [\"href\", \"data-method\"],\n ALLOWED_URI_REGEXP: /^\\/[^/]/,\n });\n\n const isSafeButtonLink =\n typeof buttonLink === \"string\" &&\n (/^\\/(?!\\/)/.test(buttonLink) || /^https?:\\/\\//.test(buttonLink));\n\n // have to add the style classes here as src/core/Notice/component.css is not being properly imported or distributed when ably-ui is used as a package.\n return (\n <div\n className={cn(\n \"ui-announcement relative z-[60]\",\n isClosing\n ? \"ui-announcement-hidden max-h-0 -translate-y-full opacity-0 overflow-hidden\"\n : \"ui-announcement-visible\",\n bgColor,\n textColor,\n )}\n data-id=\"ui-notice\"\n >\n <div className=\"ui-grid-px py-4 max-w-screen-xl mx-auto flex items-start\">\n <div className={cn(contentWrapperClasses, textColor)}>\n <strong className=\"font-bold whitespace-nowrap pr-1\">{title}</strong>\n <span\n ref={contentRef}\n className=\"pr-1\"\n // Sanitised inline-markup allowlist (a + relative href) applied above\n // via DOMPurify. Slice 3a replaces the local config with the shared\n // sanitizeInlineMarkup helper.\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{\n __html: safeContent,\n }}\n ></span>\n {buttonLabel &&\n (isSafeButtonLink ? (\n <a\n href={buttonLink}\n className=\"focus-base transition-colors cursor-pointer whitespace-nowrap text-gui-blue-default-light dark:text-gui-blue-default-dark\"\n >\n {buttonLabel}\n </a>\n ) : (\n <span className=\"focus-base transition-colors cursor-pointer whitespace-nowrap text-gui-blue-default-light dark:text-gui-blue-default-dark\">\n {buttonLabel}\n </span>\n ))}\n </div>\n\n {closeBtn && (\n <button\n type=\"button\"\n className=\"ml-auto h-5 w-5 border-none bg-none self-baseline outline-none focus:outline-none focus:ring-0 focus:border-transparent\"\n onClick={() => {\n setIsClosing(true);\n setTimeout(() => {\n document.dispatchEvent(new CustomEvent(\"notice-closed\"));\n onClose?.();\n }, 300);\n }}\n >\n <Icon\n name=\"icon-gui-x-mark-outline\"\n size=\"1.25rem\"\n color={textColor}\n />\n </button>\n )}\n </div>\n </div>\n );\n};\n\nexport default Notice;\n"],"names":["React","useEffect","useRef","useState","DOMPurify","Icon","cn","NoticeScripts","useRailsUjsLinks","defaultTextColor","contentWrapperClasses","Notice","buttonLink","buttonLabel","bodyText","title","config","closeBtn","bgColor","textColor","onClose","contentRef","isClosing","setIsClosing","cleanup","bannerContainer","document","querySelector","cookieId","noticeId","options","collapse","safeContent","sanitize","ALLOWED_TAGS","ALLOWED_ATTR","ALLOWED_URI_REGEXP","isSafeButtonLink","test","div","className","data-id","strong","span","ref","dangerouslySetInnerHTML","__html","a","href","button","type","onClick","setTimeout","dispatchEvent","CustomEvent","name","size","color"],"mappings":"AAAA,OAAOA,OAASC,SAAS,CAAEC,MAAM,CAAEC,QAAQ,KAAQ,OAAQ,AAC3D,QAAOC,cAAe,WAAY,AAGlC,QAAOC,SAAU,QAAS,AAC1B,QAAOC,OAAQ,eAAgB,AAC/B,QAAOC,kBAAmB,uBAAwB,AAClD,QAAOC,qBAAsB,6BAA8B,CA6B3D,MAAMC,iBAAmB,0CAEzB,MAAMC,sBAAwB,qCAE9B,MAAMC,OAAS,CAAC,CACdC,UAAU,CACVC,WAAW,CACXC,QAAQ,CACRC,KAAK,CACLC,MAAM,CACNC,QAAQ,CACRC,QAAU,mCAAmC,CAC7CC,UAAYV,gBAAgB,CAC5BW,OAAO,CACK,IACZ,MAAMC,WAAanB,OAAwB,MAC3C,KAAM,CAACoB,UAAWC,aAAa,CAAGpB,SAAS,OAC3CK,iBAAiBa,YAEjBpB,UAAU,KACR,MAAMuB,QAAUjB,cAAc,CAC5BkB,gBAAiBC,SAASC,aAAa,CAAC,yBACxCC,SAAUZ,QAAQY,SAClBC,SAAUb,QAAQa,SAClBC,QAAS,CACPC,SAAUf,QAAQc,SAASC,UAAY,KACzC,CACF,GAEA,OAAOP,OACT,EAAG,CAACR,QAAQY,SAAUZ,QAAQa,SAAUb,QAAQc,SAASC,SAAS,EAElE,MAAMC,YAAc5B,UAAU6B,QAAQ,CAACnB,UAAY,GAAI,CACrDoB,aAAc,CAAC,IAAI,CACnBC,aAAc,CAAC,OAAQ,cAAc,CACrCC,mBAAoB,SACtB,GAEA,MAAMC,iBACJ,OAAOzB,aAAe,UACrB,CAAA,YAAY0B,IAAI,CAAC1B,aAAe,eAAe0B,IAAI,CAAC1B,WAAU,EAGjE,OACE,oBAAC2B,OACCC,UAAWlC,GACT,kCACAgB,UACI,6EACA,0BACJJ,QACAC,WAEFsB,UAAQ,aAER,oBAACF,OAAIC,UAAU,4DACb,oBAACD,OAAIC,UAAWlC,GAAGI,sBAAuBS,YACxC,oBAACuB,UAAOF,UAAU,oCAAoCzB,OACtD,oBAAC4B,QACCC,IAAKvB,WACLmB,UAAU,OAKVK,wBAAyB,CACvBC,OAAQd,WACV,IAEDnB,aACEwB,CAAAA,iBACC,oBAACU,KACCC,KAAMpC,WACN4B,UAAU,6HAET3B,aAGH,oBAAC8B,QAAKH,UAAU,6HACb3B,YAEL,GAGHI,UACC,oBAACgC,UACCC,KAAK,SACLV,UAAU,0HACVW,QAAS,KACP5B,aAAa,MACb6B,WAAW,KACT1B,SAAS2B,aAAa,CAAC,IAAIC,YAAY,kBACvClC,WACF,EAAG,IACL,GAEA,oBAACf,MACCkD,KAAK,0BACLC,KAAK,UACLC,MAAOtC,cAOrB,CAEA,gBAAeR,MAAO"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/SessionData.tsx"],"sourcesContent":["import React, {\n createContext,\n useContext,\n PropsWithChildren,\n useMemo,\n} from \"react\";\nimport useSWR from \"swr\";\n\n// Feature flag for enabling credentials in fetch requests\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\ndeclare const __ENABLE_FETCH_WITH_CREDENTIALS__: boolean | undefined;\n\ntype SessionUser = {\n firstName?: string;\n lastName?: string;\n email?: string;\n};\n\ntype SessionData = {\n user?: SessionUser;\n} | null;\n\ntype SessionDataContextType = {\n sessionData: SessionData;\n isLoading: boolean;\n error: Error | undefined;\n};\n\nconst SessionDataContext = createContext<SessionDataContextType | undefined>(\n undefined,\n);\n\ntype SessionDataProviderProps = PropsWithChildren<{\n sessionDataUrl?: string;\n}>;\n\n/**\n * fetcher - Retrieves session data from a backend API endpoint.\n *\n * Makes a JSON request to fetch current user session information. Optionally includes\n * credentials (cookies) based on the __ENABLE_FETCH_WITH_CREDENTIALS__ feature flag.\n * Handles \"not-found\" responses gracefully by returning null instead of throwing.\n * Validates response is JSON to prevent parsing errors.\n */\nconst fetcher = async (url: string): Promise<SessionData> => {\n const options: RequestInit = {\n headers: {\n accept: \"application/json\",\n },\n cache: \"no-cache\",\n };\n\n // Check if credentials should be included (feature flag)\n if (\n typeof __ENABLE_FETCH_WITH_CREDENTIALS__ !== \"undefined\" &&\n __ENABLE_FETCH_WITH_CREDENTIALS__\n ) {\n options.credentials = \"include\";\n }\n\n const res = await fetch(url, options);\n\n if (!res.ok) {\n throw new Error(\"Failed to fetch session data\");\n }\n\n const contentType = res.headers.get(\"content-type\");\n if (!contentType?.includes(\"application/json\")) {\n throw new Error(\"Session endpoint is not serving json\");\n }\n\n const data = await res.json();\n\n // Handle \"not found\" error gracefully\n if (data.error === \"not-found\") {\n return null;\n }\n\n return data;\n};\n\nconst SESSION_SWR_OPTIONS = {\n revalidateOnFocus: false,\n revalidateOnReconnect: false,\n shouldRetryOnError: false,\n onError: (err: Error) => {\n console.warn(\"Could not fetch session data due to error:\", err);\n },\n};\n\n/**\n * SessionDataProvider - Context provider for managing user session data throughout the app.\n *\n * Fetches and caches session data using SWR (stale-while-revalidate) from the provided URL.\n * Exposes session data, loading state, and errors through context. Use this at the app root\n * level and access via useSessionData() hook in child components. Disables auto-revalidation\n * to avoid excessive network requests - session data is fetched once on mount.\n */\nexport const SessionDataProvider = ({\n children,\n sessionDataUrl,\n}: SessionDataProviderProps) => {\n const { data, error, isLoading } = useSWR<SessionData>(\n sessionDataUrl || null,\n fetcher,\n SESSION_SWR_OPTIONS,\n );\n\n const contextValue = useMemo(\n () => ({\n sessionData: data ?? null,\n isLoading,\n error,\n }),\n [data, isLoading, error],\n );\n\n return (\n <SessionDataContext.Provider value={contextValue}>\n {children}\n </SessionDataContext.Provider>\n );\n};\n\n/**\n * useSessionData - Hook to access session data from SessionDataProvider context.\n *\n * Returns the current user session data, loading state, and any errors from the provider.\n * Must be used within a SessionDataProvider or will throw an error. Use this hook in\n * components that need to read or display user session information.\n */\nexport const useSessionData = () => {\n const context = useContext(SessionDataContext);\n if (context === undefined) {\n throw new Error(\"useSessionData must be used within SessionDataProvider\");\n }\n return context;\n};\n\n/**\n * useSessionDataDirect - Direct session data hook without requiring a provider (legacy).\n *\n * Fetches session data using SWR directly, bypassing the context provider pattern.\n * Provided for backward compatibility with code that doesn't use SessionDataProvider.\n * For new code, prefer using SessionDataProvider + useSessionData() for better\n * performance and centralized session management.\n */\nexport const useSessionDataDirect = (sessionDataUrl?: string) => {\n const { data, error, isLoading } = useSWR<SessionData>(\n sessionDataUrl || null,\n fetcher,\n SESSION_SWR_OPTIONS,\n );\n\n return {\n sessionData: data ?? null,\n isLoading,\n error,\n };\n};\n"],"names":["React","createContext","useContext","useMemo","useSWR","SessionDataContext","undefined","fetcher","url","options","headers","accept","cache","credentials","res","fetch","ok","Error","contentType","get","includes","data","json","error","SESSION_SWR_OPTIONS","revalidateOnFocus","revalidateOnReconnect","shouldRetryOnError","onError","err","console","warn","SessionDataProvider","children","sessionDataUrl","isLoading","contextValue","sessionData","Provider","value","useSessionData","context","useSessionDataDirect"],"mappings":"AAAA,OAAOA,OACLC,aAAa,CACbC,UAAU,CAEVC,OAAO,KACF,OAAQ,AACf,QAAOC,WAAY,KAAM,CAsBzB,MAAMC,mBAAqBJ,cACzBK,WAeF,MAAMC,QAAU,MAAOC,MACrB,MAAMC,QAAuB,CAC3BC,QAAS,CACPC,OAAQ,kBACV,EACAC,MAAO,UACT,EAGA,GACE,eAA6C,mBAE7C,CACAH,QAAQI,WAAW,CAAG,SACxB,CAEA,MAAMC,IAAM,MAAMC,MAAMP,IAAKC,SAE7B,GAAI,CAACK,IAAIE,EAAE,CAAE,CACX,MAAM,IAAIC,MAAM,+BAClB,CAEA,MAAMC,YAAcJ,IAAIJ,OAAO,CAACS,GAAG,CAAC,gBACpC,GAAI,CAACD,aAAaE,SAAS,oBAAqB,CAC9C,MAAM,IAAIH,MAAM,uCAClB,CAEA,MAAMI,KAAO,MAAMP,IAAIQ,IAAI,GAG3B,GAAID,KAAKE,KAAK,GAAK,YAAa,CAC9B,OAAO,IACT,CAEA,OAAOF,IACT,EAEA,MAAMG,oBAAsB,CAC1BC,kBAAmB,MACnBC,sBAAuB,MACvBC,mBAAoB,MACpBC,QAAS,AAACC,MACRC,QAAQC,IAAI,CAAC,6CAA8CF,IAC7D,CACF,CAUA,QAAO,MAAMG,oBAAsB,CAAC,CAClCC,QAAQ,CACRC,cAAc,CACW,IACzB,KAAM,CAAEb,IAAI,CAAEE,KAAK,CAAEY,SAAS,CAAE,CAAG/B,OACjC8B,gBAAkB,KAClB3B,QACAiB,qBAGF,MAAMY,aAAejC,QACnB,IAAO,CAAA,CACLkC,YAAahB,MAAQ,KACrBc,UACAZ,KACF,CAAA,EACA,CAACF,KAAMc,UAAWZ,MAAM,EAG1B,OACE,oBAAClB,mBAAmBiC,QAAQ,EAACC,MAAOH,cACjCH,SAGP,CAAE,AASF,QAAO,MAAMO,eAAiB,KAC5B,MAAMC,QAAUvC,WAAWG,oBAC3B,GAAIoC,UAAYnC,UAAW,CACzB,MAAM,IAAIW,MAAM,yDAClB,CACA,OAAOwB,OACT,CAAE,AAUF,QAAO,MAAMC,qBAAuB,AAACR,iBACnC,KAAM,CAAEb,IAAI,CAAEE,KAAK,CAAEY,SAAS,CAAE,CAAG/B,OACjC8B,gBAAkB,KAClB3B,QACAiB,qBAGF,MAAO,CACLa,YAAahB,MAAQ,KACrBc,UACAZ,KACF,CACF,CAAE"}
1
+ {"version":3,"sources":["../../src/core/SessionData.tsx"],"sourcesContent":["import React, {\n createContext,\n useContext,\n PropsWithChildren,\n useMemo,\n} from \"react\";\nimport useSWR from \"swr\";\n\n// Feature flag for enabling credentials in fetch requests\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\ndeclare const __ENABLE_FETCH_WITH_CREDENTIALS__: boolean | undefined;\n\ntype SessionUser = {\n firstName?: string;\n lastName?: string;\n email?: string;\n};\n\ntype SessionData = {\n user?: SessionUser;\n} | null;\n\ntype SessionDataContextType = {\n sessionData: SessionData;\n isLoading: boolean;\n error: Error | undefined;\n};\n\nconst SessionDataContext = createContext<SessionDataContextType | undefined>(\n undefined,\n);\n\ntype SessionDataProviderProps = PropsWithChildren<{\n sessionDataUrl?: string;\n}>;\n\n/**\n * fetcher - Retrieves session data from a backend API endpoint.\n *\n * Makes a JSON request to fetch current user session information. Optionally includes\n * credentials (cookies) based on the __ENABLE_FETCH_WITH_CREDENTIALS__ feature flag.\n * Handles \"not-found\" responses gracefully by returning null instead of throwing.\n * Validates response is JSON to prevent parsing errors.\n */\nconst fetcher = async (url: string): Promise<SessionData> => {\n const options: RequestInit = {\n headers: {\n accept: \"application/json\",\n },\n cache: \"no-cache\",\n };\n\n // Check if credentials should be included (feature flag)\n if (\n typeof __ENABLE_FETCH_WITH_CREDENTIALS__ !== \"undefined\" &&\n __ENABLE_FETCH_WITH_CREDENTIALS__\n ) {\n options.credentials = \"include\";\n }\n\n const res = await fetch(url, options);\n\n if (!res.ok) {\n throw new Error(\"Failed to fetch session data\");\n }\n\n const contentType = res.headers.get(\"content-type\");\n if (!contentType?.includes(\"application/json\")) {\n throw new Error(\"Session endpoint is not serving json\");\n }\n\n const data = await res.json();\n\n // Handle \"not found\" error gracefully\n if (data.error === \"not-found\") {\n return null;\n }\n\n return data;\n};\n\nconst SESSION_SWR_OPTIONS = {\n revalidateOnFocus: false,\n revalidateOnReconnect: false,\n shouldRetryOnError: false,\n onError: (err: Error) => {\n console.warn(\"Could not fetch session data due to error:\", err);\n },\n};\n\n/**\n * SessionDataProvider - Context provider for managing user session data throughout the app.\n *\n * Fetches and caches session data using SWR (stale-while-revalidate) from the provided URL.\n * Exposes session data, loading state, and errors through context. Use this at the app root\n * level and access via useSessionData() hook in child components. Disables auto-revalidation\n * to avoid excessive network requests - session data is fetched once on mount.\n */\nexport const SessionDataProvider = ({\n children,\n sessionDataUrl,\n}: SessionDataProviderProps) => {\n const { data, error, isLoading } = useSWR<SessionData>(\n sessionDataUrl || null,\n fetcher,\n SESSION_SWR_OPTIONS,\n );\n\n const contextValue = useMemo(\n () => ({\n sessionData: data ?? null,\n isLoading,\n error,\n }),\n [data, isLoading, error],\n );\n\n return (\n <SessionDataContext.Provider value={contextValue}>\n {children}\n </SessionDataContext.Provider>\n );\n};\n\n/**\n * useSessionData - Hook to access session data from SessionDataProvider context.\n *\n * Returns the current user session data, loading state, and any errors from the provider.\n * Must be used within a SessionDataProvider or will throw an error. Use this hook in\n * components that need to read or display user session information.\n */\nexport const useSessionData = () => {\n const context = useContext(SessionDataContext);\n if (context === undefined) {\n throw new Error(\"useSessionData must be used within SessionDataProvider\");\n }\n return context;\n};\n\n/**\n * useSessionDataDirect - Direct session data hook without requiring a provider (legacy).\n *\n * Fetches session data using SWR directly, bypassing the context provider pattern.\n * Provided for backward compatibility with code that doesn't use SessionDataProvider.\n * For new code, prefer using SessionDataProvider + useSessionData() for better\n * performance and centralized session management.\n */\nexport const useSessionDataDirect = (sessionDataUrl?: string) => {\n const { data, error, isLoading } = useSWR<SessionData>(\n sessionDataUrl || null,\n fetcher,\n SESSION_SWR_OPTIONS,\n );\n\n return {\n sessionData: data ?? null,\n isLoading,\n error,\n };\n};\n"],"names":["React","createContext","useContext","useMemo","useSWR","SessionDataContext","undefined","fetcher","url","options","headers","accept","cache","credentials","res","fetch","ok","Error","contentType","get","includes","data","json","error","SESSION_SWR_OPTIONS","revalidateOnFocus","revalidateOnReconnect","shouldRetryOnError","onError","err","console","warn","SessionDataProvider","children","sessionDataUrl","isLoading","contextValue","sessionData","Provider","value","useSessionData","context","useSessionDataDirect"],"mappings":"AAAA,OAAOA,OACLC,aAAa,CACbC,UAAU,CAEVC,OAAO,KACF,OAAQ,AACf,QAAOC,WAAY,KAAM,CAsBzB,MAAMC,mBAAqBJ,cACzBK,WAeF,MAAMC,QAAU,MAAOC,MACrB,MAAMC,QAAuB,CAC3BC,QAAS,CACPC,OAAQ,kBACV,EACAC,MAAO,UACT,EAGA,GACE,OAgGD,QAhG8C,aAgG9C,MA9FC,CACAH,QAAQI,WAAW,CAAG,SACxB,CAEA,MAAMC,IAAM,MAAMC,MAAMP,IAAKC,SAE7B,GAAI,CAACK,IAAIE,EAAE,CAAE,CACX,MAAM,IAAIC,MAAM,+BAClB,CAEA,MAAMC,YAAcJ,IAAIJ,OAAO,CAACS,GAAG,CAAC,gBACpC,GAAI,CAACD,aAAaE,SAAS,oBAAqB,CAC9C,MAAM,IAAIH,MAAM,uCAClB,CAEA,MAAMI,KAAO,MAAMP,IAAIQ,IAAI,GAG3B,GAAID,KAAKE,KAAK,GAAK,YAAa,CAC9B,OAAO,IACT,CAEA,OAAOF,IACT,EAEA,MAAMG,oBAAsB,CAC1BC,kBAAmB,MACnBC,sBAAuB,MACvBC,mBAAoB,MACpBC,QAAS,AAACC,MACRC,QAAQC,IAAI,CAAC,6CAA8CF,IAC7D,CACF,CAUA,QAAO,MAAMG,oBAAsB,CAAC,CAClCC,QAAQ,CACRC,cAAc,CACW,IACzB,KAAM,CAAEb,IAAI,CAAEE,KAAK,CAAEY,SAAS,CAAE,CAAG/B,OACjC8B,gBAAkB,KAClB3B,QACAiB,qBAGF,MAAMY,aAAejC,QACnB,IAAO,CAAA,CACLkC,YAAahB,MAAQ,KACrBc,UACAZ,KACF,CAAA,EACA,CAACF,KAAMc,UAAWZ,MAAM,EAG1B,OACE,oBAAClB,mBAAmBiC,QAAQ,EAACC,MAAOH,cACjCH,SAGP,CAAE,AASF,QAAO,MAAMO,eAAiB,KAC5B,MAAMC,QAAUvC,WAAWG,oBAC3B,GAAIoC,UAAYnC,UAAW,CACzB,MAAM,IAAIW,MAAM,yDAClB,CACA,OAAOwB,OACT,CAAE,AAUF,QAAO,MAAMC,qBAAuB,AAACR,iBACnC,KAAM,CAAEb,IAAI,CAAEE,KAAK,CAAEY,SAAS,CAAE,CAAG/B,OACjC8B,gBAAkB,KAClB3B,QACAiB,qBAGF,MAAO,CACLa,YAAahB,MAAQ,KACrBc,UACAZ,KACF,CACF,CAAE"}
@@ -0,0 +1,2 @@
1
+ import DOMPurify from"dompurify";const RELATIVE_URI=/^\/[^/\\]/;const DANGEROUS_URI_SCHEMES=/^\s*(data|javascript|vbscript|file|blob):/i;const URI_BEARING_ATTRS=new Set(["src","href","xlink:href","action","formaction","background","poster","srcset"]);DOMPurify.addHook("uponSanitizeAttribute",(_node,data)=>{if(URI_BEARING_ATTRS.has(data.attrName)&&DANGEROUS_URI_SCHEMES.test(data.attrValue)){data.keepAttr=false}});export const sanitizeInlineMarkup=input=>DOMPurify.sanitize(input??"",{ALLOWED_TAGS:["a"],ALLOWED_ATTR:["href","data-method"],ALLOWED_URI_REGEXP:RELATIVE_URI});export const sanitizeRichText=input=>DOMPurify.sanitize(input??"",{ALLOWED_TAGS:["a","b","br","em","i","p","strong"],ALLOWED_ATTR:["href"],ALLOWED_URI_REGEXP:RELATIVE_URI});export const sanitizeMarketingHtml=input=>DOMPurify.sanitize(input??"",{ALLOWED_TAGS:["a","b","blockquote","br","code","em","figcaption","figure","h2","h3","h4","i","img","li","ol","p","pre","span","strong","table","tbody","td","th","thead","tr","ul"],ALLOWED_ATTR:["alt","class","href","src","title"],ALLOWED_URI_REGEXP:/^(https?:\/\/|\/[^/\\])/});
2
+ //# sourceMappingURL=sanitize-html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/core/utils/sanitize-html.ts"],"sourcesContent":["import DOMPurify from \"dompurify\";\n\n// Restricts hrefs to same-origin paths (no scheme, no protocol-relative).\n// Matches the rule applied historically in Flash and Notice, with one\n// hardening: the exclusion class also rejects backslash. Per WHATWG URL,\n// browsers parsing a special-scheme URL treat `/\\` as an authority delimiter\n// identical to `//`, so `href=\"/\\evil.com\"` resolves to `http://evil.com`.\n// DOMPurify checks ALLOWED_URI_REGEXP against the raw attribute string, not\n// the resolved URL, so the exclusion has to do the work.\nconst RELATIVE_URI = /^\\/[^/\\\\]/;\n\n// DOMPurify hard-codes a data: URI allowance on `img`, `audio`, `video`,\n// `source`, `image`, and `track` regardless of ALLOWED_URI_REGEXP - intended\n// to support inline base64 images. We don't want that exception: a\n// `data:image/svg+xml,<svg onload=alert(1)>` URI on an <img> still executes\n// in some contexts, and we'd rather force authors to host their images. The\n// hook below catches dangerous schemes on every URI-bearing attribute and\n// drops the attribute, regardless of which built-in safe-list DOMPurify\n// would normally apply.\nconst DANGEROUS_URI_SCHEMES = /^\\s*(data|javascript|vbscript|file|blob):/i;\nconst URI_BEARING_ATTRS = new Set([\n \"src\",\n \"href\",\n \"xlink:href\",\n \"action\",\n \"formaction\",\n \"background\",\n \"poster\",\n \"srcset\",\n]);\n\nDOMPurify.addHook(\"uponSanitizeAttribute\", (_node, data) => {\n if (\n URI_BEARING_ATTRS.has(data.attrName) &&\n DANGEROUS_URI_SCHEMES.test(data.attrValue)\n ) {\n data.keepAttr = false;\n }\n});\n\n/**\n * sanitizeInlineMarkup — tightest allowlist, intended for short pieces of\n * trusted-but-defence-in-depthed text (flash messages, banner bodies that\n * already went through Rails sanitisation). Only inline links to same-origin\n * paths survive.\n *\n * Use this for surfaces where the producer (Rails helper, backend flash)\n * already sanitised the input and React-side sanitisation is the second\n * layer of defence.\n */\nexport const sanitizeInlineMarkup = (input: string | null | undefined) =>\n DOMPurify.sanitize(input ?? \"\", {\n ALLOWED_TAGS: [\"a\"],\n ALLOWED_ATTR: [\"href\", \"data-method\"],\n ALLOWED_URI_REGEXP: RELATIVE_URI,\n });\n\n/**\n * sanitizeRichText — mirror of DashboardNoticeHelper::SANITISE_TAGS on the\n * Rails side. Use for content that an admin types as light HTML (banner\n * body text, dashboard notices). Keeps href so links work; same-origin URI\n * restriction prevents the URI from carrying a script payload.\n *\n * Tag set deliberately matches the server-side allowlist so the trust\n * boundary is identical regardless of which side did the rendering.\n */\nexport const sanitizeRichText = (input: string | null | undefined) =>\n DOMPurify.sanitize(input ?? \"\", {\n ALLOWED_TAGS: [\"a\", \"b\", \"br\", \"em\", \"i\", \"p\", \"strong\"],\n ALLOWED_ATTR: [\"href\"],\n ALLOWED_URI_REGEXP: RELATIVE_URI,\n });\n\n/**\n * sanitizeMarketingHtml — wider allowlist for Contentful-sourced marketing\n * content (the `ContentfulBlockHtml` escape-hatch field). Trust boundary is\n * \"compromised Contentful editor account\" — wider than inline/rich-text but\n * still blocks scripts, event handlers, and non-http(s) URIs.\n *\n * Not suitable for the Ghost blog body. A survey of all 396 published Ably\n * blog posts (built locally against Ghost) shows the body legitimately\n * contains HubSpot CTA scripts (~70 posts), Twitter widget scripts, YouTube\n * and Vimeo iframes (~50), and inline SVG diagrams (~130). Sanitising those\n * with this allowlist would silently break the site. Ghost body is treated\n * as a trusted-CMS surface in the ADR — editor account control is the\n * security boundary.\n *\n * Marketing copy needs headings, lists, code blocks, blockquotes, and\n * external links, so the URI rule allows http(s) instead of being clamped\n * to relative paths.\n */\nexport const sanitizeMarketingHtml = (input: string | null | undefined) =>\n DOMPurify.sanitize(input ?? \"\", {\n ALLOWED_TAGS: [\n \"a\",\n \"b\",\n \"blockquote\",\n \"br\",\n \"code\",\n \"em\",\n \"figcaption\",\n \"figure\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"i\",\n \"img\",\n \"li\",\n \"ol\",\n \"p\",\n \"pre\",\n \"span\",\n \"strong\",\n \"table\",\n \"tbody\",\n \"td\",\n \"th\",\n \"thead\",\n \"tr\",\n \"ul\",\n ],\n ALLOWED_ATTR: [\"alt\", \"class\", \"href\", \"src\", \"title\"],\n // Either a full http(s):// URL, or a same-origin relative path whose\n // second character is neither `/` nor `\\`. The earlier `/^(https?:)?\\//`\n // was anchored only at the start, so `//evil.com` (protocol-relative)\n // survived: the regex matched the leading `/` and left the rest\n // unanchored. Browsers resolve `//evil.com` to the page scheme, so this\n // was an external-redirect bypass exactly in the threat model the\n // marketing sanitiser is meant to harden against (compromised Contentful\n // editor sneaking off-site links past review).\n ALLOWED_URI_REGEXP: /^(https?:\\/\\/|\\/[^/\\\\])/,\n });\n"],"names":["DOMPurify","RELATIVE_URI","DANGEROUS_URI_SCHEMES","URI_BEARING_ATTRS","Set","addHook","_node","data","has","attrName","test","attrValue","keepAttr","sanitizeInlineMarkup","input","sanitize","ALLOWED_TAGS","ALLOWED_ATTR","ALLOWED_URI_REGEXP","sanitizeRichText","sanitizeMarketingHtml"],"mappings":"AAAA,OAAOA,cAAe,WAAY,CASlC,MAAMC,aAAe,YAUrB,MAAMC,sBAAwB,6CAC9B,MAAMC,kBAAoB,IAAIC,IAAI,CAChC,MACA,OACA,aACA,SACA,aACA,aACA,SACA,SACD,EAEDJ,UAAUK,OAAO,CAAC,wBAAyB,CAACC,MAAOC,QACjD,GACEJ,kBAAkBK,GAAG,CAACD,KAAKE,QAAQ,GACnCP,sBAAsBQ,IAAI,CAACH,KAAKI,SAAS,EACzC,CACAJ,KAAKK,QAAQ,CAAG,KAClB,CACF,EAYA,QAAO,MAAMC,qBAAuB,AAACC,OACnCd,UAAUe,QAAQ,CAACD,OAAS,GAAI,CAC9BE,aAAc,CAAC,IAAI,CACnBC,aAAc,CAAC,OAAQ,cAAc,CACrCC,mBAAoBjB,YACtB,EAAG,AAWL,QAAO,MAAMkB,iBAAmB,AAACL,OAC/Bd,UAAUe,QAAQ,CAACD,OAAS,GAAI,CAC9BE,aAAc,CAAC,IAAK,IAAK,KAAM,KAAM,IAAK,IAAK,SAAS,CACxDC,aAAc,CAAC,OAAO,CACtBC,mBAAoBjB,YACtB,EAAG,AAoBL,QAAO,MAAMmB,sBAAwB,AAACN,OACpCd,UAAUe,QAAQ,CAACD,OAAS,GAAI,CAC9BE,aAAc,CACZ,IACA,IACA,aACA,KACA,OACA,KACA,aACA,SACA,KACA,KACA,KACA,IACA,MACA,KACA,KACA,IACA,MACA,OACA,SACA,QACA,QACA,KACA,KACA,QACA,KACA,KACD,CACDC,aAAc,CAAC,MAAO,QAAS,OAAQ,MAAO,QAAQ,CAStDC,mBAAoB,yBACtB,EAAG"}
@@ -0,0 +1,2 @@
1
+ import{describe,expect,it}from"vitest";import{sanitizeInlineMarkup,sanitizeMarketingHtml,sanitizeRichText}from"./sanitize-html";const parseDom=html=>{const wrapper=document.createElement("div");wrapper.innerHTML=html;return wrapper};const eventHandlerAttrsOf=root=>{const offenders=[];root.querySelectorAll("*").forEach(el=>{for(const attr of Array.from(el.attributes)){if(attr.name.toLowerCase().startsWith("on")){offenders.push(`${el.tagName}:${attr.name}`)}}});return offenders};const dangerousProtocols=/^(javascript|data|vbscript|file|blob):/i;const dangerousHrefsIn=root=>{const offenders=[];root.querySelectorAll("[href]").forEach(el=>{if(dangerousProtocols.test(el.getAttribute("href")??"")){offenders.push(el.getAttribute("href")??"")}});root.querySelectorAll("[src]").forEach(el=>{if(dangerousProtocols.test(el.getAttribute("src")??"")){offenders.push(el.getAttribute("src")??"")}});return offenders};const scriptTagsIn=root=>Array.from(root.querySelectorAll("script"));describe("sanitizeInlineMarkup",()=>{describe("baseline allowlist behaviour",()=>{it("keeps same-origin relative links",()=>{expect(sanitizeInlineMarkup('<a href="/dashboard">go</a>')).toBe('<a href="/dashboard">go</a>')});it("keeps a data-method attribute on a link (Rails UJS)",()=>{const out=sanitizeInlineMarkup('<a href="/logout" data-method="delete">Log out</a>');expect(out).toContain('data-method="delete"')});it("strips disallowed tags but keeps their text content",()=>{expect(sanitizeInlineMarkup("<b>bold</b>")).toBe("bold")});it("returns an empty string for null and undefined",()=>{expect(sanitizeInlineMarkup(null)).toBe("");expect(sanitizeInlineMarkup(undefined)).toBe("")});it("returns an empty string for empty input",()=>{expect(sanitizeInlineMarkup("")).toBe("")})});describe("script-tag injection",()=>{it.each(["<script>alert(1)</script>","<SCRIPT>alert(1)</SCRIPT>","<ScRiPt>alert(1)</ScRiPt>","<script src=//evil.com/x.js></script>","<script\nsrc=//evil.com/x.js></script>","<script type='text/javascript'>alert(1)</script>","<script >alert(1)</script>"])("strips '%s'",payload=>{const out=sanitizeInlineMarkup(payload+"safe");expect(scriptTagsIn(parseDom(out))).toHaveLength(0);expect(out).toContain("safe")})});describe("event-handler injection",()=>{it.each(['<a href="/x" onmouseover="alert(1)">l</a>','<a href="/x" onclick="alert(1)">l</a>','<a href="/x" onfocus="alert(1)" autofocus>l</a>','<a href="/x" oNmOuSeOvEr="alert(1)">l</a>',"<a href=\"/x\" onmouseover='alert(1)'>l</a>","<a href=/x onmouseover=alert(1)>l</a>","<a href=/x onmouseover=alert(1)>l</a>","<a href=/x\nonmouseover=alert(1)>l</a>","<a href=/x onmouseover=`alert(1)`>l</a>"])("strips event handlers in '%s'",payload=>{const out=sanitizeInlineMarkup(payload);expect(eventHandlerAttrsOf(parseDom(out))).toEqual([])})});describe("javascript:/data:/vbscript: URI bypasses",()=>{it.each(["javascript:alert(1)","JaVaScRiPt:alert(1)","java script:alert(1)","java\nscript:alert(1)","java\rscript:alert(1)","java\0script:alert(1)","javascript&colon;alert(1)","&#x6A;avascript:alert(1)","&#0000106;avascript:alert(1)","vbscript:msgbox(1)","data:text/html,<script>alert(1)</script>","data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==","file:///etc/passwd","blob:https://example.com/abc"])("strips href='%s'",href=>{const out=sanitizeInlineMarkup(`<a href="${href}">x</a>`);expect(dangerousHrefsIn(parseDom(out))).toEqual([])})});describe("authority-delimiter / protocol-relative bypasses",()=>{it.each(["//evil.com","///evil.com","/\\evil.com","/\\\\evil.com","/\\/evil.com","/%5cevil.com"])("strips href='%s'",href=>{const out=sanitizeInlineMarkup(`<a href="${href}">click</a>`);const root=parseDom(out);root.querySelectorAll("a[href]").forEach(a=>{const raw=a.getAttribute("href")??"";expect(raw).toMatch(/^\/[^/\\]/)})})});describe("DOMPurify historical mXSS / mutation vectors",()=>{it("strips noscript-wrapped HTML injection",()=>{const payload='<noscript><p title="</noscript><img src=x onerror=alert(1)>">';const out=sanitizeInlineMarkup(payload);const root=parseDom(out);expect(scriptTagsIn(root)).toHaveLength(0);expect(eventHandlerAttrsOf(root)).toEqual([]);expect(root.querySelector("img")).toBeNull()});it("strips template / svg foreignObject smuggling",()=>{const payload="<template><svg><foreignObject><body onload=alert(1)>x</body></foreignObject></svg></template>";const out=sanitizeInlineMarkup(payload);expect(eventHandlerAttrsOf(parseDom(out))).toEqual([])});it("strips MathML annotation-xml encoded HTML",()=>{const payload='<math><annotation-xml encoding="text/html"><iframe src="javascript:alert(1)"></iframe></annotation-xml></math>';const out=sanitizeInlineMarkup(payload);const root=parseDom(out);expect(root.querySelector("iframe")).toBeNull();expect(dangerousHrefsIn(root)).toEqual([])})})});describe("sanitizeRichText",()=>{describe("baseline allowlist behaviour",()=>{it("keeps the allowlisted inline tags",()=>{const input='<p>Hello <strong>world</strong>, <em>see</em> <a href="/x">x</a><br>and <b>more</b>.</p>';expect(sanitizeRichText(input)).toBe(input)});it("strips img tags (not in allowlist) but keeps trailing text",()=>{const out=sanitizeRichText('<img src=x onerror="alert(1)">trail');const root=parseDom(out);expect(root.querySelector("img")).toBeNull();expect(eventHandlerAttrsOf(root)).toEqual([]);expect(out).toContain("trail")})});describe("script + event-handler corpus (symmetric with sanitizeInlineMarkup)",()=>{it.each(["<script>alert(1)</script>","<SCRIPT>alert(1)</SCRIPT>",'<p onclick="alert(1)">x</p>','<a href="/x" onmouseover="alert(1)">l</a>','<strong onerror="alert(1)">x</strong>'])("strips '%s'",payload=>{const out=sanitizeRichText(payload+"tail");const root=parseDom(out);expect(scriptTagsIn(root)).toHaveLength(0);expect(eventHandlerAttrsOf(root)).toEqual([]);expect(out).toContain("tail")})});describe("URI bypasses (symmetric with sanitizeInlineMarkup)",()=>{it.each(["javascript:alert(1)","JaVaScRiPt:alert(1)","java script:alert(1)","javascript&colon;alert(1)","&#x6A;avascript:alert(1)","data:text/html,<script>alert(1)</script>","vbscript:msgbox(1)","https://evil.com","//evil.com","///evil.com","/\\evil.com","/\\\\evil.com"])("strips href='%s'",href=>{const out=sanitizeRichText(`<a href="${href}">x</a>`);const root=parseDom(out);expect(dangerousHrefsIn(root)).toEqual([]);root.querySelectorAll("a[href]").forEach(a=>{expect(a.getAttribute("href")).toMatch(/^\/[^/\\]/)})})});describe("disallowed-but-tempting tags",()=>{it.each(["<iframe src=javascript:alert(1)></iframe>","<object data=javascript:alert(1)></object>","<embed src=javascript:alert(1)></embed>","<svg><script>alert(1)</script></svg>","<svg onload=alert(1)></svg>","<details ontoggle=alert(1) open>x</details>","<marquee onstart=alert(1)>x</marquee>","<video><source onerror=alert(1)></video>","<form><input type=image src=x onerror=alert(1)></form>","<isindex action=javascript:alert(1) type=image>","<base href=javascript:alert(1)//>","<meta http-equiv=refresh content=0;url=javascript:alert(1)>"])("strips '%s'",payload=>{const out=sanitizeRichText(payload+"ok");const root=parseDom(out);expect(scriptTagsIn(root)).toHaveLength(0);expect(eventHandlerAttrsOf(root)).toEqual([]);expect(dangerousHrefsIn(root)).toEqual([]);["iframe","object","embed","svg","details","marquee","video","source","form","input","isindex","base","meta"].forEach(t=>expect(root.querySelector(t)).toBeNull())})})});describe("sanitizeMarketingHtml",()=>{describe("baseline allowlist behaviour",()=>{it("keeps headings, lists, code, blockquote",()=>{const input="<h2>Title</h2><ul><li>One</li></ul><pre><code>code</code></pre><blockquote>quote</blockquote>";expect(sanitizeMarketingHtml(input)).toBe(input)});it("allows external https links",()=>{const out=sanitizeMarketingHtml('<a href="https://ably.com">go</a>');expect(out).toContain('href="https://ably.com"')});it("allows http and relative URIs on img src",()=>{const out=sanitizeMarketingHtml('<img src="https://ably.com/x.png" alt="x">');expect(out).toContain('src="https://ably.com/x.png"')});it("preserves figure/figcaption around images (Ghost-style)",()=>{const input='<figure><img src="/x.png" alt="x"><figcaption>caption</figcaption></figure>';expect(sanitizeMarketingHtml(input)).toBe(input)})});describe("script and embed stripping",()=>{it.each(["<script>alert(1)</script>",'<script src="https://js.hscta.net/cta/current.js"></script>','<script type="application/ld+json">{"x":1}</script>',"<iframe src=https://youtube.com></iframe>","<iframe src=javascript:alert(1)></iframe>",'<object data="https://evil.com"></object>',"<embed src=javascript:alert(1)>","<form action=javascript:alert(1)><input></form>","<svg><script>alert(1)</script></svg>","<svg onload=alert(1)></svg>","<math><mtext><img src=x onerror=alert(1)></mtext></math>"])("strips '%s' while keeping trailing copy",payload=>{const out=sanitizeMarketingHtml(payload+"copy");const root=parseDom(out);expect(scriptTagsIn(root)).toHaveLength(0);expect(eventHandlerAttrsOf(root)).toEqual([]);expect(dangerousHrefsIn(root)).toEqual([]);["iframe","object","embed","form","input","svg","script","math"].forEach(t=>expect(root.querySelector(t)).toBeNull());expect(out).toContain("copy")})});describe("URL-protocol bypasses on allowed-tag attributes",()=>{it.each(["javascript:alert(1)","JaVaScRiPt:alert(1)","java script:alert(1)","java\nscript:alert(1)","javascript&colon;alert(1)","&#x6A;avascript:alert(1)","&#0000106;avascript:alert(1)","data:text/html,<script>alert(1)</script>","vbscript:msgbox(1)","file:///etc/passwd","blob:https://example.com/abc"])("strips href='%s' on <a>",href=>{const out=sanitizeMarketingHtml(`<a href="${href}">x</a>`);expect(dangerousHrefsIn(parseDom(out))).toEqual([])});it.each(["javascript:alert(1)","data:image/svg+xml,<svg onload=alert(1)>","vbscript:msgbox(1)"])("strips img src='%s'",src=>{const out=sanitizeMarketingHtml(`<img src="${src}" alt="x">`);expect(dangerousHrefsIn(parseDom(out))).toEqual([])})});describe("authority-delimiter / protocol-relative bypasses",()=>{it.each(["//evil.com","///evil.com","/\\evil.com","/\\\\evil.com","/\\/evil.com","/%5cevil.com"])("strips href='%s' on <a>",href=>{const out=sanitizeMarketingHtml(`<a href="${href}">click</a>`);const root=parseDom(out);root.querySelectorAll("a[href]").forEach(a=>{const raw=a.getAttribute("href")??"";expect(raw).toMatch(/^(https?:\/\/|\/[^/\\])/)})});it.each(["//evil.com/x.png","/\\evil.com/x.png","/\\\\evil.com/x.png"])("strips img src='%s'",src=>{const out=sanitizeMarketingHtml(`<img src="${src}" alt="x">`);const root=parseDom(out);root.querySelectorAll("img[src]").forEach(img=>{expect(img.getAttribute("src")).toMatch(/^(https?:\/\/|\/[^/\\])/)})})});describe("style-based payloads",()=>{it("strips style attribute entirely (we do not allow it)",()=>{const out=sanitizeMarketingHtml('<p style="background:url(javascript:alert(1))">x</p>');expect(out).not.toContain("style");expect(out).not.toContain("javascript:")});it("strips inline style with expression()",()=>{const out=sanitizeMarketingHtml('<p style="width:expression(alert(1))">x</p>');expect(out).not.toContain("style");expect(out).not.toContain("expression")})});describe("event-handler corpus on allowed tags",()=>{it.each(['<p onclick="alert(1)">x</p>','<a href="/x" onmouseover="alert(1)">l</a>','<img src="/x.png" onerror="alert(1)" alt="x">','<h2 onmouseenter="alert(1)">t</h2>','<table onclick="alert(1)"><tbody><tr><td>x</td></tr></tbody></table>'])("strips event handlers in '%s'",payload=>{const out=sanitizeMarketingHtml(payload);expect(eventHandlerAttrsOf(parseDom(out))).toEqual([])})});describe("mXSS / parser-confusion vectors",()=>{it("strips noscript-wrapped img injection",()=>{const payload='<noscript><p title="</noscript><img src=x onerror=alert(1)>">';const out=sanitizeMarketingHtml(payload);const root=parseDom(out);expect(eventHandlerAttrsOf(root)).toEqual([]);const img=root.querySelector("img");if(img){expect(img.getAttribute("onerror")).toBeNull();expect(img.getAttribute("src")).not.toBe("x")}});it("strips xlink:href javascript on svg use",()=>{const out=sanitizeMarketingHtml('<svg><use xlink:href="javascript:alert(1)"></use></svg>');const root=parseDom(out);expect(root.querySelector("svg")).toBeNull();expect(dangerousHrefsIn(root)).toEqual([])});it("strips title-attribute escape inside an allowed tag",()=>{const payload='<a href="/x" title=\'"><script>alert(1)</script><p \'>link</a>';const out=sanitizeMarketingHtml(payload);expect(scriptTagsIn(parseDom(out))).toHaveLength(0)})})});
2
+ //# sourceMappingURL=sanitize-html.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/core/utils/sanitize-html.test.ts"],"sourcesContent":["/**\n * @vitest-environment jsdom\n *\n * Adversarial test corpus for the sanitize-html primitives. Cases are sourced\n * from public XSS cheatsheets (OWASP, PortSwigger, html5sec.org) and from\n * past DOMPurify CVEs / advisories. They exercise the wrappers, not DOMPurify\n * itself — the assertion in every case is \"the rendered string does not\n * carry an executable payload through to a DOM that would fire on hydration\".\n *\n * The shape of each assertion is intentionally strict: we don't trust that\n * \"alert\" or \"javascript\" not appearing as a substring means safety, so most\n * checks combine \"tag/attr stripped\" + \"no live-handler attribute survives\"\n * + \"raw text content may survive but is rendered as text\".\n */\n\nimport { describe, expect, it } from \"vitest\";\n\nimport {\n sanitizeInlineMarkup,\n sanitizeMarketingHtml,\n sanitizeRichText,\n} from \"./sanitize-html\";\n\n// Helper: parse the sanitised string into a real DOM, then assert no element\n// in the resulting tree carries an attribute whose name starts with `on`,\n// nor an `href`/`src` whose value resolves to anything beyond the allowlist.\nconst parseDom = (html: string) => {\n const wrapper = document.createElement(\"div\");\n wrapper.innerHTML = html;\n return wrapper;\n};\n\nconst eventHandlerAttrsOf = (root: HTMLElement) => {\n const offenders: string[] = [];\n root.querySelectorAll(\"*\").forEach((el) => {\n for (const attr of Array.from(el.attributes)) {\n if (attr.name.toLowerCase().startsWith(\"on\")) {\n offenders.push(`${el.tagName}:${attr.name}`);\n }\n }\n });\n return offenders;\n};\n\nconst dangerousProtocols = /^(javascript|data|vbscript|file|blob):/i;\nconst dangerousHrefsIn = (root: HTMLElement) => {\n const offenders: string[] = [];\n root.querySelectorAll<HTMLAnchorElement>(\"[href]\").forEach((el) => {\n if (dangerousProtocols.test(el.getAttribute(\"href\") ?? \"\")) {\n offenders.push(el.getAttribute(\"href\") ?? \"\");\n }\n });\n root.querySelectorAll<HTMLImageElement>(\"[src]\").forEach((el) => {\n if (dangerousProtocols.test(el.getAttribute(\"src\") ?? \"\")) {\n offenders.push(el.getAttribute(\"src\") ?? \"\");\n }\n });\n return offenders;\n};\n\nconst scriptTagsIn = (root: HTMLElement) =>\n Array.from(root.querySelectorAll(\"script\"));\n\ndescribe(\"sanitizeInlineMarkup\", () => {\n describe(\"baseline allowlist behaviour\", () => {\n it(\"keeps same-origin relative links\", () => {\n expect(sanitizeInlineMarkup('<a href=\"/dashboard\">go</a>')).toBe(\n '<a href=\"/dashboard\">go</a>',\n );\n });\n\n it(\"keeps a data-method attribute on a link (Rails UJS)\", () => {\n const out = sanitizeInlineMarkup(\n '<a href=\"/logout\" data-method=\"delete\">Log out</a>',\n );\n expect(out).toContain('data-method=\"delete\"');\n });\n\n it(\"strips disallowed tags but keeps their text content\", () => {\n expect(sanitizeInlineMarkup(\"<b>bold</b>\")).toBe(\"bold\");\n });\n\n it(\"returns an empty string for null and undefined\", () => {\n expect(sanitizeInlineMarkup(null)).toBe(\"\");\n expect(sanitizeInlineMarkup(undefined)).toBe(\"\");\n });\n\n it(\"returns an empty string for empty input\", () => {\n expect(sanitizeInlineMarkup(\"\")).toBe(\"\");\n });\n });\n\n describe(\"script-tag injection\", () => {\n it.each([\n \"<script>alert(1)</script>\",\n \"<SCRIPT>alert(1)</SCRIPT>\",\n \"<ScRiPt>alert(1)</ScRiPt>\",\n \"<script src=//evil.com/x.js></script>\",\n \"<script\\nsrc=//evil.com/x.js></script>\",\n \"<script type='text/javascript'>alert(1)</script>\",\n \"<script\t>alert(1)</script>\",\n ])(\"strips '%s'\", (payload) => {\n const out = sanitizeInlineMarkup(payload + \"safe\");\n expect(scriptTagsIn(parseDom(out))).toHaveLength(0);\n expect(out).toContain(\"safe\");\n });\n });\n\n describe(\"event-handler injection\", () => {\n it.each([\n '<a href=\"/x\" onmouseover=\"alert(1)\">l</a>',\n '<a href=\"/x\" onclick=\"alert(1)\">l</a>',\n '<a href=\"/x\" onfocus=\"alert(1)\" autofocus>l</a>',\n '<a href=\"/x\" oNmOuSeOvEr=\"alert(1)\">l</a>',\n \"<a href=\\\"/x\\\" onmouseover='alert(1)'>l</a>\",\n // Unquoted attribute value\n \"<a href=/x onmouseover=alert(1)>l</a>\",\n // Tab between attribute name and equals\n \"<a\\thref=/x\\tonmouseover=alert(1)>l</a>\",\n // Newline between attribute name and value\n \"<a href=/x\\nonmouseover=alert(1)>l</a>\",\n // Backtick around value (some parsers accept)\n \"<a href=/x onmouseover=`alert(1)`>l</a>\",\n ])(\"strips event handlers in '%s'\", (payload) => {\n const out = sanitizeInlineMarkup(payload);\n expect(eventHandlerAttrsOf(parseDom(out))).toEqual([]);\n });\n });\n\n describe(\"javascript:/data:/vbscript: URI bypasses\", () => {\n it.each([\n \"javascript:alert(1)\",\n \"JaVaScRiPt:alert(1)\",\n // Tab in scheme (browsers normalise away whitespace inside URLs)\n \"java\\tscript:alert(1)\",\n // Newline in scheme\n \"java\\nscript:alert(1)\",\n // Carriage-return in scheme\n \"java\\rscript:alert(1)\",\n // Null byte in scheme\n \"java\\0script:alert(1)\",\n // HTML-entity-encoded colon\n \"javascript&colon;alert(1)\",\n // HTML decimal entity\n \"&#x6A;avascript:alert(1)\",\n // Long form decimal\n \"&#0000106;avascript:alert(1)\",\n // vbscript\n \"vbscript:msgbox(1)\",\n // data: with embedded script\n \"data:text/html,<script>alert(1)</script>\",\n \"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\",\n // file:\n \"file:///etc/passwd\",\n // blob:\n \"blob:https://example.com/abc\",\n ])(\"strips href='%s'\", (href) => {\n const out = sanitizeInlineMarkup(`<a href=\"${href}\">x</a>`);\n expect(dangerousHrefsIn(parseDom(out))).toEqual([]);\n });\n });\n\n describe(\"authority-delimiter / protocol-relative bypasses\", () => {\n it.each([\n \"//evil.com\",\n \"///evil.com\",\n \"/\\\\evil.com\", // CRITICAL: slash-backslash is authority-delimiter in WHATWG\n \"/\\\\\\\\evil.com\", // slash + two backslashes\n \"/\\\\/evil.com\", // mixed slash-backslash\n // URL-encoded backslash - browsers may or may not normalise\n \"/%5cevil.com\",\n ])(\"strips href='%s'\", (href) => {\n const out = sanitizeInlineMarkup(`<a href=\"${href}\">click</a>`);\n const root = parseDom(out);\n // Either the href is gone entirely, or it doesn't resolve to a foreign host\n root.querySelectorAll<HTMLAnchorElement>(\"a[href]\").forEach((a) => {\n const raw = a.getAttribute(\"href\") ?? \"\";\n // A surviving href must start with a single slash followed by a non-slash,\n // non-backslash character.\n expect(raw).toMatch(/^\\/[^/\\\\]/);\n });\n });\n });\n\n describe(\"DOMPurify historical mXSS / mutation vectors\", () => {\n // Mutation XSS via noscript - DOMPurify has had advisories around these\n it(\"strips noscript-wrapped HTML injection\", () => {\n const payload =\n '<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">';\n const out = sanitizeInlineMarkup(payload);\n const root = parseDom(out);\n expect(scriptTagsIn(root)).toHaveLength(0);\n expect(eventHandlerAttrsOf(root)).toEqual([]);\n expect(root.querySelector(\"img\")).toBeNull();\n });\n\n it(\"strips template / svg foreignObject smuggling\", () => {\n const payload =\n \"<template><svg><foreignObject><body onload=alert(1)>x</body></foreignObject></svg></template>\";\n const out = sanitizeInlineMarkup(payload);\n expect(eventHandlerAttrsOf(parseDom(out))).toEqual([]);\n });\n\n it(\"strips MathML annotation-xml encoded HTML\", () => {\n const payload =\n '<math><annotation-xml encoding=\"text/html\"><iframe src=\"javascript:alert(1)\"></iframe></annotation-xml></math>';\n const out = sanitizeInlineMarkup(payload);\n const root = parseDom(out);\n expect(root.querySelector(\"iframe\")).toBeNull();\n expect(dangerousHrefsIn(root)).toEqual([]);\n });\n });\n});\n\ndescribe(\"sanitizeRichText\", () => {\n describe(\"baseline allowlist behaviour\", () => {\n it(\"keeps the allowlisted inline tags\", () => {\n const input =\n '<p>Hello <strong>world</strong>, <em>see</em> <a href=\"/x\">x</a><br>and <b>more</b>.</p>';\n expect(sanitizeRichText(input)).toBe(input);\n });\n\n it(\"strips img tags (not in allowlist) but keeps trailing text\", () => {\n const out = sanitizeRichText('<img src=x onerror=\"alert(1)\">trail');\n const root = parseDom(out);\n expect(root.querySelector(\"img\")).toBeNull();\n expect(eventHandlerAttrsOf(root)).toEqual([]);\n expect(out).toContain(\"trail\");\n });\n });\n\n describe(\"script + event-handler corpus (symmetric with sanitizeInlineMarkup)\", () => {\n it.each([\n \"<script>alert(1)</script>\",\n \"<SCRIPT>alert(1)</SCRIPT>\",\n '<p onclick=\"alert(1)\">x</p>',\n '<a href=\"/x\" onmouseover=\"alert(1)\">l</a>',\n '<strong onerror=\"alert(1)\">x</strong>',\n ])(\"strips '%s'\", (payload) => {\n const out = sanitizeRichText(payload + \"tail\");\n const root = parseDom(out);\n expect(scriptTagsIn(root)).toHaveLength(0);\n expect(eventHandlerAttrsOf(root)).toEqual([]);\n expect(out).toContain(\"tail\");\n });\n });\n\n describe(\"URI bypasses (symmetric with sanitizeInlineMarkup)\", () => {\n it.each([\n \"javascript:alert(1)\",\n \"JaVaScRiPt:alert(1)\",\n \"java\\tscript:alert(1)\",\n \"javascript&colon;alert(1)\",\n \"&#x6A;avascript:alert(1)\",\n \"data:text/html,<script>alert(1)</script>\",\n \"vbscript:msgbox(1)\",\n \"https://evil.com\",\n \"//evil.com\",\n \"///evil.com\",\n \"/\\\\evil.com\",\n \"/\\\\\\\\evil.com\",\n ])(\"strips href='%s'\", (href) => {\n const out = sanitizeRichText(`<a href=\"${href}\">x</a>`);\n const root = parseDom(out);\n expect(dangerousHrefsIn(root)).toEqual([]);\n root.querySelectorAll<HTMLAnchorElement>(\"a[href]\").forEach((a) => {\n expect(a.getAttribute(\"href\")).toMatch(/^\\/[^/\\\\]/);\n });\n });\n });\n\n describe(\"disallowed-but-tempting tags\", () => {\n it.each([\n \"<iframe src=javascript:alert(1)></iframe>\",\n \"<object data=javascript:alert(1)></object>\",\n \"<embed src=javascript:alert(1)></embed>\",\n \"<svg><script>alert(1)</script></svg>\",\n \"<svg onload=alert(1)></svg>\",\n \"<details ontoggle=alert(1) open>x</details>\",\n \"<marquee onstart=alert(1)>x</marquee>\",\n \"<video><source onerror=alert(1)></video>\",\n \"<form><input type=image src=x onerror=alert(1)></form>\",\n \"<isindex action=javascript:alert(1) type=image>\",\n \"<base href=javascript:alert(1)//>\",\n \"<meta http-equiv=refresh content=0;url=javascript:alert(1)>\",\n ])(\"strips '%s'\", (payload) => {\n const out = sanitizeRichText(payload + \"ok\");\n const root = parseDom(out);\n expect(scriptTagsIn(root)).toHaveLength(0);\n expect(eventHandlerAttrsOf(root)).toEqual([]);\n expect(dangerousHrefsIn(root)).toEqual([]);\n [\n \"iframe\",\n \"object\",\n \"embed\",\n \"svg\",\n \"details\",\n \"marquee\",\n \"video\",\n \"source\",\n \"form\",\n \"input\",\n \"isindex\",\n \"base\",\n \"meta\",\n ].forEach((t) => expect(root.querySelector(t)).toBeNull());\n });\n });\n});\n\ndescribe(\"sanitizeMarketingHtml\", () => {\n describe(\"baseline allowlist behaviour\", () => {\n it(\"keeps headings, lists, code, blockquote\", () => {\n const input =\n \"<h2>Title</h2><ul><li>One</li></ul><pre><code>code</code></pre><blockquote>quote</blockquote>\";\n expect(sanitizeMarketingHtml(input)).toBe(input);\n });\n\n it(\"allows external https links\", () => {\n const out = sanitizeMarketingHtml('<a href=\"https://ably.com\">go</a>');\n expect(out).toContain('href=\"https://ably.com\"');\n });\n\n it(\"allows http and relative URIs on img src\", () => {\n const out = sanitizeMarketingHtml(\n '<img src=\"https://ably.com/x.png\" alt=\"x\">',\n );\n expect(out).toContain('src=\"https://ably.com/x.png\"');\n });\n\n it(\"preserves figure/figcaption around images (Ghost-style)\", () => {\n const input =\n '<figure><img src=\"/x.png\" alt=\"x\"><figcaption>caption</figcaption></figure>';\n expect(sanitizeMarketingHtml(input)).toBe(input);\n });\n });\n\n describe(\"script and embed stripping\", () => {\n it.each([\n \"<script>alert(1)</script>\",\n '<script src=\"https://js.hscta.net/cta/current.js\"></script>',\n '<script type=\"application/ld+json\">{\"x\":1}</script>',\n \"<iframe src=https://youtube.com></iframe>\",\n \"<iframe src=javascript:alert(1)></iframe>\",\n '<object data=\"https://evil.com\"></object>',\n \"<embed src=javascript:alert(1)>\",\n \"<form action=javascript:alert(1)><input></form>\",\n \"<svg><script>alert(1)</script></svg>\",\n \"<svg onload=alert(1)></svg>\",\n \"<math><mtext><img src=x onerror=alert(1)></mtext></math>\",\n ])(\"strips '%s' while keeping trailing copy\", (payload) => {\n const out = sanitizeMarketingHtml(payload + \"copy\");\n const root = parseDom(out);\n expect(scriptTagsIn(root)).toHaveLength(0);\n expect(eventHandlerAttrsOf(root)).toEqual([]);\n expect(dangerousHrefsIn(root)).toEqual([]);\n [\n \"iframe\",\n \"object\",\n \"embed\",\n \"form\",\n \"input\",\n \"svg\",\n \"script\",\n \"math\",\n ].forEach((t) => expect(root.querySelector(t)).toBeNull());\n expect(out).toContain(\"copy\");\n });\n });\n\n describe(\"URL-protocol bypasses on allowed-tag attributes\", () => {\n it.each([\n \"javascript:alert(1)\",\n \"JaVaScRiPt:alert(1)\",\n \"java\\tscript:alert(1)\",\n \"java\\nscript:alert(1)\",\n \"javascript&colon;alert(1)\",\n \"&#x6A;avascript:alert(1)\",\n \"&#0000106;avascript:alert(1)\",\n \"data:text/html,<script>alert(1)</script>\",\n \"vbscript:msgbox(1)\",\n \"file:///etc/passwd\",\n \"blob:https://example.com/abc\",\n ])(\"strips href='%s' on <a>\", (href) => {\n const out = sanitizeMarketingHtml(`<a href=\"${href}\">x</a>`);\n expect(dangerousHrefsIn(parseDom(out))).toEqual([]);\n });\n\n it.each([\n \"javascript:alert(1)\",\n \"data:image/svg+xml,<svg onload=alert(1)>\",\n \"vbscript:msgbox(1)\",\n ])(\"strips img src='%s'\", (src) => {\n const out = sanitizeMarketingHtml(`<img src=\"${src}\" alt=\"x\">`);\n expect(dangerousHrefsIn(parseDom(out))).toEqual([]);\n });\n });\n\n describe(\"authority-delimiter / protocol-relative bypasses\", () => {\n // Same corpus as the inline/rich-text wrappers - the marketing wrapper\n // earlier permitted `//evil.com` because the URI regex was unanchored\n // past the leading slash. Symmetric coverage protects against regression.\n it.each([\n \"//evil.com\",\n \"///evil.com\",\n \"/\\\\evil.com\",\n \"/\\\\\\\\evil.com\",\n \"/\\\\/evil.com\",\n \"/%5cevil.com\",\n ])(\"strips href='%s' on <a>\", (href) => {\n const out = sanitizeMarketingHtml(`<a href=\"${href}\">click</a>`);\n const root = parseDom(out);\n root.querySelectorAll<HTMLAnchorElement>(\"a[href]\").forEach((a) => {\n const raw = a.getAttribute(\"href\") ?? \"\";\n // A surviving href must be either a full http(s):// URL or a\n // single-slash same-origin path. No protocol-relative variants.\n expect(raw).toMatch(/^(https?:\\/\\/|\\/[^/\\\\])/);\n });\n });\n\n it.each([\"//evil.com/x.png\", \"/\\\\evil.com/x.png\", \"/\\\\\\\\evil.com/x.png\"])(\n \"strips img src='%s'\",\n (src) => {\n const out = sanitizeMarketingHtml(`<img src=\"${src}\" alt=\"x\">`);\n const root = parseDom(out);\n root.querySelectorAll<HTMLImageElement>(\"img[src]\").forEach((img) => {\n expect(img.getAttribute(\"src\")).toMatch(/^(https?:\\/\\/|\\/[^/\\\\])/);\n });\n },\n );\n });\n\n describe(\"style-based payloads\", () => {\n it(\"strips style attribute entirely (we do not allow it)\", () => {\n const out = sanitizeMarketingHtml(\n '<p style=\"background:url(javascript:alert(1))\">x</p>',\n );\n expect(out).not.toContain(\"style\");\n expect(out).not.toContain(\"javascript:\");\n });\n\n it(\"strips inline style with expression()\", () => {\n const out = sanitizeMarketingHtml(\n '<p style=\"width:expression(alert(1))\">x</p>',\n );\n expect(out).not.toContain(\"style\");\n expect(out).not.toContain(\"expression\");\n });\n });\n\n describe(\"event-handler corpus on allowed tags\", () => {\n it.each([\n '<p onclick=\"alert(1)\">x</p>',\n '<a href=\"/x\" onmouseover=\"alert(1)\">l</a>',\n '<img src=\"/x.png\" onerror=\"alert(1)\" alt=\"x\">',\n '<h2 onmouseenter=\"alert(1)\">t</h2>',\n '<table onclick=\"alert(1)\"><tbody><tr><td>x</td></tr></tbody></table>',\n ])(\"strips event handlers in '%s'\", (payload) => {\n const out = sanitizeMarketingHtml(payload);\n expect(eventHandlerAttrsOf(parseDom(out))).toEqual([]);\n });\n });\n\n describe(\"mXSS / parser-confusion vectors\", () => {\n it(\"strips noscript-wrapped img injection\", () => {\n const payload =\n '<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">';\n const out = sanitizeMarketingHtml(payload);\n const root = parseDom(out);\n expect(eventHandlerAttrsOf(root)).toEqual([]);\n const img = root.querySelector(\"img\");\n // If an <img> survives (it's allowed in marketing) it must not carry onerror\n if (img) {\n expect(img.getAttribute(\"onerror\")).toBeNull();\n expect(img.getAttribute(\"src\")).not.toBe(\"x\"); // src=x with no scheme is fine, but the onerror must be gone\n }\n });\n\n it(\"strips xlink:href javascript on svg use\", () => {\n const out = sanitizeMarketingHtml(\n '<svg><use xlink:href=\"javascript:alert(1)\"></use></svg>',\n );\n const root = parseDom(out);\n expect(root.querySelector(\"svg\")).toBeNull();\n expect(dangerousHrefsIn(root)).toEqual([]);\n });\n\n it(\"strips title-attribute escape inside an allowed tag\", () => {\n const payload =\n '<a href=\"/x\" title=\\'\"><script>alert(1)</script><p \\'>link</a>';\n const out = sanitizeMarketingHtml(payload);\n expect(scriptTagsIn(parseDom(out))).toHaveLength(0);\n });\n });\n});\n"],"names":["describe","expect","it","sanitizeInlineMarkup","sanitizeMarketingHtml","sanitizeRichText","parseDom","html","wrapper","document","createElement","innerHTML","eventHandlerAttrsOf","root","offenders","querySelectorAll","forEach","el","attr","Array","from","attributes","name","toLowerCase","startsWith","push","tagName","dangerousProtocols","dangerousHrefsIn","test","getAttribute","scriptTagsIn","toBe","out","toContain","undefined","each","payload","toHaveLength","toEqual","href","a","raw","toMatch","querySelector","toBeNull","input","t","src","img","not"],"mappings":"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"}
package/index.d.ts CHANGED
@@ -6602,6 +6602,69 @@ export const componentMaxHeight: (...heights: number[]) => string;
6602
6602
  //# sourceMappingURL=heights.d.ts.map
6603
6603
  }
6604
6604
 
6605
+ declare module '@ably/ui/core/utils/sanitize-html' {
6606
+ /**
6607
+ * sanitizeInlineMarkup — tightest allowlist, intended for short pieces of
6608
+ * trusted-but-defence-in-depthed text (flash messages, banner bodies that
6609
+ * already went through Rails sanitisation). Only inline links to same-origin
6610
+ * paths survive.
6611
+ *
6612
+ * Use this for surfaces where the producer (Rails helper, backend flash)
6613
+ * already sanitised the input and React-side sanitisation is the second
6614
+ * layer of defence.
6615
+ */
6616
+ export const sanitizeInlineMarkup: (input: string | null | undefined) => string;
6617
+ /**
6618
+ * sanitizeRichText — mirror of DashboardNoticeHelper::SANITISE_TAGS on the
6619
+ * Rails side. Use for content that an admin types as light HTML (banner
6620
+ * body text, dashboard notices). Keeps href so links work; same-origin URI
6621
+ * restriction prevents the URI from carrying a script payload.
6622
+ *
6623
+ * Tag set deliberately matches the server-side allowlist so the trust
6624
+ * boundary is identical regardless of which side did the rendering.
6625
+ */
6626
+ export const sanitizeRichText: (input: string | null | undefined) => string;
6627
+ /**
6628
+ * sanitizeMarketingHtml — wider allowlist for Contentful-sourced marketing
6629
+ * content (the `ContentfulBlockHtml` escape-hatch field). Trust boundary is
6630
+ * "compromised Contentful editor account" — wider than inline/rich-text but
6631
+ * still blocks scripts, event handlers, and non-http(s) URIs.
6632
+ *
6633
+ * Not suitable for the Ghost blog body. A survey of all 396 published Ably
6634
+ * blog posts (built locally against Ghost) shows the body legitimately
6635
+ * contains HubSpot CTA scripts (~70 posts), Twitter widget scripts, YouTube
6636
+ * and Vimeo iframes (~50), and inline SVG diagrams (~130). Sanitising those
6637
+ * with this allowlist would silently break the site. Ghost body is treated
6638
+ * as a trusted-CMS surface in the ADR — editor account control is the
6639
+ * security boundary.
6640
+ *
6641
+ * Marketing copy needs headings, lists, code blocks, blockquotes, and
6642
+ * external links, so the URI rule allows http(s) instead of being clamped
6643
+ * to relative paths.
6644
+ */
6645
+ export const sanitizeMarketingHtml: (input: string | null | undefined) => string;
6646
+ //# sourceMappingURL=sanitize-html.d.ts.map
6647
+ }
6648
+
6649
+ declare module '@ably/ui/core/utils/sanitize-html.test' {
6650
+ /**
6651
+ * @vitest-environment jsdom
6652
+ *
6653
+ * Adversarial test corpus for the sanitize-html primitives. Cases are sourced
6654
+ * from public XSS cheatsheets (OWASP, PortSwigger, html5sec.org) and from
6655
+ * past DOMPurify CVEs / advisories. They exercise the wrappers, not DOMPurify
6656
+ * itself — the assertion in every case is "the rendered string does not
6657
+ * carry an executable payload through to a DOM that would fire on hydration".
6658
+ *
6659
+ * The shape of each assertion is intentionally strict: we don't trust that
6660
+ * "alert" or "javascript" not appearing as a substring means safety, so most
6661
+ * checks combine "tag/attr stripped" + "no live-handler attribute survives"
6662
+ * + "raw text content may survive but is rendered as text".
6663
+ */
6664
+ export {};
6665
+ //# sourceMappingURL=sanitize-html.test.d.ts.map
6666
+ }
6667
+
6605
6668
  declare module '@ably/ui/core/utils/syntax-highlighter-registry' {
6606
6669
  export default registry;
6607
6670
  const registry: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ably/ui",
3
- "version": "18.1.0",
3
+ "version": "18.2.0",
4
4
  "description": "Home of the Ably design system library ([design.ably.com](https://design.ably.com)). It provides a showcase, development/test environment and a publishing pipeline for different distributables.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,11 +53,11 @@
53
53
  "heroicons": "^2.2.0",
54
54
  "http-server": "14.1.1",
55
55
  "jsdom": "^29.1.0",
56
- "mixpanel-browser": "^2.78.0",
56
+ "mixpanel-browser": "^2.79.0",
57
57
  "msw": "2.14.3",
58
58
  "msw-storybook-addon": "^2.0.7",
59
59
  "playwright": "^1.59.1",
60
- "posthog-js": "1.372.3",
60
+ "posthog-js": "1.375.0",
61
61
  "prettier": "^3.8.0",
62
62
  "storybook": "^10.4.0",
63
63
  "svg-sprite": "^2.0.4",