diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/molecules/layout/code.module.scss | 307 | ||||
| -rw-r--r-- | src/components/molecules/layout/code.stories.tsx | 118 | ||||
| -rw-r--r-- | src/components/molecules/layout/code.test.tsx | 16 | ||||
| -rw-r--r-- | src/components/molecules/layout/code.tsx | 101 | ||||
| -rw-r--r-- | src/utils/hooks/use-prism-plugins.tsx | 115 |
5 files changed, 657 insertions, 0 deletions
diff --git a/src/components/molecules/layout/code.module.scss b/src/components/molecules/layout/code.module.scss new file mode 100644 index 0000000..19d1d70 --- /dev/null +++ b/src/components/molecules/layout/code.module.scss @@ -0,0 +1,307 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.wrapper { + :global { + .code-toolbar { + --gutter-size: clamp(#{fun.convert-px(75)}, 20vw, #{fun.convert-px(90)}); + --toolbar-height: #{fun.convert-px(90)}; + + position: relative; + margin-top: calc(var(--toolbar-height) + var(--spacing-md)); + + @include mix.media("screen") { + @include mix.dimensions("2xs") { + --toolbar-height: #{fun.convert-px(60)}; + } + } + + .toolbar { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + justify-items: end; + 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); + + @include mix.media("screen") { + @include mix.dimensions("2xs") { + display: flex; + flex-flow: row wrap; + } + } + } + + .toolbar-item { + display: flex; + align-items: center; + } + + .toolbar-item:nth-child(1) { + grid-column: 1; + grid-row: 1 / 3; + margin-right: auto; + padding: 0 var(--spacing-sm); + background: var(--color-bg-code); + border-right: fun.convert-px(1) solid var(--color-border); + color: var(--color-primary-darker); + font-size: var(--font-size-sm); + font-weight: 600; + } + + .toolbar-item:nth-child(2) { + grid-column: 2; + grid-row: 1; + margin: 0 var(--spacing-2xs); + } + + .toolbar-item:nth-child(3) { + grid-column: 2; + grid-row: 2; + margin: 0 var(--spacing-2xs); + } + } + + pre[class*="language-"] { + max-height: max(30vw, fun.convert-px(300)); + margin: var(--spacing-md) 0; + padding: 0; + position: relative; + background: var(--color-bg-secondary); + color: var(--color-fg); + border: fun.convert-px(1) solid var(--color-border); + + > code { + display: block; + padding: var(--spacing-xs) 0 var(--spacing-xs) + calc(var(--gutter-size) + var(--spacing-xs)); + } + + .line-numbers-rows, + .command-line-prompt { + width: var(--gutter-size); + min-height: 100%; + padding: var(--spacing-xs) var(--spacing-2xs); + position: absolute; + top: 0; + left: 0; + pointer-events: none; + user-select: none; + background: var(--color-bg); + border-right: fun.convert-px(1) solid var(--color-border); + } + + .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( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path fill="gray" d="M0 0h2v2H0z"/><path fill="white" d="M0 0h1v1H0zM1 1h1v1H1z"/></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%; + } + } + + pre.line-numbers { + counter-reset: lineNumber; + + .line-numbers-rows { + > span { + counter-increment: lineNumber; + + &::before { + display: block; + padding: 0 var(--spacing-xs); + content: counter(lineNumber); + color: var(--color-primary-darker); + text-align: right; + line-height: var(--line-height); + } + } + } + } + + pre.command-line { + --gutter-size: clamp( + #{fun.convert-px(195)}, + 48vw, + #{fun.convert-px(235)} + ); + + ~ .toolbar { + --gutter-size: clamp( + #{fun.convert-px(195)}, + 48vw, + #{fun.convert-px(235)} + ); + } + + .command-line-prompt { + > span { + &::before { + display: block; + 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); + } + } + } + } + + .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); + } + } + } +} diff --git a/src/components/molecules/layout/code.stories.tsx b/src/components/molecules/layout/code.stories.tsx new file mode 100644 index 0000000..a2a6b2c --- /dev/null +++ b/src/components/molecules/layout/code.stories.tsx @@ -0,0 +1,118 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import CodeComponent from './code'; + +/** + * Code - Storybook Meta + */ +export default { + title: 'Molecules/Layout/Code', + component: CodeComponent, + args: { + filterOutput: false, + outputPattern: '#output#', + }, + argTypes: { + 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, + }, + }, + }, + decorators: [ + (Story) => ( + <IntlProvider locale="en"> + <Story /> + </IntlProvider> + ), + ], +} as ComponentMeta<typeof CodeComponent>; + +const Template: ComponentStory<typeof CodeComponent> = (args) => ( + <CodeComponent {...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 new file mode 100644 index 0000000..ebcfae5 --- /dev/null +++ b/src/components/molecules/layout/code.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@test-utils'; +import Code from './code'; + +const code = ` +function foo() { + return 'bar'; +} +`; + +const language = 'javascript'; + +describe('Code', () => { + it('renders a code block', () => { + render(<Code language={language}>{code}</Code>); + }); +}); diff --git a/src/components/molecules/layout/code.tsx b/src/components/molecules/layout/code.tsx new file mode 100644 index 0000000..2959ae5 --- /dev/null +++ b/src/components/molecules/layout/code.tsx @@ -0,0 +1,101 @@ +import usePrismPlugins, { + type PrismPlugin, +} from '@utils/hooks/use-prism-plugins'; +import { FC } from 'react'; +import styles from './code.module.scss'; + +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 OptionalPrismPlugin = Extract< + PrismPlugin, + | 'command-line' + | 'diff-highlight' + | 'inline-color' + | 'line-highlight' + | 'line-numbers' +>; + +export type CodeProps = { + /** + * 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. + */ +const Code: FC<CodeProps> = ({ + children, + filterOutput = false, + language, + plugins = [], + outputPattern = '#output#', +}) => { + const { pluginsAttribute, pluginsClassName } = usePrismPlugins(plugins); + + const outputAttribute = filterOutput + ? { 'data-filter-output': outputPattern } + : {}; + + return ( + <div className={styles.wrapper}> + <pre + className={`language-${language} ${pluginsClassName}`} + {...pluginsAttribute} + {...outputAttribute} + > + <code className={`language-${language}`}>{children}</code> + </pre> + </div> + ); +}; + +export default Code; diff --git a/src/utils/hooks/use-prism-plugins.tsx b/src/utils/hooks/use-prism-plugins.tsx new file mode 100644 index 0000000..c4959ac --- /dev/null +++ b/src/utils/hooks/use-prism-plugins.tsx @@ -0,0 +1,115 @@ +import Prism from 'prismjs'; +import { useEffect, useMemo } from 'react'; +import { useIntl } from 'react-intl'; + +export type PrismPlugin = + | 'autoloader' + | 'color-scheme' + | 'command-line' + | 'copy-to-clipboard' + | 'diff-highlight' + | 'inline-color' + | 'line-highlight' + | 'line-numbers' + | 'match-braces' + | 'normalize-whitespace' + | 'show-language' + | 'toolbar'; + +/** + * Import and configure all given Prism plugins. + * + * @param {PrismPlugin[]} prismPlugins - The Prism plugins to activate. + */ +const loadPrismPlugins = async (prismPlugins: PrismPlugin[]) => { + for (const plugin of prismPlugins) { + try { + if (plugin === 'color-scheme') { + await import(`@utils/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( + 'usePrismPlugins: an error occurred while loading Prism plugins.' + ); + console.error(error); + } + } +}; + +/** + * Load both the given Prism plugins and the default plugins. + * + * @param {PrismPlugin[]} plugins - The Prism plugins to activate. + */ +const usePrismPlugins = (plugins: PrismPlugin[]) => { + const intl = useIntl(); + + const copyText = intl.formatMessage({ + defaultMessage: 'Copy', + description: 'usePrismPlugins: copy button text (not clicked)', + id: 'FIE/eC', + }); + const copiedText = intl.formatMessage({ + defaultMessage: 'Copied!', + description: 'usePrismPlugins: copy button text (clicked)', + id: 'MzLdEl', + }); + const errorText = intl.formatMessage({ + defaultMessage: 'Use Ctrl+c to copy', + description: 'usePrismPlugins: copy button error text', + id: '0XePFn', + }); + const darkTheme = intl.formatMessage({ + defaultMessage: 'Dark Theme 🌙', + description: 'usePrismPlugins: toggle dark theme button text', + id: 'jo9vr5', + }); + const lightTheme = intl.formatMessage({ + defaultMessage: 'Light Theme 🌞', + description: 'usePrismPlugins: toggle light theme button text', + id: '6EUEtH', + }); + + 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, + }; + + const defaultPlugins: PrismPlugin[] = useMemo( + () => [ + 'toolbar', + 'autoloader', + 'show-language', + 'copy-to-clipboard', + 'color-scheme', + 'match-braces', + 'normalize-whitespace', + ], + [] + ); + + useEffect(() => { + loadPrismPlugins([...defaultPlugins, ...plugins]).then(() => { + Prism.highlightAll(); + }); + }, [defaultPlugins, plugins]); + + const defaultPluginsClasses = 'match-braces'; + const pluginsClasses = plugins.join(' '); + + return { + pluginsAttribute: attributes, + pluginsClassName: `${defaultPluginsClasses} ${pluginsClasses}`, + }; +}; + +export default usePrismPlugins; |
