Skip to content

Latest commit

Β 

History

History
761 lines (643 loc) Β· 24 KB

File metadata and controls

761 lines (643 loc) Β· 24 KB

Plugins

Both EuiMarkdownEditor and EuiMarkdownFormat utilize the same underlying plugin architecture to transform string based syntax into React components. At a high level Unified JS is used in combination with Remark to provide EUI's markdown components, which are separated into a parsing and processing layer. These two concepts are kept distinct in EUI components to provide concrete locations for your plugins to be injected, be it editing or rendering. Finally you provide UI to the component to handle interactions with the editor.

In addition to running the full pipeline, EuiMarkdownEditor uses just the parsing configuration to determine the input's validity, provide messages back to the application, and allow the toolbar buttons to interact with existing markdown tags.

Default plugins

EUI provides additional plugins by default, but these can be omitted or otherwise customized by providing the parsingPluginList, processingPluginList, and uiPlugins props to the editor and formatter components.

The parsing plugins, responsible for parsing markdown are:

  1. remark-parse
  2. additional pre-processing for code blocks
  3. remark-emoji
  4. remark-breaks
  5. link validation for security
  6. injection of EuiCheckbox for markdown check boxes
  7. tooltip plugin parser

The above set provides an abstract syntax tree used by the editor to provide feedback, and the renderer passes that output to the set of processing plugins to allow it to be rendered:

  1. remark-rehype
  2. rehype-react
  3. tooltip plugin renderer

The last set of plugin configuration - uiPlugins - allows toolbar buttons to be defined and how they alter or inject markdown and returns with only one plugin:

  1. tooltip plugin ui

These plugin definitions can be obtained by calling getDefaultEuiMarkdownParsingPlugins, getDefaultEuiMarkdownProcessingPlugins, and getDefaultEuiMarkdownUiPlugins respectively.

Configuring the default plugins

The above plugin utils, as well as getDefaultEuiMarkdownPlugins, accept an optional configuration object of:

  • exclude: an array of default plugins to unregister
  • parsingConfig: allows overriding the configuration of any default parsing plugin
  • processingConfig: currently only accepts a linkProps key, which accepts any prop that EuiLink accepts

The below example has the emoji plugin excluded, and custom configuration on the link validator parsing plugin and link processing plugin. See the Props table for all plugin config options.

import React from 'react';
import { EuiMarkdownFormat, getDefaultEuiMarkdownPlugins } from '@elastic/eui';

const markdownContent = `
- :cry: Automatic emoji formatting has been excluded from this markdown.
- In the example below, only \`https:\` and \`mailto:\` protocols should turn into links.
- Links should open in a new tab.

https://elastic.co
http://elastic.co
someone@elastic.co
`;

export default () => {
  const { processingPlugins, parsingPlugins } = getDefaultEuiMarkdownPlugins({
    exclude: ['emoji'],
    processingConfig: {
      linkProps: { target: '_blank' },
    },
    parsingConfig: {
      linkValidator: { allowProtocols: ['https:', 'mailto:'] },
    },
  });

  return (
    <EuiMarkdownFormat
      processingPluginList={processingPlugins}
      parsingPluginList={parsingPlugins}
    >
      {markdownContent}
    </EuiMarkdownFormat>
  );
};

Unregistering plugins

EuiMarkdownEditor comes with several default plugins, demo'd below. If these defaults are unnecessary for your use-case or context, you can unregister these plugins with a single exclude parameter passed to getDefaultEuiMarkdownPlugins(). This will ensure the syntax won't be identified or rendered, and no additional UI (like toolbar buttons or help syntax) will be displayed by the unregistered plugins.

import React, { useState, useMemo } from 'react';
import {
  EuiMarkdownEditor,
  getDefaultEuiMarkdownPlugins,
  EuiFlexGroup,
  EuiFlexItem,
  EuiSwitch,
} from '@elastic/eui';

const initialContent = `
### tooltip

When disabled, the button in the toolbar and the help syntax won't be displayed, and the following syntax will no longer works.

!{tooltip[anchor text](Tooltip content)}

### checkbox

When disabled, a EuiCheckbox will no longer render.

- [ ] TODO: Disable some default plugins

### emoji

When disabled, text will render instead of an emoji.

:smile:

### linkValidator

When disabled, all links will render as links instead of as text.

[This is a sus link](file://)

### lineBreaks

When disabled, these sentence will be in the same line.
When enabled, these sentences will be separated by a line break.

Two blank lines in a row will create a new paragraph as usual.
`;

export default () => {
  const [value, setValue] = useState(initialContent);

  const [tooltip, excludeTooltips] = useState(false);
  const [checkbox, excludeCheckboxes] = useState(true);
  const [emoji, excludeEmojis] = useState(true);
  const [linkValidator, excludeLinkValidator] = useState(true);
  const [lineBreaks, excludeLineBreaks] = useState(false);

  const { parsingPlugins, processingPlugins, uiPlugins } = useMemo(() => {
    const excludedPlugins = [];
    if (!tooltip) excludedPlugins.push('tooltip');
    if (!checkbox) excludedPlugins.push('checkbox');
    if (!emoji) excludedPlugins.push('emoji');
    if (!linkValidator) excludedPlugins.push('linkValidator');
    if (!lineBreaks) excludedPlugins.push('lineBreaks');

    return getDefaultEuiMarkdownPlugins({
      exclude: excludedPlugins,
    });
  }, [tooltip, checkbox, emoji, linkValidator, lineBreaks]);

  return (
    <>
      <EuiFlexGroup>
        <EuiFlexItem grow={false} css={{ gap: 20 }}>
          <EuiSwitch
            label="tooltip"
            checked={tooltip}
            onChange={() => excludeTooltips(!tooltip)}
          />
          <EuiSwitch
            label="checkbox"
            checked={checkbox}
            onChange={() => excludeCheckboxes(!checkbox)}
          />
          <EuiSwitch
            label="emoji"
            checked={emoji}
            onChange={() => excludeEmojis(!emoji)}
          />
          <EuiSwitch
            label="linkValidator"
            checked={linkValidator}
            onChange={() => excludeLinkValidator(!linkValidator)}
          />
          <EuiSwitch
            label="lineBreaks"
            checked={lineBreaks}
            onChange={() => excludeLineBreaks(!lineBreaks)}
          />
        </EuiFlexItem>
        <EuiFlexItem>
          <EuiMarkdownEditor
            aria-label="Demo with excluded default plugins"
            value={value}
            onChange={setValue}
            parsingPluginList={parsingPlugins}
            processingPluginList={processingPlugins}
            uiPlugins={uiPlugins}
            initialViewMode="viewing"
            autoExpandPreview={false}
            height={400}
          />
        </EuiFlexItem>
      </EuiFlexGroup>
    </>
  );
};

Plugin development

An EuiMarkdown plugin is comprised of three major pieces, which are passed separately as props.

<EuiMarkdownEditor
  uiPlugin={myPluginUI}
  parsingPluginList={myPluginParsingList}
  processingPluginList={myPluginProcessingList}
  {..otherProps}
/>

<!-- Note that the format component does not need a UI prop. -->
<EuiMarkdownFormat
  parsingPluginList={myPluginParsingList}
  processingPluginList={myPluginProcessingList}
/>

import { EuiDescriptionList } from '@elastic/eui';

<EuiDescriptionList compressed type="responsiveColumn" listItems={[ { title: 'uiPlugin', description: ( Provides the UI for the button in the toolbar as well as any modals or extra UI that provides content to the editor. ), }, { title: 'parsingPluginList', description: ( Provides the logic to identify the new syntax and parse it into an{' '} AST node. ), }, { title: 'processingPluginList', description: ( Provides the logic to process the new AST node into a{' '} React node. ), }, ]} />


uiPlugin

const myPluginUI = {
  name: 'myPlugin',
  button: {
    label: 'Chart',
    iconType: 'visArea',
  },
  helpText: (<div>A node that explains how the syntax works</div>),
  editor: function editor({ node, onSave, onCancel }) { return ('something'); },
};

<EuiDescriptionList compressed type="responsiveColumn" listItems={[ { title: 'name', description: ( The name of your plugin. Use the button.label listed below if you need a more friendly display name. The button can be omitted if you wish the user to only utilize syntax to author the content. ), }, { title: 'button', description: ( Takes a label and an icon type. This forms the button that appear in the toolbar. Clicking the button will trigger either the editor or formatter . ), }, { title: 'editor', description: ( Provides UI controls (like an interactive modal) for how to build the initial content. Must exist if formatting does not. ), }, { title: 'formatting', description: ( If no editor is provided, this is an object defining how the plugins markdown tag is styled. ), }, { title: 'helpText', description: ( Contains a React node. Should contain some information and an example for how to utilize the syntax. Appears when the markdown icon is clicked on the bottom of the editor. ), }, ]} />


parsingPluginList

Remark-parse is used to parse the input text into markdown AST nodes. Its documentation for writing parsers is under the Extending the Parser section, but highlights are included below.

A parser is comprised of three pieces. There is a wrapping function which is provided to remark-parse and injects the parser, the parser method itself, and a locator function if the markdown tag is inline.

The parsing method is called at locations where its markdown down might be found at. The method is responsible for determining if the location is a valid tag, process the tag, and mark report the result.

Inline vs block

Inline tags are allowed at any point in text, and will be rendered somewhere within a <p> element. For better performance, inline parsers must provide a locate method which reports the location where their next tag might be found. They are not allowed to span multiple lines of the input.

Block tags are rendered inside <span> elements, and do not have a locate method. They can consume as much input text as desired, across multiple lines.

// example plugin parser
function EmojiMarkdownParser() {
  const Parser = this.Parser;
  const tokenizers = Parser.prototype.inlineTokenizers;
  const methods = Parser.prototype.inlineMethods;

  const emojiMap = {
    wave: 'πŸ‘‹',
    smile: 'πŸ˜€',
    plane: 'πŸ›©',
  };
  const emojiNames = Object.keys(emojiMap);

  // function to parse a matching string
  function tokenizeEmoji(eat, value, silent) {
    const tokenMatch = value.match(/^:(.*?):/);

    if (!tokenMatch) return false; // no match
    const [, emojiName] = tokenMatch;

    // ensure we know this one
    if (emojiNames.indexOf(emojiName) === -1) return false;

    if (silent) {
      return true;
    }

    // must consume the exact & entire match string
    return eat(`:${emojiName}:`)({
      type: 'emojiPlugin',
      emoji: emojiMap[emojiName], // configuration is passed to the renderer
    });
  }

  // function to detect where the next emoji match might be found
  tokenizeEmoji.locator = (value, fromIndex) => {
    return value.indexOf(':', fromIndex);
  };

  // define the emoji plugin and inject it just before the existing text plugin
  tokenizers.emoji = tokenizeEmoji;
  methods.splice(methods.indexOf('text'), 0, 'emoji');
}

// add the parser for `emojiPlugin`
const parsingList = getDefaultEuiMarkdownParsingPlugins();
parsingList.push(EmojiMarkdownParser);

processingPluginList

After parsing the input into an AST, the nodes need to be transformed into React elements. This is performed by a list of processors, the default set converts remark AST into rehype and then into React. Plugins need to define themselves within this transformation process, identifying with the same type its parser uses in its eat call.

// example plugin processor

// receives the configuration from the parser and renders
const EmojiMarkdownRenderer = ({ emoji }) => {
  return <span>{emoji}</span>;
};

// add the renderer for `emojiPlugin`
const processingList = getDefaultEuiMarkdownProcessingPlugins();
processingList[1][1].components.emojiPlugin = EmojiMarkdownRenderer;

Putting it all together: a simple chart plugin

The below example takes the concepts from above to construct a simple chart embed that is initiated from a new button in the editor toolbar.

Note that the EuiMarkdownEditor and EuiMarkdownFormat examples utilize the same prop list. The editor manages additional controls through the uiPlugins prop.

import * as ElasticCharts from '@elastic/charts';

<Demo scope={{ ...ElasticCharts }}>

import React, { useCallback, useState } from "react";
import {
  getDefaultEuiMarkdownParsingPlugins,
  getDefaultEuiMarkdownProcessingPlugins,
  EuiMarkdownEditor,
  EuiMarkdownFormat,
  EuiSpacer,
  EuiCodeBlock,
  EuiModalHeader,
  EuiModalHeaderTitle,
  EuiModalBody,
  EuiModalFooter,
  EuiButton,
  EuiButtonEmpty,
  EuiForm,
  EuiFormRow,
  EuiColorPalettePicker,
  EuiRange,
  EuiText,
  EuiFlexGroup,
  EuiFlexItem,
  useEuiTheme,
  euiPaletteComplementary,
  euiPaletteCool,
  euiPaletteForStatus,
  euiPaletteForTemperature,
  euiPaletteGray,
  euiPaletteRed,
  euiPaletteGreen,
  euiPaletteSkyBlue,
  euiPaletteYellow,
  euiPaletteOrange,
  euiPaletteWarm,
  getDefaultEuiMarkdownUiPlugins,
} from "@elastic/eui";
import {
  Chart,
  Settings,
  Axis,
  BarSeries,
  DataGenerator,
  LIGHT_THEME,
  DARK_THEME,
} from "@elastic/charts";

const paletteData = {
  euiPaletteForStatus,
  euiPaletteForTemperature,
  euiPaletteComplementary,
  euiPaletteRed,
  euiPaletteGreen,
  euiPaletteSkyBlue,
  euiPaletteYellow,
  euiPaletteOrange,
  euiPaletteCool,
  euiPaletteWarm,
  euiPaletteGray,
};

const paletteNames = Object.keys(paletteData);

const dg = new DataGenerator();
const generateData = (categories) => dg.generateGroupedSeries(10, categories);

const chartDemoPlugin = {
  name: "chartDemoPlugin",
  button: {
    label: "Chart",
    iconType: "visBarVerticalStacked",
  },
  helpText: (
    <div>
      <EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
        {"!{chart{options}}"}
      </EuiCodeBlock>
      <EuiSpacer size="s" />
      <EuiText size="xs" style={{ marginLeft: 16 }}>
        <p>Where options can contain:</p>
        <ul>
          <li>
            <strong>palette: </strong>A number between 1-8 for each palette.
          </li>
          <li>
            <strong>categories: </strong>
            The number of categories per column
          </li>
        </ul>
      </EuiText>
    </div>
  ),
  editor: function ChartEditor({ node, onSave, onCancel }) {
    const [palette, setPalette] = useState((node && node.palette) || "1");
    const [categories, setCategories] = useState(5);

    const onChange = (e) => {
      setCategories(Number(e.target.value));
    };

    const palettes = paletteNames.map((paletteName, index) => {
      return {
        value: String(index + 1),
        title: paletteName,
        palette: paletteData[paletteNames[index]](categories),
        type: "fixed",
      };
    });

    return (
      <>
        <EuiModalHeader>
          <EuiModalHeaderTitle>Add chart</EuiModalHeaderTitle>
        </EuiModalHeader>

        <EuiModalBody>
          <>
            <EuiForm component="form">
              <EuiFlexGroup gutterSize="m" style={{ width: 600 }}>
                <EuiFlexItem>
                  <EuiFormRow label="Palette">
                    <EuiColorPalettePicker
                      palettes={palettes}
                      onChange={setPalette}
                      value={palette}
                      compressed
                    />
                  </EuiFormRow>
                </EuiFlexItem>
                <EuiFlexItem>
                  <EuiFormRow label="Categories">
                    <EuiRange
                      value={categories}
                      onChange={onChange}
                      min={1}
                      max={10}
                      compressed
                      showValue
                    />
                  </EuiFormRow>
                </EuiFlexItem>
              </EuiFlexGroup>
            </EuiForm>
            <EuiSpacer />
            <ChartMarkdownRenderer palette={palette} categories={categories} />
          </>
        </EuiModalBody>

        <EuiModalFooter>
          <EuiButtonEmpty onClick={onCancel}>Cancel</EuiButtonEmpty>

          <EuiButton
            onClick={() =>
              onSave(`!{chart${JSON.stringify({ palette, categories })}}`, {
                block: true,
              })
            }
            fill
          >
            Save
          </EuiButton>
        </EuiModalFooter>
      </>
    );
  },
};

function ChartMarkdownParser() {
  const Parser = this.Parser;
  const tokenizers = Parser.prototype.blockTokenizers;
  const methods = Parser.prototype.blockMethods;

  function tokenizeChart(eat, value, silent) {
    if (value.startsWith("!{chart") === false) return false;

    const nextChar = value[7];

    if (nextChar !== "{" && nextChar !== "}") return false; // this isn't actually a chart

    if (silent) {
      return true;
    }

    // is there a configuration?
    const hasConfiguration = nextChar === "{";

    let match = "!{chart";
    let configuration = {};

    if (hasConfiguration) {
      let configurationString = "";

      let openObjects = 0;

      for (let i = 7; i < value.length; i++) {
        const char = value[i];
        if (char === "{") {
          openObjects++;
          configurationString += char;
        } else if (char === "}") {
          openObjects--;
          if (openObjects === -1) {
            break;
          }
          configurationString += char;
        } else {
          configurationString += char;
        }
      }

      match += configurationString;
      try {
        configuration = JSON.parse(configurationString);
      } catch (e) {
        const now = eat.now();
        this.file.fail(`Unable to parse chart JSON configuration: ${e}`, {
          line: now.line,
          column: now.column + 7,
        });
      }
    }

    match += "}";

    return eat(match)({
      type: "chartDemoPlugin",
      ...configuration,
    });
  }

  tokenizers.chart = tokenizeChart;
  methods.splice(methods.indexOf("text"), 0, "chart");
}

// receives the configuration from the parser and renders
const ChartMarkdownRenderer = ({ palette, categories }) => {
  const { colorMode } = useEuiTheme();
  const chartBaseTheme = colorMode === "DARK" ? DARK_THEME : LIGHT_THEME;
  const customColors = {
    colors: {
      vizColors: paletteData[paletteNames[palette - 1]](categories),
    },
  };
  return (
    <Chart size={{ height: 320 }}>
      <Settings
        baseTheme={chartBaseTheme}
        theme={[customColors]}
        showLegend={false}
        showLegendDisplayValue={false}
      />
      <BarSeries
        id="status"
        name="Status"
        data={generateData(categories)}
        xAccessor={"x"}
        yAccessors={["y"]}
        splitSeriesAccessors={["g"]}
        stackAccessors={["g"]}
      />
      <Axis id="bottom-axis" position="bottom" showGridLines />
      <Axis
        id="left-axis"
        position="left"
        showGridLines
        tickFormat={(d) => Number(d).toFixed(2)}
      />
    </Chart>
  );
};

const exampleParsingList = getDefaultEuiMarkdownParsingPlugins();
exampleParsingList.push(ChartMarkdownParser);

const exampleProcessingList = getDefaultEuiMarkdownProcessingPlugins();
exampleProcessingList[1][1].components.chartDemoPlugin = ChartMarkdownRenderer;

const exampleUiPlugins = getDefaultEuiMarkdownUiPlugins();
exampleUiPlugins.push(chartDemoPlugin);

const initialExample = `## Chart plugin

Notice the toolbar above has a new chart button. Click it to add a chart.

Once you finish it'll add some syntax that looks like the below.

!{chart{"palette":"2","categories":5}}
`;

export default () => {
  const [value, setValue] = useState(initialExample);
  const [messages, setMessages] = useState([]);
  const [ast, setAst] = useState(null);
  const [isAstShowing, setIsAstShowing] = useState(false);
  const onParse = useCallback((err, { messages, ast }) => {
    setMessages(err ? [err] : messages);
    setAst(JSON.stringify(ast, null, 2));
  }, []);
  return (
    <>
      <EuiMarkdownEditor
        aria-label="EUI markdown editor with plugins demo"
        placeholder="Your markdown here..."
        value={value}
        onChange={setValue}
        height={400}
        uiPlugins={exampleUiPlugins}
        parsingPluginList={exampleParsingList}
        processingPluginList={exampleProcessingList}
        onParse={onParse}
        errors={messages}
      />
      <EuiSpacer size="s" />
      <div className="eui-textRight">
        <EuiButton
          size="s"
          iconType={isAstShowing ? "eyeClosed" : "eye"}
          onClick={() => setIsAstShowing(!isAstShowing)}
          fill={isAstShowing}
        >
          {isAstShowing ? "Hide editor AST" : "Show editor AST"}
        </EuiButton>
      </div>
      {isAstShowing && <EuiCodeBlock language="json">{ast}</EuiCodeBlock>}

      <EuiMarkdownFormat
        parsingPluginList={exampleParsingList}
        processingPluginList={exampleProcessingList}
      >
        {value}
      </EuiMarkdownFormat>
    </>
  );
};