import { DEV } from 'distilt/env' import { CSSNested, Preset, CustomProperties, CSSObject, Context, BaseTheme, ColorValue, RuleResolver, AutocompleteProvider, withAutocomplete, } from '@twind/core' import { toColorValue } from '@twind/core' declare module '@twind/core' { export interface CustomProperties { '--tw-prose-body'?: string '--tw-prose-lead'?: string '--tw-prose-headings'?: string '--tw-prose-links'?: string '--tw-prose-bold'?: string '--tw-prose-counters'?: string '--tw-prose-bullets'?: string '--tw-prose-hr'?: string '--tw-prose-quote-borders'?: string '--tw-prose-captions'?: string '--tw-prose-code'?: string '--tw-prose-pre-code'?: string '--tw-prose-pre-bg'?: string '--tw-prose-th-borders'?: string '--tw-prose-td-borders'?: string } } export type FontSizeValue = | string | [size: string, lineHeight: string] | [size: string, options: { lineHeight?: string; letterSpacing?: string }] export interface TypographyTheme extends BaseTheme { fontSize: Record } export interface TypographyOptions { /** * The class name to use the typographic utilities. * To undo the styles to the elements, use it like * `not-${className}` which is by default `not-prose`. * * Note: `not` utility is only available in class. * * @defaultValue `prose` */ className?: string /** * Default color to use. * * @defaultValue 'gray' */ defaultColor?: string colors?: { /** * @defaultValue '700' */ body?: string /** * @defaultValue '900' */ headings?: string /** * @defaultValue '600' */ lead?: string /** * @defaultValue '900' */ links?: string /** * @defaultValue '900' */ bold?: string /** * @defaultValue '500' */ counters?: string /** * @defaultValue '300' */ bullets?: string /** * @defaultValue '200' */ hr?: string /** * @defaultValue '900' */ quotes?: string /** * @defaultValue '200' */ 'quote-borders'?: string /** * @defaultValue '500' */ captions?: string /** * @defaultValue '900' */ code?: string /** * @defaultValue '200' */ 'pre-code'?: string /** * @defaultValue '800' */ 'pre-bg'?: string /** * @defaultValue '300' */ 'th-borders'?: string /** * @defaultValue '200' */ 'td-borders'?: string // invert colors (dark mode) dark?: | null | undefined | { /** * @defaultValue '300' */ body?: string /** * @defaultValue '#fff' */ headings?: string /** * @defaultValue '400' */ lead?: string /** * @defaultValue '#fff' */ links?: string /** * @defaultValue '#fff' */ bold?: string /** * @defaultValue '400' */ counters?: string /** * @defaultValue '600' */ bullets?: string /** * @defaultValue '700' */ hr?: string /** * @defaultValue '100' */ quotes?: string /** * @defaultValue '700' */ 'quote-borders'?: string /** * @defaultValue '400' */ captions?: string /** * @defaultValue '#fff' */ code?: string /** * @defaultValue '300' */ 'pre-code'?: string /** * @defaultValue 'rgb(0 0 0 / 50%)' */ 'pre-bg'?: string /** * @defaultValue '600' */ 'th-borders'?: string /** * @defaultValue '700' */ 'td-borders'?: string } } /** * Extend or override CSS selectors with CSS declaration block. * * @defaultValue undefined */ extend?: CSSNested } // indirection wrapper to remove autocomplete functions from production bundles function withAutocomplete$( resolver: RuleResolver, autocomplete: AutocompleteProvider | false, ): RuleResolver { if (DEV) { return withAutocomplete(resolver, autocomplete) } return resolver } /** * Twind Preset for Typography * * ```js * // twind.config.js * import { defineConfig } from '@twind/core' * import presetTypography from '@twind/preset-typography' * * export default defineConfig({ * presets: [ * presetTypography(), * ], * }) * ``` * * @returns typography preset */ export default function presetTypography({ className = 'prose', defaultColor = 'gray', extend = {}, colors = {}, }: TypographyOptions = {}): Preset { colors = { body: '700', headings: '900', lead: '600', links: '900', bold: '900', counters: '500', bullets: '300', hr: '200', quotes: '900', 'quote-borders': '200', captions: '500', code: '900', 'pre-code': '200', 'pre-bg': '800', 'th-borders': '300', 'td-borders': '200', ...colors, // invert colors (dark mode) dark: colors.dark === null ? null : { body: '300', headings: '#fff', lead: '400', links: '#fff', bold: '#fff', counters: '400', bullets: '600', hr: '700', quotes: '100', 'quote-borders': '700', captions: '400', code: '#fff', 'pre-code': '300', 'pre-bg': 'rgb(0 0 0 / 50%)', 'th-borders': '600', 'td-borders': '700', ...colors.dark, }, } return { // for element modifiers: prose-img:rounded-xl, prose-headings // & :is() // & :is(:where(code):not(:where([class~="not-prose"] *))) variants: [ ['headings', 'h1,h2,h3,h4,h5,h6,th'], ['h1'], ['h2'], ['h3'], ['h4'], ['h5'], ['h6'], ['p'], ['a'], ['blockquote'], ['figure'], ['figcaption'], ['strong'], ['em'], ['code'], ['pre'], ['ol'], ['ul'], ['li'], ['table'], ['thead'], ['tr'], ['th'], ['td'], ['img'], ['video'], ['hr'], ['lead', '.lead'], ].map(([name, selector = name]) => [ `${className}-${name}`, (_, context) => adjustSelector( className, selector[0] == '.' ? '.' + context.e(context.h(selector.slice(1))) : selector, context, (selector) => `& :is(${selector.trim()})`, ), ]), rules: [ // marker classes lead and not-prose [`(lead|not-${className})`, ({ 1: $1 }, { h }) => [{ c: h($1) }]], [ `${className}-invert`, { '@layer defaults': { '--tw-prose-body': 'var(--tw-prose-invert-body)', '--tw-prose-headings': 'var(--tw-prose-invert-headings)', '--tw-prose-lead': 'var(--tw-prose-invert-lead)', '--tw-prose-links': 'var(--tw-prose-invert-links)', '--tw-prose-bold': 'var(--tw-prose-invert-bold)', '--tw-prose-counters': 'var(--tw-prose-invert-counters)', '--tw-prose-bullets': 'var(--tw-prose-invert-bullets)', '--tw-prose-hr': 'var(--tw-prose-invert-hr)', '--tw-prose-quotes': 'var(--tw-prose-invert-quotes)', '--tw-prose-quote-borders': 'var(--tw-prose-invert-quote-borders)', '--tw-prose-captions': 'var(--tw-prose-invert-captions)', '--tw-prose-code': 'var(--tw-prose-invert-code)', '--tw-prose-pre-code': 'var(--tw-prose-invert-pre-code)', '--tw-prose-pre-bg': 'var(--tw-prose-invert-pre-bg)', '--tw-prose-th-borders': 'var(--tw-prose-invert-th-borders)', '--tw-prose-td-borders': 'var(--tw-prose-invert-td-borders)', }, }, ], // for type scale: prose-xl [ className + '-', withAutocomplete$(({ $$ }, context) => { const css = getFontSize(context.theme('fontSize', $$)) return css && { '@layer components': css } }, DEV && ((_, { theme }) => Object.keys(theme('fontSize')))), ], // for colors: prose-sky [ className + '-', withAutocomplete$( ({ $$ }, context) => getColors($$, context), DEV && ((_, { theme }) => Object.keys(theme('colors')).filter( (key) => key && key != 'DEFAULT' && !/[.-]/.test(key), )), ), ], // prose [ className, (_, context) => ({ // layer defaults ...getColors(defaultColor, context), '@layer base': [ adjustSelectors(className, context, { a: { color: 'var(--tw-prose-links)', textDecorationLine: 'underline', fontWeight: '500', }, strong: { color: 'var(--tw-prose-bold)', fontWeight: '600', }, 'a strong,blockquote strong,thead th strong': { color: 'inherit', }, ul: { listStyleType: 'disc', }, ol: { listStyleType: 'decimal', }, 'ol[type="A"]': { listStyleType: 'upper-alpha', }, 'ol[type="a"]': { listStyleType: 'lower-alpha', }, 'ol[type="A" s]': { listStyleType: 'upper-alpha', }, 'ol[type="a" s]': { listStyleType: 'lower-alpha', }, 'ol[type="I"]': { listStyleType: 'upper-roman', }, 'ol[type="i"]': { listStyleType: 'lower-roman', }, 'ol[type="I" s]': { listStyleType: 'upper-roman', }, 'ol[type="i" s]': { listStyleType: 'lower-roman', }, 'ol[type="1"]': { listStyleType: 'decimal', }, 'ol,ul': { marginTop: em(20, 16), marginBottom: em(20, 16), paddingLeft: em(26, 16), }, li: { marginTop: em(8, 16), marginBottom: em(8, 16), }, 'ol>li,ul>li': { paddingLeft: em(6, 16), }, '>ul>li p': { marginTop: em(12, 16), marginBottom: em(12, 16), }, '>ul>li>*:first-child,>ol>li>*:last-child': { marginTop: em(20, 16), }, '>ul>li>*:last-child,>ol>li>*:last-child': { marginBottom: em(20, 16), }, 'ol>li::marker': { fontWeight: '400', color: 'var(--tw-prose-counters)', }, 'ul>li::marker': { color: 'var(--tw-prose-bullets)', }, 'ul ul,ul ol,ol ul,ol ol': { marginTop: em(12, 16), marginBottom: em(12, 16), }, hr: { borderColor: 'var(--tw-prose-hr)', borderTopWidth: '1', marginTop: em(48, 16), marginBottom: em(48, 16), }, blockquote: { marginTop: em(32, 20), marginBottom: em(32, 20), paddingLeft: em(20, 20), fontWeight: '500', fontStyle: 'italic', color: 'var(--tw-prose-quotes)', borderLeftWidth: '0.25rem', borderLeftColor: 'var(--tw-prose-quote-borders)', quotes: '"\\201C""\\201D""\\2018""\\2019"', }, 'blockquote p:first-of-type::before': { content: 'open-quote', }, 'blockquote p:last-of-type::after': { content: 'close-quote', }, p: { marginTop: em(20, 16), marginBottom: em(20, 16), }, h1: { color: 'var(--tw-prose-headings)', fontWeight: '800', fontSize: em(36, 16), marginTop: '0', marginBottom: em(32, 36), lineHeight: 1.15, }, 'h1 strong': { fontWeight: '900', color: 'inherit', }, h2: { color: 'var(--tw-prose-headings)', fontWeight: '700', fontSize: em(24, 16), marginTop: em(48, 24), marginBottom: em(24, 24), lineHeight: '1.35', }, 'h2 strong': { fontWeight: '800', color: 'inherit', }, h3: { color: 'var(--tw-prose-headings)', fontWeight: '600', fontSize: em(20, 16), marginTop: em(32, 20), marginBottom: em(12, 20), lineHeight: '1.6', }, 'h3 strong': { fontWeight: '700', color: 'inherit', }, h4: { color: 'var(--tw-prose-headings)', fontWeight: '600', marginTop: em(24, 16), marginBottom: em(8, 16), lineHeight: '1.5', }, 'h4 strong': { fontWeight: '700', color: 'inherit', }, 'hr+*,h2+*,h3+*,h4+*': { marginTop: '0', }, 'img,video,figure': { marginTop: em(32, 16), marginBottom: em(32, 16), }, 'figure>*': { marginTop: '0', marginBottom: '0', }, figcaption: { color: 'var(--tw-prose-captions)', fontSize: em(14, 16), lineHeight: '1.4', marginTop: em(12, 14), }, code: { color: 'var(--tw-prose-code)', fontWeight: '600', fontSize: em(14, 16), }, 'code::before,code::after': { content: '"`"', }, 'h2 code': { fontSize: em(21, 24), }, 'h3 code': { fontSize: em(18, 20), }, 'a code,h1 code,h2 code,h3 code,h4 code,blockquote code,thead th code': { color: 'inherit', }, pre: { color: 'var(--tw-prose-pre-code)', backgroundColor: 'var(--tw-prose-pre-bg)', overflowX: 'auto', fontWeight: '400', fontSize: em(14, 16), lineHeight: '1.7', marginTop: em(24, 14), marginBottom: em(24, 14), borderRadius: '0.375rem', paddingTop: em(12, 14), paddingRight: em(16, 14), paddingBottom: em(12, 14), paddingLeft: em(16, 14), }, 'pre code': { backgroundColor: 'transparent', borderWidth: '0', borderRadius: '0', padding: '0', fontWeight: 'inherit', color: 'inherit', fontSize: 'inherit', fontFamily: 'inherit', lineHeight: 'inherit', }, 'pre code::before': { content: 'none', }, 'pre code::after': { content: 'none', }, table: { width: '100%', tableLayout: 'auto', textAlign: 'left', marginTop: em(32, 16), marginBottom: em(32, 16), fontSize: em(14, 16), lineHeight: '1.7', }, thead: { borderBottomWidth: '1px', borderBottomColor: 'var(--tw-prose-th-borders)', }, 'thead th': { color: 'var(--tw-prose-headings)', fontWeight: '600', verticalAlign: 'bottom', paddingRight: em(8, 14), paddingBottom: em(8, 14), paddingLeft: em(8, 14), }, 'thead th:first-child': { paddingLeft: '0', }, 'thead th:last-child': { paddingRight: '0', }, 'tbody tr': { borderBottomWidth: '1px', borderBottomColor: 'var(--tw-prose-td-borders)', }, 'tbody tr:last-child': { borderBottomWidth: '0', }, 'tbody td,tfoot td': { verticalAlign: 'baseline', paddingTop: em(8, 14), paddingRight: em(8, 14), paddingBottom: em(8, 14), paddingLeft: em(8, 14), }, 'tbody td:first-child,tfoot td:first-child': { paddingLeft: '0', }, 'tbody td:last-child,tfoot td:last-child': { paddingRight: '0', }, [`.${context.e(context.h('lead'))}`]: { color: 'var(--tw-prose-lead)', fontSize: em(20, 16), lineHeight: '1.6', marginTop: em(24, 20), marginBottom: em(24, 20), }, '>:first-child': { marginTop: '0', }, '>:last-child': { marginBottom: '0', }, }), adjustSelectors(className, context, extend), ], '@layer components': { ...getFontSize(context.theme('fontSize', 'base')), color: 'var(--tw-prose-body)', maxWidth: 'theme(max-w.prose, 65ch)', }, } as CSSObject), ], ], } function getColors( colorName: string, context: Context, ): CSSObject | undefined { const properties: CustomProperties = {} const darkProperties: CustomProperties = {} const set = (key: string, shade: string, target: CustomProperties) => { const color = context.theme(`colors.${colorName}.${shade}`, shade) as ColorValue target[('--tw-prose-' + key) as keyof CustomProperties] = toColorValue(color) // support auto dark colors const darkColor = target != darkProperties && context.d('colors', `${colorName}.${shade}`, color) if (darkColor) { darkProperties[('--tw-prose-' + key) as keyof CustomProperties] = toColorValue(darkColor) } } for (const key in colors) { const shade = colors[key as keyof typeof colors] if (key != 'dark' && shade) { set(key, shade as string, properties) } } for (const key in colors.dark || {}) { const shade = (colors.dark as Record)[key] if (shade) { if (colors.dark) { // explicit dark colors - need to use `dark:prose-invert` set('invert-' + key, shade, properties) } else { // auto dark colors set(key, shade, darkProperties) } } } return Object.keys(properties).length ? ({ '@layer defaults': { '&': properties, [context.v('dark') as string]: darkProperties, } as CSSNested, } as CSSObject) : undefined } } function adjustSelectors( className: string, context: Context, css: CSSNested, ): CSSNested { const result: CSSNested = {} for (const selector in css) { result[ adjustSelector( className, selector, context, (selector) => `.${context.e(context.h(className))}${selector}`, ) ] = css[selector] } return result } function adjustSelector( className: string, selector: string, { e, h }: Context, replace: (selector: string) => string, ): string { // pseudo elements can't be matched return selector.replace( // 1. if there no pseudo use whole selector // 2. if there are pseudo replace prefix /^[^>:]+$|(>)?((?:[^:,]+(?::[\w-]+)?)|:[\w-]+)(::[\w-]+)?/g, (_, prefix = ' ', selector = _, pseudoElement = '') => replace( `${prefix}:where(${selector}):not(:where(.${e(h('not-' + className))} *))${pseudoElement}`, ), ) } function getFontSize(_: FontSizeValue | undefined): CSSObject | undefined { return _ ? typeof _ == 'string' ? { fontSize: _ } : { fontSize: _[0], ...(typeof _[1] == 'string' ? { lineHeight: _[1] } : _[1]), } : undefined } function em(px: number, base: number) { return `${(px / base).toFixed(3).replace(/^0|\.?0+$/g, '')}em` }