diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 1428517d..651bc3d2 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -110,19 +110,53 @@ async function applyTheme() {
setCssVar('negative', userStore.getAccessibleColor('negative', '#f03a47'))
// -> Highlight.js Theme
- if (siteStore.theme.codeBlocksTheme) {
- const desiredHljsTheme = userStore.cvd !== 'none' ? 'github' : siteStore.theme.codeBlocksTheme
+ await applyCodeBlocksTheme()
+}
- const hljsStyleEl = document.querySelector('#hljs-theme')
- if (hljsStyleEl) {
- hljsStyleEl.remove()
- }
+/**
+ * Every highlight.js theme the admin area offers, as loaders that fetch one on demand.
+ *
+ * `?inline` hands back the stylesheet as a STRING rather than injecting it: these have to be scoped to
+ * the page content before they are applied (see below), which cannot be done to a stylesheet the
+ * bundler has already added to the document. `**` covers the `base16/` family, since that is how the
+ * admin's list names half of its options.
+ *
+ * Only the theme in use is ever fetched; the rest sit in the build as assets nobody asks for.
+ */
+const HLJS_THEMES = import.meta.glob('../node_modules/highlight.js/styles/**/*.min.css', {
+ query: '?inline',
+ import: 'default'
+})
+
+/**
+ * Paint code blocks in the theme chosen under Admin → Theme.
+ *
+ * The stylesheet is wrapped in `.page-contents { ... }` and applied through CSS nesting, for two
+ * reasons: a highlight.js theme is written as bare `.hljs*` rules that would otherwise reach every
+ * code sample in the interface, and nesting lifts its selectors to the same weight as the fallback
+ * palette in `_page-contents.scss` -- so this one wins on being applied later, which is exactly the
+ * relationship wanted. With no theme chosen, nothing is injected and that fallback is what shows.
+ */
+async function applyCodeBlocksTheme() {
+ document.querySelector('#hljs-theme')?.remove()
+
+ // -> A colour-vision-deficient palette cannot be honoured per theme, so it takes a neutral one
+ const desiredHljsTheme = userStore.cvd !== 'none' ? 'github' : siteStore.theme.codeBlocksTheme
+ if (!desiredHljsTheme) {
+ return
+ }
- const newHljsStyleEl = document.createElement('style')
- newHljsStyleEl.id = 'hljs-theme'
- // newHljsStyleEl.innerHTML = (await import(`../node_modules/highlight.js/styles/${desiredHljsTheme}.css`)).default
- document.head.appendChild(newHljsStyleEl)
+ const load = HLJS_THEMES[`../node_modules/highlight.js/styles/${desiredHljsTheme}.min.css`]
+ if (!load) {
+ // -> A name the admin area offers that highlight.js does not ship; the fallback palette stands in
+ console.warn(`Unknown code blocks theme: ${desiredHljsTheme}`)
+ return
}
+
+ const styleEl = document.createElement('style')
+ styleEl.id = 'hljs-theme'
+ styleEl.textContent = `.page-contents {\n${await load()}\n}`
+ document.head.appendChild(styleEl)
}
// INIT SITE STORE
diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue
index d8a862f4..102bd8b5 100644
--- a/frontend/src/components/EditorMarkdown.vue
+++ b/frontend/src/components/EditorMarkdown.vue
@@ -224,11 +224,17 @@
` by hand.
+ It keeps its border in both themes: the preview pane in the editor sits on grey, where a tinted
+ panel with no edge would dissolve into the page.
+ */
+ pre {
+ margin: 1.5em 0;
+ padding: 0.9rem 1.1rem;
+ border: 1px solid var(--content-rule);
+ border-radius: 8px;
+ background-color: var(--content-surface-code);
+ color: var(--content-ink);
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ /* -> Tracks the body's leading; code at 13px needs a little more of it, not much */
+ line-height: 1.55;
+ /* -> Code does not wrap: a wrapped line is a different line, and indentation stops meaning anything */
+ overflow-x: auto;
+ tab-size: 2;
+
+ /* -> The panel is the block's own; the code inside it drops the inline span's wash and padding */
+ > code {
+ display: block;
+ padding: 0;
+ border-radius: 0;
+ background: none;
color: inherit;
+ font-size: inherit;
}
}
- // ---------------------------------
- // LISTS
- // ---------------------------------
+ /*
+ The copy button, added to each block by `helpers/renderedContent.js`.
- ol,
- ul:not(.tabset-tabs) {
- width: 100%;
+ Out of the way until the pointer is over the block, and revealed by keyboard focus as well -- it
+ stays in the tab order the whole time, so `opacity` rather than `visibility` is what hides it: a
+ reader who cannot use a pointer would otherwise have no way to reach it at all.
+ */
+ pre.codeblock[data-code-copy] {
+ position: relative;
+ }
- li > p {
- &:first-child {
- margin-top: 0;
- }
- &:last-child {
- margin-bottom: 0;
- }
+ /*
+ The button takes its colours from the block it sits on, not from the site's theme.
+
+ `inherit` is all it takes, and it needs no filter: a highlight.js theme paints `.hljs`, which is the
+ `pre` itself, so the panel's colour and its ink are already on this button's parent -- dark panel,
+ light icon; light panel, dark icon; and a mid-tone theme handled as correctly as either. Inverting
+ what is behind the button (`backdrop-filter: invert()`) could only ever approximate that, and would
+ hue-shift anything that is not near-black or near-white.
+
+ That also means the code theme and the site theme can disagree freely -- a dark code theme on a
+ light page keeps a dark button -- which is what makes it read as part of the block.
+ */
+ .code-copy {
+ position: absolute;
+ top: 0.4rem;
+ right: 0.4rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
+ /* -> Derived from the block's ink, so the edge shows on a panel of any colour */
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+ border-radius: 5px;
+ background-color: inherit;
+ color: inherit;
+ cursor: pointer;
+ opacity: 0;
+ transition:
+ opacity 0.15s var(--ease-standard),
+ box-shadow 0.15s var(--ease-standard);
+
+ /*
+ Hover lifts it off the panel with a wash of the panel's own ink, laid inside the box: a second
+ background colour would have to be a fixed value, and any fixed value is wrong for some theme.
+ */
+ &:hover {
+ box-shadow: inset 0 0 0 2rem color-mix(in srgb, currentColor 12%, transparent);
}
- @at-root .is-rtl & {
- padding-left: 0;
- padding-right: 1em;
+ /* -> Mixed toward the panel's ink rather than a flat green, so it stays legible on any theme */
+ &.is-copied {
+ color: color-mix(in srgb, #2ea043 70%, currentColor);
+ opacity: 1;
}
+ }
- li > ul,
- li > ol {
- padding-top: 0.5rem;
- padding-left: 1em;
+ pre.codeblock:hover > .code-copy,
+ .code-copy:focus-visible {
+ opacity: 1;
+ }
- @at-root .is-rtl & {
- padding-left: 0;
- padding-right: 1em;
- }
+ @media (prefers-reduced-motion: reduce) {
+ .code-copy {
+ transition-duration: 0.01ms;
}
+ }
+
+ /*
+ The line-number gutter, for a block of more than one line. The renderer emits one empty per
+ line inside `.line-numbers-rows`; a counter turns those into numbers, so the digits are never part
+ of the text and a copied selection stays clean.
+ */
+ pre.codeblock.line-numbers {
+ position: relative;
+ padding-left: 3.6rem;
+ counter-reset: codeline;
- li + li {
- margin-top: 0.5rem;
+ > code {
+ position: relative;
}
- &.links-list {
- padding-left: 0;
- list-style-type: none;
+ .line-numbers-rows {
+ position: absolute;
+ top: 0;
+ left: -2.5rem;
+ width: 2rem;
+ border-right: 1px solid var(--content-rule);
+ user-select: none;
+ pointer-events: none;
- @at-root .is-rtl & {
- padding-right: 0;
- }
+ > span {
+ display: block;
+ counter-increment: codeline;
- li {
- background-color: palette.$grey-1;
- background-image: linear-gradient(to bottom, #fff, palette.$grey-1);
- border-right: 1px solid palette.$grey-3;
- border-bottom: 1px solid palette.$grey-3;
- border-left: 5px solid palette.$grey-4;
- box-shadow: 0 3px 8px 0 rgba(116, 129, 141, 0.1);
- padding: 1rem;
- border-radius: 5px;
- font-weight: 500;
-
- @at-root .is-rtl & {
- border-left-width: 1px;
- border-right-width: 5px;
+ &::before {
+ content: counter(codeline);
+ display: block;
+ padding-right: 0.7em;
+ color: var(--content-ink-faint);
+ text-align: right;
}
+ }
+ }
+ }
- &:hover {
- background-image: linear-gradient(
- to bottom,
- #fff,
- color.adjust(palette.$blue-1, $lightness: 4%)
- );
- border-left-color: palette.$blue-5;
- cursor: pointer;
-
- @at-root .is-rtl & {
- border-left-color: palette.$grey-3;
- border-right-width: palette.$blue-5;
- }
- }
+ /*
+ Diagram sources -- a mermaid or plantuml fence, or a base64 `diagram` block -- reach the page as
+ code, and are drawn later or not at all. A quiet dashed panel says "this is a diagram that has not
+ been drawn" rather than pretending to be a code sample.
+ */
+ pre.codeblock-mermaid,
+ pre.codeblock-plantuml,
+ pre.diagram {
+ border-style: dashed;
+ border-color: var(--content-rule-strong);
+ background-color: var(--content-surface-alt);
+ color: var(--content-ink-muted);
+ }
- &::before {
- content: '';
- display: none;
- }
+ /* -- highlight.js tokens ------------------------------------------------------ */
- > a {
- display: block;
- text-decoration: none;
- margin: -1rem;
- padding: 1rem;
-
- > em {
- font-weight: 400;
- font-style: normal;
- color: palette.$grey-7;
- display: inline-block;
- padding-left: 0.5rem;
- border-left: 1px solid palette.$grey-4;
- margin-left: 0.5rem;
-
- &.is-block {
- display: block;
- padding-left: 0;
- margin-left: 0;
- border-left: none;
- }
- }
- }
+ .hljs-comment,
+ .hljs-quote {
+ color: var(--content-code-comment);
+ font-style: italic;
+ }
- > em {
- font-weight: 400;
- font-style: normal;
- }
+ .hljs-keyword,
+ .hljs-selector-tag,
+ .hljs-literal,
+ .hljs-doctag,
+ .hljs-name,
+ .hljs-subst {
+ color: var(--content-code-keyword);
+ }
- @at-root .body--dark & {
- background-color: palette.$grey-1;
- background-image: linear-gradient(
- to bottom,
- color.adjust(palette.$grey-9, $lightness: 5%),
- palette.$grey-9
- );
- border-right: 1px solid palette.$grey-9;
- border-bottom: 1px solid palette.$grey-9;
- border-left: 5px solid palette.$grey-7;
- box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.1);
-
- @at-root .body--dark.is-rtl & {
- border-left-width: 1px;
- border-right-width: 5px;
- }
-
- &:hover {
- background-image: linear-gradient(
- to bottom,
- color.adjust(palette.$grey-9, $lightness: 2%),
- color.adjust(palette.$grey-9, $lightness: -3%)
- );
- border-left-color: mc('indigo', '300');
- cursor: pointer;
-
- @at-root .body--dark.is-rtl & {
- border-left-color: palette.$grey-9;
- border-right-width: mc('indigo', '300');
- }
- }
- }
- }
- }
+ .hljs-string,
+ .hljs-regexp,
+ .hljs-template-tag,
+ .hljs-template-variable,
+ .hljs-addition {
+ color: var(--content-code-string);
+ }
- &.grid-list {
- margin: 1rem 0 0 0;
- background-color: #fff;
- border: 1px solid palette.$grey-3;
- padding: 1px;
- display: inline-block;
- list-style-type: none;
+ .hljs-number,
+ .hljs-symbol,
+ .hljs-bullet,
+ .hljs-link {
+ color: var(--content-code-number);
+ }
- @at-root .body--dark & {
- background-color: #000;
- border: 1px solid mc('grey', '800');
- }
+ .hljs-title,
+ .hljs-section,
+ .hljs-title.function_,
+ .hljs-selector-id {
+ color: var(--content-code-title);
+ }
- li {
- background-color: palette.$grey-1;
- padding: 0.6rem 1rem;
- display: block;
+ .hljs-type,
+ .hljs-class .hljs-title,
+ .hljs-title.class_,
+ .hljs-built_in,
+ .hljs-builtin-name,
+ .hljs-params {
+ color: var(--content-code-type);
+ }
- &:nth-child(odd) {
- background-color: mc('grey', '100');
- }
+ .hljs-attr,
+ .hljs-attribute,
+ .hljs-variable,
+ .hljs-selector-attr,
+ .hljs-selector-class,
+ .hljs-selector-pseudo {
+ color: var(--content-code-attr);
+ }
- & + li {
- margin-top: 0;
- }
+ .hljs-meta,
+ .hljs-comment .hljs-doctag,
+ .hljs-tag {
+ color: var(--content-code-meta);
+ }
- &::before {
- content: '';
- display: none;
- }
+ /* A diff's added and removed lines are marked by the line, not by the token */
+ .hljs-addition {
+ display: inline-block;
+ width: 100%;
+ background-color: var(--content-code-addition-wash);
+ color: var(--content-code-addition);
+ }
+ .hljs-deletion {
+ display: inline-block;
+ width: 100%;
+ background-color: var(--content-code-deletion-wash);
+ color: var(--content-code-deletion);
+ }
+
+ .hljs-emphasis {
+ font-style: italic;
+ }
+ .hljs-strong {
+ font-weight: 600;
+ }
- @at-root .body--dark & {
- background-color: palette.$grey-9;
+ // ---------------------------------------------------------------------------
+ // TABLES
+ // ---------------------------------------------------------------------------
+
+ /*
+ A wide table scrolls itself rather than widening the article.
+
+ `display: block` is what makes that possible -- a table box cannot scroll -- and the rows and cells
+ still lay out as a table inside it through anonymous table boxes. `width: max-content` keeps the
+ table its natural width up to the column's, so a narrow table does not stretch.
+ */
+ table {
+ display: block;
+ width: max-content;
+ max-width: 100%;
+ margin: 1.5em 0;
+ border-collapse: collapse;
+ font-size: 0.9375em;
+ overflow-x: auto;
+ }
- &:nth-child(odd) {
- background-color: color.adjust(palette.$grey-9, $lightness: -5%);
- }
- }
- }
+ th,
+ td {
+ padding: 0.5em 0.8em;
+ border: 1px solid var(--content-rule);
+ text-align: left;
+ vertical-align: top;
+ }
+
+ th {
+ background-color: var(--content-surface-alt);
+ font-weight: 600;
+ }
+
+ /* Banding, faint enough to guide the eye across a wide row without striping the page */
+ tbody tr:nth-child(even) > td {
+ background-color: rgba(0, 0, 0, 0.02);
+ }
+
+ @at-root .body--dark & {
+ tbody tr:nth-child(even) > td {
+ background-color: rgba(255, 255, 255, 0.03);
}
}
- ul:not(.tabset-tabs):not(.contains-task-list) {
- list-style: square;
+ caption {
+ padding-bottom: 0.5em;
+ color: var(--content-ink-muted);
+ font-size: 0.875em;
+ text-align: left;
}
- ol,
- ul:not(.tabset-tabs) {
- > li {
- position: relative;
- > p {
- display: inline-block;
- vertical-align: top;
- padding-top: 0;
- }
+
+ // ---------------------------------------------------------------------------
+ // FIGURES, IMAGES AND DIAGRAMS
+ // ---------------------------------------------------------------------------
+
+ img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 4px;
+ /* -> An image is a block when it is the whole paragraph, and inline when it sits in a sentence */
+ vertical-align: middle;
+ }
+
+ /* The alignment classes an author can attach through `markdown-it-attrs` */
+ img,
+ figure {
+ &.align-left {
+ float: left;
+ margin: 0.3em 1.2em 0.8em 0;
+ }
+ &.align-right {
+ float: right;
+ margin: 0.3em 0 0.8em 1.2em;
+ }
+ &.align-center {
+ display: block;
+ float: none;
+ margin-inline: auto;
}
}
- // ---------------------------------
- // TASK LISTS
- // ---------------------------------
+ figure {
+ margin: 1.5em 0;
+ text-align: center;
+ }
- .contains-task-list {
- padding-left: 1em;
+ figcaption {
+ margin-top: 0.6em;
+ color: var(--content-ink-muted);
+ font-size: 0.875em;
}
- .task-list-item {
- position: relative;
- list-style-type: none;
+ /*
+ PlantUML and Kroki return SVGs drawn in black on nothing. On a dark page that is a diagram of
+ invisible lines, so it gets a light card of its own -- the same treatment either way, since a
+ diagram is an illustration rather than part of the text.
+ */
+ img.uml-diagram {
+ display: block;
+ margin: 1.5em auto;
+ padding: 0.75rem;
+ border: 1px solid var(--content-rule);
+ border-radius: 8px;
+ background-color: #fff;
+ }
- &-checkbox[disabled] {
- width: 1.1rem;
- height: 1.1rem;
- top: 2px;
- position: relative;
- margin-right: 0.4em;
- background-color: theme.$dark-5;
- border-width: 0;
+ /* Twemoji, which the renderer swaps in for `:shortcodes:` */
+ img.emoji {
+ display: inline-block;
+ width: 1.15em;
+ height: 1.15em;
+ margin: 0 0.05em;
+ border-radius: 0;
+ vertical-align: -0.2em;
+ }
- &::after {
- position: absolute;
- left: 0;
- top: 0;
- font-family: 'Material Design Icons';
- font-size: 1.25em;
- font-weight: normal;
- content: '\F0131';
- color: palette.$grey-10;
- display: block;
- border: none;
- background-color: #fff;
- line-height: 1em;
- cursor: default;
-
- @at-root .body--dark & {
- color: #fff;
- background-color: theme.$dark-6;
- }
- }
+ // ---------------------------------------------------------------------------
+ // RULES AND DISCLOSURES
+ // ---------------------------------------------------------------------------
- &[checked]::after {
- content: '\F0C52';
+ hr {
+ height: 1px;
+ margin: 2.5em 0;
+ border: 0;
+ background-color: var(--content-rule);
+ }
+
+ details {
+ margin: 1.5em 0;
+ padding: 0.75em 1.1em;
+ border: 1px solid var(--content-rule);
+ border-radius: 8px;
+ background-color: var(--content-surface-alt);
+
+ > summary {
+ cursor: pointer;
+ font-weight: 600;
+
+ /* -> Only spaced away from the content once there IS content below it */
+ &:focus-visible {
+ outline: 2px solid var(--content-link);
+ outline-offset: 2px;
}
}
- .contains-task-list {
- padding: 0.5rem 0 0 1.5rem;
+ &[open] > summary {
+ margin-bottom: 0.75em;
+ padding-bottom: 0.5em;
+ border-bottom: 1px solid var(--content-rule);
+ }
+
+ > :last-child {
+ margin-bottom: 0;
}
}
- // ---------------------------------
- // CODE
- // ---------------------------------
-
- // code {
- // background-color: mc('indigo', '50');
- // padding: 0 5px;
- // color: mc('indigo', '800');
- // font-family: 'Roboto Mono', monospace;
- // font-weight: normal;
- // font-size: 1rem;
- // box-shadow: none;
-
- // &::before, &::after {
- // display: none;
- // }
-
- // @at-root .theme--dark & {
- // background-color: color.adjust(mc('grey', '900'), $lightness: -5%);
- // color: mc('indigo', '100');
- // }
- // }
-
- pre.codeblock {
- border: 1px solid rgba(0, 0, 0, 0.2);
- border-radius: 5px;
- box-shadow: initial;
- padding: 1rem;
- margin: 1rem 0;
- overflow: auto;
+ // ---------------------------------------------------------------------------
+ // FOOTNOTES
+ // ---------------------------------------------------------------------------
- > code {
- // background-color: transparent;
- padding: 0;
- // color: #FFF;
- box-shadow: initial;
- display: block;
- font-size: 0.85rem;
- font-family: 'Roboto Mono', monospace;
+ /* The reference in the text: a superscript number that does not need underlining to be seen */
+ .footnote-ref > a {
+ padding: 0 0.15em;
+ font-weight: 500;
+ text-decoration: none;
+ }
- &:after,
- &:before {
- content: initial;
- letter-spacing: initial;
- }
- }
+ .footnotes-sep {
+ margin-top: 2.5em;
+ }
- &.line-numbers {
- counter-reset: linenumber;
- padding-left: 3rem;
+ .footnotes {
+ color: var(--content-ink-muted);
+ font-size: 0.875em;
- > code {
- position: relative;
- white-space: inherit;
- }
+ .footnotes-list {
+ padding-left: 1.4em;
+ }
- .line-numbers-rows {
- position: absolute;
- pointer-events: none;
- top: 0;
- font-size: 100%;
- left: -3.8em;
- width: 3em;
- letter-spacing: -1px;
- border-right: 1px solid #999;
- -webkit-user-select: none;
- -moz-user-select: none;
- user-select: none;
-
- & > span {
- display: block;
- counter-increment: linenumber;
-
- &:before {
- content: counter(linenumber);
- color: #999;
- display: block;
- padding-right: 0.8em;
- text-align: right;
- }
- }
- }
+ .footnote-item > p {
+ margin: 0.3em 0;
}
}
- // ---------------------------------
- // LEGACY
- // ---------------------------------
+ /* -> Where a footnote link has just landed, so the reader can see which one they were sent to */
+ .footnote-item:target,
+ h1:target,
+ h2:target,
+ h3:target,
+ h4:target,
+ h5:target,
+ h6:target {
+ /* Drawn outside the box so it cannot shift the text it highlights */
+ box-shadow: 0 0 0 0.4em var(--content-mark);
+ background-color: var(--content-mark);
+ border-radius: 2px;
+ }
- .align-abstopright {
- width: 100px;
- max-height: 100px;
- border: 2px dashed palette.$red;
- border-radius: 5px;
- padding: 5px;
+ .footnote-backref {
+ margin-left: 0.3em;
+ text-decoration: none;
+ }
+
+ // ---------------------------------------------------------------------------
+ // MATH
+ // ---------------------------------------------------------------------------
+
+ /*
+ KaTeX sizes itself; what it cannot do is decide what happens when an equation is wider than the
+ column. A display equation scrolls on its own rather than stretching the page.
+ */
+ .katex-display {
+ margin: 1.4em 0;
+ padding: 0.2em 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+
+ .katex {
+ /* -> KaTeX's default 1.21em is oversized next to 16px body text */
+ font-size: 1.1em;
+ }
+
+ // ---------------------------------------------------------------------------
+ // PRINT
+ // ---------------------------------------------------------------------------
+
+ @media print {
+ /* Washes and tints cost ink and print muddy; the structure has to come from the rules instead */
+ --content-ink: #000;
+ --content-ink-muted: #333;
+ --content-surface-code: #fff;
+ --content-surface-alt: #fff;
+
+ font-size: 11pt;
+ line-height: 1.55;
+
+ pre.codeblock,
+ table,
+ img,
+ blockquote,
+ details {
+ /* -> A block split across a page break is unreadable in both halves */
+ break-inside: avoid;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ break-after: avoid;
+ }
+
+ /* -> On paper there is no "leaves the wiki": every link is equally unfollowable */
+ a.is-external-link::after {
+ content: none;
+ }
}
}
diff --git a/frontend/src/helpers/renderedContent.js b/frontend/src/helpers/renderedContent.js
new file mode 100644
index 00000000..eac72e42
--- /dev/null
+++ b/frontend/src/helpers/renderedContent.js
@@ -0,0 +1,172 @@
+import { BUNDLED_ICONS } from '@/assets/icons.generated'
+
+import { copyToClipboard } from './clipboard'
+import { notify } from '@/composables/notify'
+
+/**
+ * The affordances a rendered page grows once it is on screen: a copy button on every code block, and a
+ * pilcrow on every heading that copies a link to it.
+ *
+ * Scripted rather than rendered, because a page's HTML arrives through `v-html`: there is no template
+ * to put a component in, and no Vue instance inside the render to hang one off. So the same treatment
+ * is applied to whatever the render just produced -- in the page view and in the editor's preview
+ * alike, both of which call `enhanceRenderedContent` after the content changes.
+ *
+ * Idempotent: a decorated element is marked, so re-running over content that has not been replaced
+ * adds nothing. The controls carry their own listeners and are discarded wholesale when `v-html` next
+ * writes over them, which is why nothing has to be torn down.
+ */
+
+/** Drawn from the same inlined set the interface uses; see `scripts/generate-icons.mjs`. */
+const ICON_COPY = 'la:copy'
+const ICON_DONE = 'mdi:check'
+
+/** How long a control reports success before offering itself again. */
+const COPIED_FOR_MS = 1600
+
+/**
+ * An inlined icon as SVG markup.
+ *
+ * `WIcon` does this in a template; a control built in script cannot use it, so the same record is read
+ * directly. Missing icons are impossible in practice -- the names above are literals, so the generator
+ * bundles them -- but an empty string is a nicer failure than a broken template string.
+ */
+function iconSvg(name) {
+ const icon = BUNDLED_ICONS[name]
+ if (!icon) {
+ return ''
+ }
+ return ``
+}
+
+/**
+ * Copy, then have the control say so itself: a toast for something this small would be noise, and the
+ * pointer is already on the thing that changed.
+ */
+async function copyWithFeedback({ text, control, restingLabel, restingHtml, doneLabel }) {
+ try {
+ await copyToClipboard(text)
+ } catch (err) {
+ notify({ type: 'negative', message: 'Could not copy to the clipboard.', caption: err.message })
+ return
+ }
+
+ control.classList.add('is-copied')
+ control.innerHTML = iconSvg(ICON_DONE)
+ setLabel(control, doneLabel)
+ clearTimeout(control._resetTimer)
+ control._resetTimer = setTimeout(() => {
+ control.classList.remove('is-copied')
+ control.innerHTML = restingHtml
+ setLabel(control, restingLabel)
+ }, COPIED_FOR_MS)
+}
+
+/**
+ * One string for the two things that have to say it: the accessible name, and the tooltip a control
+ * draws for itself (see `.heading-anchor::after`). `data-tooltip` is absent on controls whose icon
+ * already says what they do, and the stylesheet then has nothing to render.
+ */
+function setLabel(control, label) {
+ control.setAttribute('aria-label', label)
+ if (control.dataset.tooltip !== undefined) {
+ control.dataset.tooltip = label
+ }
+}
+
+/** The code as the author wrote it, without the line numbers the gutter draws. */
+function codeOf(pre) {
+ const code = pre.querySelector('code')
+ if (!code) {
+ return pre.textContent
+ }
+ /*
+ Cloned so the gutter can be dropped without touching what is on screen. Its spans hold no text --
+ the numbers are drawn by a counter -- but the clone keeps the copy honest if that ever changes.
+ */
+ const copy = code.cloneNode(true)
+ for (const gutter of copy.querySelectorAll('.line-numbers-rows')) {
+ gutter.remove()
+ }
+ return copy.textContent.replace(/\n$/, '')
+}
+
+function addCodeCopyButtons(root) {
+ for (const pre of root.querySelectorAll('pre.codeblock:not([data-code-copy])')) {
+ // -> Marks the block as done, and is what the stylesheet keys the button's position off
+ pre.dataset.codeCopy = ''
+
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.className = 'code-copy'
+ setLabel(button, 'Copy code')
+ button.innerHTML = iconSvg(ICON_COPY)
+ button.addEventListener('click', () =>
+ copyWithFeedback({
+ text: codeOf(pre),
+ control: button,
+ restingLabel: 'Copy code',
+ restingHtml: iconSvg(ICON_COPY),
+ doneLabel: 'Copied'
+ })
+ )
+
+ pre.appendChild(button)
+ }
+}
+
+/**
+ * The link a heading's pilcrow copies.
+ *
+ * Built from the address bar rather than from the page store, so it carries whatever the reader is
+ * actually on -- locale prefix included. The editor is the one place where those diverge: it previews a
+ * page that lives at its own address, not at `/_edit/…`, so that prefix is dropped.
+ */
+function headingUrl(id) {
+ const path = window.location.pathname.replace(/^\/_edit\//, '/')
+ return `${window.location.origin}${path}#${id}`
+}
+
+/** The pilcrow, as a character: no icon set carries it, and every font does. */
+const PILCROW = '¶'
+
+function addHeadingAnchors(root) {
+ const headings = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
+
+ for (const heading of root.querySelectorAll(headings)) {
+ if (heading.dataset.headingAnchor !== undefined) {
+ continue
+ }
+ heading.dataset.headingAnchor = ''
+
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.className = 'heading-anchor'
+ // -> Declares that this control has a tooltip; `setLabel` keeps the two in step
+ button.dataset.tooltip = ''
+ setLabel(button, 'Copy link to this section')
+ button.textContent = PILCROW
+ button.addEventListener('click', () =>
+ copyWithFeedback({
+ text: headingUrl(heading.id),
+ control: button,
+ restingLabel: 'Copy link to this section',
+ restingHtml: PILCROW,
+ doneLabel: 'Link copied'
+ })
+ )
+
+ heading.appendChild(button)
+ }
+}
+
+/**
+ * @param {HTMLElement|null} root The element the render was written into.
+ */
+export function enhanceRenderedContent(root) {
+ if (!root) {
+ return
+ }
+ addCodeCopyButtons(root)
+ addHeadingAnchors(root)
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 7174a7f4..8b6e4cba 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -14,6 +14,18 @@ import { initializeHairlines } from './helpers/hairline'
// so no icon webfont is loaded.
import '@quasar/extras/roboto-font/roboto-font.css'
+/*
+ KaTeX's own stylesheet, which the math in a page is drawn with.
+
+ Vendor CSS rather than something `_page-contents.scss` could express: it carries the maths fonts and
+ the geometry of every symbol. Without it a formula renders TWICE -- KaTeX emits both an HTML tree and
+ a MathML fallback, and the CSS is what hides the one the browser is not using -- which is what made
+ every equation appear as a broken rendering followed by its own source.
+
+ Global, but inert outside content: `.katex` appears nowhere else in the app.
+*/
+import 'katex/dist/katex.min.css'
+
import './css/tailwind.css'
import './css/app.scss'
diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue
index d06a8933..651067ce 100644
--- a/frontend/src/pages/Index.vue
+++ b/frontend/src/pages/Index.vue
@@ -182,6 +182,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
+import { enhanceRenderedContent } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc'
import { useCommonStore } from '@/stores/common'
@@ -317,6 +318,19 @@ const breadcrumbs = computed(() => [
// WATCHERS
+/*
+ The copy buttons on code blocks are part of the content, so they are re-added whenever the content
+ is. Keyed on the render rather than on the route: it arrives after the page has already mounted, and
+ it is replaced again on every save without the route moving at all.
+*/
+watch(
+ () => pageStore.render,
+ () => {
+ nextTick(() => enhanceRenderedContent(pageContents.value))
+ },
+ { immediate: true }
+)
+
watch(
() => route.path,
async (newValue) => {
diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js
index 09024be5..d2844d9e 100644
--- a/frontend/src/renderers/markdown.js
+++ b/frontend/src/renderers/markdown.js
@@ -39,6 +39,32 @@ const quoteStyles = {
swedish: '””’’'
}
+/**
+ * Whether a link leaves this wiki.
+ *
+ * Resolved against the page's own address, so a relative path, an absolute one and a protocol-relative
+ * URL are all judged the same way -- by the host they end up on. `mailto:`, `tel:` and the rest are not
+ * pages at all, and are left unmarked: they announce themselves by what they are.
+ *
+ * With no document to resolve against -- a render outside a browser -- only an absolute URL can be
+ * judged, and it is judged external; a relative one fails to parse and comes back internal.
+ */
+function isExternalHref(href) {
+ if (!href) {
+ return false
+ }
+ const here = globalThis.location?.href
+ try {
+ const url = new URL(href, here)
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ return false
+ }
+ return here ? url.origin !== new URL(here).origin : true
+ } catch {
+ return false
+ }
+}
+
export class MarkdownRenderer {
constructor(config = {}) {
this.md = new MarkdownIt({
@@ -174,6 +200,24 @@ export class MarkdownRenderer {
}
}
+ // --------------------------------
+ // LINK DESTINATIONS
+ // --------------------------------
+
+ /*
+ Where a link goes is decided here, at render time, and recorded as a class -- `is-external-link`
+ -- for the stylesheet to mark. It cannot be decided in CSS: a selector can match on the shape of
+ an href but not compare its host with the wiki's own, which is the whole question.
+
+ The class survives being stored: `models/rendering.ts` keeps `class` on every element.
+ */
+ this.md.renderer.rules.link_open = (tokens, idx, options, env, slf) => {
+ if (isExternalHref(tokens[idx].attrGet('href'))) {
+ tokens[idx].attrJoin('class', 'is-external-link')
+ }
+ return slf.renderToken(tokens, idx, options, env, slf)
+ }
+
// --------------------------------
// TWEMOJI
// --------------------------------