diff --git a/.changeset/great-adults-hear.md b/.changeset/great-adults-hear.md new file mode 100644 index 0000000000..704268400c --- /dev/null +++ b/.changeset/great-adults-hear.md @@ -0,0 +1,14 @@ +--- +"@rhds/elements": minor +--- +``: syntax highlighting via prerendered prismjs code-blocks. Use +the `highlighting="prerendered"` attribute when rendering code blocks using +server side prism, e.g. in a markdown fenced code block. + +```html + +
a {
+  color: var(--rh-color-interactive-primary-default);
+  }
+
+``` diff --git a/elements/rh-badge/rh-badge.css b/elements/rh-badge/rh-badge.css index ca5a4c6d0e..6e491ff2e2 100644 --- a/elements/rh-badge/rh-badge.css +++ b/elements/rh-badge/rh-badge.css @@ -13,7 +13,7 @@ span { font-size: var(--rh-font-size-body-text-xs, 0.75rem); font-weight: 700; line-height: var(--rh-line-height-body-text, 1.5); - padding-inline: var(--rh-space-md, 8px); + padding-inline: var(--_badge-padding, var(--rh-space-md, 8px)); } .on.dark { background-color: var(--rh-color-surface-darker, #1f1f1f); } diff --git a/elements/rh-code-block/demo/callout-badges.html b/elements/rh-code-block/demo/callout-badges.html index 4c899f8349..fc0f285a7f 100644 --- a/elements/rh-code-block/demo/callout-badges.html +++ b/elements/rh-code-block/demo/callout-badges.html @@ -1,9 +1,9 @@ - 1 + 1 22
1
diff --git a/elements/rh-code-block/demo/prerendered-prism-highlighting.html b/elements/rh-code-block/demo/prerendered-prism-highlighting.html new file mode 100644 index 0000000000..9fa73818bb --- /dev/null +++ b/elements/rh-code-block/demo/prerendered-prism-highlighting.html @@ -0,0 +1,75 @@ + + +
<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width">
+    <title>Cards Galore!</title>
+  </head>
+  <body>
+    <main>
+      <rh-card>
+        <h2 slot="header">Card</h2>
+        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+           Nullam eleifend elit sed est egestas, a sollicitudin mauris
+           tincidunt. Pellentesque vel dapibus risus. Nullam aliquam
+           felis orci, eget cursus mi lacinia quis. Vivamus at felis sem.</p>
+        <rh-cta slot="footer" priority="primary">
+          <a href="#">Call to action</a>
+        </rh-cta>
+      </rh-card>
+    </main>
+  </body>
+</html>
+
+ + +
rh-card.avatar-card {
+  width: 360px;
+  &::part(body) {
+    margin-block-start: var(--rh-space-lg, 16px);
+  }
+
+  & p {
+    margin-block-start: 0;
+  }
+
+  & h4 {
+    font-weight: var(--rh-font-weight-heading-regular, 300);
+    font-size: var(--rh-font-size-body-text-md, 1rem);
+    font-family: var(--rh-font-family-body-text);
+    line-height: var(--rh-line-height-body-text, 1.5);
+  }
+}
+
+ + +
extends:
+  - stylelint-config-standard
+  - '@stylistic/stylelint-config'
+
+plugins:
+  - ./node_modules/@rhds/tokens/plugins/stylelint.js
+  - '@stylistic/stylelint-plugin'
+
+rules:
+  rhds/token-values: true
+  rhds/no-unknown-token-name:
+    - true
+    - allowed:
+      - --rh-icon-size
+
+
+ + + + diff --git a/elements/rh-code-block/prism.css.ts b/elements/rh-code-block/prism.css.ts index 3477c2ab69..248bef9261 100644 --- a/elements/rh-code-block/prism.css.ts +++ b/elements/rh-code-block/prism.css.ts @@ -1,73 +1,82 @@ import { css } from 'lit'; -export const prismStyles = css` code[class*="language-"], -pre[class*="language-"] { - color: var(--_code-color); - font-family: var(--rh-font-family-code, RedHatMono, "Red Hat Mono", "Courier New", Courier, monospace); - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - line-height: var(--rh-line-height-code, 1.5); - tab-size: 4; - hyphens: none; -} - -pre[class*="language-"]::selection, -pre[class*="language-"] ::selection, -code[class*="language-"]::selection, -code[class*="language-"] ::selection { - text-shadow: none; - background: var(--_selected-text-background); -} +const styles = css` + & code[class*="language-"], + & pre[class*="language-"] { + color: var(--_code-color); + font-family: var(--rh-font-family-code, RedHatMono, "Red Hat Mono", "Courier New", Courier, monospace); + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: var(--rh-line-height-code, 1.5); + tab-size: 4; + hyphens: none; + background: transparent; + } -@media print { - code[class*="language-"], - pre[class*="language-"] { + & pre[class*="language-"]::selection, + & pre[class*="language-"] ::selection, + & code[class*="language-"]::selection, + & code[class*="language-"] ::selection { text-shadow: none; + background: var(--_selected-text-background); + } + + @media print { + & code[class*="language-"], + & pre[class*="language-"] { + text-shadow: none; + } } -} -.token.atrule { color: var(--_at-rule-color); } -.token.attr-name { color: var(--_attr-name-color); } -.token.attr-value { color: var(--_attr-value-color); } -.token.bold { font-weight: var(--_important-color); } -.token.boolean { color: var(--_boolean-color); } -.token.builtin { color: var(--_built-in-color); } -.token.cdata { color: var(--_cdata-color); } -.token.char { color: var(--_character-color); } -.token.class-name { color: var(--_class-name-color); } -.token.comment { color: var(--_comment-color); } -.token.constant { color: var(--_constant-color); } -.token.deleted { color: var(--_deleted-color); } -.token.function { color: var(--_function-name-color); } -.token.important { color: var(--_important-color); } -.token.inserted { color: var(--_inserted-color); } -.token.keyword { color: var(--_keyword-color); } -.token.namespace { color: var(--_namespace-color); } -.token.number { color: var(--_number-color); } -.token.operator { color: var(--_operator-color); } -.token.property { color: var(--_property-color); } -.token.punctuation { color: var(--_punctuation-color); } -.token.regex { color: var(--_regex-color); } -.token.selector { color: var(--_selector-color); } -.token.string { color: var(--_string-color); } -.token.symbol { color: var(--_symbol-color); } -.token.tag { color: var(--_tag-color); } -.token.url { color: var(--_url-color); } -.token.variable { color: var(--_variable-color); } + & .token.atrule { color: var(--_at-rule-color); } + & .token.attr-name { color: var(--_attr-name-color); } + & .token.attr-value { color: var(--_attr-value-color); } + & .token.bold { font-weight: var(--_important-color); } + & .token.boolean { color: var(--_boolean-color); } + & .token.builtin { color: var(--_built-in-color); } + & .token.cdata { color: var(--_cdata-color); } + & .token.char { color: var(--_character-color); } + & .token.class-name { color: var(--_class-name-color); } + & .token.comment { color: var(--_comment-color); } + & .token.constant { color: var(--_constant-color); } + & .token.deleted { color: var(--_deleted-color); } + & .token.function { color: var(--_function-name-color); } + & .token.important { color: var(--_important-color); } + & .token.inserted { color: var(--_inserted-color); } + & .token.keyword { color: var(--_keyword-color); } + & .token.namespace { color: var(--_namespace-color); } + & .token.number { color: var(--_number-color); } + & .token.operator { color: var(--_operator-color); } + & .token.property { color: var(--_property-color); } + & .token.punctuation { color: var(--_punctuation-color); } + & .token.regex { color: var(--_regex-color); } + & .token.selector { color: var(--_selector-color); } + & .token.string { color: var(--_string-color); } + & .token.symbol { color: var(--_symbol-color); } + & .token.tag { color: var(--_tag-color); } + & .token.url { color: var(--_url-color); } + & .token.variable { color: var(--_variable-color); } -.token.italic { font-style: italic; } + & .token.italic { font-style: italic; } -.token.entity { - color: var(--_entity-color); - cursor: help; -} + & .token.entity { + color: var(--_entity-color); + cursor: help; + } -.token.prolog, -.token.doctype { color: var(--_doctype-color); } + & .token.prolog, + & .token.doctype { color: var(--_doctype-color); } -.language-css .token.string, -.style .token.string { color: var(--_operator-color); } + & .language-css .token.string, + & .style .token.string { color: var(--_operator-color); } `; + +export const prismStyles = css`#prism-output {${styles}}`; +export const preRenderedLightDomStyles = css`rh-code-block { +--_styles-applied: true; +${styles} +& > pre { opacity: 1; } +}`; diff --git a/elements/rh-code-block/prism.ts b/elements/rh-code-block/prism.ts index bd749670e2..f4512eafeb 100644 --- a/elements/rh-code-block/prism.ts +++ b/elements/rh-code-block/prism.ts @@ -1,10 +1,9 @@ import type { RhCodeBlock } from './rh-code-block.js'; import { Prism } from 'prism-esm'; -import { Plugin as LineNumbers } from 'prism-esm/plugins/line-numbers/prism-line-numbers.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { html } from 'lit'; const prism = new Prism({ manual: true }); -LineNumbers(prism); /** * Autoload a supported language @@ -37,8 +36,8 @@ async function autoloader(language: RhCodeBlock['language']) { export async function highlight(textContent: string, language: RhCodeBlock['language']) { await autoloader(language); const highlighted = prism.highlight(textContent, prism.languages[language!], language!); - return unsafeHTML(highlighted); + return html`${unsafeHTML(highlighted)}`; } -export { prismStyles } from './prism.css.js'; +export { prismStyles, preRenderedLightDomStyles } from './prism.css.js'; diff --git a/elements/rh-code-block/rh-code-block.css b/elements/rh-code-block/rh-code-block.css index 95e5cf4723..c602d9c3d8 100644 --- a/elements/rh-code-block/rh-code-block.css +++ b/elements/rh-code-block/rh-code-block.css @@ -2,6 +2,7 @@ --rh-code-block-callout-size: var(--rh-size-icon-02, 24px); --_aspect-ratio: 1; --_badge-size: var(--rh-code-block-callout-size); + --_badge-padding: 0; display: block; max-width: 1000px; @@ -25,6 +26,14 @@ border: none !important; } +:host([highlighting='prerendered']) ::slotted(pre) { + opacity: 0; + transition: + opacity + var(--rh-animation-speed) + var(--rh-animation-timing, cubic-bezier(0.465, 0.183, 0.153, 0.946)); +} + .shadow-fab { display: flex; align-items: center; @@ -63,6 +72,13 @@ #prism-output { margin: 0; + + & code { + font-size: inherit; + font-family: inherit; + font-weight: inherit; + line-height: inherit; + } } #container { @@ -143,6 +159,7 @@ opacity: 0; pointer-events: none; z-index: -10000; + line-height: var(--rh-line-height-code, 1.5); } #line-numbers { @@ -157,6 +174,11 @@ color: var(--rh-color-text-secondary); font-weight: var(--rh-font-weight-code-regular, 400); border-inline-end: var(--rh-border-width-sm, 1px) solid var(--rh-color-border-subtle); + + li { + line-height: var(--rh-line-height-code, 1.5); + display: block; + } } #actions { @@ -330,3 +352,7 @@ --_important-color: var(--rh-color-purple-30, #b6a6e9); --_variable-color: var(--rh-color-green-40, #87bb62); } + +:host([highlighting='client']) #content::slotted(:is(script, pre)) { + display: none; +} diff --git a/elements/rh-code-block/rh-code-block.ts b/elements/rh-code-block/rh-code-block.ts index 4cb6c202bc..3469cc543c 100644 --- a/elements/rh-code-block/rh-code-block.ts +++ b/elements/rh-code-block/rh-code-block.ts @@ -12,7 +12,6 @@ import { type ColorTheme, colorContextConsumer } from '../../lib/context/color/c import style from './rh-code-block.css'; - /* TODO * - style slotted and shadow fake-fabs * - manage state of copy and wrap, including if they are slotted. see actions.html @@ -25,7 +24,8 @@ import style from './rh-code-block.css'; function dedent(str: string) { const stripped = str.replace(/^\n/, ''); const match = stripped.match(/^\s+/); - return match ? stripped.replace(new RegExp(`^${match[0]}`, 'gm'), '') : str; + const out = match ? stripped.replace(new RegExp(`^${match[0]}`, 'gm'), '') : str; + return out.trim(); } interface CodeLineHeightsInfo { @@ -95,8 +95,12 @@ export class RhCodeBlock extends LitElement { }, }) actions: ('copy' | 'wrap')[] = []; - /** When set to "client", `` will automatically highlight the source code using Prism.js */ - @property() highlighting?: 'client'; + /** + * When set to "client", `` will automatically highlight the source using Prism.js + * When set to "Prerendered", `` will apply supported RHDS styles to children with + * prismjs classnames in the element's root. + */ + @property() highlighting?: 'client' | 'prerendered'; /** When set along with `highlighting="client"`, this grammar will be used to highlight source code */ @property() language?: @@ -228,11 +232,24 @@ export class RhCodeBlock extends LitElement { async #onSlotChange() { switch (this.highlighting) { - case 'client': await this.#highlightWithPrism(); + case 'client': await this.#highlightWithPrism(); break; + // TODO: if we ever support other tokenizers e.g. highlightjs, + // dispatch here off of some supplemental attribute like `tokenizer="highlightjs"` + case 'prerendered': await this.#applyPrismPrerenderedStyles(); break; } this.#computeLineNumbers(); } + async #applyPrismPrerenderedStyles() { + if (getComputedStyle(this).getPropertyValue('--_styles-applied') !== 'true') { + const root = this.getRootNode(); + if (root instanceof Document || root instanceof ShadowRoot) { + const { preRenderedLightDomStyles: { styleSheet } } = await import('./prism.js'); + root.adoptedStyleSheets = [...root.adoptedStyleSheets, styleSheet!]; + } + } + } + async #highlightWithPrism() { const { highlight, prismStyles } = await import('./prism.js'); const styleSheet = @@ -273,37 +290,33 @@ export class RhCodeBlock extends LitElement { : []); } - #getPrismCodeElements() { - const container = this.shadowRoot?.getElementById('prism-output') as HTMLSlotElement; - return Array.from(container.children); - } - /** * Clone the text content and connect it to the document, in order to calculate the number of lines * @license MIT * Portions copyright prism.js authors (MIT license) */ async #computeLineNumbers() { - if (this.#prismOutput) { - return; - } - await this.updateComplete; - const codes = this.#prismOutput ? this.#getPrismCodeElements() : this.#getSlottedCodeElements(); + const codes = + this.#prismOutput ? [this.shadowRoot?.getElementById('prism-output')].filter(x => !!x) + : this.#getSlottedCodeElements(); const infos: CodeLineHeightsInfo[] = codes.map(element => { - const sizer = document.createElement('span'); - sizer.className = 'sizer'; - sizer.innerText = '0'; - sizer.style.display = 'block'; - this.shadowRoot?.getElementById('sizers')?.appendChild(sizer); - return { - lines: element.textContent?.split(/\n(?!$)/g) ?? [], - lineHeights: [], - sizer, - oneLinerHeight: sizer.getBoundingClientRect().height, - }; - }); + const codeElement = this.#prismOutput ? element.querySelector('code') : element; + if (codeElement) { + const sizer = document.createElement('span'); + sizer.className = 'sizer'; + sizer.innerText = '0'; + sizer.style.display = 'block'; + this.shadowRoot?.getElementById('sizers')?.appendChild(sizer); + return { + lines: element.textContent?.split(/\n(?!$)/g) ?? [], + lineHeights: [], + sizer, + oneLinerHeight: sizer.getBoundingClientRect().height, + }; + } + }).filter(x => !!x); for (const { lines, lineHeights, sizer, oneLinerHeight } of infos) { lineHeights[lines.length - 1] = undefined; // why?