diff --git a/backend/locales/en.json b/backend/locales/en.json index 945773f6c..95da04728 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/backend/models/icons.ts b/backend/models/icons.ts index c3cf43e7e..ade307dc9 100644 --- a/backend/models/icons.ts +++ b/backend/models/icons.ts @@ -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 `` 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 ========================== /** diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts index 696b9df3a..8482c29e9 100644 --- a/backend/models/rendering.ts +++ b/backend/models/rendering.ts @@ -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 `` + the rest of the paragraph as children, and the element's shadow root has no slot to show them + with. `` belongs on the end of every one. + */ + 'iconify-icon', 'img', 'ins', 'kbd', @@ -218,6 +239,11 @@ const BASE_ALLOWED_ATTRIBUTES: Record = { '*': ['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 `` back out, after it. + * + * `` 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 `` into the page as the `` 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 { + const elements = $('iconify-icon').toArray() + if (elements.length < 1) { + return + } + + const referenceOf = (element: cheerio.Cheerio) => + (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>() + 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() + 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 `` that stands in for one ``, 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, + icon: IconifyIcon + ): cheerio.Cheerio { + 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. * diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 2f9306903..984b85569 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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":"","width":32,"height":32}, @@ -221,6 +221,7 @@ export const BUNDLED_ICONS = { "mdi:format-superscript": {"body":"","width":24,"height":24}, "mdi:format-title": {"body":"","width":24,"height":24}, "mdi:hand-wave-outline": {"body":"","width":24,"height":24}, + "mdi:home": {"body":"","width":24,"height":24}, "mdi:image-plus": {"body":"","width":24,"height":24}, "mdi:image-plus-outline": {"body":"","width":24,"height":24}, "mdi:image-sync-outline": {"body":"","width":24,"height":24}, @@ -248,6 +249,7 @@ export const BUNDLED_ICONS = { "mdi:play": {"body":"","width":24,"height":24}, "mdi:playlist-edit": {"body":"","width":24,"height":24}, "mdi:redo-variant": {"body":"","width":24,"height":24}, + "mdi:seed-plus-outline": {"body":"","width":24,"height":24}, "mdi:star": {"body":"","width":24,"height":24}, "mdi:tab-plus": {"body":"","width":24,"height":24}, "mdi:table": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index ebaeb77a2..0aaee20b4 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -50,6 +50,16 @@ t('editor.markup.insertEmoji') }} + + + + + + {{ + t('editor.markup.insertIcon') + }} + {{ 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' diff --git a/frontend/src/components/IconPickerDialog.vue b/frontend/src/components/IconPickerDialog.vue index 67f7eefbb..de3acf7ed 100644 --- a/frontend/src/components/IconPickerDialog.vue +++ b/frontend/src/components/IconPickerDialog.vue @@ -4,7 +4,7 @@ it sits ON the card rather than spanning it edge to edge --> - + @@ -69,7 +69,7 @@ - +
@@ -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 ?? '')) { diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index 59410b646..4282cec78 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -1388,6 +1388,20 @@ font-size: 0.875em; } + /* + An Iconify icon, drawn into the page as an `` 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 `` 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; diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js index 7367e82e8..875557e2a 100644 --- a/frontend/src/renderers/markdown.js +++ b/frontend/src/renderers/markdown.js @@ -142,6 +142,69 @@ function rewriteHtmlImages(html, pagePath) { }) } +/** + * An `` written the way a Vue component is, ``. + * + * The lookahead rather than a `\b`: a hyphen ends a word, so a boundary alone also matches the start + * of `` and would close it with the wrong tag. + */ +const SELF_CLOSED_ICON = /]*?)\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 `` 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, '') +} + +/** + * 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) => + `` + if (config.underline) { this.md.use(mdUnderline) } @@ -351,12 +426,15 @@ export class MarkdownRenderer { /* And the same for an `` 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 `` 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)) } // --------------------------------