From 006b15b467a5cd835a6eab1b49023100bdc8f2e6 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 13 Oct 2023 19:32:56 +0200 Subject: refactor(components): rewrite Code component and usePrism hook * move Prism styles to Sass placeholders to avoid repeats * let usePrism consumer define its plugins (remove default ones) * remove `plugins` prop from Code component * add new props to Code component to let consumer configure plugins (and handle plugin list from the given options) However there are some problems with Prism plugins: line-highlight and treeview does not seems to be loaded. I don't want to use Babel instead of SWC so I have no solution for now. --- src/components/atoms/figure/figure.module.scss | 4 +- src/components/molecules/code/code.module.scss | 13 + src/components/molecules/code/code.stories.tsx | 192 ++++++++++++++ src/components/molecules/code/code.test.tsx | 14 + src/components/molecules/code/code.tsx | 183 ++++++++++++++ src/components/molecules/code/index.ts | 1 + src/components/molecules/index.ts | 1 + src/components/molecules/layout/code.module.scss | 305 ---------------------- src/components/molecules/layout/code.stories.tsx | 121 --------- src/components/molecules/layout/code.test.tsx | 17 -- src/components/molecules/layout/code.tsx | 69 ----- src/components/molecules/layout/index.ts | 1 - src/content | 2 +- src/pages/article/[slug].tsx | 28 +- src/styles/abstracts/_placeholders.scss | 1 + src/styles/abstracts/placeholders/_prism.scss | 309 +++++++++++++++++++++++ src/styles/pages/article.module.scss | 3 +- src/styles/pages/partials/_article-prism.scss | 302 ---------------------- src/utils/hooks/use-prism.tsx | 183 -------------- src/utils/hooks/use-prism/index.ts | 1 + src/utils/hooks/use-prism/use-prism.test.tsx | 91 +++++++ src/utils/hooks/use-prism/use-prism.ts | 217 ++++++++++++++++ src/utils/plugins/prism-color-scheme.cjs | 246 ++++++++++++++++++ src/utils/plugins/prism-color-scheme.js | 246 ------------------ 24 files changed, 1292 insertions(+), 1258 deletions(-) create mode 100644 src/components/molecules/code/code.module.scss create mode 100644 src/components/molecules/code/code.stories.tsx create mode 100644 src/components/molecules/code/code.test.tsx create mode 100644 src/components/molecules/code/code.tsx create mode 100644 src/components/molecules/code/index.ts delete mode 100644 src/components/molecules/layout/code.module.scss delete mode 100644 src/components/molecules/layout/code.stories.tsx delete mode 100644 src/components/molecules/layout/code.test.tsx delete mode 100644 src/components/molecules/layout/code.tsx create mode 100644 src/styles/abstracts/placeholders/_prism.scss delete mode 100644 src/styles/pages/partials/_article-prism.scss delete mode 100644 src/utils/hooks/use-prism.tsx create mode 100644 src/utils/hooks/use-prism/index.ts create mode 100644 src/utils/hooks/use-prism/use-prism.test.tsx create mode 100644 src/utils/hooks/use-prism/use-prism.ts create mode 100644 src/utils/plugins/prism-color-scheme.cjs delete mode 100644 src/utils/plugins/prism-color-scheme.js (limited to 'src') diff --git a/src/components/atoms/figure/figure.module.scss b/src/components/atoms/figure/figure.module.scss index e7ba5c2..7722e59 100644 --- a/src/components/atoms/figure/figure.module.scss +++ b/src/components/atoms/figure/figure.module.scss @@ -7,15 +7,13 @@ border: fun.convert-px(1) solid var(--color-border-light); font-size: var(--font-size-sm); font-weight: 500; + text-align: center; } .wrapper { - display: flex; - flex-flow: column; width: fit-content; margin: 0 auto; position: relative; - text-align: center; &--has-borders { padding: fun.convert-px(4); diff --git a/src/components/molecules/code/code.module.scss b/src/components/molecules/code/code.module.scss new file mode 100644 index 0000000..b551040 --- /dev/null +++ b/src/components/molecules/code/code.module.scss @@ -0,0 +1,13 @@ +@use "../../../styles/abstracts/placeholders"; + +.wrapper { + width: 100%; + + :global { + @extend %prism; + } + + figcaption { + margin-top: calc(var(--spacing-sm) * -1); + } +} diff --git a/src/components/molecules/code/code.stories.tsx b/src/components/molecules/code/code.stories.tsx new file mode 100644 index 0000000..1127839 --- /dev/null +++ b/src/components/molecules/code/code.stories.tsx @@ -0,0 +1,192 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Code } from './code'; + +/** + * Code - Storybook Meta + */ +export default { + title: 'Molecules/Code', + component: Code, + argTypes: { + children: { + control: { + type: 'text', + }, + description: 'The code sample.', + type: { + name: 'string', + required: true, + }, + }, + filterPattern: { + control: { + type: 'text', + }, + description: 'Define a pattern to filter the command line output.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + language: { + control: { + type: 'text', + }, + description: 'The code sample language.', + type: { + name: 'string', + required: true, + }, + }, + plugins: { + description: 'An array of Prism plugins to activate.', + type: { + name: 'object', + required: false, + value: {}, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +const javascriptCodeSample = ` +const foo = () => { + return 'bar'; +} +`; + +/** + * Code Stories - Code sample + */ +export const CodeSample = Template.bind({}); +CodeSample.args = { + children: javascriptCodeSample, + language: 'javascript', +}; + +/** + * Code Stories - Highlighting + * + * @todo Find a way to make it working: line-highlight plugin is not loaded. + */ +export const Highlighting = Template.bind({}); +Highlighting.args = { + children: javascriptCodeSample, + highlight: '3', + language: 'javascript', +}; + +// cSpell:ignore xinitrc +const commandLineCode = ` +ls -lah +#output#drwxr-x---+ 42 armand armand 4,0K 17 april 11:15 . +#output#drwxr-xr-x 4 root root 4,0K 30 mai 2021 .. +#output#-rw-r--r-- 1 armand armand 2,0K 21 jul. 2021 .xinitrc +`; + +/** + * Code Stories - Command Line + */ +export const CommandLine = Template.bind({}); +CommandLine.args = { + children: commandLineCode, + cmdOutputFilter: '#output#', + isCmd: true, + language: 'bash', +}; + +// cSpell:ignore lcov +const treeSample = ` +. +├── bin +│ └── deploy.sh +├── CHANGELOG.md +├── commitlint.config.cjs +├── coverage +│ ├── clover.xml +│ ├── coverage-final.json +│ ├── lcov-report +│ └── lcov.info +├── cspell.json +├── cypress.config.js +├── docker-compose.yml +├── Dockerfile +├── jest.config.js +├── jest.setup.js +├── lang +│ ├── en.json +│ └── fr.json +├── LICENSE +├── lint-staged.config.js +├── mdx.d.ts +├── next-env.d.ts +├── next-sitemap.config.cjs +├── next.config.js +├── package.json +├── public +│ ├── apple-touch-icon.png +│ ├── armand-philippot.jpg +│ ├── favicon.ico +│ ├── icon-192.png +│ ├── icon-512.png +│ ├── icon.svg +│ ├── manifest.webmanifest +│ ├── prism +│ ├── projects +│ ├── robots.txt +│ ├── sitemap-0.xml +│ ├── sitemap.xml +│ └── vercel.svg +├── README.md +├── src +│ ├── assets +│ ├── components +│ ├── content +│ ├── i18n +│ ├── pages +│ ├── services +│ ├── styles +│ ├── types +│ └── utils +├── tests +│ ├── cypress +│ ├── jest +│ └── utils +├── tsconfig.eslint.json +├── tsconfig.json +├── tsconfig.tsbuildinfo +└── yarn.lock`; + +/** + * Code Stories - Tree view + * + * @todo Find a way to make it working: treeview plugin is not loaded. + */ +export const TreeView = Template.bind({}); +TreeView.args = { + children: treeSample, + language: 'treeview', +}; + +const diffSample = ` +--- file1.js 2023-10-13 19:17:05.540644084 +0200 ++++ file2.js 2023-10-13 19:17:15.310564281 +0200 +@@ -1,2 +1 @@ +-let foo = bar.baz([1, 2, 3]); +-foo = foo + 1; ++const foo = bar.baz([1, 2, 3]) + 1;`; + +/** + * Code Stories - Diff + */ +export const Diff = Template.bind({}); +Diff.args = { + children: diffSample, + isDiff: true, + language: 'diff', +}; diff --git a/src/components/molecules/code/code.test.tsx b/src/components/molecules/code/code.test.tsx new file mode 100644 index 0000000..5b946b3 --- /dev/null +++ b/src/components/molecules/code/code.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { Code } from './code'; + +describe('Code', () => { + it('renders a code block', () => { + const language = 'javascript'; + const code = 'nam'; + + render({code}); + + expect(rtlScreen.getByText(code)).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/code/code.tsx b/src/components/molecules/code/code.tsx new file mode 100644 index 0000000..0168fd6 --- /dev/null +++ b/src/components/molecules/code/code.tsx @@ -0,0 +1,183 @@ +import { forwardRef, type ForwardRefRenderFunction } from 'react'; +import { + usePrism, + type PrismLanguage, + type PrismAvailablePlugin, +} from '../../../utils/hooks'; +import { Figure, type FigureProps } from '../../atoms'; +import styles from './code.module.scss'; + +export type CodeProps = Omit & { + /** + * The code to highlight. + */ + children: string; + /** + * Define a pattern to automatically present some lines as continuation lines + * when using command line. + * + * @default undefined + */ + cmdContinuationFilter?: string; + /** + * Define the prompt to be displayed when the command has continued beyond + * the first line. Only used with command line. + * + * @default '>' + */ + cmdContinuationPrompt?: string; + /** + * Define the line continuation string or character when using command line. + */ + cmdContinuationStr?: string; + /** + * Define the host when using command line. + */ + cmdHost?: string; + /** + * Define a custom prompt when using command line. By default, `#` will be + * used for the root user and `$` for all other users. + */ + cmdPrompt?: string; + /** + * Define the line(s) that must be presented as output when using command + * line. + * + * @example '6' // a single line + * @example '2-7' // a range + * @example '3,9-11' // multiple lines with a range + * + * @default undefined + */ + cmdOutput?: string; + /** + * Define a pattern to automatically present some lines as output when using + * command line. + * + * @default undefined + */ + cmdOutputFilter?: string; + /** + * Specify the user when using command line. + */ + cmdUser?: string; + /** + * Define the line(s) that must be highlighted. + * + * DON'T USE: it seems the plugin is not correctly loaded. + * + * @example '6' // a single line + * @example '2-7' // a range + * @example '3,9-11' // multiple lines with a range + * + * @default undefined + */ + highlight?: string; + /** + * Should the code be treated as command lines? + * + * @default false + */ + isCmd?: boolean; + /** + * Should the code be treated as a diff block? + * + * @default false + */ + isDiff?: boolean; + /** + * The code language. + */ + language: PrismLanguage; + /** + * Define the starting line number. It will be ignored with command lines. + * + * @default undefined // Starts with 1. + */ + start?: string; +}; + +const CodeWithRef: ForwardRefRenderFunction = ( + { + children, + className = '', + cmdContinuationFilter, + cmdContinuationPrompt, + cmdContinuationStr, + cmdHost, + cmdOutput, + cmdOutputFilter, + cmdPrompt, + cmdUser, + highlight, + isCmd = false, + isDiff = false, + language, + start, + ...props + }, + ref +) => { + const wrapperClass = `${styles.wrapper} ${className}`; + const codeClass = isDiff + ? `language-diff-${language}` + : `language-${language}`; + const plugins: PrismAvailablePlugin[] = [ + 'toolbar', + 'autoloader', + 'show-language', + 'color-scheme', + 'copy-to-clipboard', + 'inline-color', + 'match-braces', + 'normalize-whitespace', + ]; + + if (isDiff || language === 'diff') plugins.push('diff-highlight'); + + if (language.endsWith('treeview')) plugins.push('treeview'); + else plugins.push(isCmd ? 'command-line' : 'line-numbers'); + + const { attributes: prismAttributes, className: prismClass } = usePrism({ + attributes: { + 'data-continuation-prompt': cmdContinuationPrompt, + 'data-continuation-str': cmdContinuationStr, + 'data-filter-continuation': cmdContinuationFilter, + 'data-filter-output': cmdOutputFilter, + 'data-host': cmdHost, + 'data-line': highlight, + 'data-output': cmdOutput, + 'data-prompt': cmdPrompt, + 'data-start': start, + 'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme', + 'data-user': cmdUser, + }, + language, + plugins, + }); + + return ( +
+
+        {children}
+      
+
+ ); +}; + +/** + * Code component + * + * Render a code block with syntax highlighting. + * + * @todo Find a way to load Prism plugins without Babel (Next uses SWC). It + * seems some plugins are not loaded correctly (`line-highlight` or `treeview` + * for example). + */ +export const Code = forwardRef(CodeWithRef); diff --git a/src/components/molecules/code/index.ts b/src/components/molecules/code/index.ts new file mode 100644 index 0000000..d18a4e0 --- /dev/null +++ b/src/components/molecules/code/index.ts @@ -0,0 +1 @@ +export * from './code'; diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index cb0b7eb..a1e2c7a 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -1,5 +1,6 @@ export * from './branding'; export * from './buttons'; +export * from './code'; export * from './collapsible'; export * from './forms'; export * from './images'; diff --git a/src/components/molecules/layout/code.module.scss b/src/components/molecules/layout/code.module.scss deleted file mode 100644 index 2eaf9a2..0000000 --- a/src/components/molecules/layout/code.module.scss +++ /dev/null @@ -1,305 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; - -.wrapper { - :global { - .code-toolbar { - --toolbar-height: #{fun.convert-px(100)}; - - position: relative; - margin-top: calc(var(--toolbar-height) + var(--spacing-sm)); - - @include mix.media("screen") { - @include mix.dimensions("2xs") { - --toolbar-height: #{fun.convert-px(60)}; - } - } - - .toolbar { - display: flex; - flex-flow: row wrap; - justify-content: center; - width: 100%; - height: var(--toolbar-height); - position: absolute; - top: calc(var(--toolbar-height) * -1); - left: 0; - right: 0; - background: var(--color-bg-tertiary); - border: fun.convert-px(1) solid var(--color-border); - } - - .toolbar-item { - display: flex; - align-items: center; - margin: 0 var(--spacing-2xs); - } - - .toolbar-item:nth-child(1) { - flex: 0 0 100%; - justify-content: center; - margin: 0 auto 0 0; - padding: 0 var(--spacing-sm); - background: var(--color-bg-code); - border-bottom: fun.convert-px(1) solid var(--color-border); - color: var(--color-primary-darker); - font-size: var(--font-size-sm); - font-weight: 600; - - @include mix.media("screen") { - @include mix.dimensions("2xs") { - flex: 0 0 auto; - justify-content: left; - border-bottom: none; - border-right: fun.convert-px(1) solid var(--color-border); - } - } - } - } - - .copy-to-clipboard-button, - .prism-color-scheme-button { - display: block; - padding: fun.convert-px(3) var(--spacing-xs); - background: var(--color-bg); - border: 0.4ex solid var(--color-primary); - border-radius: fun.convert-px(30); - box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) - var(--color-shadow), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) - var(--color-shadow), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) - var(--color-shadow); - color: var(--color-primary); - font-size: var(--font-size-sm); - font-weight: 600; - transition: all 0.35s ease-in-out 0s; - - &:hover, - &:focus { - transform: translateX(#{fun.convert-px(-2)}) - translateY(#{fun.convert-px(-2)}); - box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) - var(--color-shadow-light), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) - fun.convert-px(-2) var(--color-shadow-light), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) - fun.convert-px(-4) var(--color-shadow-light), - fun.convert-px(4) fun.convert-px(7) fun.convert-px(8) - fun.convert-px(-3) var(--color-shadow-light); - } - - &:focus { - text-decoration: underline var(--color-primary) fun.convert-px(3); - } - - &:active { - text-decoration: none; - transform: translateY(#{fun.convert-px(2)}); - box-shadow: 0 0 0 0 var(--color-shadow); - } - } - - pre[class*="language-"] { - --gutter-size-with-spacing: calc(var(--gutter-size) + var(--spacing-xs)); - - position: relative; - overflow: auto; - background: var(--color-bg-secondary); - border: fun.convert-px(1) solid var(--color-border-light); - color: var(--color-fg); - hyphens: none; - tab-size: 4; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - - &.command-line { - --gutter-size: 19ch; - padding-left: var(--gutter-size-with-spacing); - } - - &.line-numbers { - --gutter-size: 6ch; - - counter-reset: lineNumber; - padding-left: var(--gutter-size-with-spacing); - } - - code { - display: block; - padding: var(--spacing-xs) 0; - position: relative; - } - - .line-numbers-rows, - .command-line-prompt { - display: block; - width: var(--gutter-size); - padding: var(--spacing-xs) 0; - position: absolute; - top: 0; - left: calc(var(--gutter-size-with-spacing) * -1); - background: var(--color-bg); - border-right: fun.convert-px(1) solid var(--color-border); - font-size: 100%; - letter-spacing: -1px; - text-align: right; - pointer-events: none; - user-select: none; - - > span { - &::before { - display: block; - padding-right: var(--spacing-xs); - color: var(--color-fg-light); - } - } - } - - .command-line-prompt { - > span { - &::before { - content: " "; - } - - &[data-user]::before { - content: "[" attr(data-user) "@" attr(data-host) "] $"; - } - - &[data-user="root"]::before { - content: "[" attr(data-user) "@" attr(data-host) "] #"; - } - - &[data-prompt]::before { - content: attr(data-prompt); - } - - &[data-continuation-prompt]::before { - content: attr(data-continuation-prompt); - } - } - } - - .line-numbers-rows { - > span { - counter-increment: lineNumber; - - &::before { - content: counter(lineNumber); - } - } - } - - .token { - &.comment, - &.doc-comment { - color: var(--color-fg-light); - } - - &.punctuation { - color: var(--color-fg); - } - - &.attr-name, - &.hexcode, - &.inserted, - &.string { - color: var(--color-token-green); - } - - &.class, - &.coord, - &.id, - &.function { - color: var(--color-token-purple); - } - - &.builtin, - &.builtin.class-name, - &.property-access, - &.regex, - &.scope { - color: var(--color-token-magenta); - } - - &.class-name, - &.constant, - &.global, - &.interpolation, - &.key, - &.package, - &.this, - &.title, - &.variable { - color: var(--color-token-blue); - } - - &.combinator, - &.keyword, - &.operator, - &.pseudo-class, - &.pseudo-element, - &.rule, - &.selector, - &.unit { - color: var(--color-token-orange); - } - - &.attr-value, - &.boolean, - &.number { - color: var(--color-token-yellow); - } - - &.delimiter, - &.doctype, - &.parameter, - &.parent, - &.property, - &.shebang, - &.tag { - color: var(--color-token-cyan); - } - - &.deleted { - color: var(--color-token-red); - } - - &.punctuation.brace-hover, - &.punctuation.brace-selected { - background: var(--color-bg); - outline: solid fun.convert-px(1) var(--color-primary-light); - } - } - - span.inline-color-wrapper { - background: url(fun.encode-svg( - '' - )); - - // Prevent repeating pattern to be seen. - background-position: center; - background-size: 110%; - - display: inline-block; - height: 1.1ch; - width: 1.1ch; - margin: 0 0.5ch 0 0; - border: fun.convert-px(1) solid var(--color-bg); - outline: fun.convert-px(1) solid var(--color-border-dark); - overflow: hidden; - } - - span.inline-color { - display: block; - - /* To prevent visual glitches again */ - height: 120%; - width: 120%; - } - } - } -} diff --git a/src/components/molecules/layout/code.stories.tsx b/src/components/molecules/layout/code.stories.tsx deleted file mode 100644 index d20cdbe..0000000 --- a/src/components/molecules/layout/code.stories.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Code } from './code'; - -/** - * Code - Storybook Meta - */ -export default { - title: 'Molecules/Layout/Code', - component: Code, - args: { - filterOutput: false, - outputPattern: '#output#', - }, - argTypes: { - 'aria-label': { - control: { - type: 'text', - }, - description: 'An accessible name for the code sample.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - children: { - control: { - type: 'text', - }, - description: 'The code sample.', - type: { - name: 'string', - required: true, - }, - }, - filterOutput: { - control: { - type: 'boolean', - }, - description: 'Filter the command line output.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - language: { - control: { - type: 'text', - }, - description: 'The code sample language.', - type: { - name: 'string', - required: true, - }, - }, - plugins: { - description: 'An array of Prism plugins to activate.', - type: { - name: 'object', - required: false, - value: {}, - }, - }, - outputPattern: { - control: { - type: 'text', - }, - description: 'The command line output pattern.', - table: { - category: 'Options', - defaultValue: { summary: '#output#' }, - }, - type: { - name: 'string', - required: false, - }, - }, - }, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ; - -const javascriptCodeSample = ` -const foo = () => { - return 'bar'; -} -`; - -/** - * Code Stories - Code sample - */ -export const CodeSample = Template.bind({}); -CodeSample.args = { - children: javascriptCodeSample, - language: 'javascript', - plugins: ['line-numbers'], -}; - -const commandLineCode = ` -ls -lah -#output#drwxr-x---+ 42 armand armand 4,0K 17 avril 11:15 . -#output#drwxr-xr-x 4 root root 4,0K 30 mai 2021 .. -#output#-rw-r--r-- 1 armand armand 2,0K 21 juil. 2021 .xinitrc -`; - -/** - * Code Stories - Command Line - */ -export const CommandLine = Template.bind({}); -CommandLine.args = { - children: commandLineCode, - filterOutput: true, - language: 'bash', - plugins: ['command-line'], -}; diff --git a/src/components/molecules/layout/code.test.tsx b/src/components/molecules/layout/code.test.tsx deleted file mode 100644 index a0e4143..0000000 --- a/src/components/molecules/layout/code.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render } from '../../../../tests/utils'; -import { Code } from './code'; - -const code = ` -function foo() { - return 'bar'; -} -`; - -const language = 'javascript'; - -describe('Code', () => { - it('renders a code block', () => { - render({code}); - }); -}); diff --git a/src/components/molecules/layout/code.tsx b/src/components/molecules/layout/code.tsx deleted file mode 100644 index a1aadd8..0000000 --- a/src/components/molecules/layout/code.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { FC, useRef } from 'react'; -import { - type OptionalPrismPlugin, - type PrismLanguage, - usePrism, -} from '../../../utils/hooks'; -import styles from './code.module.scss'; - -export type CodeProps = { - /** - * An accessible name. - */ - 'aria-label'?: string; - /** - * The code to highlight. - */ - children: string; - /** - * Filter command line output. Default: false. - */ - filterOutput?: boolean; - /** - * The code language. - */ - language: PrismLanguage; - /** - * The optional Prism plugins. - */ - plugins?: OptionalPrismPlugin[]; - /** - * Filter command line output using the given string. Default: #output# - */ - outputPattern?: string; -}; - -/** - * Code component - * - * Render a code block with syntax highlighting. - */ -export const Code: FC = ({ - children, - filterOutput = false, - language, - plugins = [], - outputPattern = '#output#', - ...props -}) => { - const wrapperRef = useRef(null); - const { attributes, className } = usePrism({ language, plugins }); - - const outputAttribute = filterOutput - ? { 'data-filter-output': outputPattern } - : {}; - - return ( -
-
-        {children}
-      
-
- ); -}; diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts index 58d5442..80db10a 100644 --- a/src/components/molecules/layout/index.ts +++ b/src/components/molecules/layout/index.ts @@ -1,5 +1,4 @@ export * from './card'; -export * from './code'; export * from './columns'; export * from './page-footer'; export * from './page-header'; diff --git a/src/content b/src/content index 0a5267c..c6be8a1 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 0a5267ca7df1b6600741aa172ffdfe7b4f762d9a +Subproject commit c6be8a1c511e5848a0317f825a29d07d09c47318 diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index dea240f..d1e680c 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -33,7 +33,6 @@ import { } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { - type OptionalPrismPlugin, useArticle, useBreadcrumb, useComments, @@ -70,8 +69,23 @@ const ArticlePage: NextPageWithLayout = ({ }); const readingTime = useReadingTime(article?.meta.wordsCount ?? 0, true); const { website } = useSettings(); - const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers']; - const { attributes, className } = usePrism({ plugins: prismPlugins }); + const { attributes, className } = usePrism({ + attributes: { + 'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme', + }, + plugins: [ + 'toolbar', + 'autoloader', + 'show-language', + 'color-scheme', + 'copy-to-clipboard', + 'inline-color', + 'match-braces', + 'normalize-whitespace', + 'command-line', + 'line-numbers', + ], + }); const loadingArticle = intl.formatMessage({ defaultMessage: 'Loading the requested article...', description: 'ArticlePage: loading article message', @@ -231,10 +245,10 @@ const ArticlePage: NextPageWithLayout = ({ str.includes('command-line') || (!str.includes('command-line') && str.includes('language-bash')) ) { - return `class="${wpBlockClassName} ${commandLineClassName}${languageClassName}" tabindex="0" data-filter-output="#output#`; + return `class="${wpBlockClassName} ${commandLineClassName} ${languageClassName}" tabindex="0" data-filter-output="#output#`; } - return `class="${wpBlockClassName} ${lineNumbersClassName}${languageClassName}" tabindex="0`; + return `class="${wpBlockClassName} ${lineNumbersClassName} ${languageClassName}" tabindex="0`; }; const contentWithPrismClasses = content.replaceAll( @@ -265,9 +279,7 @@ const ArticlePage: NextPageWithLayout = ({ /> ), - }} + bodyAttributes={attributes as HTMLAttributes} bodyClassName={styles.body} breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} diff --git a/src/styles/abstracts/_placeholders.scss b/src/styles/abstracts/_placeholders.scss index 04522d7..4070730 100644 --- a/src/styles/abstracts/_placeholders.scss +++ b/src/styles/abstracts/_placeholders.scss @@ -5,3 +5,4 @@ @forward "./placeholders/layout"; @forward "./placeholders/links"; @forward "./placeholders/lists"; +@forward "./placeholders/prism"; diff --git a/src/styles/abstracts/placeholders/_prism.scss b/src/styles/abstracts/placeholders/_prism.scss new file mode 100644 index 0000000..97f28b6 --- /dev/null +++ b/src/styles/abstracts/placeholders/_prism.scss @@ -0,0 +1,309 @@ +@use "../functions" as fun; +@use "../mixins" as mix; + +%prism { + .code-toolbar { + --toolbar-height: #{fun.convert-px(100)}; + + position: relative; + margin-top: calc(var(--toolbar-height) + var(--spacing-sm)); + + @include mix.media("screen") { + @include mix.dimensions("2xs") { + --toolbar-height: #{fun.convert-px(60)}; + } + } + + .toolbar { + display: flex; + flex-flow: row wrap; + justify-content: center; + width: 100%; + height: var(--toolbar-height); + position: absolute; + top: calc(var(--toolbar-height) * -1); + left: 0; + right: 0; + background: var(--color-bg-tertiary); + border: fun.convert-px(1) solid var(--color-border); + } + + .toolbar-item { + display: flex; + align-items: center; + margin: 0 var(--spacing-2xs); + } + + .toolbar-item:nth-child(1) { + flex: 0 0 100%; + justify-content: center; + margin: 0 auto 0 0; + padding: 0 var(--spacing-sm); + background: var(--color-bg-code); + border-bottom: fun.convert-px(1) solid var(--color-border); + color: var(--color-primary-darker); + font-size: var(--font-size-sm); + font-weight: 600; + + @include mix.media("screen") { + @include mix.dimensions("2xs") { + flex: 0 0 auto; + justify-content: left; + border-bottom: none; + border-right: fun.convert-px(1) solid var(--color-border); + } + } + } + } + + .copy-to-clipboard-button, + .prism-color-scheme-button { + display: block; + padding: fun.convert-px(3) var(--spacing-xs); + background: var(--color-bg); + border: 0.4ex solid var(--color-primary); + border-radius: fun.convert-px(30); + box-shadow: + fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) var(--color-shadow), + fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) + var(--color-shadow), + fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) + var(--color-shadow); + color: var(--color-primary); + font-size: var(--font-size-sm); + font-weight: 600; + transition: all 0.35s ease-in-out 0s; + + &:hover, + &:focus { + transform: translateX(#{fun.convert-px(-2)}) + translateY(#{fun.convert-px(-2)}); + box-shadow: + fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) + var(--color-shadow-light), + fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) + var(--color-shadow-light), + fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) + var(--color-shadow-light), + fun.convert-px(4) fun.convert-px(7) fun.convert-px(8) fun.convert-px(-3) + var(--color-shadow-light); + } + + &:focus { + text-decoration: underline var(--color-primary) fun.convert-px(3); + } + + &:active { + text-decoration: none; + transform: translateY(#{fun.convert-px(2)}); + box-shadow: 0 0 0 0 var(--color-shadow); + } + } + + pre[class*="language-"] { + --gutter-size-with-spacing: calc(var(--gutter-size) + var(--spacing-xs)); + + padding: 0; + position: relative; + overflow: auto; + background: var(--color-bg-secondary); + border: fun.convert-px(1) solid var(--color-border-light); + color: var(--color-fg); + hyphens: none; + tab-size: 4; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + + &.command-line { + --gutter-size: 19ch; + padding-left: var(--gutter-size-with-spacing); + } + + &.line-numbers { + --gutter-size: 6ch; + + counter-reset: linenumber; + padding-left: var(--gutter-size-with-spacing); + } + + code { + display: block; + padding: var(--spacing-xs) 0; + position: relative; + } + + .line-numbers-rows, + .command-line-prompt { + display: block; + width: var(--gutter-size); + padding: var(--spacing-xs) 0; + position: absolute; + top: 0; + left: calc(var(--gutter-size-with-spacing) * -1); + background: var(--color-bg); + border-right: fun.convert-px(1) solid var(--color-border); + font-size: 100%; + letter-spacing: -1px; + text-align: right; + pointer-events: none; + user-select: none; + + > span { + &::before { + display: block; + padding-right: var(--spacing-xs); + color: var(--color-fg-light); + } + } + } + + .command-line-prompt { + > span { + &::before { + content: " "; + } + + &[data-user]::before { + content: "[" attr(data-user) "@" attr(data-host) "] $"; + } + + &[data-user="root"]::before { + content: "[" attr(data-user) "@" attr(data-host) "] #"; + } + + &[data-prompt]::before { + content: attr(data-prompt); + } + + &[data-continuation-prompt]::before { + content: attr(data-continuation-prompt); + } + } + } + + .line-numbers-rows { + > span { + counter-increment: linenumber; + + &::before { + content: counter(linenumber); + } + } + } + + .token { + &.comment, + &.doc-comment { + color: var(--color-fg-light); + } + + &.punctuation { + color: var(--color-fg); + } + + &.attr-name, + &.hexcode, + &.inserted, + &.string { + color: var(--color-token-green); + } + + &.class, + &.coord, + &.id, + &.function { + color: var(--color-token-purple); + } + + &.builtin, + &.builtin.class-name, + &.property-access, + &.regex, + &.scope { + color: var(--color-token-magenta); + } + + &.class-name, + &.constant, + &.global, + &.interpolation, + &.key, + &.package, + &.this, + &.title, + &.variable { + color: var(--color-token-blue); + } + + &.combinator, + &.keyword, + &.operator, + &.pseudo-class, + &.pseudo-element, + &.rule, + &.selector, + &.unit { + color: var(--color-token-orange); + } + + &.attr-value, + &.boolean, + &.number { + color: var(--color-token-yellow); + } + + &.delimiter, + &.doctype, + &.parameter, + &.parent, + &.property, + &.shebang, + &.tag { + color: var(--color-token-cyan); + } + + &.deleted { + color: var(--color-token-red); + } + + &.punctuation.brace-hover, + &.punctuation.brace-selected { + background: var(--color-bg); + outline: solid fun.convert-px(1) var(--color-primary-light); + } + + &.output { + user-select: none; + } + } + + span.inline-color-wrapper { + background: url(fun.encode-svg( + '' + )); + + /* Prevent glitches where 1px from the repeating pattern could be seen. */ + background-position: center; + background-size: 110%; + + display: inline-block; + height: 1.1ch; + width: 1.1ch; + margin: 0 0.5ch 0 0; + border: fun.convert-px(1) solid var(--color-bg); + outline: fun.convert-px(1) solid var(--color-border-dark); + overflow: hidden; + } + + span.inline-color { + display: block; + + /* To prevent visual glitches again */ + height: 120%; + width: 120%; + } + } +} diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss index d2e7822..5e7d520 100644 --- a/src/styles/pages/article.module.scss +++ b/src/styles/pages/article.module.scss @@ -5,7 +5,6 @@ @use "partials/article-links"; @use "partials/article-lists"; @use "partials/article-media"; -@use "partials/article-prism"; @use "partials/article-wp-blocks"; .btn { @@ -23,8 +22,8 @@ @include article-links.styles; @include article-lists.styles; @include article-media.styles; - @include article-prism.styles; @include article-wp-blocks.styles; + @extend %prism; } } diff --git a/src/styles/pages/partials/_article-prism.scss b/src/styles/pages/partials/_article-prism.scss deleted file mode 100644 index 7d23e38..0000000 --- a/src/styles/pages/partials/_article-prism.scss +++ /dev/null @@ -1,302 +0,0 @@ -@use "../../abstracts/functions" as fun; -@use "../../abstracts/mixins" as mix; - -@mixin styles { - .code-toolbar { - --toolbar-height: #{fun.convert-px(100)}; - - position: relative; - margin-top: calc(var(--toolbar-height) + var(--spacing-sm)); - - @include mix.media("screen") { - @include mix.dimensions("2xs") { - --toolbar-height: #{fun.convert-px(60)}; - } - } - - .toolbar { - display: flex; - flex-flow: row wrap; - justify-content: center; - width: 100%; - height: var(--toolbar-height); - position: absolute; - top: calc(var(--toolbar-height) * -1); - left: 0; - right: 0; - background: var(--color-bg-tertiary); - border: fun.convert-px(1) solid var(--color-border); - } - - .toolbar-item { - display: flex; - align-items: center; - margin: 0 var(--spacing-2xs); - } - - .toolbar-item:nth-child(1) { - flex: 0 0 100%; - justify-content: center; - margin: 0 auto 0 0; - padding: 0 var(--spacing-sm); - background: var(--color-bg-code); - border-bottom: fun.convert-px(1) solid var(--color-border); - color: var(--color-primary-darker); - font-size: var(--font-size-sm); - font-weight: 600; - - @include mix.media("screen") { - @include mix.dimensions("2xs") { - flex: 0 0 auto; - justify-content: left; - border-bottom: none; - border-right: fun.convert-px(1) solid var(--color-border); - } - } - } - } - - .copy-to-clipboard-button, - .prism-color-scheme-button { - display: block; - padding: fun.convert-px(3) var(--spacing-xs); - background: var(--color-bg); - border: 0.4ex solid var(--color-primary); - border-radius: fun.convert-px(30); - box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) - var(--color-shadow), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) - var(--color-shadow), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) - var(--color-shadow); - color: var(--color-primary); - font-size: var(--font-size-sm); - font-weight: 600; - transition: all 0.35s ease-in-out 0s; - - &:hover, - &:focus { - transform: translateX(#{fun.convert-px(-2)}) - translateY(#{fun.convert-px(-2)}); - box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) - var(--color-shadow-light), - fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2) - var(--color-shadow-light), - fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4) - var(--color-shadow-light), - fun.convert-px(4) fun.convert-px(7) fun.convert-px(8) fun.convert-px(-3) - var(--color-shadow-light); - } - - &:focus { - text-decoration: underline var(--color-primary) fun.convert-px(3); - } - - &:active { - text-decoration: none; - transform: translateY(#{fun.convert-px(2)}); - box-shadow: 0 0 0 0 var(--color-shadow); - } - } - - pre[class*="language-"] { - --gutter-size-with-spacing: calc(var(--gutter-size) + var(--spacing-xs)); - - padding: 0; - position: relative; - overflow: auto; - border: fun.convert-px(1) solid var(--color-border-light); - hyphens: none; - tab-size: 4; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - - &.command-line { - --gutter-size: 19ch; - padding-left: var(--gutter-size-with-spacing); - } - - &.line-numbers { - --gutter-size: 6ch; - - counter-reset: lineNumber; - padding-left: var(--gutter-size-with-spacing); - } - - code { - display: block; - padding: var(--spacing-xs) 0; - position: relative; - } - - .line-numbers-rows, - .command-line-prompt { - display: block; - width: var(--gutter-size); - padding: var(--spacing-xs) 0; - position: absolute; - top: 0; - left: calc(var(--gutter-size-with-spacing) * -1); - background: var(--color-bg); - border-right: fun.convert-px(1) solid var(--color-border); - font-size: 100%; - letter-spacing: -1px; - text-align: right; - pointer-events: none; - user-select: none; - - > span { - &::before { - display: block; - padding-right: var(--spacing-xs); - color: var(--color-fg-light); - } - } - } - - .command-line-prompt { - > span { - &::before { - content: " "; - } - - &[data-user]::before { - content: "[" attr(data-user) "@" attr(data-host) "] $"; - } - - &[data-user="root"]::before { - content: "[" attr(data-user) "@" attr(data-host) "] #"; - } - - &[data-prompt]::before { - content: attr(data-prompt); - } - - &[data-continuation-prompt]::before { - content: attr(data-continuation-prompt); - } - } - } - - .line-numbers-rows { - > span { - counter-increment: lineNumber; - - &::before { - content: counter(lineNumber); - } - } - } - - .token { - &.comment, - &.doc-comment { - color: var(--color-fg-light); - } - - &.punctuation { - color: var(--color-fg); - } - - &.attr-name, - &.hexcode, - &.inserted, - &.string { - color: var(--color-token-green); - } - - &.class, - &.coord, - &.id, - &.function { - color: var(--color-token-purple); - } - - &.builtin, - &.builtin.class-name, - &.property-access, - &.regex, - &.scope { - color: var(--color-token-magenta); - } - - &.class-name, - &.constant, - &.global, - &.interpolation, - &.key, - &.package, - &.this, - &.title, - &.variable { - color: var(--color-token-blue); - } - - &.combinator, - &.keyword, - &.operator, - &.pseudo-class, - &.pseudo-element, - &.rule, - &.selector, - &.unit { - color: var(--color-token-orange); - } - - &.attr-value, - &.boolean, - &.number { - color: var(--color-token-yellow); - } - - &.delimiter, - &.doctype, - &.parameter, - &.parent, - &.property, - &.shebang, - &.tag { - color: var(--color-token-cyan); - } - - &.deleted { - color: var(--color-token-red); - } - - &.punctuation.brace-hover, - &.punctuation.brace-selected { - background: var(--color-bg); - outline: solid fun.convert-px(1) var(--color-primary-light); - } - } - - span.inline-color-wrapper { - background: url(fun.encode-svg( - '' - )); - - /* Prevent glitches where 1px from the repeating pattern could be seen. */ - background-position: center; - background-size: 110%; - - display: inline-block; - height: 1.1ch; - width: 1.1ch; - margin: 0 0.5ch 0 0; - border: fun.convert-px(1) solid var(--color-bg); - outline: fun.convert-px(1) solid var(--color-border-dark); - overflow: hidden; - } - - span.inline-color { - display: block; - - /* To prevent visual glitches again */ - height: 120%; - width: 120%; - } - } -} diff --git a/src/utils/hooks/use-prism.tsx b/src/utils/hooks/use-prism.tsx deleted file mode 100644 index 429808f..0000000 --- a/src/utils/hooks/use-prism.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import Prism from 'prismjs'; -import { useEffect, useMemo } from 'react'; -import { useIntl } from 'react-intl'; - -const PRISM_PLUGINS = [ - 'autoloader', - 'color-scheme', - 'command-line', - 'copy-to-clipboard', - 'diff-highlight', - 'inline-color', - 'line-highlight', - 'line-numbers', - 'match-braces', - 'normalize-whitespace', - 'show-language', - 'toolbar', -] as const; - -export type PrismPlugin = (typeof PRISM_PLUGINS)[number]; - -export type DefaultPrismPlugin = Extract< - PrismPlugin, - | 'autoloader' - | 'color-scheme' - | 'copy-to-clipboard' - | 'match-braces' - | 'normalize-whitespace' - | 'show-language' - | 'toolbar' ->; - -export type OptionalPrismPlugin = Exclude; - -export type PrismLanguage = - | 'apacheconf' - | 'bash' - | 'css' - | 'diff' - | 'docker' - | 'editorconfig' - | 'ejs' - | 'git' - | 'graphql' - | 'html' - | 'ignore' - | 'ini' - | 'javascript' - | 'jsdoc' - | 'json' - | 'jsx' - | 'makefile' - | 'markup' - | 'php' - | 'phpdoc' - | 'regex' - | 'scss' - | 'shell-session' - | 'smarty' - | 'tcl' - | 'toml' - | 'tsx' - | 'twig' - | 'yaml'; - -export type PrismAttributes = { - 'data-prismjs-copy': string; - 'data-prismjs-copy-success': string; - 'data-prismjs-copy-error': string; - 'data-prismjs-color-scheme-dark': string; - 'data-prismjs-color-scheme-light': string; -}; - -export type UsePrismProps = { - language?: PrismLanguage; - plugins: OptionalPrismPlugin[]; -}; - -export type UsePrismReturn = { - attributes: PrismAttributes; - className: string; -}; - -/** - * Import and configure all given Prism plugins. - * - * @param {PrismPlugin[]} plugins - The Prism plugins to activate. - */ -const loadPrismPlugins = async (plugins: PrismPlugin[]) => { - for (const plugin of plugins) { - try { - if (plugin === 'color-scheme') { - await import(`../plugins/prism-${plugin}`); - } else { - await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`); - } - - if (plugin === 'autoloader') { - Prism.plugins.autoloader.languages_path = '/prism/'; - } - } catch (error) { - console.error('usePrism: an error occurred while loading Prism plugins.'); - console.error(error); - } - } -}; - -/** - * Use Prism and its plugins. - * - * @param {UsePrismProps} props - An object of options. - * @returns {UsePrismReturn} An object of data. - */ -export const usePrism = ({ - language, - plugins, -}: UsePrismProps): UsePrismReturn => { - /** - * The order matter. Toolbar must be loaded before some other plugins. - */ - const defaultPlugins: DefaultPrismPlugin[] = useMemo( - () => [ - 'toolbar', - 'autoloader', - 'show-language', - 'copy-to-clipboard', - 'color-scheme', - 'match-braces', - 'normalize-whitespace', - ], - [] - ); - - useEffect(() => { - loadPrismPlugins([...defaultPlugins, ...plugins]).then(() => { - Prism.highlightAll(); - }); - }, [defaultPlugins, plugins]); - - const defaultClassName = 'match-braces'; - const languageClassName = language ? `language-${language}` : ''; - const pluginsClassName = plugins.join(' '); - const className = `${defaultClassName} ${pluginsClassName} ${languageClassName}`; - - const intl = useIntl(); - const copyText = intl.formatMessage({ - defaultMessage: 'Copy', - description: 'usePrism: copy button text (not clicked)', - id: '6GySNl', - }); - const copiedText = intl.formatMessage({ - defaultMessage: 'Copied!', - description: 'usePrism: copy button text (clicked)', - id: 'nsw6Th', - }); - const errorText = intl.formatMessage({ - defaultMessage: 'Use Ctrl+c to copy', - description: 'usePrism: copy button error text', - id: 'lKhTGM', - }); - const darkTheme = intl.formatMessage({ - defaultMessage: 'Dark Theme 🌙', - description: 'usePrism: toggle dark theme button text', - id: 'QLisK6', - }); - const lightTheme = intl.formatMessage({ - defaultMessage: 'Light Theme 🌞', - description: 'usePrism: toggle light theme button text', - id: 'hHVgW3', - }); - const attributes = { - 'data-prismjs-copy': copyText, - 'data-prismjs-copy-success': copiedText, - 'data-prismjs-copy-error': errorText, - 'data-prismjs-color-scheme-dark': darkTheme, - 'data-prismjs-color-scheme-light': lightTheme, - }; - - return { - attributes, - className, - }; -}; diff --git a/src/utils/hooks/use-prism/index.ts b/src/utils/hooks/use-prism/index.ts new file mode 100644 index 0000000..751cde7 --- /dev/null +++ b/src/utils/hooks/use-prism/index.ts @@ -0,0 +1 @@ +export * from './use-prism'; diff --git a/src/utils/hooks/use-prism/use-prism.test.tsx b/src/utils/hooks/use-prism/use-prism.test.tsx new file mode 100644 index 0000000..f7e83a2 --- /dev/null +++ b/src/utils/hooks/use-prism/use-prism.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it } from '@jest/globals'; +import { renderHook } from '@testing-library/react'; +import type { PropsWithChildren, ReactElement } from 'react'; +import { type IntlConfig, IntlProvider } from 'react-intl'; +import { + type PrismLanguage, + usePrism, + type PrismAvailablePlugin, + type PrismAttributes, + type PrismToolbarAttributes, +} from './use-prism'; + +type WrapperProps = { + children: ReactElement; +}; + +const createWrapper = ( + Wrapper: typeof IntlProvider, + props: PropsWithChildren +) => + function CreatedWrapper({ children }: WrapperProps) { + return {children}; + }; + +const toolbarAttributes: PrismToolbarAttributes = { + 'data-prismjs-color-scheme-dark': 'Dark Theme 🌙', + 'data-prismjs-color-scheme-light': 'Light Theme 🌞', + 'data-prismjs-copy': 'Copy', + 'data-prismjs-copy-error': 'Use Ctrl+c to copy', + 'data-prismjs-copy-success': 'Copied!', +}; + +describe('usePrism', () => { + it('returns the className and the attributes', () => { + const { result } = renderHook(() => usePrism({}), { + wrapper: createWrapper(IntlProvider, { locale: 'en' }), + }); + + expect(result.current.className).toStrictEqual(''); + expect(result.current.attributes).toStrictEqual(toolbarAttributes); + }); + + it('can return a className based on the given language', () => { + const language: PrismLanguage = 'docker'; + const { result } = renderHook(() => usePrism({ language }), { + wrapper: createWrapper(IntlProvider, { locale: 'en' }), + }); + + expect(result.current.className).toStrictEqual(`language-${language}`); + }); + + it('can return a className based on the given plugins', () => { + const pluginWithClass: PrismAvailablePlugin = 'diff-highlight'; + const { result } = renderHook( + () => usePrism({ plugins: [pluginWithClass] }), + { + wrapper: createWrapper(IntlProvider, { locale: 'en' }), + } + ); + + expect(result.current.className).toMatch(pluginWithClass); + }); + + it('can return a className based on the given language and plugins', () => { + const language: PrismLanguage = 'javascript'; + const pluginWithClass: PrismAvailablePlugin = 'diff-highlight'; + const { result } = renderHook( + () => usePrism({ language, plugins: [pluginWithClass] }), + { + wrapper: createWrapper(IntlProvider, { locale: 'en' }), + } + ); + + expect(result.current.className).toMatch(`language-diff-${language}`); + expect(result.current.className).toMatch(pluginWithClass); + }); + + it('can return the default attributes with the given owns', () => { + const attributes: Partial = { + 'data-filter-output': '(out)', + }; + const { result } = renderHook(() => usePrism({ attributes }), { + wrapper: createWrapper(IntlProvider, { locale: 'en' }), + }); + + expect(result.current.attributes).toStrictEqual({ + ...toolbarAttributes, + ...attributes, + }); + }); +}); diff --git a/src/utils/hooks/use-prism/use-prism.ts b/src/utils/hooks/use-prism/use-prism.ts new file mode 100644 index 0000000..7f8330b --- /dev/null +++ b/src/utils/hooks/use-prism/use-prism.ts @@ -0,0 +1,217 @@ +import Prism from 'prismjs'; +import { useEffect } from 'react'; +import { useIntl } from 'react-intl'; + +export type PrismToolbarAttributes = { + 'data-prismjs-copy': string; + 'data-prismjs-copy-success': string; + 'data-prismjs-copy-error': string; + 'data-prismjs-color-scheme-dark': string; + 'data-prismjs-color-scheme-light': string; +}; + +export type PrismAttributes = PrismToolbarAttributes & { + 'data-continuation-prompt'?: string; + 'data-continuation-str'?: string; + 'data-filter-output'?: string; + 'data-filter-continuation'?: string; + 'data-host'?: string; + 'data-line'?: string; + 'data-prompt'?: string; + 'data-output'?: string; + 'data-start'?: string; + 'data-toolbar-order'?: string; + 'data-user'?: string; +}; + +export type PrismLanguage = + | 'apacheconf' + | 'bash' + | 'css' + | 'diff' + | 'docker' + | 'editorconfig' + | 'ejs' + | 'git' + | 'graphql' + | 'html' + | 'ignore' + | 'ini' + | 'javascript' + | 'jsdoc' + | 'json' + | 'jsx' + | 'makefile' + | 'markup' + | 'php' + | 'phpdoc' + | 'regex' + | 'scss' + | 'shell-session' + | 'smarty' + | 'tcl' + | 'toml' + | 'treeview' + | 'tsx' + | 'twig' + | 'yaml'; + +export type PrismAvailablePlugin = + | 'autoloader' + | 'color-scheme' + | 'command-line' + | 'copy-to-clipboard' + | 'diff-highlight' + | 'inline-color' + | 'line-highlight' + | 'line-numbers' + | 'match-braces' + | 'normalize-whitespace' + | 'show-language' + | 'toolbar' + | 'treeview'; + +type PrismPlugin = { + name: PrismAvailablePlugin; + hasClassName: boolean; +}; + +const prismPlugins: PrismPlugin[] = [ + { name: 'toolbar', hasClassName: false }, + { name: 'autoloader', hasClassName: false }, + { name: 'show-language', hasClassName: false }, + { name: 'color-scheme', hasClassName: false }, + { name: 'copy-to-clipboard', hasClassName: false }, + { name: 'command-line', hasClassName: true }, + { name: 'diff-highlight', hasClassName: true }, + { name: 'inline-color', hasClassName: false }, + { name: 'line-highlight', hasClassName: false }, + { name: 'line-numbers', hasClassName: true }, + { name: 'match-braces', hasClassName: true }, + { name: 'normalize-whitespace', hasClassName: false }, + { name: 'treeview', hasClassName: false }, +]; + +/** + * Reorder the given plugins. + * + * The toolbar plugin must be loaded before some other plugins, so we need to + * ensure it is at the beginning of the array. + * + * @param {PrismAvailablePlugin[]} plugins - An array of Prism plugins. + * @returns {PrismAvailablePlugin[]} The sorted plugins. + */ +const sortPlugins = ( + plugins: PrismAvailablePlugin[] +): PrismAvailablePlugin[] => { + if (!plugins.includes('toolbar')) return plugins; + + const remainingPlugins = plugins.filter((plugin) => plugin !== 'toolbar'); + + return ['toolbar', ...remainingPlugins]; +}; + +/** + * Import and configure all given Prism plugins. + * + * @param {PrismAvailablePlugin[]} plugins - The plugins to activate. + */ +const loadPrismPlugins = async (plugins: PrismAvailablePlugin[]) => { + if (!plugins.length) return; + + const orderedPlugins = sortPlugins(plugins); + + try { + const importPromises = orderedPlugins.map(async (plugin) => { + if (plugin === 'color-scheme') { + return import('../../plugins/prism-color-scheme.cjs'); + } + + return import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`); + }); + + await importPromises.reduce(async (currImport, nextImport) => + currImport.then(await nextImport) + ); + + if (orderedPlugins.includes('autoloader')) + // cSpell:ignore camelcase + // eslint-disable-next-line camelcase -- Case is coming from Prism + Prism.plugins.autoloader.languages_path = '/prism/'; + } catch (error) { + console.error('usePrism: an error occurred while loading Prism plugins.'); + console.error(error); + } +}; + +export type UsePrismProps = { + attributes?: Omit; + language?: PrismLanguage; + plugins?: PrismAvailablePlugin[]; +}; + +/** + * Use Prism and its plugins. + * + * @param {UsePrismProps} props - An object of options. + * @returns An object with attributes and className. + */ +export const usePrism = ({ attributes, language, plugins }: UsePrismProps) => { + const intl = useIntl(); + const pluginsToLoad = prismPlugins.filter( + (plugin) => plugins?.includes(plugin.name) + ); + + const pluginClasses = pluginsToLoad + .map((plugin) => { + if (plugin.hasClassName) return plugin.name; + return undefined; + }) + .filter((maybeStr): maybeStr is PrismAvailablePlugin => !!maybeStr); + + const diffClass = language ? `language-diff-${language}` : 'language-diff'; + const languageClass = plugins?.includes('diff-highlight') + ? diffClass + : `language-${language}`; + + const className = [language ? languageClass : '', ...pluginClasses].join(' '); + + const toolbarAttributes: PrismToolbarAttributes = { + 'data-prismjs-color-scheme-dark': intl.formatMessage({ + defaultMessage: 'Dark Theme 🌙', + description: 'usePrism: toggle dark theme button text', + id: 'QLisK6', + }), + 'data-prismjs-color-scheme-light': intl.formatMessage({ + defaultMessage: 'Light Theme 🌞', + description: 'usePrism: toggle light theme button text', + id: 'hHVgW3', + }), + 'data-prismjs-copy': intl.formatMessage({ + defaultMessage: 'Copy', + description: 'usePrism: copy button text (not clicked)', + id: '6GySNl', + }), + 'data-prismjs-copy-error': intl.formatMessage({ + defaultMessage: 'Use Ctrl+c to copy', + description: 'usePrism: copy button error text', + id: 'lKhTGM', + }), + 'data-prismjs-copy-success': intl.formatMessage({ + defaultMessage: 'Copied!', + description: 'usePrism: copy button text (clicked)', + id: 'nsw6Th', + }), + }; + + useEffect(() => { + loadPrismPlugins(pluginsToLoad.map((plugin) => plugin.name)).then(() => { + Prism.highlightAll(); + }); + }, [pluginsToLoad]); + + return { + attributes: { ...toolbarAttributes, ...attributes }, + className, + }; +}; diff --git a/src/utils/plugins/prism-color-scheme.cjs b/src/utils/plugins/prism-color-scheme.cjs new file mode 100644 index 0000000..2632dd3 --- /dev/null +++ b/src/utils/plugins/prism-color-scheme.cjs @@ -0,0 +1,246 @@ +(function () { + if (typeof Prism === 'undefined' || typeof document === 'undefined') { + return; + } + + if (!Prism.plugins.toolbar) { + console.warn('Color scheme plugin loaded before Toolbar plugin.'); + + return; + } + + /** + * + * @typedef {"dark" | "light" | "system"} Theme + * @typedef {Record<"current", Theme> & Record<"dark" | "light", string>} Settings + */ + + var storage = { + /** + * Get a deserialized value from local storage. + * + * @param {string} key - The local storage key. + * @returns {string | undefined} The value of the given key. + */ + get: function (key) { + var serializedItem = localStorage.getItem(key); + return serializedItem ? JSON.parse(serializedItem) : undefined; + }, + /** + * Set or update a local storage key with a new serialized value. + * + * @param {string} key - The local storage key. + * @param {string} value - The value of the given key. + */ + set: function (key, value) { + var serializedValue = JSON.stringify(value); + localStorage.setItem(key, serializedValue); + }, + }; + + /** + * Check if user has set its color scheme preference. + * + * @returns {boolean} True if user prefers dark color scheme. + */ + function prefersDarkScheme() { + return ( + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches + ); + } + + /** + * Get the theme that matches the system theme. + * + * @returns {Theme} The theme to use. + */ + function getThemeFromSystem() { + return prefersDarkScheme() ? 'dark' : 'light'; + } + + /** + * Check if the provided string is a valid theme. + * + * @param {string} theme - A theme to check. + * @returns {boolean} True if it is a valid theme. + */ + function isValidTheme(theme) { + return theme === 'dark' || theme === 'light' || theme === 'system'; + } + + /** + * Set the default theme depending on user preferences. + * + * @returns {Theme} The default theme. + */ + function setDefaultTheme() { + var theme = storage.get('prismjs-color-scheme'); + + return theme && isValidTheme(theme) ? theme : 'system'; + } + + /** + * Traverses up the DOM tree to find data attributes that override the + * default plugin settings. + * + * @param {Element} startElement - An element to start from. + * @returns {Settings} The plugin settings. + */ + function getSettings(startElement) { + /** @type Settings */ + var settings = { + current: setDefaultTheme(), + dark: 'Toggle Dark Theme', + light: 'Toggle Light Theme', + }; + var prefix = 'data-prismjs-color-scheme-'; + + for (var key in settings) { + var attr = prefix + key; + var element = startElement; + + while (element && !element.hasAttribute(attr)) { + element = element.parentElement; + } + + if (element) { + settings[key] = element.getAttribute(attr); + } + } + + return settings; + } + + /** + * Retrieve the new theme depending on current theme value. + * + * @param {Theme} currentTheme - The current theme. + * @returns {Theme} The new theme. + */ + function getNewTheme(currentTheme) { + switch (currentTheme) { + case 'light': + return 'dark'; + case 'dark': + return 'light'; + case 'system': + default: + return getNewTheme(getThemeFromSystem()); + } + } + + /** + * Get the button content depending on current theme. + * + * @param {Theme} theme - The current theme. + * @param {Settings} settings - The plugin settings. + * @returns {string} The button text. + */ + function getButtonContent(theme, settings) { + return theme === 'dark' ? settings['light'] : settings['dark']; + } + + /** + * Update the button text depending on the current theme. + * + * @param {HTMLButtonElement} button - The color scheme button. + * @param {Settings} settings - The plugin settings. + */ + function updateButtonText(button, settings) { + var theme = + settings['current'] === 'system' + ? getThemeFromSystem() + : settings['current']; + + button.textContent = getButtonContent(theme, settings); + } + + /** + * Update pre data-prismjs-color-scheme attribute. + * + * @param {HTMLPreElement} pre - The pre element wrapping the code. + * @param {Theme} theme - The current theme. + */ + function updatePreAttribute(pre, theme) { + pre.setAttribute('data-prismjs-color-scheme-current', theme); + } + + /** + * Update pre attribute for all code blocks. + * + * @param {Theme} theme - The new theme. + */ + function switchTheme(theme) { + var allPre = document.querySelectorAll( + 'pre[data-prismjs-color-scheme-current]' + ); + allPre.forEach((pre) => { + updatePreAttribute(pre, theme); + }); + } + + /** + * Set current theme on pre attribute change. + * + * @param {HTMLPreElement} pre - The pre element wrapping the code. + * @param {Settings} settings - The plugin settings. + */ + function listenAttributeChange(pre, settings) { + var observer = new MutationObserver(function (mutations) { + mutations.forEach((record) => { + var mutatedPre = record.target; + var button = mutatedPre.parentElement.querySelector( + '.prism-color-scheme-button' + ); + var newTheme = mutatedPre.getAttribute( + 'data-prismjs-color-scheme-current' + ); + settings['current'] = newTheme; + updateButtonText(button, settings); + }); + }); + observer.observe(pre, { + attributes: true, + attributeFilter: ['data-prismjs-color-scheme-current'], + }); + } + + /** + * Create a color scheme button. + * + * @param {Object} env - The environment variables of the hook. + * @returns {HTMLButtonElement} The color scheme button. + */ + function getColorSchemeButton(env) { + var element = env.element; + var pre = element.parentElement; + var settings = getSettings(element); + var themeButton = document.createElement('button'); + themeButton.className = 'prism-color-scheme-button'; + themeButton.setAttribute('type', 'button'); + updateButtonText(themeButton, settings); + updatePreAttribute(pre, settings['current']); + listenAttributeChange(pre, settings); + + themeButton.addEventListener('click', () => { + var newTheme = getNewTheme(settings['current']); + switchTheme(newTheme); + storage.set('prismjs-color-scheme', newTheme); + }); + + window.addEventListener('storage', (e) => { + if (e.key === 'prismjs-color-scheme') { + var newTheme = JSON.parse(e.newValue); + if (isValidTheme(newTheme)) updatePreAttribute(pre, newTheme); + } + }); + + return themeButton; + } + + /** + * Register a new button in Prism toolbar plugin. + */ + Prism.plugins.toolbar.registerButton('color-scheme', getColorSchemeButton); +})(); diff --git a/src/utils/plugins/prism-color-scheme.js b/src/utils/plugins/prism-color-scheme.js deleted file mode 100644 index 2632dd3..0000000 --- a/src/utils/plugins/prism-color-scheme.js +++ /dev/null @@ -1,246 +0,0 @@ -(function () { - if (typeof Prism === 'undefined' || typeof document === 'undefined') { - return; - } - - if (!Prism.plugins.toolbar) { - console.warn('Color scheme plugin loaded before Toolbar plugin.'); - - return; - } - - /** - * - * @typedef {"dark" | "light" | "system"} Theme - * @typedef {Record<"current", Theme> & Record<"dark" | "light", string>} Settings - */ - - var storage = { - /** - * Get a deserialized value from local storage. - * - * @param {string} key - The local storage key. - * @returns {string | undefined} The value of the given key. - */ - get: function (key) { - var serializedItem = localStorage.getItem(key); - return serializedItem ? JSON.parse(serializedItem) : undefined; - }, - /** - * Set or update a local storage key with a new serialized value. - * - * @param {string} key - The local storage key. - * @param {string} value - The value of the given key. - */ - set: function (key, value) { - var serializedValue = JSON.stringify(value); - localStorage.setItem(key, serializedValue); - }, - }; - - /** - * Check if user has set its color scheme preference. - * - * @returns {boolean} True if user prefers dark color scheme. - */ - function prefersDarkScheme() { - return ( - window.matchMedia && - window.matchMedia('(prefers-color-scheme: dark)').matches - ); - } - - /** - * Get the theme that matches the system theme. - * - * @returns {Theme} The theme to use. - */ - function getThemeFromSystem() { - return prefersDarkScheme() ? 'dark' : 'light'; - } - - /** - * Check if the provided string is a valid theme. - * - * @param {string} theme - A theme to check. - * @returns {boolean} True if it is a valid theme. - */ - function isValidTheme(theme) { - return theme === 'dark' || theme === 'light' || theme === 'system'; - } - - /** - * Set the default theme depending on user preferences. - * - * @returns {Theme} The default theme. - */ - function setDefaultTheme() { - var theme = storage.get('prismjs-color-scheme'); - - return theme && isValidTheme(theme) ? theme : 'system'; - } - - /** - * Traverses up the DOM tree to find data attributes that override the - * default plugin settings. - * - * @param {Element} startElement - An element to start from. - * @returns {Settings} The plugin settings. - */ - function getSettings(startElement) { - /** @type Settings */ - var settings = { - current: setDefaultTheme(), - dark: 'Toggle Dark Theme', - light: 'Toggle Light Theme', - }; - var prefix = 'data-prismjs-color-scheme-'; - - for (var key in settings) { - var attr = prefix + key; - var element = startElement; - - while (element && !element.hasAttribute(attr)) { - element = element.parentElement; - } - - if (element) { - settings[key] = element.getAttribute(attr); - } - } - - return settings; - } - - /** - * Retrieve the new theme depending on current theme value. - * - * @param {Theme} currentTheme - The current theme. - * @returns {Theme} The new theme. - */ - function getNewTheme(currentTheme) { - switch (currentTheme) { - case 'light': - return 'dark'; - case 'dark': - return 'light'; - case 'system': - default: - return getNewTheme(getThemeFromSystem()); - } - } - - /** - * Get the button content depending on current theme. - * - * @param {Theme} theme - The current theme. - * @param {Settings} settings - The plugin settings. - * @returns {string} The button text. - */ - function getButtonContent(theme, settings) { - return theme === 'dark' ? settings['light'] : settings['dark']; - } - - /** - * Update the button text depending on the current theme. - * - * @param {HTMLButtonElement} button - The color scheme button. - * @param {Settings} settings - The plugin settings. - */ - function updateButtonText(button, settings) { - var theme = - settings['current'] === 'system' - ? getThemeFromSystem() - : settings['current']; - - button.textContent = getButtonContent(theme, settings); - } - - /** - * Update pre data-prismjs-color-scheme attribute. - * - * @param {HTMLPreElement} pre - The pre element wrapping the code. - * @param {Theme} theme - The current theme. - */ - function updatePreAttribute(pre, theme) { - pre.setAttribute('data-prismjs-color-scheme-current', theme); - } - - /** - * Update pre attribute for all code blocks. - * - * @param {Theme} theme - The new theme. - */ - function switchTheme(theme) { - var allPre = document.querySelectorAll( - 'pre[data-prismjs-color-scheme-current]' - ); - allPre.forEach((pre) => { - updatePreAttribute(pre, theme); - }); - } - - /** - * Set current theme on pre attribute change. - * - * @param {HTMLPreElement} pre - The pre element wrapping the code. - * @param {Settings} settings - The plugin settings. - */ - function listenAttributeChange(pre, settings) { - var observer = new MutationObserver(function (mutations) { - mutations.forEach((record) => { - var mutatedPre = record.target; - var button = mutatedPre.parentElement.querySelector( - '.prism-color-scheme-button' - ); - var newTheme = mutatedPre.getAttribute( - 'data-prismjs-color-scheme-current' - ); - settings['current'] = newTheme; - updateButtonText(button, settings); - }); - }); - observer.observe(pre, { - attributes: true, - attributeFilter: ['data-prismjs-color-scheme-current'], - }); - } - - /** - * Create a color scheme button. - * - * @param {Object} env - The environment variables of the hook. - * @returns {HTMLButtonElement} The color scheme button. - */ - function getColorSchemeButton(env) { - var element = env.element; - var pre = element.parentElement; - var settings = getSettings(element); - var themeButton = document.createElement('button'); - themeButton.className = 'prism-color-scheme-button'; - themeButton.setAttribute('type', 'button'); - updateButtonText(themeButton, settings); - updatePreAttribute(pre, settings['current']); - listenAttributeChange(pre, settings); - - themeButton.addEventListener('click', () => { - var newTheme = getNewTheme(settings['current']); - switchTheme(newTheme); - storage.set('prismjs-color-scheme', newTheme); - }); - - window.addEventListener('storage', (e) => { - if (e.key === 'prismjs-color-scheme') { - var newTheme = JSON.parse(e.newValue); - if (isValidTheme(newTheme)) updatePreAttribute(pre, newTheme); - } - }); - - return themeButton; - } - - /** - * Register a new button in Prism toolbar plugin. - */ - Prism.plugins.toolbar.registerButton('color-scheme', getColorSchemeButton); -})(); -- cgit v1.2.3