@heliosgraphics/ui 2.0.1-alpha.1 → 2.0.1-alpha.10

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/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2023 Helios Graphics
3
+ Copyright (c) 2016 Helios Graphics
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -5,7 +5,9 @@
5
5
  user-select: none;
6
6
  }
7
7
 
8
- .dropdownOpen [data-ui-component="Icon"] {
8
+ .dropdownOpen .dropdown__activator [data-ui-icon="caret-down"],
9
+ .dropdownOpen .dropdown__activator [data-ui-icon="arrow-down"],
10
+ .dropdownOpen .dropdown__activator [data-ui-icon="chevron-down"] {
9
11
  transition: transform 96ms ease-in-out;
10
12
  transform: rotate(180deg);
11
13
  }
@@ -53,40 +55,19 @@
53
55
  position: absolute;
54
56
  z-index: var(--z-index-8);
55
57
 
56
- display: none;
57
58
  min-width: 240px;
58
59
  opacity: 0;
59
60
 
60
61
  transition:
61
- display 96ms ease-in-out allow-discrete,
62
62
  transform 96ms ease-in-out,
63
63
  opacity 96ms ease-in-out;
64
64
  pointer-events: none;
65
65
  }
66
66
 
67
- .dropdown__navActive {
68
- display: block;
69
- opacity: 1;
67
+ .dropdown__nav.dropdown__navActive {
68
+ opacity: 1 !important;
70
69
 
71
- transform: translateY(0);
70
+ transform: translateY(0) !important;
72
71
 
73
72
  pointer-events: all;
74
-
75
- @starting-style {
76
- opacity: 0;
77
- }
78
- }
79
-
80
- .dropdownBottomLeft .dropdown__navActive,
81
- .dropdownBottomRight .dropdown__navActive {
82
- @starting-style {
83
- transform: translateY(-6px);
84
- }
85
- }
86
-
87
- .dropdownTopLeft .dropdown__navActive,
88
- .dropdownTopRight .dropdown__navActive {
89
- @starting-style {
90
- transform: translateY(6px);
91
- }
92
73
  }
@@ -95,6 +95,7 @@ export const Dropdown: FC<DropdownProps> = ({ children, items, isDisabled, posit
95
95
  <div
96
96
  role="button"
97
97
  tabIndex={0}
98
+ className={styles.dropdown__activator}
98
99
  onClick={onSetVisible}
99
100
  onKeyDown={onKeyDown}
100
101
  aria-expanded={isVisible}
@@ -73,7 +73,7 @@ export const getFlexUtility = (props?: FlexProps): string => {
73
73
  export const getResponsiveScale = (value?: ResponsiveScaleType, prefix: string = "p"): string => {
74
74
  if (!value) return ""
75
75
 
76
- const isArray: boolean = Boolean(value && value instanceof Array)
76
+ const isArray: boolean = Array.isArray(value)
77
77
  const classes = new Set<string>()
78
78
 
79
79
  if (!isArray) return `${prefix}-${value}`
@@ -92,7 +92,7 @@ export const getResponsiveScale = (value?: ResponsiveScaleType, prefix: string =
92
92
  export const getPadding = (paddingValue?: ResponsiveScaleType): string => {
93
93
  if (!paddingValue) return ""
94
94
 
95
- const isArray: boolean = Boolean(paddingValue && paddingValue instanceof Array)
95
+ const isArray: boolean = Array.isArray(paddingValue)
96
96
  const paddingClasses = new Set<string>()
97
97
 
98
98
  if (!isArray) return `p-${paddingValue}`
@@ -111,7 +111,7 @@ export const getPadding = (paddingValue?: ResponsiveScaleType): string => {
111
111
  export const getRadius = (radiusValue?: ResponsiveRadiusType): string => {
112
112
  if (!radiusValue) return ""
113
113
 
114
- const isArray: boolean = Boolean(radiusValue && radiusValue instanceof Array)
114
+ const isArray: boolean = Array.isArray(radiusValue)
115
115
  const radiusClasses = new Set<string>()
116
116
 
117
117
  if (!isArray) return `radius-${radiusValue}`
@@ -1,4 +1,4 @@
1
- import { getTypographyUtility } from "../Text/Text.utils"
1
+ import { getTypographyUtility, stripTypographyProps } from "../Text/Text.utils"
2
2
  import { getClasses } from "@heliosgraphics/utils"
3
3
  import { H0 } from "./components/H0"
4
4
  import { H1 } from "./components/H1"
@@ -9,7 +9,7 @@ import { H5 } from "./components/H5"
9
9
  import { H6 } from "./components/H6"
10
10
  import styles from "./Heading.module.css"
11
11
  import type { HeadingProps } from "./Heading.types"
12
- import type { FC, CSSProperties } from "react"
12
+ import type { FC, CSSProperties, HTMLAttributes } from "react"
13
13
 
14
14
  export const Heading: FC<HeadingProps> = (props) => {
15
15
  const { level, lineClamp, lineHeight, style, className, ...rest } = props
@@ -19,10 +19,7 @@ export const Heading: FC<HeadingProps> = (props) => {
19
19
  [styles.headingSecondary]: props.emphasis === "secondary",
20
20
  [styles.headingTertiary]: props.emphasis === "tertiary",
21
21
  })
22
- const utility: string = getTypographyUtility({
23
- ...props,
24
- className: headingClasses,
25
- })
22
+ const utility: string = getTypographyUtility(props, headingClasses)
26
23
  const lineClampStyle: CSSProperties | undefined = lineClamp
27
24
  ? {
28
25
  display: "-webkit-box",
@@ -35,12 +32,12 @@ export const Heading: FC<HeadingProps> = (props) => {
35
32
  const lineHeightStyle: CSSProperties | undefined = lineHeight !== undefined ? { lineHeight } : undefined
36
33
 
37
34
  const mergedStyle: CSSProperties | undefined =
38
- style || lineClampStyle || lineHeightStyle
39
- ? { ...(style || {}), ...(lineClampStyle || {}), ...(lineHeightStyle || {}) }
40
- : undefined
35
+ style || lineClampStyle || lineHeightStyle ? { ...style, ...lineClampStyle, ...lineHeightStyle } : undefined
36
+ const safeHeadingProps: Omit<HeadingProps, "level" | "lineClamp" | "lineHeight" | "className" | "style"> =
37
+ stripTypographyProps(rest)
41
38
 
42
- const allProps: Omit<HeadingProps, "level"> = {
43
- ...rest,
39
+ const allProps: HTMLAttributes<HTMLHeadingElement> = {
40
+ ...safeHeadingProps,
44
41
  children: props.children,
45
42
  style: mergedStyle,
46
43
  className: utility,
@@ -7,16 +7,20 @@
7
7
  width: 100%;
8
8
 
9
9
  fill: currentcolor;
10
+ stroke: currentcolor;
10
11
  }
11
12
 
12
13
  .iconPrimary svg {
13
14
  fill: var(--ui-text-primary);
15
+ stroke: var(--ui-text-primary);
14
16
  }
15
17
 
16
18
  .iconSecondary svg {
17
19
  fill: var(--ui-text-secondary);
20
+ stroke: var(--ui-text-secondary);
18
21
  }
19
22
 
20
23
  .iconTertiary svg {
21
24
  fill: var(--ui-text-tertiary);
25
+ stroke: var(--ui-text-tertiary);
22
26
  }
@@ -24,6 +24,7 @@ export const Icon: FC<IconProps> = ({ icon, className, emphasis, size }) => {
24
24
  style={iconSizeStyle}
25
25
  aria-hidden={true}
26
26
  data-ui-component="Icon"
27
+ data-ui-icon={icon}
27
28
  dangerouslySetInnerHTML={{ __html: icons[icon] || "" }}
28
29
  />
29
30
  )
@@ -3,6 +3,10 @@
3
3
  let lastLocation: string | null = null
4
4
  let isHistoryPatched: boolean = false
5
5
 
6
+ const dispatchLocationChange = (): void => {
7
+ globalThis.dispatchEvent(new Event("locationchange"))
8
+ }
9
+
6
10
  const getLocationKey = (): string | null => {
7
11
  if (!globalThis?.location) return null
8
12
 
@@ -15,9 +19,6 @@ const patchHistory = (): void => {
15
19
  isHistoryPatched = true
16
20
 
17
21
  const { pushState, replaceState } = globalThis.history
18
- const dispatchLocationChange = (): void => {
19
- globalThis.dispatchEvent(new Event("locationchange"))
20
- }
21
22
 
22
23
  const pushStateWrapper = (...args: Parameters<History["pushState"]>): ReturnType<History["pushState"]> => {
23
24
  const result = pushState.apply(globalThis.history, args as Parameters<History["pushState"]>)
@@ -16,6 +16,11 @@ export const meta: HeliosAttributeMeta<MarkdownProps> = {
16
16
  isOptional: true,
17
17
  description: "Children to render inside the markdown wrapper",
18
18
  },
19
+ isLinksAllowed: {
20
+ type: "boolean",
21
+ isOptional: true,
22
+ description: "Preserves markdown links and auto-linked URLs in sanitized output",
23
+ },
19
24
  isNonSelectable: {
20
25
  type: "boolean",
21
26
  isOptional: true,
@@ -4,7 +4,7 @@ import styles from "./Markdown.module.css"
4
4
  import type { FC } from "react"
5
5
  import type { MarkdownProps } from "./Markdown.types"
6
6
 
7
- export const Markdown: FC<MarkdownProps> = ({ text, children, isNonSelectable }) => {
7
+ export const Markdown: FC<MarkdownProps> = ({ text, children, isNonSelectable, isLinksAllowed }) => {
8
8
  if (!text && !children) return null
9
9
 
10
10
  const markdownClasses: string = getClasses(styles.markdown, {
@@ -12,7 +12,7 @@ export const Markdown: FC<MarkdownProps> = ({ text, children, isNonSelectable })
12
12
  })
13
13
 
14
14
  if (text) {
15
- const innerHTML = { __html: renderMarkdown(text) }
15
+ const innerHTML = { __html: renderMarkdown(text, { allowLinks: !!isLinksAllowed }) }
16
16
 
17
17
  return <div className={markdownClasses} dangerouslySetInnerHTML={innerHTML} data-ui-component="Markdown"></div>
18
18
  }
@@ -2,6 +2,7 @@ import type { ReactNode } from "react"
2
2
 
3
3
  export interface MarkdownProps {
4
4
  children?: ReactNode
5
+ isLinksAllowed?: boolean
5
6
  isNonSelectable?: boolean
6
7
  text?: string
7
8
  }
@@ -1,6 +1,5 @@
1
- "use client"
2
-
3
- import { type FC, useId, useInsertionEffect } from "react"
1
+ import { Children, type FC } from "react"
2
+ import { Masonry as Plock } from "react-plock"
4
3
  import type { MasonryProps } from "./Masonry.types"
5
4
 
6
5
  export const Masonry: FC<MasonryProps> = ({
@@ -9,40 +8,21 @@ export const Masonry: FC<MasonryProps> = ({
9
8
  gap = [2, 4, 6],
10
9
  breakpoints = [0, 640, 960],
11
10
  }) => {
12
- const reactId = useId()
13
- const id = `masonry${reactId.replace(/:/g, "")}`
14
-
15
- useInsertionEffect(() => {
16
- const style = document.createElement("style")
17
-
18
- style.dataset["masonryId"] = id
19
- style.textContent = `
20
- .${id} { column-count: ${columns[0]}; column-gap: ${gap[0]}px; }
21
- .${id} > * { break-inside: avoid; margin-bottom: ${gap[0]}px; }
22
-
23
- @media (min-width: ${breakpoints[1]}px) {
24
- .${id} { column-count: ${columns[1]}; column-gap: ${gap[1]}px; }
25
- .${id} > * { margin-bottom: ${gap[1]}px; }
26
- }
27
-
28
- @media (min-width: ${breakpoints[2]}px) {
29
- .${id} { column-count: ${columns[2]}; column-gap: ${gap[2]}px; }
30
- .${id} > * { margin-bottom: ${gap[2]}px; }
31
- }
32
- `
33
- document.head.appendChild(style)
34
-
35
- return (): void => {
36
- style.remove()
37
- }
38
- }, [id, columns, gap, breakpoints])
39
-
40
11
  if (!children) return null
41
12
 
13
+ const items = Children.toArray(children)
14
+ const numericGap = gap.map((g) => (g === "px" ? 1 : g)) as Array<number>
15
+
42
16
  return (
43
- <div className={id} data-ui-component="Masonry">
44
- {children}
45
- </div>
17
+ <Plock
18
+ items={items}
19
+ config={{
20
+ columns,
21
+ gap: numericGap,
22
+ media: breakpoints,
23
+ }}
24
+ render={(item, index) => <div key={index}>{item}</div>}
25
+ />
46
26
  )
47
27
  }
48
28
 
@@ -1,21 +1,17 @@
1
1
  import type { HeliosFixedThemeType, HeliosThemeType } from "../../types/themes"
2
2
 
3
- export const code = (theme: HeliosThemeType = "system", fixedTheme?: HeliosFixedThemeType): void => {
4
- globalThis.__onThemeChange = function (_theme: HeliosFixedThemeType): void {}
5
-
6
- const setTheme = (newTheme: HeliosFixedThemeType): void => {
7
- globalThis.__theme = newTheme
8
- document.documentElement.dataset["theme"] = newTheme
9
- globalThis.__onThemeChange?.(newTheme)
10
- }
3
+ const setTheme = (newTheme: HeliosFixedThemeType): void => {
4
+ globalThis.__theme = newTheme
5
+ document.documentElement.dataset["theme"] = newTheme
6
+ globalThis.__onThemeChange?.(newTheme)
7
+ }
11
8
 
12
- const handleDocumentClick = (event: MouseEvent): void => {
13
- if (event.x > 256 && globalThis.location.hash === "#ui-menu") {
14
- globalThis.location.hash = "#ui"
15
- }
16
- }
9
+ const handleDarkModeChange = (event: MediaQueryListEvent): void => {
10
+ globalThis.__setPreferredTheme(event.matches ? "dark" : "light")
11
+ }
17
12
 
18
- document.addEventListener("click", handleDocumentClick)
13
+ export const code = (theme: HeliosThemeType = "system", fixedTheme?: HeliosFixedThemeType): void => {
14
+ globalThis.__onThemeChange = function (_theme: HeliosFixedThemeType): void {}
19
15
 
20
16
  const isLocked: boolean = fixedTheme !== undefined || theme !== "system"
21
17
 
@@ -51,10 +47,6 @@ export const code = (theme: HeliosThemeType = "system", fixedTheme?: HeliosFixed
51
47
  }
52
48
  }
53
49
 
54
- const handleDarkModeChange = (event: MediaQueryListEvent): void => {
55
- globalThis.__setPreferredTheme(event.matches ? "dark" : "light")
56
- }
57
-
58
50
  darkQuery.addEventListener("change", handleDarkModeChange)
59
51
 
60
52
  setTheme(preferredTheme ?? (darkQuery.matches ? "dark" : "light"))
@@ -1,19 +1,13 @@
1
1
  import { getClasses } from "@heliosgraphics/utils"
2
2
  import { Text } from "../Text"
3
+ import { onTabKeyDown } from "./Tabs.utils"
3
4
  import styles from "./Tabs.module.css"
4
- import type { FC, KeyboardEvent } from "react"
5
+ import type { FC } from "react"
5
6
  import type { TabItem, TabsProps } from "./Tabs.types"
6
7
 
7
8
  export const Tabs: FC<TabsProps> = ({ items, children }) => {
8
9
  if (!items?.length) return null
9
10
 
10
- const onKeyDown = (event: KeyboardEvent<HTMLElement>, item: TabItem): void => {
11
- if (event.key === " " || event.key === "Enter") {
12
- event.preventDefault()
13
- item.onClick?.()
14
- }
15
- }
16
-
17
11
  return (
18
12
  <div data-ui-component="Tabs">
19
13
  <div role="tablist" className={styles.tabs__list}>
@@ -46,7 +40,7 @@ export const Tabs: FC<TabsProps> = ({ items, children }) => {
46
40
  <div
47
41
  {...commonProps}
48
42
  onClick={item.isDisabled ? undefined : item.onClick}
49
- onKeyDown={(e) => onKeyDown(e, item)}
43
+ onKeyDown={(event) => onTabKeyDown(event, item)}
50
44
  >
51
45
  <Text type="small" fontWeight="medium">
52
46
  {item.name}
@@ -0,0 +1,9 @@
1
+ import type { KeyboardEvent } from "react"
2
+ import type { TabItem } from "./Tabs.types"
3
+
4
+ export const onTabKeyDown = (event: KeyboardEvent<HTMLElement>, item: TabItem): void => {
5
+ if (event.key === " " || event.key === "Enter") {
6
+ event.preventDefault()
7
+ item.onClick?.()
8
+ }
9
+ }
@@ -17,10 +17,7 @@ export const Text: FC<TextProps> = (props) => {
17
17
  [styles.textInherit]: props.emphasis === "inherit",
18
18
  })
19
19
 
20
- const utility: string = getTypographyUtility({
21
- ...props,
22
- className: textClasses,
23
- })
20
+ const utility: string = getTypographyUtility(props, textClasses)
24
21
  const lineClampStyle: object | undefined = props.lineClamp
25
22
  ? {
26
23
  display: "-webkit-box",
@@ -31,7 +28,7 @@ export const Text: FC<TextProps> = (props) => {
31
28
  : undefined
32
29
 
33
30
  const mergedStyle: object | undefined =
34
- props.style || lineClampStyle ? { ...(props.style || {}), ...(lineClampStyle || {}) } : undefined
31
+ props.style || lineClampStyle ? { ...props.style, ...lineClampStyle } : undefined
35
32
 
36
33
  const baseTextProps: Omit<TextProps, "type"> = {
37
34
  onClick: props.onClick,
@@ -1,7 +1,54 @@
1
- import type { TextProps } from "./Text.types"
1
+ import type { TextBaseProps, TextProps } from "./Text.types"
2
2
  import type { HeadingProps } from "../Heading/Heading.types"
3
3
 
4
- export const _getFontWeight = (fw: TextProps["fontWeight"]): string => {
4
+ export interface TypographyUtilityInput {
5
+ emphasis?: TextBaseProps["emphasis"] | undefined
6
+ fontFamily?: TextBaseProps["fontFamily"] | undefined
7
+ fontStyle?: TextBaseProps["fontStyle"] | undefined
8
+ fontWeight?: TextBaseProps["fontWeight"] | undefined
9
+ isBalanced?: TextBaseProps["isBalanced"] | undefined
10
+ isEllipsis?: TextBaseProps["isEllipsis"] | undefined
11
+ isNonSelectable?: TextBaseProps["isNonSelectable"] | undefined
12
+ textAlign?: TextBaseProps["textAlign"] | undefined
13
+ textDecoration?: TextBaseProps["textDecoration"] | undefined
14
+ whiteSpace?: TextBaseProps["whiteSpace"] | undefined
15
+ wordWrap?: TextBaseProps["wordWrap"] | undefined
16
+ }
17
+
18
+ export const stripTypographyProps = <T extends TypographyUtilityInput>(
19
+ props: T,
20
+ ): Omit<T, keyof TypographyUtilityInput> => {
21
+ const {
22
+ emphasis,
23
+ fontFamily,
24
+ fontStyle,
25
+ fontWeight,
26
+ isBalanced,
27
+ isEllipsis,
28
+ isNonSelectable,
29
+ textAlign,
30
+ textDecoration,
31
+ whiteSpace,
32
+ wordWrap,
33
+ ...rest
34
+ } = props
35
+
36
+ void emphasis
37
+ void fontFamily
38
+ void fontStyle
39
+ void fontWeight
40
+ void isBalanced
41
+ void isEllipsis
42
+ void isNonSelectable
43
+ void textAlign
44
+ void textDecoration
45
+ void whiteSpace
46
+ void wordWrap
47
+
48
+ return rest
49
+ }
50
+
51
+ export const _getFontWeight = (fw: TextBaseProps["fontWeight"]): string => {
5
52
  switch (fw) {
6
53
  case "thin":
7
54
  return "fw-thin"
@@ -27,7 +74,10 @@ export const _getFontWeight = (fw: TextProps["fontWeight"]): string => {
27
74
  }
28
75
  }
29
76
 
30
- export const getTypographyUtility = (props: TextProps | HeadingProps): string => {
77
+ export const getTypographyUtility = (
78
+ props: TextProps | HeadingProps | TypographyUtilityInput,
79
+ className?: string,
80
+ ): string => {
31
81
  const typoClasses: Array<string> = []
32
82
 
33
83
  const fontFamily = props.fontFamily ? props.fontFamily : "sans"
@@ -36,7 +86,7 @@ export const getTypographyUtility = (props: TextProps | HeadingProps): string =>
36
86
  typoClasses.push(fontFamily)
37
87
  typoClasses.push(fontWeight)
38
88
 
39
- if (props.className) typoClasses.push(props.className)
89
+ if (className) typoClasses.push(className)
40
90
  if (props.fontStyle) typoClasses.push(props.fontStyle)
41
91
  if (props.isBalanced) typoClasses.push("text-balanced")
42
92
  if (props.isEllipsis) typoClasses.push("ellipsis")
@@ -6,9 +6,17 @@ export const useIntersection = (ref: RefObject<HTMLElement>): boolean => {
6
6
  const [isIntersecting, setIntersecting] = useState<boolean>(false)
7
7
 
8
8
  useEffect(() => {
9
- if (!ref?.current) return
9
+ if (!ref?.current) {
10
+ setIntersecting(false)
11
+ return
12
+ }
10
13
 
11
- const observer = new IntersectionObserver(([entry]) => setIntersecting(!!entry?.isIntersecting))
14
+ if (typeof globalThis.IntersectionObserver !== "function") {
15
+ setIntersecting(false)
16
+ return
17
+ }
18
+
19
+ const observer = new globalThis.IntersectionObserver(([entry]) => setIntersecting(!!entry?.isIntersecting))
12
20
 
13
21
  observer.observe(ref.current)
14
22
 
@@ -18,7 +18,14 @@ export const useResizeObserver = (): readonly [(element: HTMLDivElement | null)
18
18
  }
19
19
 
20
20
  if (element) {
21
- resizeObserverRef.current = new ResizeObserver((entries) => {
21
+ const rect = element.getBoundingClientRect()
22
+ setWidth(rect.width)
23
+
24
+ if (typeof globalThis.ResizeObserver !== "function") {
25
+ return
26
+ }
27
+
28
+ resizeObserverRef.current = new globalThis.ResizeObserver((entries) => {
22
29
  if (entries[0]) {
23
30
  const entryWidth = entries[0].contentRect.width
24
31
  setWidth(entryWidth)
@@ -26,9 +33,6 @@ export const useResizeObserver = (): readonly [(element: HTMLDivElement | null)
26
33
  })
27
34
 
28
35
  resizeObserverRef.current.observe(element)
29
-
30
- const rect = element.getBoundingClientRect()
31
- setWidth(rect.width)
32
36
  } else {
33
37
  setWidth(0)
34
38
  }
@@ -3,6 +3,8 @@
3
3
  import { useCallback, useEffect, useState } from "react"
4
4
  import type { HeliosFixedThemeType } from "../types/themes"
5
5
 
6
+ const getTheme = (): HeliosFixedThemeType => (typeof globalThis !== "undefined" ? globalThis.__theme : "light")
7
+
6
8
  export const useTheme = (): { theme: HeliosFixedThemeType; isDark: boolean; toggleTheme: () => void } => {
7
9
  const [theme, setTheme] = useState<HeliosFixedThemeType | null>(null)
8
10
 
@@ -10,8 +12,6 @@ export const useTheme = (): { theme: HeliosFixedThemeType; isDark: boolean; togg
10
12
  setTheme(getTheme())
11
13
  }, [])
12
14
 
13
- const getTheme = (): HeliosFixedThemeType => (typeof globalThis !== "undefined" ? globalThis.__theme : "light")
14
-
15
15
  const toggleTheme = useCallback(() => {
16
16
  const currentIsDark: boolean = getTheme() === "dark"
17
17
  globalThis.__setPreferredTheme?.(currentIsDark ? "light" : "dark")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heliosgraphics/ui",
3
- "version": "2.0.1-alpha.1",
3
+ "version": "2.0.1-alpha.10",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css",
@@ -40,23 +40,24 @@
40
40
  "ts:watch": "tsc --noEmit --incremental --watch"
41
41
  },
42
42
  "dependencies": {
43
- "@heliosgraphics/css": "^1.0.0-alpha.5",
44
- "@heliosgraphics/icons": "^1.0.0-alpha.11",
45
- "@heliosgraphics/utils": "^6.0.0-alpha.9",
46
- "marked": "^17.0.3",
43
+ "@heliosgraphics/css": "^1.0.0-alpha.10",
44
+ "@heliosgraphics/icons": "^1.0.0-alpha.14",
45
+ "@heliosgraphics/utils": "^6.0.0-alpha.16",
46
+ "marked": "^17.0.5",
47
47
  "marked-linkify-it": "^3.1.14",
48
- "marked-xhtml": "^1.0.14"
48
+ "marked-xhtml": "^1.0.14",
49
+ "react-plock": "^3.6.1"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@testing-library/react": "^16.3.2",
52
53
  "@types/node": "^25",
53
- "esbuild": "^0.27.3",
54
+ "esbuild": "^0.28.0",
54
55
  "esbuild-css-modules-plugin": "^3.1.5",
55
56
  "glob": "^13.0.6",
56
- "jsdom": "^28.1.0",
57
- "next": "^16.1.6",
57
+ "jsdom": "^29.0.1",
58
+ "next": "^16.2.2",
58
59
  "prettier": "^3.8.1",
59
- "typescript": "^5.9.3"
60
+ "typescript": "^6.0.2"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "@types/react": "^19",
@@ -0,0 +1,64 @@
1
+ import { it, describe, expect } from "bun:test"
2
+ import { renderMarkdown } from "./markdown"
3
+
4
+ describe("markdown", () => {
5
+ describe("renderMarkdown", () => {
6
+ const SAMPLE = `Hello\n\nHey`
7
+ const SAMPLE_OUTPUT = `<p>Hello</p>\n<p>Hey</p>\n`
8
+ const LINK_SAMPLE = `[x](https://example.com)`
9
+ const LINK_SAMPLE_OUTPUT = `<p><a href="https://example.com">x</a></p>\n`
10
+ const BARE_URL_SAMPLE = `Visit https://example.com/path for details.`
11
+ const BARE_URL_SAMPLE_OUTPUT = `<p>Visit <a href="https://example.com/path">https://example.com/path</a> for details.</p>\n`
12
+ const MALICIOUS_SAMPLE = `Hello <script>alert(1)</script> **world**`
13
+ const MALICIOUS_SAMPLE_OUTPUT = `<p>Hello <strong>world</strong></p>`
14
+ const MALICIOUS_LINK_SAMPLE = `[x](data:text/html,<script>alert(1)</script>)`
15
+ const MALICIOUS_LINK_SAMPLE_OUTPUT = `<p>x</p>\n`
16
+ const MALICIOUS_EVENT_HANDLER_SAMPLE = `<div onclick="alert(1)">hi</div>`
17
+ const MALICIOUS_EVENT_HANDLER_SAMPLE_OUTPUT = `<div>hi</div>`
18
+
19
+ it("Returns", () => expect(renderMarkdown(SAMPLE)).toEqual(SAMPLE_OUTPUT))
20
+
21
+ it("keeps explicit markdown links when links are allowed", () => {
22
+ expect(renderMarkdown(LINK_SAMPLE, { allowLinks: true })).toEqual(LINK_SAMPLE_OUTPUT)
23
+ })
24
+
25
+ it("autolinks bare urls when links are allowed", () => {
26
+ expect(renderMarkdown(BARE_URL_SAMPLE, { allowLinks: true })).toEqual(BARE_URL_SAMPLE_OUTPUT)
27
+ })
28
+
29
+ it("keeps links disabled by default", () => {
30
+ expect(renderMarkdown(LINK_SAMPLE)).toEqual(`<p>x</p>\n`)
31
+ expect(renderMarkdown(BARE_URL_SAMPLE)).toEqual(`<p>Visit https://example.com/path for details.</p>\n`)
32
+ })
33
+
34
+ it("strips unsafe html from markdown output", () => {
35
+ const result = renderMarkdown(MALICIOUS_SAMPLE)
36
+
37
+ expect(result).toContain(MALICIOUS_SAMPLE_OUTPUT)
38
+ expect(result).not.toContain("<script")
39
+ expect(result).not.toContain("alert(1)")
40
+ })
41
+
42
+ it("strips unsafe link protocols without leaving broken markup", () => {
43
+ const result = renderMarkdown(MALICIOUS_LINK_SAMPLE, { allowLinks: true })
44
+
45
+ expect(result).toEqual(MALICIOUS_LINK_SAMPLE_OUTPUT)
46
+ expect(result).not.toContain("data:")
47
+ expect(result).not.toContain("<script")
48
+ })
49
+
50
+ it("does not autolink urls inside inline code", () => {
51
+ const result = renderMarkdown("`https://example.com`", { allowLinks: true })
52
+
53
+ expect(result).toEqual(`<p><code>https://example.com</code></p>\n`)
54
+ })
55
+
56
+ it("removes inline event handler attributes from raw html", () => {
57
+ const result = renderMarkdown(MALICIOUS_EVENT_HANDLER_SAMPLE)
58
+
59
+ expect(result).toEqual(MALICIOUS_EVENT_HANDLER_SAMPLE_OUTPUT)
60
+ expect(result).not.toContain("onclick")
61
+ expect(result).not.toContain("alert(1)")
62
+ })
63
+ })
64
+ })
package/utils/markdown.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  import { Marked } from "marked"
2
2
  import { markedXhtml } from "marked-xhtml"
3
3
  import markedLinkifyIt from "marked-linkify-it"
4
+ import { sanitizeText } from "@heliosgraphics/utils"
4
5
 
5
6
  const marked = new Marked({ breaks: true, gfm: true })
7
+ const SAFE_MARKDOWN_LINK_PATTERN: RegExp = /^(?:https?:|mailto:|tel:|\/|#)/i
8
+
9
+ export interface RenderMarkdownOptions {
10
+ allowLinks?: boolean
11
+ }
6
12
 
7
13
  marked.use(markedXhtml())
8
14
  marked.use(markedLinkifyIt())
9
15
 
10
- export const renderMarkdown = (text: string): string => marked.parse(text) as string
16
+ const stripUnsafeMarkdownLinks = (html: string): string => {
17
+ return html.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, (match: string, href: string, text: string) => {
18
+ return SAFE_MARKDOWN_LINK_PATTERN.test(href) ? match : text
19
+ })
20
+ }
21
+
22
+ export const renderMarkdown = (text: string, options: RenderMarkdownOptions = {}): string => {
23
+ const renderedMarkdown: string = marked.parse(text) as string
24
+ const sanitizedMarkdown: string = sanitizeText(stripUnsafeMarkdownLinks(renderedMarkdown), options)
25
+
26
+ return renderedMarkdown.endsWith("\n") ? `${sanitizedMarkdown}\n` : sanitizedMarkdown
27
+ }
@@ -1,11 +0,0 @@
1
- import { it, describe, expect } from "bun:test"
2
- import { renderMarkdown } from "./Markdown.utils"
3
-
4
- describe("markdown", () => {
5
- describe("renderMarkdown", () => {
6
- const SAMPLE = `Hello\n\nHey`
7
- const SAMPLE_OUTPUT = `<p>Hello</p>\n<p>Hey</p>\n`
8
-
9
- it("Returns", () => expect(renderMarkdown(SAMPLE)).toEqual(SAMPLE_OUTPUT))
10
- })
11
- })