feat: add icon dialog in markdown editor

scarlett
NGPixel 4 weeks ago
parent 0c62c97eba
commit ce530a40d2
No known key found for this signature in database

@ -1763,6 +1763,7 @@
"editor.markup.insertEmoji": "Insert Emoji",
"editor.markup.insertFootnote": "Insert Footnote",
"editor.markup.insertHorizontalBar": "Insert Horizontal Bar",
"editor.markup.insertIcon": "Insert Icon",
"editor.markup.insertLink": "Insert Link",
"editor.markup.insertMathExpression": "Insert Math Expression",
"editor.markup.insertTable": "Insert Table",

@ -1,8 +1,9 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { and, count, eq, inArray } from 'drizzle-orm'
import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils'
import { getIconData, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
import { icons as iconsTable, iconSets as iconSetsTable } from '../db/schema.ts'
import type { IconifyIconCustomisations } from '@iconify/utils'
import type { IconifyIcon, IconifyInfo, IconifyJSON } from '@iconify/types'
/** An icon set as stored, plus how many of its icons the wiki holds. */
@ -588,6 +589,22 @@ class Icons {
return iconToHTML(rendered.body, rendered.attributes)
}
/**
* Turn resolved icon data into SVG markup to be drawn INTO a document.
*
* Sized in `em` Iconify's default when neither dimension is given so the icon follows the text
* it sits in, and painted in `currentColor` by the body itself, so it follows that text's colour.
*
* `replaceIDs` is what makes it safe to have more than one on a page: an icon that masks or
* gradients refers to its own `<defs>` by id, those ids come from the set rather than from this
* document, and two icons carrying the same one would each draw with whichever won. A standalone
* file has no such problem, which is why `renderSvg` does not do this.
*/
renderInlineSvg(icon: IconifyIcon, customisations: IconifyIconCustomisations = {}): string {
const rendered = iconToSVG(icon, customisations)
return iconToHTML(replaceIDs(rendered.body), rendered.attributes)
}
// == CACHE ==========================
/**

@ -1,8 +1,11 @@
import * as cheerio from 'cheerio'
import sanitizeHtml from 'sanitize-html'
import { eq, inArray, sql } from 'drizzle-orm'
import { flipFromString, rotateFromString } from '@iconify/utils'
import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts'
import type { IconifyIcon } from '@iconify/types'
import type { IconifyIconCustomisations } from '@iconify/utils'
/**
* Rendering model
@ -19,6 +22,9 @@ import { CustomError } from '../helpers/common.ts'
* - **Normalizing.** The editor leaves scaffolding in its output (line markers for preview scroll
* sync) that has no business being stored, and headings arrive without the anchors a table of
* contents needs.
* - **Resolving.** An icon is a reference when it is written and a picture when it is read, and this
* is where it stops being the former drawn into the page once, at save time, rather than fetched
* by every reader's browser on every view.
* - **Extracting.** The table of contents and the plain text the search index is built from are both
* derived from the final HTML, once it is settled.
*
@ -105,6 +111,21 @@ const BASE_ALLOWED_TAGS = [
'details',
'figcaption',
'figure',
/*
The Iconify element, so a page can carry an icon the way the interface does.
The only custom element allowed here that is not a block, and it is allowed unconditionally
because there is nothing to gate: it is inert markup like the rest of this list, and what draws
it is already on every page `boot/iconify.js` defines the element and points it at this
instance's `/_icons`, so an icon in content resolves against the wiki's own store and reaches no
third party. A block is gated because an administrator installs and enables it; nobody installs
this one.
Note it is not self-closing, whatever the author writes: the parser gives `<iconify-icon … />`
the rest of the paragraph as children, and the element's shadow root has no slot to show them
with. `</iconify-icon>` belongs on the end of every one.
*/
'iconify-icon',
'img',
'ins',
'kbd',
@ -218,6 +239,11 @@ const BASE_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
'*': ['id', 'class', 'style', 'title', 'dir', 'lang', 'aria-*', 'role', 'data-*'],
a: ['href', 'name', 'target', 'rel', 'download'],
audio: ['controls', 'loop', 'muted', 'preload', 'src'],
// -> Everything the element reads except `mode`, which picks how it paints (mask/background) and
// only matters to an author working around a specific icon set's colouring. Size and colour are
// inherited from the surrounding text by default, which is what an icon in a sentence wants;
// `inline` shifts it onto the text baseline, `width`/`height` override the 1em box.
'iconify-icon': ['icon', 'inline', 'width', 'height', 'rotate', 'flip'],
img: ['src', 'srcset', 'alt', 'width', 'height', 'loading', 'decoding'],
input: ['type', 'checked', 'disabled'],
ol: ['start', 'reversed', 'type'],
@ -311,6 +337,8 @@ class Rendering {
this.stripEditorArtifacts($)
this.unwrapOrphanedChildBlocks($)
this.liftIconChildren($)
await this.inlineIcons($)
const toc = this.anchorHeadings($)
return {
@ -471,6 +499,161 @@ class Rendering {
})
}
/**
* Move anything nested inside an `<iconify-icon>` back out, after it.
*
* `<iconify-icon icon="…" />` is what an author reaches for, and it is not a self-closing tag: the
* parser hands the element the rest of the paragraph as children, and the element paints a shadow
* root with no slot in it so that text is in the document, counted as content, and invisible on
* the page. Nothing legitimately goes inside an icon, so lifting the children out is the only
* reading of that markup that keeps what was written.
*
* Document order means a nested pair unpicks itself: the outer icon's children include the inner
* one, which is then reached in its own turn with whatever it swallowed.
*/
private liftIconChildren($: cheerio.CheerioAPI): void {
$('iconify-icon').each((_, el) => {
const icon = $(el)
const swallowed = icon.contents()
if (swallowed.length > 0) {
icon.after(swallowed)
}
})
}
/**
* Draw every `<iconify-icon>` into the page as the `<svg>` it stands for.
*
* The element is a reference: opening a page that carries one costs a request to `/_icons` per icon
* set, for every reader, before the icon appears. Resolving it here spends that once, on the person
* saving the page, and what gets stored is a picture the page then draws its icons with no second
* request at all, and goes on drawing them if the set is later deleted or the instance goes offline.
*
* An icon that does not resolve is left as the element it was. That is the honest fallback rather
* than a hole in the page: the set may be one an administrator is about to add, or upstream may be
* briefly unreachable, and the element still resolves at view time in either case. It also means
* this is safe to run over a render that has already been through it there is nothing left to do.
*
* The resolve itself is the same call `/_icons` serves readers from, so this inherits its rules
* whole: a disabled set is not filled from upstream, an unknown name is not asked about twice, and
* the upstream budget applies. What is stored is therefore never more than a reader could have got.
*/
private async inlineIcons($: cheerio.CheerioAPI): Promise<void> {
const elements = $('iconify-icon').toArray()
if (elements.length < 1) {
return
}
const referenceOf = (element: cheerio.Cheerio<any>) =>
(element.attr('icon') ?? '').trim().toLowerCase()
/*
Gathered per set before anything is resolved, because `resolveIcons` takes a list: a page built
out of twenty icons of one set is one query and at most one upstream request, not twenty.
*/
const wanted = new Map<string, Set<string>>()
for (const el of elements) {
const parsed = WIKI.models.icons.parseRef(referenceOf($(el)))
if (parsed) {
wanted.set(parsed.prefix, (wanted.get(parsed.prefix) ?? new Set()).add(parsed.name))
}
}
const resolved = new Map<string, IconifyIcon>()
for (const [prefix, names] of wanted) {
const found = await WIKI.models.icons.resolveIcons(prefix, [...names])
for (const [name, icon] of Object.entries(found.icons)) {
resolved.set(`${prefix}:${name}`, icon)
}
}
for (const el of elements) {
const element = $(el)
const icon = resolved.get(referenceOf(element))
if (icon) {
element.replaceWith(this.iconSvg($, element, icon))
}
}
}
/**
* The `<svg>` that stands in for one `<iconify-icon>`, carrying over what the author put on it.
*
* `icon`, `width`, `height`, `rotate` and `flip` are spent on the drawing itself parsed by
* Iconify's own parsers, so `flip="horizontal"` and `rotate="90deg"` mean here exactly what they
* mean to the element. Everything else the author wrote is theirs and rides along: a class, a style,
* an id to link to.
*
* `inline` becomes the baseline nudge the element applies through its host style, since a shadow
* root's `:host` rule is the one thing about it that cannot survive being drawn into the page.
*
* The attributes are set through cheerio rather than built into the markup: they are author input,
* and this is the difference between a value that gets escaped on the way out and one that closes
* the tag it was written into.
*/
private iconSvg(
$: cheerio.CheerioAPI,
element: cheerio.Cheerio<any>,
icon: IconifyIcon
): cheerio.Cheerio<any> {
const customisations: IconifyIconCustomisations = {}
const width = element.attr('width')
const height = element.attr('height')
const rotate = element.attr('rotate')
const flip = element.attr('flip')
if (width) {
customisations.width = width
}
if (height) {
customisations.height = height
}
if (rotate) {
customisations.rotate = rotateFromString(rotate)
}
if (flip) {
flipFromString(customisations, flip)
}
const svg = $(WIKI.models.icons.renderInlineSvg(icon, customisations))
const {
icon: _icon,
width: _w,
height: _h,
rotate: _r,
flip: _f,
inline,
style,
class: authorClass,
...carried
} = element.attr() ?? {}
for (const [name, value] of Object.entries(carried)) {
svg.attr(name, value)
}
/*
`icon` is the hook `_page-contents.scss` styles it by, and it is not decorative: Tailwind's
Preflight makes every `svg` a block, so an icon left to itself takes a line of its own instead
of sitting in the sentence it was written in. The element it replaces has no such problem it
declares `display: inline-block` on its own `:host` which is exactly why this only shows up
once the page is saved, with the editor's preview looking right. The twemoji images the emoji
shortcodes become are styled there for the same reason.
*/
svg.attr('class', ['icon', authorClass].filter(Boolean).join(' '))
// -> Ours first so that an author who set `vertical-align` themselves still wins
const styles = [inline === undefined ? '' : 'vertical-align:-0.125em', style ?? '']
.filter(Boolean)
.join(';')
if (styles) {
svg.attr('style', styles)
}
// -> An icon is decoration unless the author gave it a name, in which case it is theirs to describe
if (!('role' in carried) && !('title' in carried) && !('aria-label' in carried)) {
svg.attr('aria-hidden', 'true')
}
return svg
}
/**
* Give every heading an id and build the table of contents out of them.
*

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
264 icons.
266 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -221,6 +221,7 @@ export const BUNDLED_ICONS = {
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24},
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
"mdi:home": {"body":"<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>","width":24,"height":24},
"mdi:image-plus": {"body":"<path fill=\"currentColor\" d=\"M18 15v3h-3v2h3v3h2v-3h3v-2h-3v-3zm-4.7 6H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v8.3c-.6-.2-1.3-.3-2-.3c-1.1 0-2.2.3-3.1.9L14.5 12L11 16.5l-2.5-3L5 18h8.1c-.1.3-.1.7-.1 1c0 .7.1 1.4.3 2\"/>","width":24,"height":24},
"mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:image-sync-outline": {"body":"<path fill=\"currentColor\" d=\"M13.18 19c.17.72.46 1.39.85 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v6.18c-.5-.11-1-.18-1.5-.18c-.17 0-.33 0-.5.03V5H5v14zm-1.97-3.17l-1.96-2.36L6.5 17h6.53c.11-1.46.7-2.78 1.61-3.81l-.68-.9zM19 13.5V12l-2.25 2.25L19 16.5V15a2.5 2.5 0 0 1 2.5 2.5c0 .4-.09.78-.26 1.12l1.09 1.09c.42-.63.67-1.39.67-2.21c0-2.21-1.79-4-4-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5c0-.4.09-.78.26-1.12l-1.09-1.09c-.42.63-.67 1.39-.67 2.21c0 2.21 1.79 4 4 4V23l2.25-2.25L19 18.5z\"/>","width":24,"height":24},
@ -248,6 +249,7 @@ export const BUNDLED_ICONS = {
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
"mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24},
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},
"mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24},
"mdi:star": {"body":"<path fill=\"currentColor\" d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z\"/>","width":24,"height":24},
"mdi:tab-plus": {"body":"<path fill=\"currentColor\" d=\"M3 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2h10v4h8v10H3zm7 5v3H7v2h3v3h2v-3h3v-2h-3v-3z\"/>","width":24,"height":24},
"mdi:table": {"body":"<path fill=\"currentColor\" d=\"M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2m0 4v4h6V8zm8 0v4h6V8zm-8 6v4h6v-4zm8 0v4h6v-4z\"/>","width":24,"height":24},

@ -50,6 +50,16 @@
t('editor.markup.insertEmoji')
}}</w-tooltip>
</w-btn>
<!-- -> Icons only: what goes in is a `:mdi:home:` shortcode, and the picker's other tab hands
back an `img:` URL, which is not something that syntax can say -->
<w-btn icon="mdi:seed-plus-outline" padding="sm sm" flat>
<w-menu anchor="top right" self="top left" content-class="shadow-7">
<icon-picker-dialog no-image @update:model-value="insertIcon" />
</w-menu>
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertIcon')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:line-scan" padding="sm sm" flat @click="insertHorizontalBar">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertHorizontalBar')
@ -319,6 +329,7 @@ import { findEditableTables } from '@/helpers/markdownTable'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue'
import IconPickerDialog from '@/components/IconPickerDialog.vue'
import LinkPickerDialog from '@/components/LinkPickerDialog.vue'
import { useCollabStore } from '@/stores/collab'
@ -515,6 +526,18 @@ function insertEmoji(shortcode) {
insertAtCursor({ content: `:${shortcode}:` })
}
/**
* The picked icon, as the shortcode that draws it `mdi:home` in, `:mdi:home:` out.
*
* The same delimiters an emoji uses, and the same insertion: the two are one syntax as far as the
* source is concerned, told apart by the colon inside the reference. See `renderers/markdown.js`.
*/
function insertIcon(reference) {
if (reference) {
insertAtCursor({ content: `:${reference}:` })
}
}
function insertBlock() {
siteStore.$patch({
overlay: 'BlockPicker'

@ -4,7 +4,7 @@
it sits ON the card rather than spanning it edge to edge -->
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
<w-tab name="icon" icon="la:icons" :label="t(`iconPicker.icons`)" />
<w-tab name="image" icon="la:image" :label="t(`iconPicker.image`)" />
<w-tab v-if="!props.noImage" name="image" icon="la:image" :label="t(`iconPicker.image`)" />
</w-tabs>
<w-separator />
<w-tab-panels v-model="state.currentTab">
@ -69,7 +69,7 @@
<!-- ----------------------- -->
<!-- An image file -->
<!-- ----------------------- -->
<w-tab-panel class="p-3" name="image">
<w-tab-panel v-if="!props.noImage" class="p-3" name="image">
<!-- -> `text-grey` (#9e9e9e) is too faint to read at caption size; the app's secondary-text
pair holds up on both the light panel and the dark one -->
<div class="text-caption text-black/60 dark:text-white/70">
@ -148,6 +148,17 @@ const props = defineProps({
modelValue: {
type: String,
default: ''
},
/**
* Offer icons only, leaving out the tab that points at an image file.
*
* For the callers whose value is not a `WIcon` reference and has no `img:` form to fall back on --
* the markdown editor writes an `:mdi:home:` shortcode, which is an Iconify reference and nothing
* else.
*/
noImage: {
type: Boolean,
default: false
}
})
@ -299,7 +310,7 @@ async function focusCurrentTab() {
onMounted(async () => {
// -> An image reference opens on the image tab, an Iconify one on the search tab
if (props.modelValue?.startsWith(IMAGE_PREFIX)) {
if (!props.noImage && props.modelValue?.startsWith(IMAGE_PREFIX)) {
state.currentTab = 'image'
state.image = props.modelValue.slice(IMAGE_PREFIX.length)
} else if (ICONIFY_REF.test(props.modelValue ?? '')) {

@ -1388,6 +1388,20 @@
font-size: 0.875em;
}
/*
An Iconify icon, drawn into the page as an `<svg>` when it was saved -- see `inlineIcons` in
`models/rendering.ts`. Sized and coloured by the markup itself (`1em`, `currentColor`), so all it
needs from here is to be part of the line: Preflight makes every `svg` a block, which would put
an icon written mid-sentence on a line of its own.
An `<iconify-icon>` that has not been through that yet -- a page saved before this, or an icon
whose set could not be resolved at the time -- carries the same rule from its own `:host`, so the
two sit identically and the editor's preview matches the page.
*/
svg.icon {
display: inline-block;
}
/* Twemoji, which the renderer swaps in for `:shortcodes:` */
img.emoji {
display: inline-block;

@ -142,6 +142,69 @@ function rewriteHtmlImages(html, pagePath) {
})
}
/**
* An `<iconify-icon>` written the way a Vue component is, `<iconify-icon icon="mdi:home" />`.
*
* The lookahead rather than a `\b`: a hyphen ends a word, so a boundary alone also matches the start
* of `<iconify-icon-something />` and would close it with the wrong tag.
*/
const SELF_CLOSED_ICON = /<iconify-icon(?![\w-])([^>]*?)\s*\/>/gi
/**
* Give a self-closed icon the closing tag it actually needs.
*
* `/>` closes nothing in HTML outside the void elements, so the parser hands the icon the rest of the
* paragraph as children -- and `iconify-icon` draws a shadow root with no slot in it, so that text
* lands on the page invisible. The form is the one anybody writes, having seen it in every framework
* for twenty years, and it is unambiguous about what was meant: an icon has no content.
*
* Done here, over the author's own HTML, rather than over the finished render, so that a `<iconify-icon
* />` shown INSIDE a code block stays exactly as it was written -- that text is escaped by the time it
* is rendered and is not raw HTML at all. `models/rendering.ts` lifts out anything that got nested
* anyway, since a render can also arrive from something that is not this renderer.
*/
function closeIconTags(html) {
return html.replace(SELF_CLOSED_ICON, '<iconify-icon$1></iconify-icon>')
}
/**
* An icon written the way an emoji is: `:mdi:arrow-vertical-lock:`.
*
* The inner colon is what tells the two apart, and it is a reliable tell in both directions: an
* Iconify reference is always `prefix:name` and an emoji shortcode never holds a colon. So the two
* syntaxes can share the delimiter without either having to know about the other -- `:smile:` has
* nothing here to match, and this rule runs while the inline is tokenized, well before the emoji
* plugin's core rule ever looks at the text.
*
* Sticky rather than anchored, so it is matched at the cursor without slicing the source at every
* colon in the document.
*
* The prefix must begin with a letter, which every Iconify set does. Without that, `10:30:45:` in a
* line of prose is an icon reference as far as this is concerned.
*/
const ICON_SHORTCODE = /:([a-z][a-z\d]*(?:-[a-z\d]+)*):([a-z\d]+(?:[-.][a-z\d]+)*):/y
/** The inline rule behind it. `state.pos` is at a `:` for any of this to be worth trying. */
function iconShortcode(state, silent) {
if (state.src.charCodeAt(state.pos) !== 0x3a /* : */) {
return false
}
ICON_SHORTCODE.lastIndex = state.pos
const match = ICON_SHORTCODE.exec(state.src)
// -> `posMax` is the end of what is being tokenized, which inside a link label is not the end of
// the line: a match that runs past it belongs to the text after, not to this
if (!match || state.pos + match[0].length > state.posMax) {
return false
}
if (!silent) {
const token = state.push('iconify_icon', 'iconify-icon', 0)
token.markup = match[0]
token.content = `${match[1]}:${match[2]}`
}
state.pos += match[0].length
return true
}
export class MarkdownRenderer {
constructor(config = {}) {
this.md = new MarkdownIt({
@ -280,6 +343,18 @@ export class MarkdownRenderer {
return inlineProps(state, silent)
})
/*
Icons written as shortcodes, `:mdi:home:`.
Registered ahead of every other inline rule so that the whole reference is claimed in one go.
Nothing else wants it -- MDC's inline component syntax, the only other rule that would take a
colon, is off above -- but the alternative is the emoji plugin's core rule, which runs over the
TEXT of a token that by then has already been split around the colons.
*/
this.md.inline.ruler.before('text', 'iconify_icon', iconShortcode)
this.md.renderer.rules.iconify_icon = (tokens, idx) =>
`<iconify-icon icon="${tokens[idx].content}"></iconify-icon>`
if (config.underline) {
this.md.use(mdUnderline)
}
@ -351,12 +426,15 @@ export class MarkdownRenderer {
/*
And the same for an `<img>` the author wrote as HTML, which never becomes a token to hold an
attribute -- so it is the rendered text that is rewritten, after whatever rule produced it.
Raw HTML is also where a self-closed `<iconify-icon />` turns up, and it is fixed in the same
pass for the same reason: this is the only point at which the author's own markup is still
distinguishable from the markup the renderer produced.
*/
const passthrough = (tokens, idx) => tokens[idx].content
for (const rule of ['html_block', 'html_inline']) {
const renderHtml = this.md.renderer.rules[rule] ?? passthrough
this.md.renderer.rules[rule] = (tokens, idx, options, env, slf) =>
rewriteHtmlImages(renderHtml(tokens, idx, options, env, slf), env?.pagePath)
closeIconTags(rewriteHtmlImages(renderHtml(tokens, idx, options, env, slf), env?.pagePath))
}
// --------------------------------

Loading…
Cancel
Save