'use strict'; // ============================================================================ // Rich Content Tokens // ============================================================================ import {has} from 'utilities/object'; import Markdown from 'markdown-it'; import MILA from 'markdown-it-link-attributes'; import { FFZEvent } from './events'; export const VERSION = 9; export const TOKEN_TYPES = {}; const validate = (input, valid) => valid.includes(input) ? input : null; const VALID_WEIGHTS = ['regular', 'bold', 'semibold'], VALID_COLORS = ['base', 'alt', 'alt-2', 'link'], VALID_COLORS_TWO = ['youtube'], VALID_SIZES = ['1', '2', '3', '4', '5', '6', '7', '8'], VALID_WRAPS = ['nowrap', 'pre-wrap'], VALID_PADDING = { small: '05', normal: '1', large: '2', huge: '3' }; // ============================================================================ // Markdown // ============================================================================ const md = new Markdown({ html: false, linkify: true }).disable('image'); md.use(MILA, { attrs: { class: 'ffz-tooltip', target: '_blank', rel: 'noopener', 'data-tooltip-type': 'link' } }); // ============================================================================ // Render Tokens // ============================================================================ function applySpacing(term, token, classes, styles) { for(const mode of ['', '-x','-y','-t','-r','-b','-l']) { const key = `${term}${mode}`, value = token[key]; if ( value ) { if ( VALID_PADDING[value] ) classes.push(`tw-${term}${mode}-${VALID_PADDING[value]}`); else if ( styles ) { const thing = term === 'pd' ? 'padding' : 'margin'; if ( mode === '' ) styles[thing] = value; if ( mode === '-x' || mode === '-l' ) styles[`${thing}-left`] = value; if ( mode === '-x' || mode === '-r' ) styles[`${thing}-right`] = value; if ( mode === '-y' || mode === '-t' ) styles[`${thing}-top`] = value; if ( mode === '-y' || mode === '-b' ) styles[`${thing}-bottom`] = value; } } } } export function getRoundClass(value) { let klass; if ( value === -1 ) klass = 'rounded'; else if ( value === 1 ) klass = 'small'; else if ( value === 2 ) klass = 'medium'; else if ( value === 3 ) klass = 'large'; return klass ? `tw-border-radius-${klass}` : ''; } // TODO: Mess with this more. // (It's a function for wrapping React's createElement in a function // that accepts the same input as Vue's createElement, letting us // deduplicate a ton of code in here.) /*export function wrapReactCreate(createElement) { return (tag, opts, children) => { if ( typeof tag !== 'string' ) throw new Error('invalid tag'); if ( opts ) { if ( opts.class ) { if ( typeof opts.class === 'string' ) opts.className = opts.class; else if ( Array.isArray(opts.class) ) opts.className = opts.class.join(' '); else if ( typeof opts.class === 'object' ) { const bits = []; for(const [key, val] of Object.entries(opts.class)) if ( val ) bits.push(key); opts.className = bits.join(' '); } opts.class = undefined; } if ( opts.attrs ) { for(const [key, val] of Object.entries(opts.attrs) ) opts[key] = val; opts.attrs = undefined; } if ( opts.props ) throw new Error('props unsupported'); if ( opts.domProps ) throw new Error('domProps unsupported'); if ( opts.nativeOn ) throw new Error('nativeOn unsupported'); if ( opts.on ) { for(const [key, val] of Object.entries(opts.on) ) opts[`on${key.charAt(0).toUpperCase()}${key.slice(1)}`] = val; opts.on = undefined; } if ( opts.style && typeof opts.style !== 'object' ) opts.style = undefined; } return createElement(tag, opts, children); } }*/ export function renderWithCapture(tokens, createElement, ctx, markdown) { const old_capture = ctx.text_capture, old_markdown = ctx.markdown; ctx.text_capture = []; if ( markdown ) ctx.markdown = true; const content = renderTokens(tokens, createElement, ctx); let title = ctx.text_capture.join('').trim(); if ( ! title.length ) title = null; ctx.text_capture = old_capture; ctx.markdown = old_markdown; return { content, title } } export function renderTokens(tokens, createElement, ctx, markdown) { if ( tokens == null ) return null; const old_markdown = ctx.markdown; if ( markdown ) ctx.markdown = true; let out = []; if ( ! Array.isArray(tokens) ) tokens = [tokens]; for(const token of tokens) { if ( token == null ) continue; else if ( Array.isArray(token) ) out = out.concat(renderTokens(token, createElement, ctx)); else if ( typeof token !== 'object' ) { const val = String(token); if ( ctx.text_capture ) ctx.text_capture.push(val); if ( ctx.markdown ) { const content = md.render(val); if ( content === val ) out.push(val); else if ( ctx.vue ) out.push(createElement('span', { domProps: { innerHTML: content } })); else out.push(createElement('span', { dangerouslySetInnerHTML: { __html: content } })); } else out.push(val); } else { const type = token.type, handler = TOKEN_TYPES[type]; if ( ! handler ) { console.warn('Skipping unknown token type', type, token); continue; } const result = handler(token, createElement, ctx); if ( Array.isArray(result) ) out = out.concat(result); else if ( result ) out.push(result); } } ctx.markdown = old_markdown; if ( ! out.length ) return null; return out; } export default renderTokens; // ============================================================================ // Token Type: Reference // ============================================================================ function resolveToken(token, ctx) { if ( token?.type === 'ref' ) { return ctx.fragments?.[token.name] ?? null; } return token; } TOKEN_TYPES.ref = function(token, createElement, ctx) { const frag = ctx.fragments?.[token.name]; if ( frag ) return renderTokens(frag, createElement, ctx); } // ============================================================================ // Token Type: Box // ============================================================================ TOKEN_TYPES.box = function(token, createElement, ctx) { const classes = [], style = {}; if ( VALID_WRAPS.includes(token.wrap) ) classes.push(`tw-white-space-${token.wrap}`); if ( token.ellipsis ) classes.push('tw-ellipsis'); if ( token.lines ) { classes.push('ffz--line-clamp'); style['--ffz-lines'] = token.lines; } if ( token.border ) classes.push('tw-border'); if ( token.rounding ) { const round = getRoundClass(token.rounding); if ( round ) classes.push(round); } if ( token.background ) { if ( token.background === 'text' ) style.backgroundColor = `var(--color-text-base)`; else if ( token.background === 'text-alt' ) style.backgroundColor = `var(--color-text-alt)`; else if ( token.background === 'text-alt-2' ) style.backgroundColor = `var(--color-text-alt-2)`; else if ( VALID_COLORS.includes(token.background) ) classes.push(`tw-c-background-${token.background}`); else style.backgroundColor = token.background; } if ( token.width ) style.width = token.width; if ( token.height ) style.height = token.height; applySpacing('pd', token, classes, style); applySpacing('mg', token, classes, style); const capture = token.ellipsis || token.lines, markdown = token.markdown; let content, title = null; if ( capture ) { const out = renderWithCapture(token.content, createElement, ctx, markdown); content = out.content; title = out.title; } else content = renderTokens(token.content, createElement, ctx, markdown); if ( ctx.vue ) return createElement('div', {class: classes, style, attrs: {title}}, content); return createElement('div', {className: classes.join(' '), style, title}, content); } // ============================================================================ // Token Type: open_settings // ============================================================================ TOKEN_TYPES.open_settings = function(token, createElement, ctx) { if ( ctx.tooltip ) return null; const handler = event => { const evt = new FFZEvent({ item: token.item, event, errored: false }); window.FrankerFaceZ.get().emit('main_menu:open', evt); } const label = ctx.i18n.t('embed.show-settings', 'Open Settings'); if ( ctx.vue ) return createElement('button', { class: 'tw-button', on: { click: handler } }, [ createElement('span', { class: 'tw-button__text' }, [ label ]) ]); return createElement('button', { className: 'tw-button', onClick: handler }, createElement('span', { className: 'tw-button__text' }, label)); } // ============================================================================ // Token Type: Conditional // ============================================================================ TOKEN_TYPES.conditional = function(token, createElement, ctx) { let passed = true; if ( has(token, 'media') && token.media != ctx.allow_media ) passed = false; if ( token.nsfw && ! ctx.allow_unsafe ) passed = false; if ( passed ) return renderTokens(token.content, createElement, ctx); return renderTokens(token.alternative, createElement, ctx); } // ============================================================================ // Token Type: Fieldset // ============================================================================ TOKEN_TYPES.fieldset = function(token, createElement, ctx) { if ( ! Array.isArray(token.fields) ) return null; const fields = []; for(const field of token.fields) { if ( ! field ) continue; const name = renderTokens(field.name, createElement, ctx, token.markdown), value = renderTokens(field.value, createElement, ctx, token.markdown), icon = renderTokens(field.icon, createElement, ctx, token.markdown); if ( name == null || value == null ) continue; if ( ctx.vue ) fields.push(createElement('div', { class: [ 'ffz--field', field.inline ? 'ffz--field-inline' : false, icon ? 'ffz--field-icon' : false ] }, [ createElement('div', {class: 'ffz--field__icon'}, icon), createElement('div', {class: 'ffz--field__name tw-semibold'}, name), createElement('div', {class: 'ffz--field__value tw-c-text-alt'}, value) ])); else fields.push(createElement('div', { className: `ffz--field ${field.inline ? 'ffz--field-inline' : ''} ${icon ? 'ffz--field-icon' : ''}` }, [ createElement('div', {className: 'ffz--field__icon'}, icon), createElement('div', {className: 'ffz--field__name tw-semibold'}, name), createElement('div', {className: 'ffz--field__value tw-c-text-alt'}, value) ])); } if ( ! fields.length ) return null; if ( ctx.vue ) return createElement('div', { class: 'ffz--fields' }, fields); return createElement('div', { className: 'ffz--fields' }, fields); } // ============================================================================ // Token Type: Flex // ============================================================================ const ALIGNMENTS = ['start', 'end', 'center', 'between', 'around']; TOKEN_TYPES.flex = function(token, createElement, ctx) { const classes = [], style = {}; if ( token.inline ) classes.push('tw-flex-inline'); else classes.push('tw-flex'); const overflow = validate(token.overflow, ['hidden', 'auto']); if ( overflow ) classes.push(`tw-overflow-${overflow}`); const direction = validate(token.direction, ['column', 'row', 'column-reverse', 'row-reverse']); if ( direction ) classes.push(`tw-flex-${direction}`); const wrap = validate(token.wrap, ['wrap', 'nowrap', 'wrap-reverse']); if ( wrap ) classes.push(`tw-flex-${wrap}`); let align = validate(token['align-content'], ALIGNMENTS) if ( align ) classes.push(`tw-align-content-${align}`); align = validate(token['justify-content'], ALIGNMENTS); if ( align ) classes.push(`tw-justify-content-${align}`); align = validate(token['align-items'], ALIGNMENTS) if ( align ) classes.push(`tw-align-items-${align}`); align = validate(token['align-self'], ALIGNMENTS) if ( align ) classes.push(`tw-align-self-${align}`); applySpacing('pd', token, classes, style); applySpacing('mg', token, classes, style); const content = renderTokens(token.content, createElement, ctx, token.markdown); if ( ctx.vue ) return createElement('div', {class: classes, style}, content); return createElement('div', {className: classes.join(' '), style}, content); } // ============================================================================ // Token Type: Format // ============================================================================ TOKEN_TYPES.format = function(token, createElement, ctx) { const type = token.format, val = token.value, opt = token.options; let out; if ( type === 'date' ) out = ctx.i18n.formatDate(val, opt); else if ( type === 'time' ) out = ctx.i18n.formatTime(val, opt); else if ( type === 'datetime' ) out = ctx.i18n.formatDateTime(val, opt) else if ( type === 'relative' ) out = ctx.i18n.toRelativeTime(val, opt); else if ( type === 'duration' ) out = ctx.i18n.formatDuration(val, opt); else if ( type === 'number' ) out = ctx.i18n.formatNumber(val, opt); else { console.warn('Unknown format type:', type); out = String(val); } if ( ctx.text_capture ) ctx.text_capture.push(out); return out; } // ============================================================================ // Token Type: Gallery // ============================================================================ TOKEN_TYPES.gallery = function(token, createElement, ctx) { if ( ! Array.isArray(token.items) || ! token.items.length ) return null; let first_column = [], second_column = [], first = true, i = 0; for(const item of token.items) { const content = renderTokens(item, createElement, ctx); if ( content ) { (first ? first_column : second_column).push(content); first = ! first; i++; if ( i >= 4 ) break; } } if ( second_column.length && first_column.length > second_column.length ) second_column.push(first_column.pop()); if ( ! i ) return null const columns = []; columns.push(ctx.vue ? createElement('div', { class: 'ffz--gallery-column', attrs: { 'data-items': first_column.length } }, first_column) : createElement('div', { className: 'ffz--gallery-column', 'data-items': first_column.length }, first_column) ); if ( second_column.length ) columns.push(ctx.vue ? createElement('div', { class: 'ffz--gallery-column', attrs: { 'data-items': second_column.length } }, second_column) : createElement('div', { className: 'ffz--gallery-column', 'data-items': second_column.length }, second_column) ); if ( ctx.vue ) return createElement('div', { class: 'ffz--rich-gallery', attrs: { 'data-items': first_column.length + second_column.length } }, columns); return createElement('div', { className: 'ffz--rich-gallery', 'data-items': first_column.length + second_column.length }, columns); } // ============================================================================ // Token Type: Heading // ============================================================================ function header_vue(token, h, ctx) { let content = []; let background; if ( token.title ) { const out = renderWithCapture(token.title, h, ctx, token.markdown); content.push(h('div', { class: 'tw-ellipsis tw-semibold tw-mg-x-05', attrs: { title: out.title } }, out.content)); } if ( token.subtitle ) { const out = renderWithCapture(token.subtitle, h, ctx, token.markdown); content.push(h('div', { class: 'tw-ellipsis tw-c-text-alt-2 tw-mg-x-05', attrs: { title: out.title } }, out.content)); } if ( token.extra ) { const out = renderWithCapture(token.extra, h, ctx, token.markdown); content.push(h('div', { class: 'tw-ellipsis tw-c-text-alt-2 tw-mg-x-05', attrs: { title: out.title } }, out.content)); } let bgtoken = resolveToken(token.sfw_background, ctx); const nsfw_bg_token = resolveToken(token.background, ctx); if ( nsfw_bg_token && canShowImage(nsfw_bg_token, ctx) ) bgtoken = nsfw_bg_token; if ( bgtoken ) { if ( bgtoken.type === 'image' ) background = render_image({ ...bgtoken, aspect: undefined }, h, ctx); else if ( bgtoken.type === 'icon' ) background = h('figure', { class: `ffz-i-${bgtoken.name}` }); else background = renderWithCapture(token.background, h, ctx, token.markdown).content; } let subtok = resolveToken(token.sub_logo, ctx); if ( ! token.compact && subtok && canShowImage(subtok, ctx) ) { const aspect = subtok.aspect; let image; if ( subtok.type === 'image' ) image = render_image({ ...subtok, aspect: undefined }, h, ctx); if ( subtok.type === 'icon' ) image = h('figure', { class: `ffz-i-${subtok.name}` }); if ( image ) { image = h('div', { class: `ffz--header-sublogo tw-flex-shrink-0 ${subtok.youtube_dumb ? 'tw-mg-l-05 tw-mg-r-1' : 'tw-mg-r-05'}${aspect ? ' ffz--header-aspect' : ''}`, style: { width: aspect ? `${aspect * 2}rem` : null } }, [image]); const title = content.shift(); content = [ title, h('div', { class: 'tw-flex tw-full-width tw-align-items-center' }, [ image, h('div', { class: `tw-flex tw-full-width tw-overflow-hidden tw-justify-content-center tw-flex-column tw-flex-grow-1` }, content) ]) ]; } } content = h('div', { class: [ 'tw-flex tw-full-width tw-overflow-hidden', token.compact ? 'ffz--rich-header ffz--compact-header tw-align-items-center' : 'tw-justify-content-center tw-flex-column tw-flex-grow-1' ] }, content); let imtok = resolveToken(token.sfw_image, ctx); const nsfw_token = resolveToken(token.image, ctx); if ( nsfw_token && canShowImage(nsfw_token, ctx) ) imtok = nsfw_token; if ( imtok ) { const aspect = imtok.aspect; let image; if ( imtok.type === 'image' ) image = render_image({ ...imtok, aspect: undefined }, h, ctx); if ( imtok.type === 'icon' ) image = h('figure', { class: `ffz-i-${imtok.name}` }); const right = token.image_side === 'right'; if ( image ) { image = h('div', { class: [ 'ffz--header-image tw-flex-shrink-0 tw-mg-x-05', aspect ? 'ffz--header-aspect' : null ], style: { width: aspect ? `${aspect * (token.compact ? 2.4 : 4.8)}rem` : null } }, [image]); if ( token.compact ) { if ( right ) content.children.push(image); else content.children.unshift(image); } else { content = h('div', { class: 'tw-flex ffz--rich-header' }, [ right ? content : null, image, right ? null : content ]) } } else if ( ! token.compact ) content = h('div', { class: 'tw-flex ffz--rich-header' }, [ h('div', {class: 'ffz--header-image tw-mg-x-05'}), content ]); } else if ( ! token.compact ) content = h('div', { class: 'tw-flex ffz--rich-header' }, [ h('div', {class: 'ffz--header-image tw-mg-x-05'}), content ]); if ( background ) content = h('div', { class: 'ffz--rich-header--background' }, [ h('div', { class: 'ffz--rich-header__background' }, [ background ]), content ]); return content; } function header_normal(token, createElement, ctx) { let content = []; let background; if ( token.title ) { const out = renderWithCapture(token.title, createElement, ctx, token.markdown); content.push(createElement('div', { className: `tw-ellipsis tw-semibold ${token.compact ? 'tw-mg-r-1' : ''}`, title: out.title }, out.content)); } if ( token.subtitle ) { const out = renderWithCapture(token.subtitle, createElement, ctx, token.markdown); content.push(createElement('div', { className: `tw-ellipsis tw-c-text-alt-2`, title: out.title }, out.content)); } if ( token.extra ) { const out = renderWithCapture(token.extra, createElement, ctx, token.markdown); content.push(createElement('div', { className: 'tw-ellipsis tw-c-text-alt-2', title: out.title }, out.content)); } let bgtoken = resolveToken(token.sfw_background, ctx); const nsfw_bg_token = resolveToken(token.background, ctx); if ( nsfw_bg_token && canShowImage(nsfw_bg_token, ctx) ) bgtoken = nsfw_bg_token; if ( bgtoken ) { if ( bgtoken.type === 'image' ) background = render_image({ ...bgtoken, aspect: undefined }, createElement, ctx); else if ( bgtoken.type === 'icon' ) background = createElement('figure', { className: `ffz-i-${bgtoken.name}` }); else background = renderWithCapture(token.background, createElement, ctx, token.markdown).content; } let subtok = resolveToken(token.sub_logo, ctx); if ( ! token.compact && subtok && canShowImage(subtok, ctx) ) { const aspect = subtok.aspect; let image; if ( subtok.type === 'image' ) image = render_image({ ...subtok, aspect: undefined }, createElement, ctx); if ( subtok.type === 'icon' ) image = createElement('figure', { className: `ffz-i-${subtok.name}` }); if ( image ) { image = createElement('div', { className: `ffz--header-sublogo tw-flex-shrink-0 ${subtok.youtube_dumb ? 'tw-mg-l-05 tw-mg-r-1' : 'tw-mg-r-05'}${aspect ? ' ffz--header-aspect' : ''}`, style: { width: aspect ? `${aspect * 2}rem` : null } }, image); const title = content.shift(); content = [ title, createElement('div', { className: 'tw-flex tw-full-width tw-align-items-center' }, [ image, createElement('div', { className: `tw-flex tw-full-width tw-overflow-hidden tw-justify-content-center tw-flex-column tw-flex-grow-1` }, content) ]) ]; } } content = createElement('div', { className: `tw-flex tw-full-width tw-overflow-hidden ${token.compact ? 'ffz--rich-header ffz--compact-header tw-align-items-center' : 'tw-justify-content-center tw-flex-column tw-flex-grow-1'}` }, content); let imtok = resolveToken(token.sfw_image, ctx); const nsfw_token = resolveToken(token.image, ctx); if ( nsfw_token && canShowImage(nsfw_token, ctx) ) imtok = nsfw_token; if ( imtok ) { const aspect = imtok.aspect; let image; if ( imtok.type === 'image' ) image = render_image({ ...imtok, aspect: undefined }, createElement, ctx); if ( imtok.type === 'icon' ) image = createElement('figure', { className: `ffz-i-${imtok.name}` }); const right = token.image_side === 'right'; if ( image ) { image = createElement('div', { className: `ffz--header-image tw-flex-shrink-0 tw-mg-x-05${aspect ? ' ffz--header-aspect' : ''}`, style: { width: aspect ? `${aspect * (token.compact ? 2.4 : 4.8)}rem` : null } }, image); if ( token.compact ) { // We need to do some weird pushy stuff~ // This varies if we're running with React or native. if ( content instanceof Node ) { if ( right ) content.appendChild(image); else content.insertBefore(image, content.firstChild); } else if ( Array.isArray(content?.props?.children) ) { if ( right ) content.props.children.push(image); else content.props.children.unshift(image); } } else { content = createElement('div', { className: 'tw-flex ffz--rich-header' }, [right ? content : null, image, right ? null : content]) } } else if ( ! token.compact ) content = createElement('div', { className: 'tw-flex ffz--rich-header' }, [ createElement('div', {className: 'ffz--header-image tw-mg-x-05'}), content ]); } else if ( ! token.compact ) content = createElement('div', { className: 'tw-flex ffz--rich-header' }, [ createElement('div', {className: 'ffz--header-image tw-mg-x-05'}), content ]); if ( background ) content = createElement('div', { className: 'ffz--rich-header--background' }, [ createElement('div', { className: 'ffz--rich-header__background' }, background), content ]); return content; } TOKEN_TYPES.header = function(token, createElement, ctx) { if ( ! token.title && ! token.subtitle && ! token.image && ! token.extra ) return null; return ctx.vue ? header_vue(token, createElement, ctx) : header_normal(token, createElement, ctx); } // ============================================================================ // Token Type: Icon // ============================================================================ TOKEN_TYPES.icon = function(token, createElement, ctx) { if ( ! token.name ) return null; return ctx.vue ? createElement('span', {class: `ffz-i-${token.name}`}) : createElement('span', {className: `ffz-i-${token.name}`}); } // ============================================================================ // Token Type: Image // ============================================================================ function canShowImage(token, ctx) { return !(has(token, 'sfw') && ! token.sfw && ! ctx.allow_unsafe); } function render_image(token, createElement, ctx) { if ( ! token.url || ! canShowImage(token, ctx) ) return null; const round = getRoundClass(token.rounding); let aspect; if ( token.aspect ) aspect = token.aspect else if ( token.height > 0 && token.width > 0 ) aspect = token.width / token.height; if ( ctx.vue ) { const stuff = { class: [ token.class, round ], style: { width: token.width, height: token.height }, attrs: { src: token.url, title: token.title } }; if ( token.contain ) stuff.style.objectFit = 'contain'; if ( ctx.onload ) stuff.on = {load: ctx.onload}; const image = createElement('img', stuff); if ( ! aspect ) return image; return createElement('aspect', { props: { ratio: aspect, align: 'center' } }, [image]); } const image = createElement('img', { className: `${token.class || ''} ${round}`, src: token.url, alt: token.alt || token.title || '', title: token.title || '', onLoad: ctx.onload, style: { width: token.width, height: token.height } }); if ( token.contain ) image.style.objectFit = 'contain'; if ( ! aspect ) return image; return createElement('div', { className: 'ffz-aspect ffz-aspect--align-center' }, [ createElement('div', { className: 'ffz-aspect__spacer', style: { paddingTop: `${100 * (1 / (aspect || 1))}%` } }), image ]); } TOKEN_TYPES.image = render_image; // ============================================================================ // Token Type: I18n // ============================================================================ TOKEN_TYPES.i18n = function(token, createElement, ctx) { if ( ! token.phrase ) { console.warn('Skipping i18n tag with no phrase'); return null; } let key = token.key; if ( ctx.i18n_prefix ) key = `${ctx.i18n_prefix}.${key}`; return renderTokens( ctx.i18n.tList(key, token.phrase, token.content), createElement, ctx, token.markdown ); } // ============================================================================ // Token Type: I18n Select // ============================================================================ function findMatchingLocale(locale, list) { // Is the locale present exactly? for(const item of list) { if ( item.localeCompare(locale, undefined, {sensitivity: 'accent'}) === 0 ) return locale; } // What about partials? let prefixed = `${locale.toLowerCase()}-`; for(const item of list) { if ( item.toLowerCase().startsWith(prefixed) ) return item; } // Last resort, do we have a - in the locale? const idx = locale.indexOf('-'); if ( idx !== -1 ) return findMatchingLocale(locale.slice(0, idx), list); } TOKEN_TYPES.i18n_select = function(token, createElement, ctx) { // What locale and choices do we have. const choices = token.choices || {}; let locale = ctx.i18n?.locale ?? 'en'; // Try to find a valid match, or use the default. let selected = findMatchingLocale(locale, Object.keys(choices)); if ( ! selected ) selected = token.default; // Render it. return renderTokens( choices[selected], createElement, ctx, token.markdown ); } // ============================================================================ // Token Type: Link // ============================================================================ TOKEN_TYPES.link = function(token, createElement, ctx) { if ( token.content === undefined ) token.content = token.url; const content = renderTokens(token.content, createElement, ctx, token.markdown); const klass = []; if ( token.interactive ) klass.push(`ffz-interactable ffz-interactable--hover-enabled ffz-interactable--default tw-interactive`); if ( token.tooltip !== false ) klass.push('ffz-tooltip'); if ( token.embed ) klass.push(`tw-block tw-border tw-border-radius-large tw-mg-y-05 tw-pd-05`); if ( token.no_color ) klass.push(`ffz-link--inherit`); if ( ctx.vue ) { let on = {}; if ( ctx.link_click_handler ) on.click = ctx.link_click_handler; return createElement('a', { class: klass, attrs: { rel: 'noopener noreferrer', target: '_blank', 'data-tooltip-type': 'link', href: token.url }, on }, content); } return createElement('a', { className: klass.join(' '), rel: 'noopener noreferrer', target: '_blank', 'data-tooltip-type': 'link', href: token.url, onClick: ctx.link_click_handler }, content); } // ============================================================================ // Token Type: Overlay // ============================================================================ TOKEN_TYPES.overlay = function(token, createElement, ctx) { const content = renderTokens(token.content, createElement, ctx, token.markdown); if ( ! content ) return null; const corners = []; for(const corner of ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right']) { const stuff = renderTokens(token[corner], createElement, ctx, token.markdown); if ( stuff ) corners.push(ctx.vue ? createElement('div', {class: `ffz--overlay__bit`, attrs:{'data-side':corner}}, stuff) : createElement('div', {className: `ffz--overlay__bit`, 'data-side':corner}, stuff) ); } const classes = ['ffz--overlay']; const style = {}; if ( token.background ) { if ( VALID_COLORS.includes(token.background) ) classes.push(`tw-c-background-${token.background}`); else style.backgroundColor = token.background; } if ( token.color ) { if ( VALID_COLORS.includes(token.color) ) classes.push(`tw-c-text-${token.color}`); else style.color = token.color; } if ( ctx.vue ) return createElement('div', { class: classes, style }, [ createElement('div', {class: 'ffz--overlay__content'}, content), ...corners ]); return createElement('div', { className: classes.join(' '), style }, [ createElement('div', {className: 'ffz--overlay__content'}, content), ...corners ]); } // ============================================================================ // Token Type: Player // ============================================================================ function handlePlayerClick(token, id, ctx, event) { //console.log('clicked player', token, id, ctx, event); const target = event.currentTarget, is_av = target instanceof HTMLVideoElement || target instanceof HTMLAudioElement; if ( is_av || ctx.togglePlayer ) { event.preventDefault(); event.stopPropagation(); } if ( is_av ) { if ( target.paused ) target.play(); else target.pause(); return; } if ( ctx.togglePlayer ) ctx.togglePlayer(id); } TOKEN_TYPES.player = function(token, createElement, ctx) { // Make a unique ID for this player, within the context. const id = ctx.last_player = (ctx.last_player || 0) + 1, active = ctx.player_state?.[id]; const handler = handlePlayerClick.bind(this, token, id, ctx); if ( ! active && (token.content || token.iframe) ) return render_player_content(id, handler, token, createElement, ctx); if ( token.iframe ) return render_player_iframe(id, active ?? false, handler, token, createElement, ctx); if ( ! token.sources ) return null; const autoplay = token.autoplay ?? false, loop = token.loop ?? false, playing = active ?? autoplay, controls = ! token.silent || ! autoplay; const muted = token.silent ? true : (active == null && autoplay); const style = {}; const aspect = token.active_aspect ?? token.aspect; if ( aspect ) style.aspectRatio = aspect; if ( ctx.vue ) return createElement(token.audio ? 'audio' : 'video', { style, attrs: { autoplay: playing, loop, controls, poster: token.poster }, domProps: { muted }, on: { click: handler } }, token.sources.map(source => createElement('source', { attrs: { type: source.type, src: source.src } }))); return createElement(token.audio ? 'audio' : 'video', { style, muted, autoplay: playing, loop, poster: token.poster, controls, onClick: handler }, token.sources.map(source => createElement('source', { type: source.type, src: source.src }))); } function render_player_content(id, handler, token, createElement, ctx) { const content = renderTokens(token.content, createElement, ctx, token.markdown), classes = ['ffz--rich-player'], style = {}; if ( token.aspect ) style.aspectRatio = token.aspect; if ( ctx.vue ) return createElement('div', { class: classes, style, on: { click: handler } }, content); return createElement('div', { className: classes.join(' '), onClick: handler, style }, content); } function render_player_iframe(id, active, handler, token, createElement, ctx) { const style = {}, attrs = { src: token.iframe, frameborder: 0, allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', allowfullscreen: true }; const aspect = token.active_aspect ?? token.aspect; if ( aspect ) style.aspectRatio = aspect; if ( ctx.vue ) return createElement('iframe', { style, attrs }); return createElement('iframe', { style, ...attrs }); } // ============================================================================ // Token Type: Style // ============================================================================ TOKEN_TYPES.style = function(token, createElement, ctx) { const classes = [], style = {}; if ( token.weight ) { if ( VALID_WEIGHTS.includes(token.weight) ) classes.push(`tw-${token.weight}`); else style.weight = token.weight; } if ( token.italic ) classes.push('tw-italic'); if ( token.strike ) classes.push('tw-strikethrough'); if ( token.underline ) classes.push('tw-underline'); if ( token.tabular ) classes.push('tw-tabular-nums'); if ( token.size ) { if ( typeof token.size === 'string' ) { if ( VALID_SIZES.includes(token.size) ) classes.push(`tw-font-size-${token.size}`); else style.fontSize = token.size; } else style.fontSize = `${token.size}px`; } if ( token.color ) { if ( VALID_COLORS.includes(token.color) ) classes.push(`tw-c-text-${token.color}`); else if ( VALID_COLORS_TWO.includes(token.color) ) classes.push(`ffz-c-text-${token.color}`); else style.color = token.color; } if ( VALID_WRAPS.includes(token.wrap) ) classes.push(`tw-white-space-${token.wrap}`); if ( token.ellipsis ) classes.push('tw-ellipsis'); applySpacing('pd', token, classes, style); applySpacing('mg', token, classes, style); const capture = token.ellipsis; let content, title = null; if ( capture ) { const out = renderWithCapture(token.content, createElement, ctx, token.markdown); content = out.content; title = out.title; } else content = renderTokens(token.content, createElement, ctx, token.markdown); if ( ctx.vue ) return createElement('span', {class: classes, style, attrs: {title}}, content); return createElement('span', {className: classes.join(' '), style, title}, content); } // ============================================================================ // Token Type: Tag (Deprecated) // ============================================================================ export const ALLOWED_TAGS = [ 'a', 'abbr', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'data', 'dd', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'li', 'main', 'nav', 'ol', 'p', 'picture', 'pre', 's', 'section', 'source', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'u', 'ul', 'video', 'wbr' ]; export const ALLOWED_ATTRS = { a: ['href'], audio: ['autoplay', 'controls', 'loop', 'muted', 'preload', 'src'], bdo: ['dir'], col: ['span'], colgroup: ['span'], data: ['value'], img: ['alt', 'height', 'sizes', 'src', 'srcset', 'width'], source: ['src', 'srcset', 'type', 'media', 'sizes'], td: ['colspan', 'headers', 'rowspan'], th: ['abbr', 'colspan', 'headers', 'rowspan', 'scope'], time: ['datetime'], track: ['default', 'kind', 'label', 'src', 'srclang'], video: ['autoplay', 'controls', 'height', 'loop', 'muted', 'poster', 'preload', 'src', 'width'], }; export const PROPS = [ 'muted' ]; export const GLOBAL_ATTRS = ['style', 'title']; TOKEN_TYPES.tag = function(token, createElement, ctx) { const tag = String(token.tag || 'span').toLowerCase(); if ( ! ALLOWED_TAGS.includes(tag) ) { console.warn('Skipping disallowed tag:', tag); return null; } const attrs = {}, props = {}; if ( token.attrs ) { const allowed = ALLOWED_ATTRS[tag] || []; for(const [key, val] of Object.entries(token.attrs)) { if ( ! allowed.includes(key) && ! key.startsWith('data-') && ! GLOBAL_ATTRS.includes(key) ) console.warn(`Skipping disallowed attribute for tag ${tag}:`, key); else if ( ctx.vue && PROPS.includes(key) ) props[key] = val; else attrs[key] = val; } } if ( tag === 'img' || tag === 'picture' ) attrs.onload = ctx.onload; if ( tag === 'video' || tag === 'audio' ) attrs.loadedmetadata = ctx.onload; const content = renderTokens(token.content, createElement, ctx, token.markdown); if ( ctx.vue ) return createElement(tag, { class: token.class || '', domProps: props, attrs }, content); return createElement(tag, { ...attrs, className: token.class || '' }, content); }