Преглед изворни кода

Merge pull request #6454 from weseek/feat/remark-growi-plugin

feat: Remark growi plugin
Yuki Takei пре 3 година
родитељ
комит
9e97e22929
36 измењених фајлова са 5621 додато и 17 уклоњено
  1. 4 0
      .github/workflows/ci-app-prod.yml
  2. 12 4
      .github/workflows/ci-app.yml
  3. 3 0
      packages/app/next.config.js
  4. 3 0
      packages/app/src/services/renderer/renderer.ts
  5. 1 0
      packages/remark-growi-plugin/.eslintignore
  6. 13 0
      packages/remark-growi-plugin/.eslintrc.cjs
  7. 2 0
      packages/remark-growi-plugin/.gitignore
  8. 69 0
      packages/remark-growi-plugin/package.json
  9. 420 0
      packages/remark-growi-plugin/readme.md
  10. 3 0
      packages/remark-growi-plugin/src/index.js
  11. 29 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/complex-types.d.ts
  12. 326 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/index.js
  13. 403 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/readme.md
  14. 7 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/index.js
  15. 138 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-leaf.js
  16. 108 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-text.js
  17. 336 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js
  18. 139 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-label.js
  19. 50 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-name.js
  20. 194 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/html.js
  21. 18 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/syntax.js
  22. 288 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/readme.md
  23. 35 0
      packages/remark-growi-plugin/src/remark-growi-plugin.js
  24. 11 0
      packages/remark-growi-plugin/test/fixtures/leaf/input.md
  25. 11 0
      packages/remark-growi-plugin/test/fixtures/leaf/output.md
  26. 266 0
      packages/remark-growi-plugin/test/fixtures/leaf/tree.json
  27. 7 0
      packages/remark-growi-plugin/test/fixtures/text/input.md
  28. 7 0
      packages/remark-growi-plugin/test/fixtures/text/output.md
  29. 429 0
      packages/remark-growi-plugin/test/fixtures/text/tree.json
  30. 584 0
      packages/remark-growi-plugin/test/mdast-util-growi-plugin.test.js
  31. 1063 0
      packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js
  32. 71 0
      packages/remark-growi-plugin/test/remark-growi-plugin.test.js
  33. 11 0
      packages/remark-growi-plugin/tsconfig.base.json
  34. 16 0
      packages/remark-growi-plugin/tsconfig.build.json
  35. 10 0
      packages/remark-growi-plugin/tsconfig.json
  36. 534 13
      yarn.lock

+ 4 - 0
.github/workflows/ci-app-prod.yml

@@ -13,7 +13,9 @@ on:
       - yarn.lock
       - yarn.lock
       - packages/app/**
       - packages/app/**
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
       - packages/plugin-**
       - packages/plugin-**
@@ -30,7 +32,9 @@ on:
       - yarn.lock
       - yarn.lock
       - packages/app/**
       - packages/app/**
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
       - packages/plugin-**
       - packages/plugin-**

+ 12 - 4
.github/workflows/ci-app.yml

@@ -14,7 +14,9 @@ on:
       - yarn.lock
       - yarn.lock
       - packages/app/**
       - packages/app/**
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
       - packages/plugin-*/**
       - packages/plugin-*/**
@@ -53,10 +55,10 @@ jobs:
 
 
       - name: lerna run lint for plugins
       - name: lerna run lint for plugins
         run: |
         run: |
-          yarn lerna run lint --scope @growi/plugin-*
+          yarn lerna run lint --scope @growi/remark-growi-plugin --scope @growi/plugin-*
       - name: lerna run lint for app
       - name: lerna run lint for app
         run: |
         run: |
-          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/ui
+          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
 
 
       - name: Slack Notification
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master
@@ -105,7 +107,11 @@ jobs:
         run: |
         run: |
           npx lerna bootstrap -- --frozen-lockfile
           npx lerna bootstrap -- --frozen-lockfile
 
 
-      - name: yarn test
+      - name: lerna run test for plugins
+        run: |
+          yarn lerna run test --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+
+      - name: Test app
         working-directory: ./packages/app
         working-directory: ./packages/app
         run: |
         run: |
           yarn test:ci --selectProjects unit server ; yarn test:ci --selectProjects server-v5
           yarn test:ci --selectProjects unit server ; yarn test:ci --selectProjects server-v5
@@ -116,7 +122,9 @@ jobs:
         uses: actions/upload-artifact@v3
         uses: actions/upload-artifact@v3
         with:
         with:
           name: Coverage Report
           name: Coverage Report
-          path: packages/app/coverage
+          path: |
+            packages/app/coverage
+            packages/remark-growi-plugin/coverage
 
 
       - name: Slack Notification
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master

+ 3 - 0
packages/app/next.config.js

@@ -25,6 +25,7 @@ const setupTranspileModules = () => {
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
     'react-markdown',
     'unified',
     'unified',
+    'character-entities-html4',
     'comma-separated-tokens',
     'comma-separated-tokens',
     'decode-named-character-reference',
     'decode-named-character-reference',
     'hastscript',
     'hastscript',
@@ -33,7 +34,9 @@ const setupTranspileModules = () => {
     'longest-streak',
     'longest-streak',
     'property-information',
     'property-information',
     'space-separated-tokens',
     'space-separated-tokens',
+    'stringify-entities',
     'trim-lines',
     'trim-lines',
+    'trough',
     'web-namespaces',
     'web-namespaces',
     'vfile',
     'vfile',
     'zwitch',
     'zwitch',

+ 3 - 0
packages/app/src/services/renderer/renderer.ts

@@ -1,3 +1,4 @@
+import growiPlugin from '@growi/remark-growi-plugin';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import katex from 'rehype-katex';
 import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import raw from 'rehype-raw';
@@ -9,6 +10,7 @@ import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
 import math from 'remark-math';
 
 
+
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
@@ -222,6 +224,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
     remarkPlugins: [
     remarkPlugins: [
       gfm,
       gfm,
       pukiwikiLikeLinker,
       pukiwikiLikeLinker,
+      growiPlugin,
     ],
     ],
     rehypePlugins: [
     rehypePlugins: [
       slug,
       slug,

+ 1 - 0
packages/remark-growi-plugin/.eslintignore

@@ -0,0 +1 @@
+/dist/**

+ 13 - 0
packages/remark-growi-plugin/.eslintrc.cjs

@@ -0,0 +1,13 @@
+module.exports = {
+  rules: {
+    'import/extensions': [
+      'error',
+      'ignorePackages',
+      {
+        ts: 'never',
+        tsx: 'never',
+      },
+    ],
+    '@typescript-eslint/no-use-before-define': 'off',
+  },
+};

+ 2 - 0
packages/remark-growi-plugin/.gitignore

@@ -0,0 +1,2 @@
+/dist
+/coverage

+ 69 - 0
packages/remark-growi-plugin/package.json

@@ -0,0 +1,69 @@
+{
+  "name": "@growi/remark-growi-plugin",
+  "version": "5.1.3-RC.0",
+  "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
+  "license": "MIT",
+  "keywords": [
+    "unified",
+    "remark",
+    "remark-plugin",
+    "plugin",
+    "mdast",
+    "markdown",
+    "generic"
+  ],
+  "type": "module",
+  "main": "dist/index.js",
+  "typings": "dist/index.d.ts",
+  "scripts": {
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
+    "clean": "npx -y shx rm -rf dist",
+    "tsc": "tsc -p tsconfig.build.json",
+    "tsc:w": "yarn tsc -w",
+    "test": "cross-env NODE_ENV=test npm run test-coverage",
+    "test-api": "tape --conditions development test/**.test.js",
+    "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov npm run test-api",
+    "lint": "eslint \"**/*.{cjs, js,jsx,ts,tsx}\"",
+    "lint:fix": "eslint \"**/*.{cjs, js,jsx,ts,tsx}\" --fix"
+  },
+  "dependencies": {
+    "@types/mdast": "^3.0.0",
+    "@types/unist": "^2.0.0",
+    "mdast-util-to-markdown": "^1.3.0",
+    "micromark-factory-space": "^1.0.0",
+    "micromark-factory-whitespace": "^1.0.0",
+    "micromark-util-character": "^1.0.0",
+    "micromark-util-symbol": "^1.0.0",
+    "micromark-util-types": "^1.0.0",
+    "parse-entities": "^4.0.0",
+    "stringify-entities": "^4.0.0",
+    "unified": "^10.0.0",
+    "unist-util-visit-parents": "^5.0.0",
+    "uvu": "^0.5.0"
+  },
+  "devDependencies": {
+    "@types/tape": "^4.0.0",
+    "c8": "^7.0.0",
+    "html-void-elements": "^2.0.0",
+    "is-hidden": "^2.0.0",
+    "mdast-util-from-markdown": "^1.0.0",
+    "micromark": "^3.0.0",
+    "micromark-build": "^1.0.0",
+    "remark": "^14.0.0",
+    "remark-cli": "^10.0.0",
+    "remark-preset-wooorm": "^9.0.0",
+    "rimraf": "^3.0.0",
+    "tape": "^5.0.0",
+    "to-vfile": "^7.0.0",
+    "type-coverage": "^2.0.0",
+    "typescript": "^4.0.0",
+    "unist-util-remove-position": "^4.0.0",
+    "xo": "^0.47.0"
+  },
+  "typeCoverage": {
+    "atLeast": 100,
+    "detail": true,
+    "strict": true,
+    "ignoreCatch": true
+  }
+}

+ 420 - 0
packages/remark-growi-plugin/readme.md

@@ -0,0 +1,420 @@
+# remark-directive
+
+[![Build][build-badge]][build]
+[![Coverage][coverage-badge]][coverage]
+[![Downloads][downloads-badge]][downloads]
+[![Size][size-badge]][size]
+[![Sponsors][sponsors-badge]][collective]
+[![Backers][backers-badge]][collective]
+[![Chat][chat-badge]][chat]
+
+[**remark**][remark] plugin to support the [generic directives proposal][prop]
+(`:cite[smith04]`, `::youtube[Video of a cat in a box]{v=01ab2cd3efg}`, and
+such).
+
+## Contents
+
+*   [What is this?](#what-is-this)
+*   [When should I use this?](#when-should-i-use-this)
+*   [Install](#install)
+*   [Use](#use)
+*   [API](#api)
+    *   [`unified().use(remarkDirective)`](#unifieduseremarkdirective)
+*   [Examples](#examples)
+    *   [Example: YouTube](#example-youtube)
+    *   [Example: Styled blocks](#example-styled-blocks)
+*   [Syntax](#syntax)
+*   [Syntax tree](#syntax-tree)
+*   [Types](#types)
+*   [Compatibility](#compatibility)
+*   [Security](#security)
+*   [Related](#related)
+*   [Contribute](#contribute)
+*   [License](#license)
+
+## What is this?
+
+This package is a [unified][] ([remark][]) plugin to add support for directives:
+one syntax for arbitrary extensions in markdown.
+You can use this with some more code to match your specific needs, to allow for
+anything from callouts, citations, styled blocks, forms, embeds, spoilers, etc.
+
+**unified** is a project that transforms content with abstract syntax trees
+(ASTs).
+**remark** adds support for markdown to unified.
+**mdast** is the markdown AST that remark uses.
+**micromark** is the markdown parser we use.
+This is a remark plugin that adds support for the directives syntax and AST to
+remark.
+
+## When should I use this?
+
+Directives are one of the four ways to extend markdown: an arbitrary extension
+syntax (see [Extending markdown](https://github.com/micromark/micromark#extending-markdown)
+in micromark’s docs for the alternatives and more info).
+This mechanism works well when you control the content: who authors it, what
+tools handle it, and where it’s displayed.
+When authors can read a guide on how to embed a tweet but are not expected to
+know the ins and outs of HTML or JavaScript.
+Directives don’t work well if you don’t know who authors content, what tools
+handle it, and where it ends up.
+Example use cases are a docs website for a project or product, or blogging tools
+and static site generators.
+
+## Install
+
+This package is [ESM only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c).
+In Node.js (version 12.20+, 14.14+, or 16.0+), install with [npm][]:
+
+```sh
+npm install remark-directive
+```
+
+In Deno with [`esm.sh`][esmsh]:
+
+```js
+import remarkDirective from 'https://esm.sh/remark-directive@2'
+```
+
+In browsers with [`esm.sh`][esmsh]:
+
+```html
+<script type="module">
+  import remarkDirective from 'https://esm.sh/remark-directive@2?bundle'
+</script>
+```
+
+## Use
+
+Say we have the following file, `example.md`:
+
+```markdown
+:::main{#readme}
+
+Lorem:br
+ipsum.
+
+::hr{.red}
+
+A :i[lovely] language know as :abbr[HTML]{title="HyperText Markup Language"}.
+
+:::
+```
+
+And our module, `example.js`, looks as follows:
+
+```js
+import {read} from 'to-vfile'
+import {unified} from 'unified'
+import remarkParse from 'remark-parse'
+import remarkDirective from 'remark-directive'
+import remarkRehype from 'remark-rehype'
+import rehypeFormat from 'rehype-format'
+import rehypeStringify from 'rehype-stringify'
+import {visit} from 'unist-util-visit'
+import {h} from 'hastscript'
+
+main()
+
+async function main() {
+  const file = await unified()
+    .use(remarkParse)
+    .use(remarkDirective)
+    .use(myRemarkPlugin)
+    .use(remarkRehype)
+    .use(rehypeFormat)
+    .use(rehypeStringify)
+    .process(await read('example.md'))
+
+  console.log(String(file))
+}
+
+// This plugin is an example to let users write HTML with directives.
+// It’s informative but rather useless.
+// See below for others examples.
+/** @type {import('unified').Plugin<[], import('mdast').Root>} */
+function myRemarkPlugin() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (
+        node.type === 'textGrowiPluginDirective' ||
+        node.type === 'leafGrowiPluginDirective'
+      ) {
+        const data = node.data || (node.data = {})
+        const hast = h(node.name, node.attributes)
+
+        data.hName = hast.tagName
+        data.hProperties = hast.properties
+      }
+    })
+  }
+}
+```
+
+Now, running `node example` yields:
+
+```html
+<main id="readme">
+  <p>Lorem<br>ipsum.</p>
+  <hr class="red">
+  <p>A <i>lovely</i> language know as <abbr title="HyperText Markup Language">HTML</abbr>.</p>
+</main>
+```
+
+## API
+
+This package exports no identifiers.
+The default export is `remarkDirective`.
+
+### `unified().use(remarkDirective)`
+
+Configures remark so that it can parse and serialize directives.
+Doesn’t handle the directives: [create your own plugin][create-plugin] to do
+that.
+
+## Examples
+
+### Example: YouTube
+
+This example shows how directives can be used for YouTube embeds.
+It’s based on the example in Use above.
+If `myRemarkPlugin` was replaced with this function:
+
+```js
+// This plugin is an example to turn `::youtube` into iframes.
+/** @type {import('unified').Plugin<[], import('mdast').Root>} */
+function myRemarkPlugin() {
+  return (tree, file) => {
+    visit(tree, (node) => {
+      if (
+        node.type === 'textGrowiPluginDirective' ||
+        node.type === 'leafGrowiPluginDirective'
+      ) {
+        if (node.name !== 'youtube') return
+
+        const data = node.data || (node.data = {})
+        const attributes = node.attributes || {}
+        const id = attributes.id
+
+        if (node.type === 'textGrowiPluginDirective') file.fail('Text directives for `youtube` not supported', node)
+        if (!id) file.fail('Missing video id', node)
+
+        data.hName = 'iframe'
+        data.hProperties = {
+          src: 'https://www.youtube.com/embed/' + id,
+          width: 200,
+          height: 200,
+          frameBorder: 0,
+          allow: 'picture-in-picture',
+          allowFullScreen: true
+        }
+      }
+    })
+  }
+}
+```
+
+…and `example.md` contains:
+
+```markdown
+# Cat videos
+
+::youtube[Video of a cat in a box]{#01ab2cd3efg}
+```
+
+…then running `node example` yields:
+
+```html
+<h1>Cat videos</h1>
+<iframe src="https://www.youtube.com/embed/01ab2cd3efg" width="200" height="200" frameborder="0" allow="picture-in-picture" allowfullscreen>Video of a cat in a box</iframe>
+```
+
+### Example: Styled blocks
+
+Note: This is sometimes called admonitions, callouts, etc.
+
+This example shows how directives can be used to style blocks.
+It’s based on the example in Use above.
+If `myRemarkPlugin` was replaced with this function:
+
+```js
+// This plugin is an example to turn `::note` into divs, passing arbitrary
+// attributes.
+/** @type {import('unified').Plugin<[], import('mdast').Root>} */
+function myRemarkPlugin() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (
+        node.type === 'textGrowiPluginDirective' ||
+        node.type === 'leafGrowiPluginDirective'
+      ) {
+        if (node.name !== 'note') return
+
+        const data = node.data || (node.data = {})
+        const tagName = node.type === 'textGrowiPluginDirective' ? 'span' : 'div'
+
+        data.hName = tagName
+        data.hProperties = h(tagName, node.attributes).properties
+      }
+    })
+  }
+}
+```
+
+…and `example.md` contains:
+
+```markdown
+# How to use xxx
+
+You can use xxx.
+
+:::note{.warning}
+if you chose xxx, you should also use yyy somewhere…
+:::
+```
+
+…then running `node example` yields:
+
+```html
+<h1>How to use xxx</h1>
+<p>You can use xxx.</p>
+<div class="warning">
+  <p>if you chose xxx, you should also use yyy somewhere…</p>
+</div>
+```
+
+## Syntax
+
+This plugin applies a micromark extensions to parse the syntax.
+See its readme for parse details:
+
+*   [`micromark-extension-directive`](https://github.com/micromark/micromark-extension-directive#syntax)
+
+## Syntax tree
+
+This plugin applies one mdast utility to build and serialize the AST.
+See its readme for the node types supported in the tree:
+
+*   [`mdast-util-directive`](https://github.com/syntax-tree/mdast-util-directive#syntax-tree)
+
+## Types
+
+This package is fully typed with [TypeScript][].
+If you’re working with the syntax tree, make sure to import this plugin
+somewhere in your types, as that registers the new node types in the tree.
+
+```js
+/** @typedef {import('remark-directive')} */
+
+import {visit} from 'unist-util-visit'
+
+/** @type {import('unified').Plugin<[], import('mdast').Root>} */
+export default function myRemarkPlugin() {
+  return (tree) => {
+    visit(tree, (node) => {
+      // `node` can now be one of the nodes for directives.
+    })
+  }
+}
+```
+
+## Compatibility
+
+Projects maintained by the unified collective are compatible with all maintained
+versions of Node.js.
+As of now, that is Node.js 12.20+, 14.14+, and 16.0+.
+Our projects sometimes work with older versions, but this is not guaranteed.
+
+This plugin works with unified version 9+ and remark version 14+.
+
+## Security
+
+Use of `remark-directive` does not involve [**rehype**][rehype]
+([**hast**][hast]) or user content so there are no openings for [cross-site
+scripting (XSS)][xss] attacks.
+
+## Related
+
+*   [`remark-gfm`](https://github.com/remarkjs/remark-gfm)
+    — support GFM (autolink literals, footnotes, strikethrough, tables,
+    tasklists)
+*   [`remark-frontmatter`](https://github.com/remarkjs/remark-frontmatter)
+    — support frontmatter (YAML, TOML, and more)
+*   [`remark-math`](https://github.com/remarkjs/remark-math)
+    — support math
+*   [`remark-mdx`](https://github.com/mdx-js/mdx/tree/main/packages/remark-mdx)
+    — support MDX (JSX, expressions, ESM)
+
+## Contribute
+
+See [`contributing.md`][contributing] in [`remarkjs/.github`][health] for ways
+to get started.
+See [`support.md`][support] for ways to get help.
+
+This project has a [code of conduct][coc].
+By interacting with this repository, organization, or community you agree to
+abide by its terms.
+
+## License
+
+[MIT][license] © [Titus Wormer][author]
+
+<!-- Definitions -->
+
+[build-badge]: https://github.com/remarkjs/remark-directive/workflows/main/badge.svg
+
+[build]: https://github.com/remarkjs/remark-directive/actions
+
+[coverage-badge]: https://img.shields.io/codecov/c/github/remarkjs/remark-directive.svg
+
+[coverage]: https://codecov.io/github/remarkjs/remark-directive
+
+[downloads-badge]: https://img.shields.io/npm/dm/remark-directive.svg
+
+[downloads]: https://www.npmjs.com/package/remark-directive
+
+[size-badge]: https://img.shields.io/bundlephobia/minzip/remark-directive.svg
+
+[size]: https://bundlephobia.com/result?p=remark-directive
+
+[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg
+
+[backers-badge]: https://opencollective.com/unified/backers/badge.svg
+
+[collective]: https://opencollective.com/unified
+
+[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg
+
+[chat]: https://github.com/remarkjs/remark/discussions
+
+[npm]: https://docs.npmjs.com/cli/install
+
+[esmsh]: https://esm.sh
+
+[health]: https://github.com/remarkjs/.github
+
+[contributing]: https://github.com/remarkjs/.github/blob/HEAD/contributing.md
+
+[support]: https://github.com/remarkjs/.github/blob/HEAD/support.md
+
+[coc]: https://github.com/remarkjs/.github/blob/HEAD/code-of-conduct.md
+
+[license]: license
+
+[author]: https://wooorm.com
+
+[unified]: https://github.com/unifiedjs/unified
+
+[remark]: https://github.com/remarkjs/remark
+
+[xss]: https://en.wikipedia.org/wiki/Cross-site_scripting
+
+[typescript]: https://www.typescriptlang.org
+
+[rehype]: https://github.com/rehypejs/rehype
+
+[hast]: https://github.com/syntax-tree/hast
+
+[prop]: https://talk.commonmark.org/t/generic-directives-plugins-syntax/444
+
+[create-plugin]: https://unifiedjs.com/learn/guide/create-a-plugin/

+ 3 - 0
packages/remark-growi-plugin/src/index.js

@@ -0,0 +1,3 @@
+import { remarkGrowiPlugin } from './remark-growi-plugin.js';
+
+export default remarkGrowiPlugin;

+ 29 - 0
packages/remark-growi-plugin/src/mdast-util-growi-plugin/complex-types.d.ts

@@ -0,0 +1,29 @@
+import type { PhrasingContent } from 'mdast';
+import type { Parent } from 'unist';
+
+type DirectiveAttributes = Record<string, string>
+
+interface DirectiveFields {
+  name: string
+  attributes?: DirectiveAttributes
+}
+
+export interface TextDirective extends Parent, DirectiveFields {
+  type: 'textGrowiPluginDirective'
+  children: PhrasingContent[]
+}
+
+export interface LeafDirective extends Parent, DirectiveFields {
+  type: 'leafGrowiPluginDirective'
+  children: PhrasingContent[]
+}
+
+declare module 'mdast' {
+  interface StaticPhrasingContentMap {
+    textGrowiPluginDirective: TextDirective
+  }
+
+  interface BlockContentMap {
+    leafGrowiPluginDirective: LeafDirective
+  }
+}

+ 326 - 0
packages/remark-growi-plugin/src/mdast-util-growi-plugin/index.js

@@ -0,0 +1,326 @@
+/**
+ * @typedef {import('mdast').BlockContent} BlockContent
+ * @typedef {import('mdast').Root} Root
+ * @typedef {import('mdast').Paragraph} Paragraph
+ * @typedef {import('mdast-util-from-markdown').Handle} FromMarkdownHandle
+ * @typedef {import('mdast-util-from-markdown').Extension} FromMarkdownExtension
+ * @typedef {import('mdast-util-from-markdown').CompileContext} CompileContext
+ * @typedef {import('mdast-util-from-markdown').Token} Token
+ * @typedef {import('mdast-util-to-markdown/lib/types.js').Handle} ToMarkdownHandle
+ * @typedef {import('mdast-util-to-markdown/lib/types.js').Context} Context
+ * @typedef {import('mdast-util-to-markdown/lib/types.js').Options} ToMarkdownExtension
+ * @typedef {import('./complex-types').LeafDirective} LeafDirective
+ * @typedef {import('./complex-types').TextDirective} TextDirective
+ * @typedef {LeafDirective|TextDirective} Directive
+ */
+
+import { checkQuote } from 'mdast-util-to-markdown/lib/util/check-quote.js';
+import { containerPhrasing } from 'mdast-util-to-markdown/lib/util/container-phrasing.js';
+import { track } from 'mdast-util-to-markdown/lib/util/track.js';
+import { parseEntities } from 'parse-entities';
+import { stringifyEntitiesLight } from 'stringify-entities';
+
+const own = {}.hasOwnProperty;
+
+const shortcut = /^[^\t\n\r "#'.<=>`}]+$/;
+
+handleDirective.peek = peekDirective;
+
+/** @type {FromMarkdownExtension} */
+export const directiveFromMarkdown = {
+  canContainEols: ['textGrowiPluginDirective'],
+  enter: {
+    directiveLeaf: enterLeaf,
+    directiveLeafAttributes: enterAttributes,
+
+    directiveText: enterText,
+    directiveTextAttributes: enterAttributes,
+  },
+  exit: {
+    directiveLeaf: exit,
+    directiveLeafAttributeClassValue: exitAttributeClassValue,
+    directiveLeafAttributeIdValue: exitAttributeIdValue,
+    directiveLeafAttributeName: exitAttributeName,
+    directiveLeafAttributeValue: exitAttributeValue,
+    directiveLeafAttributes: exitAttributes,
+    directiveLeafName: exitName,
+
+    directiveText: exit,
+    directiveTextAttributeClassValue: exitAttributeClassValue,
+    directiveTextAttributeIdValue: exitAttributeIdValue,
+    directiveTextAttributeName: exitAttributeName,
+    directiveTextAttributeValue: exitAttributeValue,
+    directiveTextAttributes: exitAttributes,
+    directiveTextName: exitName,
+  },
+};
+
+/** @type {ToMarkdownExtension} */
+export const directiveToMarkdown = {
+  unsafe: [
+    {
+      character: '\r',
+      inConstruct: ['leafGrowiPluginDirectiveLabel'],
+    },
+    {
+      character: '\n',
+      inConstruct: ['leafGrowiPluginDirectiveLabel'],
+    },
+    {
+      before: '[^$]',
+      character: '$',
+      after: '[A-Za-z]',
+      inConstruct: ['phrasing'],
+    },
+    { atBreak: true, character: '$', after: '$' },
+  ],
+  handlers: {
+    leafGrowiPluginDirective: handleDirective,
+    textGrowiPluginDirective: handleDirective,
+  },
+};
+
+/** @type {FromMarkdownHandle} */
+function enterLeaf(token) {
+  enter.call(this, 'leafGrowiPluginDirective', token);
+}
+
+/** @type {FromMarkdownHandle} */
+function enterText(token) {
+  enter.call(this, 'textGrowiPluginDirective', token);
+}
+
+/**
+ * @this {CompileContext}
+ * @param {Directive['type']} type
+ * @param {Token} token
+ */
+function enter(type, token) {
+  this.enter({
+    type, name: '', attributes: {}, children: [],
+  }, token);
+}
+
+/**
+ * @this {CompileContext}
+ * @param {Token} token
+ */
+function exitName(token) {
+  const node = /** @type {Directive} */ (this.stack[this.stack.length - 1]);
+  node.name = this.sliceSerialize(token);
+}
+
+/** @type {FromMarkdownHandle} */
+function enterAttributes() {
+  this.setData('directiveAttributes', []);
+  this.buffer(); // Capture EOLs
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributeIdValue(token) {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.getData('directiveAttributes')
+  );
+  list.push(['id', parseEntities(this.sliceSerialize(token))]);
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributeClassValue(token) {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.getData('directiveAttributes')
+  );
+  list.push(['class', parseEntities(this.sliceSerialize(token))]);
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributeValue(token) {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.getData('directiveAttributes')
+  );
+  list[list.length - 1][1] = parseEntities(this.sliceSerialize(token));
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributeName(token) {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.getData('directiveAttributes')
+  );
+
+  // Attribute names in CommonMark are significantly limited, so character
+  // references can’t exist.
+  list.push([this.sliceSerialize(token), '']);
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributes() {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.getData('directiveAttributes')
+  );
+  /** @type {Record.<string, string>} */
+  const cleaned = {};
+  let index = -1;
+
+  while (++index < list.length) {
+    const attribute = list[index];
+
+    if (attribute[0] === 'class' && cleaned.class) {
+      cleaned.class += ` ${attribute[1]}`;
+    }
+    else {
+      cleaned[attribute[0]] = attribute[1];
+    }
+  }
+
+  this.setData('directiveAttributes');
+  this.resume(); // Drop EOLs
+  const node = /** @type {Directive} */ (this.stack[this.stack.length - 1]);
+  node.attributes = cleaned;
+}
+
+/** @type {FromMarkdownHandle} */
+function exit(token) {
+  this.exit(token);
+}
+
+/**
+ * @type {ToMarkdownHandle}
+ * @param {Directive} node
+ */
+function handleDirective(node, _, context, safeOptions) {
+  const tracker = track(safeOptions);
+  const sequence = fence(node);
+  const exit = context.enter(node.type);
+  let value = tracker.move(sequence + (node.name || ''));
+  /** @type {Directive|Paragraph|undefined} */
+  const label = node;
+
+  if (label && label.children && label.children.length > 0) {
+    const exit = context.enter('label');
+    const subexit = context.enter(`${node.type}Label`);
+    value += tracker.move('[');
+    value += tracker.move(
+      containerPhrasing(label, context, {
+        ...tracker.current(),
+        before: value,
+        after: ']',
+      }),
+    );
+    value += tracker.move(']');
+    subexit();
+    exit();
+  }
+
+  value += tracker.move(attributes(node, context));
+
+  exit();
+  return value;
+}
+
+/** @type {ToMarkdownHandle} */
+function peekDirective() {
+  return '$';
+}
+
+/**
+ * @param {Directive} node
+ * @param {Context} context
+ * @returns {string}
+ */
+function attributes(node, context) {
+  const quote = checkQuote(context);
+  const subset = node.type === 'textGrowiPluginDirective' ? [quote] : [quote, '\n', '\r'];
+  const attrs = node.attributes || {};
+  /** @type {Array.<string>} */
+  const values = [];
+  /** @type {string|undefined} */
+  let classesFull;
+  /** @type {string|undefined} */
+  let classes;
+  /** @type {string|undefined} */
+  let id;
+  /** @type {string} */
+  let key;
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (key in attrs) {
+    if (
+      own.call(attrs, key)
+      && attrs[key] !== undefined
+      && attrs[key] !== null
+    ) {
+      const value = String(attrs[key]);
+
+      if (key === 'id') {
+        id = shortcut.test(value) ? `#${value}` : quoted('id', value);
+      }
+      else if (key === 'class') {
+        const list = value.split(/[\t\n\r ]+/g);
+        /** @type {Array.<string>} */
+        const classesFullList = [];
+        /** @type {Array.<string>} */
+        const classesList = [];
+        let index = -1;
+
+        while (++index < list.length) {
+          (shortcut.test(list[index]) ? classesList : classesFullList).push(
+            list[index],
+          );
+        }
+
+        classesFull = classesFullList.length > 0
+          ? quoted('class', classesFullList.join(' '))
+          : '';
+        classes = classesList.length > 0 ? `.${classesList.join('.')}` : '';
+      }
+      else {
+        values.push(quoted(key, value));
+      }
+    }
+  }
+
+  if (classesFull) {
+    values.unshift(classesFull);
+  }
+
+  if (classes) {
+    values.unshift(classes);
+  }
+
+  if (id) {
+    values.unshift(id);
+  }
+
+  return values.length > 0 ? `(${values.join(' ')})` : '';
+
+  /**
+   * @param {string} key
+   * @param {string} value
+   * @returns {string}
+   */
+  function quoted(key, value) {
+    return (
+      key
+      + (value
+        ? `=${quote}${stringifyEntitiesLight(value, { subset })}${quote}`
+        : '')
+    );
+  }
+}
+
+/**
+ * @param {Directive} node
+ * @returns {string}
+ */
+function fence(node) {
+  let size = 0;
+
+  if (node.type === 'leafGrowiPluginDirective') {
+    size = 1;
+  }
+  else {
+    size = 1;
+  }
+
+  return '$'.repeat(size);
+
+}

+ 403 - 0
packages/remark-growi-plugin/src/mdast-util-growi-plugin/readme.md

@@ -0,0 +1,403 @@
+# mdast-util-directive
+
+[![Build][build-badge]][build]
+[![Coverage][coverage-badge]][coverage]
+[![Downloads][downloads-badge]][downloads]
+[![Size][size-badge]][size]
+[![Sponsors][sponsors-badge]][collective]
+[![Backers][backers-badge]][collective]
+[![Chat][chat-badge]][chat]
+
+[mdast][] extensions to parse and serialize [generic directives proposal][prop]
+(`:cite[smith04]`, `::youtube[Video of a cat in a box]{v=01ab2cd3efg}`, and
+such).
+
+## Contents
+
+*   [What is this?](#what-is-this)
+*   [When to use this](#when-to-use-this)
+*   [Install](#install)
+*   [Use](#use)
+*   [API](#api)
+    *   [`directiveFromMarkdown`](#directivefrommarkdown)
+    *   [`directiveToMarkdown`](#directivetomarkdown)
+*   [Syntax tree](#syntax-tree)
+    *   [Nodes](#nodes)
+    *   [Mixin](#mixin)
+    *   [`Directive`](#directive)
+*   [Types](#types)
+*   [Compatibility](#compatibility)
+*   [Related](#related)
+*   [Contribute](#contribute)
+*   [License](#license)
+
+## What is this?
+
+This package contains extensions that add support for generic directives to
+[`mdast-util-from-markdown`][mdast-util-from-markdown] and
+[`mdast-util-to-markdown`][mdast-util-to-markdown].
+
+This package handles the syntax tree.
+You can use this with some more code to match your specific needs, to allow for
+anything from callouts, citations, styled blocks, forms, embeds, spoilers, etc.
+[Traverse the tree][traversal] to change directives to whatever you please.
+
+## When to use this
+
+These tools are all rather low-level.
+In most cases, you’d want to use [`remark-directive`][remark-directive] with
+remark instead.
+
+Directives are one of the four ways to extend markdown: an arbitrary extension
+syntax (see [Extending markdown][extending-mmarkdown] in micromark’s docs for
+the alternatives and more info).
+This mechanism works well when you control the content: who authors it, what
+tools handle it, and where it’s displayed.
+When authors can read a guide on how to embed a tweet but are not expected to
+know the ins and outs of HTML or JavaScript.
+Directives don’t work well if you don’t know who authors content, what tools
+handle it, and where it ends up.
+Example use cases are a docs website for a project or product, or blogging tools
+and static site generators.
+
+When working with `mdast-util-from-markdown`, you must combine this package with
+[`micromark-extension-directive`][extension].
+
+This utility does not handle how directives are turned to HTML.
+You must [traverse the tree][traversal] to change directives to whatever you
+please.
+
+## Install
+
+This package is [ESM only][esm].
+In Node.js (version 12.20+, 14.14+, or 16.0+), install with [npm][]:
+
+```sh
+npm install mdast-util-directive
+```
+
+In Deno with [`esm.sh`][esmsh]:
+
+```js
+import {directiveFromMarkdown, directiveToMarkdown} from 'https://esm.sh/mdast-util-directive@2'
+```
+
+In browsers with [`esm.sh`][esmsh]:
+
+```html
+<script type="module">
+  import {directiveFromMarkdown, directiveToMarkdown} from 'https://esm.sh/mdast-util-directive@2?bundle'
+</script>
+```
+
+## Use
+
+Say our document `example.md` contains:
+
+```markdown
+A lovely language know as :abbr[HTML]{title="HyperText Markup Language"}.
+```
+
+…and our module `example.js` looks as follows:
+
+```js
+import fs from 'node:fs/promises'
+import {fromMarkdown} from 'mdast-util-from-markdown'
+import {toMarkdown} from 'mdast-util-to-markdown'
+import {directive} from 'micromark-extension-directive'
+import {directiveFromMarkdown, directiveToMarkdown} from 'mdast-util-directive'
+
+const doc = await fs.readFile('example.md')
+
+const tree = fromMarkdown(doc, {
+  extensions: [directive()],
+  mdastExtensions: [directiveFromMarkdown]
+})
+
+console.log(tree)
+
+const out = toMarkdown(tree, {extensions: [directiveToMarkdown]})
+
+console.log(out)
+```
+
+…now running `node example.js` yields (positional info removed for brevity):
+
+```js
+{
+  type: 'root',
+  children: [
+    {
+      type: 'paragraph',
+      children: [
+        {type: 'text', value: 'A lovely language know as '},
+        {
+          type: 'textGrowiPluginDirective',
+          name: 'abbr',
+          attributes: {title: 'HyperText Markup Language'},
+          children: [{type: 'text', value: 'HTML'}]
+        },
+        {type: 'text', value: '.'}
+      ]
+    }
+  ]
+}
+```
+
+```markdown
+A lovely language know as :abbr[HTML]{title="HyperText Markup Language"}.
+```
+
+## API
+
+This package exports the identifiers `directiveFromMarkdown` and
+`directiveToMarkdown`.
+There is no default export.
+
+### `directiveFromMarkdown`
+
+Extension for [`mdast-util-from-markdown`][mdast-util-from-markdown].
+
+### `directiveToMarkdown`
+
+Extension for [`mdast-util-to-markdown`][mdast-util-to-markdown].
+
+There are no options, but passing [`options.quote`][quote] to
+`mdast-util-to-markdown` is honored for attributes.
+
+## Syntax tree
+
+The following interfaces are added to **[mdast][]** by this utility.
+
+### Nodes
+
+#### `TextDirective`
+
+```idl
+interface TextDirective <: Parent {
+  type: 'textGrowiPluginDirective'
+  children: [PhrasingContent]
+}
+
+TextDirective includes Directive
+```
+
+**TextDirective** (**[Parent][dfn-parent]**) is a directive.
+It can be used where **[phrasing][dfn-phrasing-content]** content is expected.
+Its content model is also **[phrasing][dfn-phrasing-content]** content.
+It includes the mixin **[Directive][dfn-mxn-directive]**.
+
+For example, the following Markdown:
+
+```markdown
+:name[Label]{#x.y.z key=value}
+```
+
+Yields:
+
+```js
+{
+  type: 'textGrowiPluginDirective',
+  name: 'name',
+  attributes: {id: 'x', class: 'y z', key: 'value'},
+  children: [{type: 'text', value: 'Label'}]
+}
+```
+
+#### `LeafDirective`
+
+```idl
+interface LeafDirective <: Parent {
+  type: 'leafGrowiPluginDirective'
+  children: [PhrasingContent]
+}
+
+LeafDirective includes Directive
+```
+
+**LeafDirective** (**[Parent][dfn-parent]**) is a directive.
+It can be used where **[flow][dfn-flow-content]** content is expected.
+Its content model is **[phrasing][dfn-phrasing-content]** content.
+It includes the mixin **[Directive][dfn-mxn-directive]**.
+
+For example, the following Markdown:
+
+```markdown
+::youtube[Label]{v=123}
+```
+
+Yields:
+
+```js
+{
+  type: 'leafGrowiPluginDirective',
+  name: 'youtube',
+  attributes: {v: '123'},
+  children: [{type: 'text', value: 'Label'}]
+}
+```
+
+#### `ContainerDirective`
+
+ContainerDirective is not supported.
+
+### Mixin
+
+### `Directive`
+
+```idl
+interface mixin Directive {
+  name: string
+  attributes: Attributes?
+}
+
+interface Attributes {}
+typedef string AttributeName
+typedef string AttributeValue
+```
+
+**Directive** represents something defined by an extension.
+
+The `name` field must be present and represents an identifier of an extension.
+
+The `attributes` field represents information associated with the node.
+The value of the `attributes` field implements the **Attributes** interface.
+
+In the **Attributes** interface, every field must be an `AttributeName` and
+every value an `AttributeValue`.
+The fields and values can be anything: there are no semantics (such as by HTML
+or hast).
+
+> In JSON, the value `null` must be treated as if the attribute was not
+> included.
+> In JavaScript, both `null` and `undefined` must be similarly ignored.
+
+## Types
+
+This package is fully typed with [TypeScript][].
+It exports the additional types `ContainerDirective`, `LeafDirective`,
+`TextDirective`, and `Directive`.
+
+It also registers the node types with `@types/mdast`.
+If you’re working with the syntax tree, make sure to import this utility
+somewhere in your types, as that registers the new node types in the tree.
+
+```js
+/**
+ * @typedef {import('mdast-util-directive')}
+ */
+
+import {visit} from 'unist-util-visit'
+
+/** @type {import('mdast').Root} */
+const tree = getMdastNodeSomeHow()
+
+visit(tree, (node) => {
+  // `node` can now be one of the nodes for directives.
+})
+```
+
+## Compatibility
+
+Projects maintained by the unified collective are compatible with all maintained
+versions of Node.js.
+As of now, that is Node.js 12.20+, 14.14+, and 16.0+.
+Our projects sometimes work with older versions, but this is not guaranteed.
+
+This plugin works with `mdast-util-from-markdown` version 1+ and
+`mdast-util-to-markdown` version 1+.
+
+## Related
+
+*   [`remarkjs/remark-directive`][remark-directive]
+    — remark plugin to support generic directives
+*   [`micromark/micromark-extension-directive`][extension]
+    — micromark extension to parse directives
+
+## Contribute
+
+See [`contributing.md`][contributing] in [`syntax-tree/.github`][health] for
+ways to get started.
+See [`support.md`][support] for ways to get help.
+
+This project has a [code of conduct][coc].
+By interacting with this repository, organization, or community you agree to
+abide by its terms.
+
+## License
+
+[MIT][license] © [Titus Wormer][author]
+
+<!-- Definitions -->
+
+[build-badge]: https://github.com/syntax-tree/mdast-util-directive/workflows/main/badge.svg
+
+[build]: https://github.com/syntax-tree/mdast-util-directive/actions
+
+[coverage-badge]: https://img.shields.io/codecov/c/github/syntax-tree/mdast-util-directive.svg
+
+[coverage]: https://codecov.io/github/syntax-tree/mdast-util-directive
+
+[downloads-badge]: https://img.shields.io/npm/dm/mdast-util-directive.svg
+
+[downloads]: https://www.npmjs.com/package/mdast-util-directive
+
+[size-badge]: https://img.shields.io/bundlephobia/minzip/mdast-util-directive.svg
+
+[size]: https://bundlephobia.com/result?p=mdast-util-directive
+
+[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg
+
+[backers-badge]: https://opencollective.com/unified/backers/badge.svg
+
+[collective]: https://opencollective.com/unified
+
+[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg
+
+[chat]: https://github.com/syntax-tree/unist/discussions
+
+[npm]: https://docs.npmjs.com/cli/install
+
+[esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
+
+[esmsh]: https://esm.sh
+
+[typescript]: https://www.typescriptlang.org
+
+[license]: license
+
+[author]: https://wooorm.com
+
+[health]: https://github.com/syntax-tree/.github
+
+[contributing]: https://github.com/syntax-tree/.github/blob/main/contributing.md
+
+[support]: https://github.com/syntax-tree/.github/blob/main/support.md
+
+[coc]: https://github.com/syntax-tree/.github/blob/main/code-of-conduct.md
+
+[mdast]: https://github.com/syntax-tree/mdast
+
+[mdast-util-from-markdown]: https://github.com/syntax-tree/mdast-util-from-markdown
+
+[mdast-util-to-markdown]: https://github.com/syntax-tree/mdast-util-to-markdown
+
+[quote]: https://github.com/syntax-tree/mdast-util-to-markdown#optionsquote
+
+[extension]: https://github.com/micromark/micromark-extension-directive
+
+[remark-directive]: https://github.com/remarkjs/remark-directive
+
+[extending-mmarkdown]: https://github.com/micromark/micromark#extending-markdown
+
+[prop]: https://talk.commonmark.org/t/generic-directives-plugins-syntax/444
+
+[traversal]: https://unifiedjs.com/learn/recipe/tree-traversal/
+
+[dfn-parent]: https://github.com/syntax-tree/mdast#parent
+
+[dfn-flow-content]: https://github.com/syntax-tree/mdast#flowcontent
+
+[dfn-phrasing-content]: https://github.com/syntax-tree/mdast#phrasingcontent
+
+[dfn-mxn-directive]: #directive

+ 7 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/index.js

@@ -0,0 +1,7 @@
+/**
+ * @typedef {import('./lib/html.js').Handle} Handle
+ * @typedef {import('./lib/html.js').HtmlOptions} HtmlOptions
+ */
+
+export { directive } from './lib/syntax.js';
+export { directiveHtml } from './lib/html.js';

+ 138 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-leaf.js

@@ -0,0 +1,138 @@
+/**
+ * @typedef {import('micromark-util-types').Construct} Construct
+ * @typedef {import('micromark-util-types').Tokenizer} Tokenizer
+ * @typedef {import('micromark-util-types').State} State
+ */
+
+import { factorySpace } from 'micromark-factory-space';
+import { markdownLineEnding } from 'micromark-util-character';
+import { codes } from 'micromark-util-symbol/codes.js';
+import { types } from 'micromark-util-symbol/types.js';
+import { ok as assert } from 'uvu/assert';
+
+import { factoryAttributes } from './factory-attributes.js';
+import { factoryLabel } from './factory-label.js';
+import { factoryName } from './factory-name.js';
+
+/** @type {Construct} */
+export const directiveLeaf = { tokenize: tokenizeDirectiveLeaf };
+
+const label = { tokenize: tokenizeLabel, partial: true };
+const attributes = { tokenize: tokenizeAttributes, partial: true };
+
+/** @type {Tokenizer} */
+function tokenizeDirectiveLeaf(effects, ok, nok) {
+  // eslint-disable-next-line @typescript-eslint/no-this-alias
+  const self = this;
+
+  return start;
+
+  // /** @type {State} */
+  // function start(code) {
+  //   assert(code === codes.dollarSign, 'expected `$`');
+  //   effects.enter('directiveLeaf');
+  //   effects.enter('directiveLeafSequence');
+  //   effects.consume(code);
+  //   return inStart;
+  // }
+
+  // /** @type {State} */
+  // function inStart(code) {
+  //   if (code === codes.dollarSign) {
+  //     effects.consume(code);
+  //     effects.exit('directiveLeafSequence');
+  //     return factoryName.call(
+  //       self,
+  //       effects,
+  //       afterName,
+  //       nok,
+  //       'directiveLeafName',
+  //     );
+  //   }
+
+  //   return nok(code);
+  // }
+
+  // /** @type {State} */
+  // function afterName(code) {
+  //   return code === codes.leftSquareBracket
+  //     ? effects.attempt(label, afterLabel, afterLabel)(code)
+  //     : afterLabel(code);
+  // }
+
+  /** @type {State} */
+  function start(code) {
+    assert(code === codes.dollarSign, 'expected `$`');
+    effects.enter('directiveLeaf');
+    effects.consume(code);
+    return factoryName.call(self, effects, afterName, nok, 'directiveLeafName');
+  }
+
+  /** @type {State} */
+  function afterName(code) {
+    // eslint-disable-next-line no-nested-ternary
+    return code === codes.dollarSign
+      ? nok(code)
+      : code === codes.leftSquareBracket
+        ? effects.attempt(label, afterLabel, afterLabel)(code)
+        : afterLabel(code);
+  }
+
+  /** @type {State} */
+  function afterLabel(code) {
+    return code === codes.leftParenthesis
+      ? effects.attempt(attributes, afterAttributes, afterAttributes)(code)
+      : afterAttributes(code);
+  }
+
+  /** @type {State} */
+  function afterAttributes(code) {
+    return factorySpace(effects, end, types.whitespace)(code);
+  }
+
+  /** @type {State} */
+  function end(code) {
+    if (code === codes.eof || markdownLineEnding(code)) {
+      effects.exit('directiveLeaf');
+      return ok(code);
+    }
+
+    return nok(code);
+  }
+}
+
+/** @type {Tokenizer} */
+function tokenizeLabel(effects, ok, nok) {
+  // Always a `[`
+  return factoryLabel(
+    effects,
+    ok,
+    nok,
+    'directiveLeafLabel',
+    'directiveLeafLabelMarker',
+    'directiveLeafLabelString',
+    true,
+  );
+}
+
+/** @type {Tokenizer} */
+function tokenizeAttributes(effects, ok, nok) {
+  // Always a `{`
+  return factoryAttributes(
+    effects,
+    ok,
+    nok,
+    'directiveLeafAttributes',
+    'directiveLeafAttributesMarker',
+    'directiveLeafAttribute',
+    'directiveLeafAttributeId',
+    'directiveLeafAttributeClass',
+    'directiveLeafAttributeName',
+    'directiveLeafAttributeInitializerMarker',
+    'directiveLeafAttributeValueLiteral',
+    'directiveLeafAttributeValue',
+    'directiveLeafAttributeValueMarker',
+    'directiveLeafAttributeValueData',
+    true,
+  );
+}

+ 108 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-text.js

@@ -0,0 +1,108 @@
+/**
+ * @typedef {import('micromark-util-types').Construct} Construct
+ * @typedef {import('micromark-util-types').Tokenizer} Tokenizer
+ * @typedef {import('micromark-util-types').Previous} Previous
+ * @typedef {import('micromark-util-types').State} State
+ */
+
+import { codes } from 'micromark-util-symbol/codes.js';
+import { types } from 'micromark-util-symbol/types.js';
+import { ok as assert } from 'uvu/assert';
+
+import { factoryAttributes } from './factory-attributes.js';
+import { factoryLabel } from './factory-label.js';
+import { factoryName } from './factory-name.js';
+
+/** @type {Construct} */
+export const directiveText = {
+  tokenize: tokenizeDirectiveText,
+  previous,
+};
+
+const label = { tokenize: tokenizeLabel, partial: true };
+const attributes = { tokenize: tokenizeAttributes, partial: true };
+
+/** @type {Previous} */
+function previous(code) {
+  // If there is a previous code, there will always be a tail.
+  return (
+    code !== codes.dollarSign
+    || this.events[this.events.length - 1][1].type === types.characterEscape
+  );
+}
+
+/** @type {Tokenizer} */
+function tokenizeDirectiveText(effects, ok, nok) {
+  // eslint-disable-next-line @typescript-eslint/no-this-alias
+  const self = this;
+
+  return start;
+
+  /** @type {State} */
+  function start(code) {
+    assert(code === codes.dollarSign, 'expected `$`');
+    assert(previous.call(self, self.previous), 'expected correct previous');
+    effects.enter('directiveText');
+    effects.enter('directiveTextMarker');
+    effects.consume(code);
+    effects.exit('directiveTextMarker');
+    return factoryName.call(self, effects, afterName, nok, 'directiveTextName');
+  }
+
+  /** @type {State} */
+  function afterName(code) {
+    // eslint-disable-next-line no-nested-ternary
+    return code === codes.dollarSign
+      ? nok(code)
+      : code === codes.leftSquareBracket
+        ? effects.attempt(label, afterLabel, afterLabel)(code)
+        : afterLabel(code);
+  }
+
+  /** @type {State} */
+  function afterLabel(code) {
+    return code === codes.leftParenthesis
+      ? effects.attempt(attributes, afterAttributes, afterAttributes)(code)
+      : afterAttributes(code);
+  }
+
+  /** @type {State} */
+  function afterAttributes(code) {
+    effects.exit('directiveText');
+    return ok(code);
+  }
+}
+
+/** @type {Tokenizer} */
+function tokenizeLabel(effects, ok, nok) {
+  // Always a `[`
+  return factoryLabel(
+    effects,
+    ok,
+    nok,
+    'directiveTextLabel',
+    'directiveTextLabelMarker',
+    'directiveTextLabelString',
+  );
+}
+
+/** @type {Tokenizer} */
+function tokenizeAttributes(effects, ok, nok) {
+  // Always a `{`
+  return factoryAttributes(
+    effects,
+    ok,
+    nok,
+    'directiveTextAttributes',
+    'directiveTextAttributesMarker',
+    'directiveTextAttribute',
+    'directiveTextAttributeId',
+    'directiveTextAttributeClass',
+    'directiveTextAttributeName',
+    'directiveTextAttributeInitializerMarker',
+    'directiveTextAttributeValueLiteral',
+    'directiveTextAttributeValue',
+    'directiveTextAttributeValueMarker',
+    'directiveTextAttributeValueData',
+  );
+}

+ 336 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js

@@ -0,0 +1,336 @@
+/**
+ * @typedef {import('micromark-util-types').Effects} Effects
+ * @typedef {import('micromark-util-types').State} State
+ * @typedef {import('micromark-util-types').Code} Code
+ */
+
+import { factorySpace } from 'micromark-factory-space';
+import { factoryWhitespace } from 'micromark-factory-whitespace';
+import {
+  asciiAlpha,
+  asciiAlphanumeric,
+  markdownLineEnding,
+  markdownLineEndingOrSpace,
+  markdownSpace,
+} from 'micromark-util-character';
+import { codes } from 'micromark-util-symbol/codes.js';
+import { types } from 'micromark-util-symbol/types.js';
+import { ok as assert } from 'uvu/assert';
+
+/**
+ * @param {Effects} effects
+ * @param {State} ok
+ * @param {State} nok
+ * @param {string} attributesType
+ * @param {string} attributesMarkerType
+ * @param {string} attributeType
+ * @param {string} attributeIdType
+ * @param {string} attributeClassType
+ * @param {string} attributeNameType
+ * @param {string} attributeInitializerType
+ * @param {string} attributeValueLiteralType
+ * @param {string} attributeValueType
+ * @param {string} attributeValueMarker
+ * @param {string} attributeValueData
+ * @param {boolean} [disallowEol=false]
+ */
+/* eslint-disable-next-line max-params */
+export function factoryAttributes(
+    effects,
+    ok,
+    nok,
+    attributesType,
+    attributesMarkerType,
+    attributeType,
+    attributeIdType,
+    attributeClassType,
+    attributeNameType,
+    attributeInitializerType,
+    attributeValueLiteralType,
+    attributeValueType,
+    attributeValueMarker,
+    attributeValueData,
+    disallowEol,
+) {
+  /** @type {string} */
+  let type;
+  /** @type {Code|undefined} */
+  let marker;
+
+  return start;
+
+  /** @type {State} */
+  function start(code) {
+    assert(code === codes.leftParenthesis, 'expected `(`');
+    effects.enter(attributesType);
+    effects.enter(attributesMarkerType);
+    effects.consume(code);
+    effects.exit(attributesMarkerType);
+    return between;
+  }
+
+  /** @type {State} */
+  function between(code) {
+    if (code === codes.numberSign) {
+      type = attributeIdType;
+      return shortcutStart(code);
+    }
+
+    if (code === codes.dot) {
+      type = attributeClassType;
+      return shortcutStart(code);
+    }
+
+    if (code === codes.colon || code === codes.underscore || asciiAlpha(code)) {
+      effects.enter(attributeType);
+      effects.enter(attributeNameType);
+      effects.consume(code);
+      return name;
+    }
+
+    if (disallowEol && markdownSpace(code)) {
+      return factorySpace(effects, between, types.whitespace)(code);
+    }
+
+    if (!disallowEol && markdownLineEndingOrSpace(code)) {
+      return factoryWhitespace(effects, between)(code);
+    }
+
+    return end(code);
+  }
+
+  /** @type {State} */
+  function shortcutStart(code) {
+    effects.enter(attributeType);
+    effects.enter(type);
+    effects.enter(`${type}Marker`);
+    effects.consume(code);
+    effects.exit(`${type}Marker`);
+    return shortcutStartAfter;
+  }
+
+  /** @type {State} */
+  function shortcutStartAfter(code) {
+    if (
+      code === codes.eof
+      || code === codes.quotationMark
+      || code === codes.numberSign
+      || code === codes.apostrophe
+      || code === codes.dot
+      || code === codes.lessThan
+      || code === codes.equalsTo
+      || code === codes.greaterThan
+      || code === codes.graveAccent
+      || code === codes.rightParenthesis
+      || markdownLineEndingOrSpace(code)
+    ) {
+      return nok(code);
+    }
+
+    effects.enter(`${type}Value`);
+    effects.consume(code);
+    return shortcut;
+  }
+
+  /** @type {State} */
+  function shortcut(code) {
+    if (
+      code === codes.eof
+      || code === codes.quotationMark
+      || code === codes.apostrophe
+      || code === codes.lessThan
+      || code === codes.equalsTo
+      || code === codes.greaterThan
+      || code === codes.graveAccent
+    ) {
+      return nok(code);
+    }
+
+    if (
+      code === codes.numberSign
+      || code === codes.dot
+      || code === codes.rightParenthesis
+      || markdownLineEndingOrSpace(code)
+    ) {
+      effects.exit(`${type}Value`);
+      effects.exit(type);
+      effects.exit(attributeType);
+      return between(code);
+    }
+
+    effects.consume(code);
+    return shortcut;
+  }
+
+  /** @type {State} */
+  function name(code) {
+    if (
+      code === codes.dash
+      || code === codes.dot
+      || code === codes.colon
+      || code === codes.underscore
+      || asciiAlphanumeric(code)
+    ) {
+      effects.consume(code);
+      return name;
+    }
+
+    effects.exit(attributeNameType);
+
+    if (disallowEol && markdownSpace(code)) {
+      return factorySpace(effects, nameAfter, types.whitespace)(code);
+    }
+
+    if (!disallowEol && markdownLineEndingOrSpace(code)) {
+      return factoryWhitespace(effects, nameAfter)(code);
+    }
+
+    return nameAfter(code);
+  }
+
+  /** @type {State} */
+  function nameAfter(code) {
+    if (code === codes.equalsTo) {
+      effects.enter(attributeInitializerType);
+      effects.consume(code);
+      effects.exit(attributeInitializerType);
+      return valueBefore;
+    }
+
+    // Attribute w/o value.
+    effects.exit(attributeType);
+    return between(code);
+  }
+
+  /** @type {State} */
+  function valueBefore(code) {
+    if (
+      code === codes.eof
+      || code === codes.lessThan
+      || code === codes.equalsTo
+      || code === codes.greaterThan
+      || code === codes.graveAccent
+      || code === codes.rightParenthesis
+      || (disallowEol && markdownLineEnding(code))
+    ) {
+      return nok(code);
+    }
+
+    if (code === codes.quotationMark || code === codes.apostrophe) {
+      effects.enter(attributeValueLiteralType);
+      effects.enter(attributeValueMarker);
+      effects.consume(code);
+      effects.exit(attributeValueMarker);
+      marker = code;
+      return valueQuotedStart;
+    }
+
+    if (disallowEol && markdownSpace(code)) {
+      return factorySpace(effects, valueBefore, types.whitespace)(code);
+    }
+
+    if (!disallowEol && markdownLineEndingOrSpace(code)) {
+      return factoryWhitespace(effects, valueBefore)(code);
+    }
+
+    effects.enter(attributeValueType);
+    effects.enter(attributeValueData);
+    effects.consume(code);
+    marker = undefined;
+    return valueUnquoted;
+  }
+
+  /** @type {State} */
+  function valueUnquoted(code) {
+    if (
+      code === codes.eof
+      || code === codes.quotationMark
+      || code === codes.apostrophe
+      || code === codes.lessThan
+      || code === codes.equalsTo
+      || code === codes.greaterThan
+      || code === codes.graveAccent
+    ) {
+      return nok(code);
+    }
+
+    if (code === codes.rightParenthesis || markdownLineEndingOrSpace(code)) {
+      effects.exit(attributeValueData);
+      effects.exit(attributeValueType);
+      effects.exit(attributeType);
+      return between(code);
+    }
+
+    effects.consume(code);
+    return valueUnquoted;
+  }
+
+  /** @type {State} */
+  function valueQuotedStart(code) {
+    if (code === marker) {
+      effects.enter(attributeValueMarker);
+      effects.consume(code);
+      effects.exit(attributeValueMarker);
+      effects.exit(attributeValueLiteralType);
+      effects.exit(attributeType);
+      return valueQuotedAfter;
+    }
+
+    effects.enter(attributeValueType);
+    return valueQuotedBetween(code);
+  }
+
+  /** @type {State} */
+  function valueQuotedBetween(code) {
+    if (code === marker) {
+      effects.exit(attributeValueType);
+      return valueQuotedStart(code);
+    }
+
+    if (code === codes.eof) {
+      return nok(code);
+    }
+
+    // Note: blank lines can’t exist in content.
+    if (markdownLineEnding(code)) {
+      return disallowEol
+        ? nok(code)
+        : factoryWhitespace(effects, valueQuotedBetween)(code);
+    }
+
+    effects.enter(attributeValueData);
+    effects.consume(code);
+    return valueQuoted;
+  }
+
+  /** @type {State} */
+  function valueQuoted(code) {
+    if (code === marker || code === codes.eof || markdownLineEnding(code)) {
+      effects.exit(attributeValueData);
+      return valueQuotedBetween(code);
+    }
+
+    effects.consume(code);
+    return valueQuoted;
+  }
+
+  /** @type {State} */
+  function valueQuotedAfter(code) {
+    return code === codes.rightParenthesis || markdownLineEndingOrSpace(code)
+      ? between(code)
+      : end(code);
+  }
+
+  /** @type {State} */
+  function end(code) {
+    if (code === codes.rightParenthesis) {
+      effects.enter(attributesMarkerType);
+      effects.consume(code);
+      effects.exit(attributesMarkerType);
+      effects.exit(attributesType);
+      return ok;
+    }
+
+    return nok(code);
+  }
+}

+ 139 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-label.js

@@ -0,0 +1,139 @@
+/**
+ * @typedef {import('micromark-util-types').Effects} Effects
+ * @typedef {import('micromark-util-types').State} State
+ * @typedef {import('micromark-util-types').Token} Token
+ */
+
+import { markdownLineEnding } from 'micromark-util-character';
+import { codes } from 'micromark-util-symbol/codes.js';
+import { constants } from 'micromark-util-symbol/constants.js';
+import { types } from 'micromark-util-symbol/types.js';
+import { ok as assert } from 'uvu/assert';
+
+// This is a fork of:
+// <https://github.com/micromark/micromark/tree/main/packages/micromark-factory-label>
+// to allow empty labels, balanced brackets (such as for nested directives),
+// text instead of strings, and optionally disallows EOLs.
+
+/**
+ * @param {Effects} effects
+ * @param {State} ok
+ * @param {State} nok
+ * @param {string} type
+ * @param {string} markerType
+ * @param {string} stringType
+ * @param {boolean} [disallowEol=false]
+ */
+// eslint-disable-next-line max-params
+export function factoryLabel(
+    effects,
+    ok,
+    nok,
+    type,
+    markerType,
+    stringType,
+    disallowEol,
+) {
+  let size = 0;
+  let balance = 0;
+  /** @type {Token|undefined} */
+  let previous;
+
+  return start;
+
+  /** @type {State} */
+  function start(code) {
+    assert(code === codes.leftSquareBracket, 'expected `[`');
+    effects.enter(type);
+    effects.enter(markerType);
+    effects.consume(code);
+    effects.exit(markerType);
+    return afterStart;
+  }
+
+  /** @type {State} */
+  function afterStart(code) {
+    if (code === codes.rightSquareBracket) {
+      effects.enter(markerType);
+      effects.consume(code);
+      effects.exit(markerType);
+      effects.exit(type);
+      return ok;
+    }
+
+    effects.enter(stringType);
+    return lineStart(code);
+  }
+
+  /** @type {State} */
+  function lineStart(code) {
+    if (code === codes.rightSquareBracket && !balance) {
+      return atClosingBrace(code);
+    }
+
+    const token = effects.enter(types.chunkText, {
+      contentType: constants.contentTypeText,
+      previous,
+    });
+    if (previous) previous.next = token;
+    previous = token;
+    return data(code);
+  }
+
+  /** @type {State} */
+  function data(code) {
+    if (code === codes.eof || size > constants.linkReferenceSizeMax) {
+      return nok(code);
+    }
+
+    if (
+      code === codes.leftSquareBracket
+      && ++balance > constants.linkResourceDestinationBalanceMax
+    ) {
+      return nok(code);
+    }
+
+    if (code === codes.rightSquareBracket && !balance--) {
+      effects.exit(types.chunkText);
+      return atClosingBrace(code);
+    }
+
+    if (markdownLineEnding(code)) {
+      if (disallowEol) {
+        return nok(code);
+      }
+
+      effects.consume(code);
+      effects.exit(types.chunkText);
+      return lineStart;
+    }
+
+    effects.consume(code);
+    return code === codes.backslash ? dataEscape : data;
+  }
+
+  /** @type {State} */
+  function dataEscape(code) {
+    if (
+      code === codes.leftSquareBracket
+      || code === codes.backslash
+      || code === codes.rightSquareBracket
+    ) {
+      effects.consume(code);
+      size++;
+      return data;
+    }
+
+    return data(code);
+  }
+
+  /** @type {State} */
+  function atClosingBrace(code) {
+    effects.exit(stringType);
+    effects.enter(markerType);
+    effects.consume(code);
+    effects.exit(markerType);
+    effects.exit(type);
+    return ok;
+  }
+}

+ 50 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-name.js

@@ -0,0 +1,50 @@
+/**
+ * @typedef {import('micromark-util-types').TokenizeContext} TokenizeContext
+ * @typedef {import('micromark-util-types').Effects} Effects
+ * @typedef {import('micromark-util-types').State} State
+ */
+
+import { asciiAlpha, asciiAlphanumeric } from 'micromark-util-character';
+import { codes } from 'micromark-util-symbol/codes.js';
+
+/**
+ * @this {TokenizeContext}
+ * @param {Effects} effects
+ * @param {State} ok
+ * @param {State} nok
+ * @param {string} type
+ */
+export function factoryName(effects, ok, nok, type) {
+  // eslint-disable-next-line @typescript-eslint/no-this-alias
+  const self = this;
+
+  return start;
+
+  /** @type {State} */
+  function start(code) {
+    if (asciiAlpha(code)) {
+      effects.enter(type);
+      effects.consume(code);
+      return name;
+    }
+
+    return nok(code);
+  }
+
+  /** @type {State} */
+  function name(code) {
+    if (
+      code === codes.dash
+      || code === codes.underscore
+      || asciiAlphanumeric(code)
+    ) {
+      effects.consume(code);
+      return name;
+    }
+
+    effects.exit(type);
+    return self.previous === codes.dash || self.previous === codes.underscore
+      ? nok(code)
+      : ok(code);
+  }
+}

+ 194 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/html.js

@@ -0,0 +1,194 @@
+/**
+ * @typedef {import('micromark-util-types').HtmlExtension} HtmlExtension
+ * @typedef {import('micromark-util-types').Handle} _Handle
+ * @typedef {import('micromark-util-types').CompileContext} CompileContext
+ */
+
+/**
+ * @typedef {[string, string]} Attribute
+ * @typedef {'leafGrowiPluginDirective'|'textGrowiPluginDirective'} DirectiveType
+ *
+ * @typedef Directive
+ * @property {DirectiveType} type
+ * @property {string} name
+ * @property {string} [label]
+ * @property {Record<string, string>} [attributes]
+ * @property {string} [content]
+ * @property {number} [_fenceCount]
+ *
+ * @typedef {(this: CompileContext, directive: Directive) => boolean|void} Handle
+ *
+ * @typedef {Record<string, Handle>} HtmlOptions
+ */
+
+import { parseEntities } from 'parse-entities';
+import { ok as assert } from 'uvu/assert';
+
+const own = {}.hasOwnProperty;
+
+/**
+  * @param {HtmlOptions} [options]
+  * @returns {HtmlExtension}
+  */
+export function directiveHtml(options = {}) {
+  return {
+    enter: {
+
+      directiveLeaf() {
+        return enter.call(this, 'leafGrowiPluginDirective');
+      },
+      directiveLeafAttributes: enterAttributes,
+      directiveLeafLabel: enterLabel,
+
+      directiveText() {
+        return enter.call(this, 'textGrowiPluginDirective');
+      },
+      directiveTextAttributes: enterAttributes,
+      directiveTextLabel: enterLabel,
+    },
+    exit: {
+      directiveLeaf: exit,
+      directiveLeafAttributeClassValue: exitAttributeClassValue,
+      directiveLeafAttributeIdValue: exitAttributeIdValue,
+      directiveLeafAttributeName: exitAttributeName,
+      directiveLeafAttributeValue: exitAttributeValue,
+      directiveLeafAttributes: exitAttributes,
+      directiveLeafLabel: exitLabel,
+      directiveLeafName: exitName,
+
+      directiveText: exit,
+      directiveTextAttributeClassValue: exitAttributeClassValue,
+      directiveTextAttributeIdValue: exitAttributeIdValue,
+      directiveTextAttributeName: exitAttributeName,
+      directiveTextAttributeValue: exitAttributeValue,
+      directiveTextAttributes: exitAttributes,
+      directiveTextLabel: exitLabel,
+      directiveTextName: exitName,
+    },
+  };
+
+  /**
+    * @this {CompileContext}
+    * @param {DirectiveType} type
+    */
+  function enter(type) {
+    /** @type {Directive[]} */
+    let stack = this.getData('directiveStack');
+    if (!stack) this.setData('directiveStack', (stack = []));
+    stack.push({ type, name: '' });
+  }
+
+  /** @type {_Handle} */
+  function exitName(token) {
+    /** @type {Directive[]} */
+    const stack = this.getData('directiveStack');
+    stack[stack.length - 1].name = this.sliceSerialize(token);
+  }
+
+  /** @type {_Handle} */
+  function enterLabel() {
+    this.buffer();
+  }
+
+  /** @type {_Handle} */
+  function exitLabel() {
+    const data = this.resume();
+    /** @type {Directive[]} */
+    const stack = this.getData('directiveStack');
+    stack[stack.length - 1].label = data;
+  }
+
+  /** @type {_Handle} */
+  function enterAttributes() {
+    this.buffer();
+    this.setData('directiveAttributes', []);
+  }
+
+  /** @type {_Handle} */
+  function exitAttributeIdValue(token) {
+    /** @type {Attribute[]} */
+    const attributes = this.getData('directiveAttributes');
+    attributes.push(['id', parseEntities(this.sliceSerialize(token))]);
+  }
+
+  /** @type {_Handle} */
+  function exitAttributeClassValue(token) {
+    /** @type {Attribute[]} */
+    const attributes = this.getData('directiveAttributes');
+
+    attributes.push(['class', parseEntities(this.sliceSerialize(token))]);
+  }
+
+  /** @type {_Handle} */
+  function exitAttributeName(token) {
+    // Attribute names in CommonMark are significantly limited, so character
+    // references can’t exist.
+    /** @type {Attribute[]} */
+    const attributes = this.getData('directiveAttributes');
+
+    attributes.push([this.sliceSerialize(token), '']);
+  }
+
+  /** @type {_Handle} */
+  function exitAttributeValue(token) {
+    /** @type {Attribute[]} */
+    const attributes = this.getData('directiveAttributes');
+    attributes[attributes.length - 1][1] = parseEntities(
+      this.sliceSerialize(token),
+    );
+  }
+
+  /** @type {_Handle} */
+  function exitAttributes() {
+    /** @type {Directive[]} */
+    const stack = this.getData('directiveStack');
+    /** @type {Attribute[]} */
+    const attributes = this.getData('directiveAttributes');
+    /** @type {Directive['attributes']} */
+    const cleaned = {};
+    /** @type {Attribute} */
+    let attribute;
+    let index = -1;
+
+    while (++index < attributes.length) {
+      attribute = attributes[index];
+
+      if (attribute[0] === 'class' && cleaned.class) {
+        cleaned.class += ` ${attribute[1]}`;
+      }
+      else {
+        cleaned[attribute[0]] = attribute[1];
+      }
+    }
+
+    this.resume();
+    this.setData('directiveAttributes');
+    stack[stack.length - 1].attributes = cleaned;
+  }
+
+  /** @type {_Handle} */
+  function exit() {
+    /** @type {Directive} */
+    const directive = this.getData('directiveStack').pop();
+    /** @type {boolean|undefined} */
+    let found;
+    /** @type {boolean|void} */
+    let result;
+
+    assert(directive.name, 'expected `name`');
+
+    if (own.call(options, directive.name)) {
+      result = options[directive.name].call(this, directive);
+      found = result !== false;
+    }
+
+    if (!found && own.call(options, '*')) {
+      result = options['*'].call(this, directive);
+      found = result !== false;
+    }
+
+    if (!found && directive.type !== 'textGrowiPluginDirective') {
+      this.setData('slurpOneLineEnding', true);
+    }
+  }
+}

+ 18 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/syntax.js

@@ -0,0 +1,18 @@
+/**
+ * @typedef {import('micromark-util-types').Extension} Extension
+ */
+
+import { codes } from 'micromark-util-symbol/codes.js';
+
+import { directiveLeaf } from './directive-leaf.js';
+import { directiveText } from './directive-text.js';
+
+/**
+ * @returns {Extension}
+ */
+export function directive() {
+  return {
+    text: { [codes.dollarSign]: directiveText },
+    flow: { [codes.dollarSign]: [directiveLeaf] },
+  };
+}

+ 288 - 0
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/readme.md

@@ -0,0 +1,288 @@
+# micromark-extension-directive
+
+[![Build][build-badge]][build]
+[![Coverage][coverage-badge]][coverage]
+[![Downloads][downloads-badge]][downloads]
+[![Size][size-badge]][size]
+[![Sponsors][sponsors-badge]][collective]
+[![Backers][backers-badge]][collective]
+[![Chat][chat-badge]][chat]
+
+**[micromark][]** extension to support the [generic directives proposal][prop]
+(`:cite[smith04]`, `::youtube[Video of a cat in a box]{v=01ab2cd3efg}`, and
+such).
+
+Generic directives solve the need for an infinite number of potential extensions
+to markdown in a single markdown-esque way.
+However, it’s just [a proposal][prop] and may never be specced.
+
+## When to use this
+
+If you’re using [`micromark`][micromark] or
+[`mdast-util-from-markdown`][from-markdown], use this package.
+Alternatively, if you’re using **[remark][]**, use
+[`remark-directive`][remark-directive].
+
+## Install
+
+This package is [ESM only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c):
+Node 12+ is needed to use it and it must be `import`ed instead of `require`d.
+
+[npm][]:
+
+```sh
+npm install micromark-extension-directive
+```
+
+## Use
+
+Say we have the following file, `example.md`:
+
+```markdown
+A lovely language know as :abbr[HTML]{title="HyperText Markup Language"}.
+```
+
+And our script, `example.js`, looks as follows:
+
+```js
+import fs from 'node:fs'
+import {micromark} from 'micromark'
+import {directive, directiveHtml} from 'micromark-extension-directive'
+
+const output = micromark(fs.readFileSync('example.md'), {
+  extensions: [directive()],
+  htmlExtensions: [directiveHtml({abbr})]
+})
+
+console.log(output)
+
+function abbr(d) {
+  if (d.type !== 'textGrowiPluginDirective') return false
+
+  this.tag('<abbr')
+
+  if (d.attributes && 'title' in d.attributes) {
+    this.tag(' title="' + this.encode(d.attributes.title) + '"')
+  }
+
+  this.tag('>')
+  this.raw(d.label || '')
+  this.tag('</abbr>')
+}
+```
+
+Now, running `node example` yields (abbreviated):
+
+```html
+<p>A lovely language know as <abbr title="HyperText Markup Language">HTML</abbr>.</p>
+```
+
+## API
+
+This package exports the following identifiers: `directive`, `directiveHtml`.
+There is no default export.
+
+The export map supports the endorsed
+[`development` condition](https://nodejs.org/api/packages.html#packages_resolving_user_conditions).
+Run `node --conditions development module.js` to get instrumented dev code.
+Without this condition, production code is loaded.
+
+### `directive(syntaxOptions?)`
+
+### `directiveHtml(htmlOptions?)`
+
+Functions that can be called with options to get an extension for micromark to
+parse directives (can be passed in `extensions`) and one to compile them to HTML
+(can be passed in `htmlExtensions`).
+
+###### `syntaxOptions`
+
+None yet, but might be added in the future.
+
+###### `htmlOptions`
+
+An object mapping names of directives to handlers
+([`Record<string, Handle>`][handle]).
+The special name `'*'` is the fallback to handle all unhandled directives.
+
+### `function handle(directive)`
+
+How to handle a `directive` ([`Directive`][directive]).
+
+##### Returns
+
+`boolean` or `void` — `false` can be used to signal that the directive could not
+be handled, in which case the fallback is used (when given).
+
+### `Directive`
+
+An object representing a directive.
+
+###### Fields
+
+*   `type` (`'textGrowiPluginDirective'|'leafGrowiPluginDirective'`)
+*   `name` (`string`) — name of directive
+*   `label` (`string?`) — compiled HTML content that was in `[brackets]`
+*   `attributes` (`Record<string, string>?`) — object w/ HTML attributes
+*   `content` (`string?`) — compiled HTML content inside container directive
+
+## Syntax
+
+The syntax looks like this:
+
+```markdown
+Directives in text can form with a single colon, such as :cite[smith04].
+Their syntax is `:name[label]{attributes}`.
+
+Leafs (block without content) can form by using two colons:
+
+::youtube[Video of a cat in a box]{vid=01ab2cd3efg}
+
+Their syntax is `::name[label]{attributes}` on its own line.
+
+Containers (blocks with content) can form by using three colons:
+
+:::spoiler
+He dies.
+:::
+
+The `name` part is required.  The first character must be a letter, other
+characters can be alphanumerical, `-`, and `_`.
+`-` or `_` cannot end a name.
+
+The `[label]` part is optional (`:x` and `:x[]` are equivalent)†.
+When used, it can include text constructs such as emphasis and so on: `x[a *b*
+c]`.
+
+The `{attributes}` part is optional (`:x` and `:x{}` are equivalent)†.
+When used, it is handled like HTML attributes, such as that `{a}`, `{a=""}`,
+, `{a=''}` but also `{a=b}`, `{a="b"}`, and `{a='b'}` are equivalent.
+Shortcuts are available for `id=` (`{#readme}` for `{id=readme}`) and
+`class` (`{.big}` for `{class=big}`).
+When multiple ids are found, the last is used; when multiple classes are found,
+they are combined: `{.red class=green .blue}` is equivalent to
+`{.red .green .blue}` and `{class="red green blue"}`.
+
+† there is one case where a name must be followed by an empty label or empty
+attributes: a *text* directive that only has a name, cannot be followed by a
+colon. So, `:red:` doesn’t work. Use either `:red[]` or `:red{}` instead.
+The reason for this is to allow GitHub emoji (gemoji) and directives to coexist.
+
+Containers can be nested by using more colons outside:
+
+::::spoiler
+He dies.
+
+:::spoiler
+She is born.
+:::
+::::
+
+The closing fence must include the same or more colons as the opening.
+If no closing is found, the container runs to the end of its parent container
+(block quote, list item, document, or other container).
+
+::::spoiler
+These three are not enough to close
+:::
+So this line is also part of the container.
+```
+
+Note that while other implementations are sometimes loose in what they allow,
+this implementation mimics CommonMark as closely as possible:
+
+*   Whitespace is not allowed between colons and name (~~`: a`~~), name and
+    label (~~`:a []`~~), name and attributes (~~`:a {}`~~), or label and
+    attributes (~~`:a[] {}`~~) — because it’s not allowed in links either
+    (~~`[] ()`~~)
+*   No trailing colons allowed on the opening fence of a container
+    (~~`:::a:::`~~) — because it’s not allowed in fenced code either
+*   The label and attributes in a leaf or container cannot include line endings
+    (~~`::a[b\nc]`~~) — because it’s not allowed in fenced code either
+
+## Related
+
+*   [`remarkjs/remark`][remark]
+    — markdown processor powered by plugins
+*   [`remarkjs/remark-directive`][remark-directive]
+    — remark plugin using this to support directive
+*   [`micromark/micromark`][micromark]
+    — the smallest commonmark-compliant markdown parser that exists
+*   [`syntax-tree/mdast-util-directive`][mdast-util-directive]
+    — mdast utility to support generic directives
+*   [`syntax-tree/mdast-util-from-markdown`][from-markdown]
+    — mdast parser using `micromark` to create mdast from markdown
+*   [`syntax-tree/mdast-util-to-markdown`][to-markdown]
+    — mdast serializer to create markdown from mdast
+
+## Contribute
+
+See [`contributing.md` in `micromark/.github`][contributing] for ways to get
+started.
+See [`support.md`][support] for ways to get help.
+
+This project has a [code of conduct][coc].
+By interacting with this repository, organization, or community you agree to
+abide by its terms.
+
+## License
+
+[MIT][license] © [Titus Wormer][author]
+
+<!-- Definitions -->
+
+[build-badge]: https://github.com/micromark/micromark-extension-directive/workflows/main/badge.svg
+
+[build]: https://github.com/micromark/micromark-extension-directive/actions
+
+[coverage-badge]: https://img.shields.io/codecov/c/github/micromark/micromark-extension-directive.svg
+
+[coverage]: https://codecov.io/github/micromark/micromark-extension-directive
+
+[downloads-badge]: https://img.shields.io/npm/dm/micromark-extension-directive.svg
+
+[downloads]: https://www.npmjs.com/package/micromark-extension-directive
+
+[size-badge]: https://img.shields.io/bundlephobia/minzip/micromark-extension-directive.svg
+
+[size]: https://bundlephobia.com/result?p=micromark-extension-directive
+
+[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg
+
+[backers-badge]: https://opencollective.com/unified/backers/badge.svg
+
+[collective]: https://opencollective.com/unified
+
+[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg
+
+[chat]: https://github.com/micromark/micromark/discussions
+
+[npm]: https://docs.npmjs.com/cli/install
+
+[license]: license
+
+[author]: https://wooorm.com
+
+[contributing]: https://github.com/micromark/.github/blob/HEAD/contributing.md
+
+[support]: https://github.com/micromark/.github/blob/HEAD/support.md
+
+[coc]: https://github.com/micromark/.github/blob/HEAD/code-of-conduct.md
+
+[micromark]: https://github.com/micromark/micromark
+
+[from-markdown]: https://github.com/syntax-tree/mdast-util-from-markdown
+
+[to-markdown]: https://github.com/syntax-tree/mdast-util-to-markdown
+
+[remark]: https://github.com/remarkjs/remark
+
+[prop]: https://talk.commonmark.org/t/generic-directives-plugins-syntax/444
+
+[mdast-util-directive]: https://github.com/syntax-tree/mdast-util-directive
+
+[remark-directive]: https://github.com/remarkjs/remark-directive
+
+[handle]: #function-handledirective
+
+[directive]: #directive

+ 35 - 0
packages/remark-growi-plugin/src/remark-growi-plugin.js

@@ -0,0 +1,35 @@
+/**
+ * @typedef {import('mdast').Root} Root
+ *
+ * @typedef {import('mdast-util-directive')} DoNotTouchAsThisImportIncludesDirectivesInTree
+ */
+
+import { directiveFromMarkdown, directiveToMarkdown } from './mdast-util-growi-plugin/index.js';
+import { directive } from './micromark-extension-growi-plugin/index.js';
+
+/**
+    * Plugin to support GROWI plugin (`$lsx(/path, depth=2)`).
+    *
+    * @type {import('unified').Plugin<void[], Root>}
+    */
+export function remarkGrowiPlugin() {
+  const data = this.data();
+
+  add('micromarkExtensions', directive());
+  add('fromMarkdownExtensions', directiveFromMarkdown);
+  add('toMarkdownExtensions', directiveToMarkdown);
+
+  /**
+      * @param {string} field
+      * @param {unknown} value
+      */
+  function add(field, value) {
+    const list = /** @type {unknown[]} */ (
+      // Other extensions
+      /* c8 ignore next 2 */
+      data[field] ? data[field] : (data[field] = [])
+    );
+
+    list.push(value);
+  }
+}

+ 11 - 0
packages/remark-growi-plugin/test/fixtures/leaf/input.md

@@ -0,0 +1,11 @@
+$a
+
+$a[b]
+
+$a(b)
+
+$a[b](c)
+
+$a[b *c* d **e**]
+
+$a(#b.c.d id=e class="f g" h="i &amp; j k")

+ 11 - 0
packages/remark-growi-plugin/test/fixtures/leaf/output.md

@@ -0,0 +1,11 @@
+$a
+
+$a[b]
+
+$a(b)
+
+$a[b](c)
+
+$a[b *c* d **e**]
+
+$a(#e .c.d.f.g h="i & j k")

+ 266 - 0
packages/remark-growi-plugin/test/fixtures/leaf/tree.json

@@ -0,0 +1,266 @@
+{
+  "type": "root",
+  "children": [
+    {
+      "type": "leafGrowiPluginDirective",
+      "name": "a",
+      "attributes": {},
+      "children": [],
+      "position": {
+        "start": {
+          "line": 1,
+          "column": 1,
+          "offset": 0
+        },
+        "end": {
+          "line": 1,
+          "column": 3,
+          "offset": 2
+        }
+      }
+    },
+    {
+      "type": "leafGrowiPluginDirective",
+      "name": "a",
+      "attributes": {},
+      "children": [
+        {
+          "type": "text",
+          "value": "b",
+          "position": {
+            "start": {
+              "line": 3,
+              "column": 4,
+              "offset": 7
+            },
+            "end": {
+              "line": 3,
+              "column": 5,
+              "offset": 8
+            }
+          }
+        }
+      ],
+      "position": {
+        "start": {
+          "line": 3,
+          "column": 1,
+          "offset": 4
+        },
+        "end": {
+          "line": 3,
+          "column": 6,
+          "offset": 9
+        }
+      }
+    },
+    {
+      "type": "leafGrowiPluginDirective",
+      "name": "a",
+      "attributes": {
+        "b": ""
+      },
+      "children": [],
+      "position": {
+        "start": {
+          "line": 5,
+          "column": 1,
+          "offset": 11
+        },
+        "end": {
+          "line": 5,
+          "column": 6,
+          "offset": 16
+        }
+      }
+    },
+    {
+      "type": "leafGrowiPluginDirective",
+      "name": "a",
+      "attributes": {
+        "c": ""
+      },
+      "children": [
+        {
+          "type": "text",
+          "value": "b",
+          "position": {
+            "start": {
+              "line": 7,
+              "column": 4,
+              "offset": 21
+            },
+            "end": {
+              "line": 7,
+              "column": 5,
+              "offset": 22
+            }
+          }
+        }
+      ],
+      "position": {
+        "start": {
+          "line": 7,
+          "column": 1,
+          "offset": 18
+        },
+        "end": {
+          "line": 7,
+          "column": 9,
+          "offset": 26
+        }
+      }
+    },
+    {
+      "type": "leafGrowiPluginDirective",
+      "name": "a",
+      "attributes": {},
+      "children": [
+        {
+          "type": "text",
+          "value": "b ",
+          "position": {
+            "start": {
+              "line": 9,
+              "column": 4,
+              "offset": 31
+            },
+            "end": {
+              "line": 9,
+              "column": 6,
+              "offset": 33
+            }
+          }
+        },
+        {
+          "type": "emphasis",
+          "children": [
+            {
+              "type": "text",
+              "value": "c",
+              "position": {
+                "start": {
+                  "line": 9,
+                  "column": 7,
+                  "offset": 34
+                },
+                "end": {
+                  "line": 9,
+                  "column": 8,
+                  "offset": 35
+                }
+              }
+            }
+          ],
+          "position": {
+            "start": {
+              "line": 9,
+              "column": 6,
+              "offset": 33
+            },
+            "end": {
+              "line": 9,
+              "column": 9,
+              "offset": 36
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": " d ",
+          "position": {
+            "start": {
+              "line": 9,
+              "column": 9,
+              "offset": 36
+            },
+            "end": {
+              "line": 9,
+              "column": 12,
+              "offset": 39
+            }
+          }
+        },
+        {
+          "type": "strong",
+          "children": [
+            {
+              "type": "text",
+              "value": "e",
+              "position": {
+                "start": {
+                  "line": 9,
+                  "column": 14,
+                  "offset": 41
+                },
+                "end": {
+                  "line": 9,
+                  "column": 15,
+                  "offset": 42
+                }
+              }
+            }
+          ],
+          "position": {
+            "start": {
+              "line": 9,
+              "column": 12,
+              "offset": 39
+            },
+            "end": {
+              "line": 9,
+              "column": 17,
+              "offset": 44
+            }
+          }
+        }
+      ],
+      "position": {
+        "start": {
+          "line": 9,
+          "column": 1,
+          "offset": 28
+        },
+        "end": {
+          "line": 9,
+          "column": 18,
+          "offset": 45
+        }
+      }
+    },
+    {
+      "type": "leafGrowiPluginDirective",
+      "name": "a",
+      "attributes": {
+        "id": "e",
+        "class": "c d f g",
+        "h": "i & j k"
+      },
+      "children": [],
+      "position": {
+        "start": {
+          "line": 11,
+          "column": 1,
+          "offset": 47
+        },
+        "end": {
+          "line": 11,
+          "column": 44,
+          "offset": 90
+        }
+      }
+    }
+  ],
+  "position": {
+    "start": {
+      "line": 1,
+      "column": 1,
+      "offset": 0
+    },
+    "end": {
+      "line": 12,
+      "column": 1,
+      "offset": 91
+    }
+  }
+}

+ 7 - 0
packages/remark-growi-plugin/test/fixtures/text/input.md

@@ -0,0 +1,7 @@
+One $a, two $a[b], three $a(b), four $a[b](c).
+
+$a[b *c*
+d **e**].
+
+$a(#b.c.d id=e class="f g" h="i &amp; j
+k").

+ 7 - 0
packages/remark-growi-plugin/test/fixtures/text/output.md

@@ -0,0 +1,7 @@
+One $a, two $a[b], three $a(b), four $a[b](c).
+
+$a[b *c*
+d **e**].
+
+$a(#e .c.d.f.g h="i & j
+k").

+ 429 - 0
packages/remark-growi-plugin/test/fixtures/text/tree.json

@@ -0,0 +1,429 @@
+{
+  "type": "root",
+  "children": [
+    {
+      "type": "paragraph",
+      "children": [
+        {
+          "type": "text",
+          "value": "One ",
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 1,
+              "offset": 0
+            },
+            "end": {
+              "line": 1,
+              "column": 5,
+              "offset": 4
+            }
+          }
+        },
+        {
+          "type": "textGrowiPluginDirective",
+          "name": "a",
+          "attributes": {},
+          "children": [],
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 5,
+              "offset": 4
+            },
+            "end": {
+              "line": 1,
+              "column": 7,
+              "offset": 6
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": ", two ",
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 7,
+              "offset": 6
+            },
+            "end": {
+              "line": 1,
+              "column": 13,
+              "offset": 12
+            }
+          }
+        },
+        {
+          "type": "textGrowiPluginDirective",
+          "name": "a",
+          "attributes": {},
+          "children": [
+            {
+              "type": "text",
+              "value": "b",
+              "position": {
+                "start": {
+                  "line": 1,
+                  "column": 16,
+                  "offset": 15
+                },
+                "end": {
+                  "line": 1,
+                  "column": 17,
+                  "offset": 16
+                }
+              }
+            }
+          ],
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 13,
+              "offset": 12
+            },
+            "end": {
+              "line": 1,
+              "column": 18,
+              "offset": 17
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": ", three ",
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 18,
+              "offset": 17
+            },
+            "end": {
+              "line": 1,
+              "column": 26,
+              "offset": 25
+            }
+          }
+        },
+        {
+          "type": "textGrowiPluginDirective",
+          "name": "a",
+          "attributes": {
+            "b": ""
+          },
+          "children": [],
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 26,
+              "offset": 25
+            },
+            "end": {
+              "line": 1,
+              "column": 31,
+              "offset": 30
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": ", four ",
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 31,
+              "offset": 30
+            },
+            "end": {
+              "line": 1,
+              "column": 38,
+              "offset": 37
+            }
+          }
+        },
+        {
+          "type": "textGrowiPluginDirective",
+          "name": "a",
+          "attributes": {
+            "c": ""
+          },
+          "children": [
+            {
+              "type": "text",
+              "value": "b",
+              "position": {
+                "start": {
+                  "line": 1,
+                  "column": 41,
+                  "offset": 40
+                },
+                "end": {
+                  "line": 1,
+                  "column": 42,
+                  "offset": 41
+                }
+              }
+            }
+          ],
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 38,
+              "offset": 37
+            },
+            "end": {
+              "line": 1,
+              "column": 46,
+              "offset": 45
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": ".",
+          "position": {
+            "start": {
+              "line": 1,
+              "column": 46,
+              "offset": 45
+            },
+            "end": {
+              "line": 1,
+              "column": 47,
+              "offset": 46
+            }
+          }
+        }
+      ],
+      "position": {
+        "start": {
+          "line": 1,
+          "column": 1,
+          "offset": 0
+        },
+        "end": {
+          "line": 1,
+          "column": 47,
+          "offset": 46
+        }
+      }
+    },
+    {
+      "type": "paragraph",
+      "children": [
+        {
+          "type": "textGrowiPluginDirective",
+          "name": "a",
+          "attributes": {},
+          "children": [
+            {
+              "type": "text",
+              "value": "b ",
+              "position": {
+                "start": {
+                  "line": 3,
+                  "column": 4,
+                  "offset": 51
+                },
+                "end": {
+                  "line": 3,
+                  "column": 6,
+                  "offset": 53
+                }
+              }
+            },
+            {
+              "type": "emphasis",
+              "children": [
+                {
+                  "type": "text",
+                  "value": "c",
+                  "position": {
+                    "start": {
+                      "line": 3,
+                      "column": 7,
+                      "offset": 54
+                    },
+                    "end": {
+                      "line": 3,
+                      "column": 8,
+                      "offset": 55
+                    }
+                  }
+                }
+              ],
+              "position": {
+                "start": {
+                  "line": 3,
+                  "column": 6,
+                  "offset": 53
+                },
+                "end": {
+                  "line": 3,
+                  "column": 9,
+                  "offset": 56
+                }
+              }
+            },
+            {
+              "type": "text",
+              "value": "\nd ",
+              "position": {
+                "start": {
+                  "line": 3,
+                  "column": 9,
+                  "offset": 56
+                },
+                "end": {
+                  "line": 4,
+                  "column": 3,
+                  "offset": 59
+                }
+              }
+            },
+            {
+              "type": "strong",
+              "children": [
+                {
+                  "type": "text",
+                  "value": "e",
+                  "position": {
+                    "start": {
+                      "line": 4,
+                      "column": 5,
+                      "offset": 61
+                    },
+                    "end": {
+                      "line": 4,
+                      "column": 6,
+                      "offset": 62
+                    }
+                  }
+                }
+              ],
+              "position": {
+                "start": {
+                  "line": 4,
+                  "column": 3,
+                  "offset": 59
+                },
+                "end": {
+                  "line": 4,
+                  "column": 8,
+                  "offset": 64
+                }
+              }
+            }
+          ],
+          "position": {
+            "start": {
+              "line": 3,
+              "column": 1,
+              "offset": 48
+            },
+            "end": {
+              "line": 4,
+              "column": 9,
+              "offset": 65
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": ".",
+          "position": {
+            "start": {
+              "line": 4,
+              "column": 9,
+              "offset": 65
+            },
+            "end": {
+              "line": 4,
+              "column": 10,
+              "offset": 66
+            }
+          }
+        }
+      ],
+      "position": {
+        "start": {
+          "line": 3,
+          "column": 1,
+          "offset": 48
+        },
+        "end": {
+          "line": 4,
+          "column": 10,
+          "offset": 66
+        }
+      }
+    },
+    {
+      "type": "paragraph",
+      "children": [
+        {
+          "type": "textGrowiPluginDirective",
+          "name": "a",
+          "attributes": {
+            "id": "e",
+            "class": "c d f g",
+            "h": "i & j\nk"
+          },
+          "children": [],
+          "position": {
+            "start": {
+              "line": 6,
+              "column": 1,
+              "offset": 68
+            },
+            "end": {
+              "line": 7,
+              "column": 4,
+              "offset": 111
+            }
+          }
+        },
+        {
+          "type": "text",
+          "value": ".",
+          "position": {
+            "start": {
+              "line": 7,
+              "column": 4,
+              "offset": 111
+            },
+            "end": {
+              "line": 7,
+              "column": 5,
+              "offset": 112
+            }
+          }
+        }
+      ],
+      "position": {
+        "start": {
+          "line": 6,
+          "column": 1,
+          "offset": 68
+        },
+        "end": {
+          "line": 7,
+          "column": 5,
+          "offset": 112
+        }
+      }
+    }
+  ],
+  "position": {
+    "start": {
+      "line": 1,
+      "column": 1,
+      "offset": 0
+    },
+    "end": {
+      "line": 8,
+      "column": 1,
+      "offset": 113
+    }
+  }
+}

+ 584 - 0
packages/remark-growi-plugin/test/mdast-util-growi-plugin.test.js

@@ -0,0 +1,584 @@
+import { fromMarkdown } from 'mdast-util-from-markdown';
+import { toMarkdown } from 'mdast-util-to-markdown';
+import test from 'tape';
+import { removePosition } from 'unist-util-remove-position';
+
+import { directiveFromMarkdown, directiveToMarkdown } from '../src/mdast-util-growi-plugin/index.js';
+import { directive } from '../src/micromark-extension-growi-plugin/index.js';
+
+test('markdown -> mdast', (t) => {
+  t.deepEqual(
+    fromMarkdown('a $b[c](d) e.', {
+      extensions: [directive()],
+      mdastExtensions: [directiveFromMarkdown],
+    }).children[0],
+    {
+      type: 'paragraph',
+      children: [
+        {
+          type: 'text',
+          value: 'a ',
+          position: {
+            start: { line: 1, column: 1, offset: 0 },
+            end: { line: 1, column: 3, offset: 2 },
+          },
+        },
+        {
+          type: 'textGrowiPluginDirective',
+          name: 'b',
+          attributes: { d: '' },
+          children: [
+            {
+              type: 'text',
+              value: 'c',
+              position: {
+                start: { line: 1, column: 6, offset: 5 },
+                end: { line: 1, column: 7, offset: 6 },
+              },
+            },
+          ],
+          position: {
+            start: { line: 1, column: 3, offset: 2 },
+            end: { line: 1, column: 11, offset: 10 },
+          },
+        },
+        {
+          type: 'text',
+          value: ' e.',
+          position: {
+            start: { line: 1, column: 11, offset: 10 },
+            end: { line: 1, column: 14, offset: 13 },
+          },
+        },
+      ],
+      position: {
+        start: { line: 1, column: 1, offset: 0 },
+        end: { line: 1, column: 14, offset: 13 },
+      },
+    },
+    'should support directives (text)',
+  );
+
+  t.deepEqual(
+    fromMarkdown('$a[b](c)', {
+      extensions: [directive()],
+      mdastExtensions: [directiveFromMarkdown],
+    }).children[0],
+    {
+      type: 'leafGrowiPluginDirective',
+      name: 'a',
+      attributes: { c: '' },
+      children: [
+        {
+          type: 'text',
+          value: 'b',
+          position: {
+            start: { line: 1, column: 4, offset: 3 },
+            end: { line: 1, column: 5, offset: 4 },
+          },
+        },
+      ],
+      position: {
+        start: { line: 1, column: 1, offset: 0 },
+        end: { line: 1, column: 9, offset: 8 },
+      },
+    },
+    'should support directives (leaf)',
+  );
+
+  t.deepEqual(
+    removePosition(
+      fromMarkdown('x $a[b *c*\nd]', {
+        extensions: [directive()],
+        mdastExtensions: [directiveFromMarkdown],
+      }),
+      true,
+    ),
+    {
+      type: 'root',
+      children: [
+        {
+          type: 'paragraph',
+          children: [
+            { type: 'text', value: 'x ' },
+            {
+              type: 'textGrowiPluginDirective',
+              name: 'a',
+              attributes: {},
+              children: [
+                { type: 'text', value: 'b ' },
+                { type: 'emphasis', children: [{ type: 'text', value: 'c' }] },
+                { type: 'text', value: '\nd' },
+              ],
+            },
+          ],
+        },
+      ],
+    },
+    'should support content in a label',
+  );
+
+  t.deepEqual(
+    removePosition(
+      fromMarkdown('x $a(#b.c.d e=f g="h&amp;i&unknown;j")', {
+        extensions: [directive()],
+        mdastExtensions: [directiveFromMarkdown],
+      }),
+      true,
+    ),
+    {
+      type: 'root',
+      children: [
+        {
+          type: 'paragraph',
+          children: [
+            { type: 'text', value: 'x ' },
+            {
+              type: 'textGrowiPluginDirective',
+              name: 'a',
+              attributes: {
+                id: 'b', class: 'c d', e: 'f', g: 'h&i&unknown;j',
+              },
+              children: [],
+            },
+          ],
+        },
+      ],
+    },
+    'should support attributes',
+  );
+
+  t.deepEqual(
+    removePosition(
+      fromMarkdown('$a(b\nc="d\ne")', {
+        extensions: [directive()],
+        mdastExtensions: [directiveFromMarkdown],
+      }),
+      true,
+    ),
+    {
+      type: 'root',
+      children: [
+        {
+          type: 'paragraph',
+          children: [
+            {
+              type: 'textGrowiPluginDirective',
+              name: 'a',
+              attributes: { b: '', c: 'd\ne' },
+              children: [],
+            },
+          ],
+        },
+      ],
+    },
+    'should support EOLs in attributes',
+  );
+
+  t.end();
+});
+
+test('mdast -> markdown', (t) => {
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          // @ts-expect-error: `children`, `name` missing.
+          { type: 'textGrowiPluginDirective' },
+          { type: 'text', value: ' b.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $ b.\n',
+    'should try to serialize a directive (text) w/o `name`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          // @ts-expect-error: `children` missing.
+          { type: 'textGrowiPluginDirective', name: 'b' },
+          { type: 'text', value: ' c.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b c.\n',
+    'should serialize a directive (text) w/ `name`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            children: [{ type: 'text', value: 'c' }],
+          },
+          { type: 'text', value: ' d.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b[c] d.\n',
+    'should serialize a directive (text) w/ `children`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            children: [{ type: 'text', value: 'c[d]e' }],
+          },
+          { type: 'text', value: ' f.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b[c\\[d\\]e] f.\n',
+    'should escape brackets in a directive (text) label',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            children: [{ type: 'text', value: 'c\nd' }],
+          },
+          { type: 'text', value: ' e.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b[c\nd] e.\n',
+    'should support EOLs in a directive (text) label',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            // @ts-expect-error: should contain only `string`s
+            attributes: {
+              c: 'd', e: 'f', g: '', h: null, i: undefined, j: 2,
+            },
+            children: [],
+          },
+          { type: 'text', value: ' k.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(c="d" e="f" g j="2") k.\n',
+    'should serialize a directive (text) w/ `attributes`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            attributes: { class: 'a b\nc', id: 'd', key: 'value' },
+            children: [],
+          },
+          { type: 'text', value: ' k.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(#d .a.b.c key="value") k.\n',
+    'should serialize a directive (text) w/ `id`, `class` attributes',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            attributes: { x: 'y"\'\r\nz' },
+            children: [],
+          },
+          { type: 'text', value: ' k.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(x="y&#x22;\'\r\nz") k.\n',
+    'should encode the quote in an attribute value (text)',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            attributes: { x: 'y"\'\r\nz' },
+            children: [],
+          },
+          { type: 'text', value: ' k.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(x="y&#x22;\'\r\nz") k.\n',
+    'should encode the quote in an attribute value (text)',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            attributes: { id: 'c#d' },
+            children: [],
+          },
+          { type: 'text', value: ' e.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(id="c#d") e.\n',
+    'should not use the `id` shortcut if impossible characters exist',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            attributes: { class: 'c.d e<f' },
+            children: [],
+          },
+          { type: 'text', value: ' g.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(class="c.d e<f") g.\n',
+    'should not use the `class` shortcut if impossible characters exist',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'text', value: 'a ' },
+          {
+            type: 'textGrowiPluginDirective',
+            name: 'b',
+            attributes: { class: 'c.d e f<g hij' },
+            children: [],
+          },
+          { type: 'text', value: ' k.' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a $b(.e.hij class="c.d f<g") k.\n',
+    'should not use the `class` shortcut if impossible characters exist (but should use it for classes that don’t)',
+  );
+
+  t.deepEqual(
+    // @ts-expect-error: `children`, `name` missing.
+    toMarkdown({ type: 'leafGrowiPluginDirective' }, { extensions: [directiveToMarkdown] }),
+    '$\n',
+    'should try to serialize a directive (leaf) w/o `name`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      // @ts-expect-error: `children` missing.
+      { type: 'leafGrowiPluginDirective', name: 'a' },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a\n',
+    'should serialize a directive (leaf) w/ `name`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'leafGrowiPluginDirective',
+        name: 'a',
+        children: [{ type: 'text', value: 'b' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a[b]\n',
+    'should serialize a directive (leaf) w/ `children`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'leafGrowiPluginDirective',
+        name: 'a',
+        children: [{ type: 'text', value: 'b' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a[b]\n',
+    'should serialize a directive (leaf) w/ `children`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'leafGrowiPluginDirective',
+        name: 'a',
+        children: [{ type: 'text', value: 'b\nc' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a[b&#xA;c]\n',
+    'should serialize a directive (leaf) w/ EOLs in `children`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'leafGrowiPluginDirective',
+        name: 'a',
+        attributes: { id: 'b', class: 'c d', key: 'e\nf' },
+        children: [],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a(#b .c.d key="e&#xA;f")\n',
+    'should serialize a directive (leaf) w/ EOLs in `attributes`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [{ type: 'text', value: 'a$b' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a\\$b\n',
+    'should escape a `:` in phrasing when followed by an alpha',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [{ type: 'text', value: 'a$9' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a$9\n',
+    'should not escape a `:` in phrasing when followed by a non-alpha',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [{ type: 'text', value: 'a$c' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    'a\\$c\n',
+    'should not escape a `:` in phrasing when preceded by a colon',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [{ type: 'text', value: '$\na' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$\na\n',
+    'should not escape a `:` at a break',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [{ type: 'text', value: '$a' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '\\$a\n',
+    'should not escape a `:` at a break when followed by an alpha',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [{ type: 'text', value: '$\na' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$\na\n',
+    'should escape a `:` at a break when followed by a colon',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: 'paragraph',
+        children: [
+          { type: 'textGrowiPluginDirective', name: 'red', children: [] },
+          { type: 'text', value: '$' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$red$\n',
+    'should escape a `:` after a text directive',
+  );
+
+  t.end();
+});

+ 1063 - 0
packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js

@@ -0,0 +1,1063 @@
+/**
+ * @typedef {import('../src/micromark-extension-growi-plugin/index.js').HtmlOptions} HtmlOptions
+ * @typedef {import('../src/micromark-extension-growi-plugin/index.js').Handle} Handle
+ */
+
+import { htmlVoidElements } from 'html-void-elements';
+import { micromark } from 'micromark';
+import test from 'tape';
+
+import { directive as syntax, directiveHtml as html } from '../src/micromark-extension-growi-plugin/index.js';
+
+const own = {}.hasOwnProperty;
+
+test('micromark-extension-directive (syntax)', (t) => {
+  t.test('text', (t) => {
+    t.equal(
+      micromark('\\$a', options()),
+      '<p>$a</p>',
+      'should support an escaped colon which would otherwise be a directive',
+    );
+
+    t.equal(
+      micromark('\\$$a', options()),
+      '<p>$</p>',
+      'should support a directive after an escaped colon',
+    );
+
+    // t.equal(
+    //   micromark('a :$b', options()),
+    //   '<p>a :$b</p>',
+    //   'should not support a directive after a colon',
+    // );
+
+    t.equal(
+      micromark('$', options()),
+      '<p>$</p>',
+      'should not support a colon not followed by an alpha',
+    );
+
+    t.equal(
+      micromark('a $a', options()),
+      '<p>a </p>',
+      'should support a colon followed by an alpha',
+    );
+
+    t.equal(
+      micromark('$9', options()),
+      '<p>$9</p>',
+      'should not support a colon followed by a digit',
+    );
+
+    t.equal(
+      micromark('$-', options()),
+      '<p>$-</p>',
+      'should not support a colon followed by a dash',
+    );
+
+    t.equal(
+      micromark('$_', options()),
+      '<p>$_</p>',
+      'should not support a colon followed by an underscore',
+    );
+
+    t.equal(
+      micromark('a $a9', options()),
+      '<p>a </p>',
+      'should support a digit in a name',
+    );
+
+    t.equal(
+      micromark('a $a-b', options()),
+      '<p>a </p>',
+      'should support a dash in a name',
+    );
+
+    t.equal(
+      micromark('$a-', options()),
+      '<p>$a-</p>',
+      'should *not* support a dash at the end of a name',
+    );
+
+    t.equal(
+      micromark('a $a_b', options()),
+      '<p>a </p>',
+      'should support an underscore in a name',
+    );
+
+    t.equal(
+      micromark('$a_', options()),
+      '<p>$a_</p>',
+      'should *not* support an underscore at the end of a name',
+    );
+
+    t.equal(
+      micromark('$a$', options()),
+      '<p>$a$</p>',
+      'should *not* support a colon right after a name',
+    );
+
+    t.equal(
+      micromark('_$directive_', options()),
+      '<p><em>$directive</em></p>',
+      'should not interfere w/ emphasis (`_`)',
+    );
+
+    t.equal(
+      micromark('$a[', options()),
+      '<p>[</p>',
+      'should support a name followed by an unclosed `[`',
+    );
+
+    t.equal(
+      micromark('$a(', options()),
+      '<p>(</p>',
+      'should support a name followed by an unclosed `{`',
+    );
+
+    t.equal(
+      micromark('$a[b', options()),
+      '<p>[b</p>',
+      'should support a name followed by an unclosed `[` w/ content',
+    );
+
+    t.equal(
+      micromark('$a(b', options()),
+      '<p>(b</p>',
+      'should support a name followed by an unclosed `{` w/ content',
+    );
+
+    t.equal(
+      micromark('a $a[]', options()),
+      '<p>a </p>',
+      'should support an empty label',
+    );
+
+    t.equal(
+      micromark('a $a[ \t]', options()),
+      '<p>a </p>',
+      'should support a whitespace only label',
+    );
+
+    t.equal(
+      micromark('$a[\n]', options()),
+      '<p></p>',
+      'should support an eol in an label',
+    );
+
+    t.equal(
+      micromark('$a[a b c]asd', options()),
+      '<p>asd</p>',
+      'should support content in an label',
+    );
+
+    t.equal(
+      micromark('$a[a *b* c]asd', options()),
+      '<p>asd</p>',
+      'should support markdown in an label',
+    );
+
+    t.equal(
+      micromark('a $b[c :d[e] f] g', options()),
+      '<p>a  g</p>',
+      'should support a directive in an label',
+    );
+
+    t.equal(
+      micromark('$a[]asd', options()),
+      '<p>asd</p>',
+      'should support content after a label',
+    );
+
+    t.equal(
+      micromark('a $a()', options()),
+      '<p>a </p>',
+      'should support empty attributes',
+    );
+
+    t.equal(
+      micromark('a $a( \t)', options()),
+      '<p>a </p>',
+      'should support whitespace only attributes',
+    );
+
+    t.equal(
+      micromark('$a(\n)', options()),
+      '<p></p>',
+      'should support an eol in attributes',
+    );
+
+    t.equal(
+      micromark('a $a(a b c)', options()),
+      '<p>a </p>',
+      'should support attributes w/o values',
+    );
+
+    t.equal(
+      micromark('a $a(a=b c=d)', options()),
+      '<p>a </p>',
+      'should support attributes w/ unquoted values',
+    );
+
+    t.equal(
+      micromark('a $a(.a .b)', options()),
+      '<p>a </p>',
+      'should support attributes w/ class shortcut',
+    );
+
+    t.equal(
+      micromark('a $a(.a.b)', options()),
+      '<p>a </p>',
+      'should support attributes w/ class shortcut w/o whitespace between',
+    );
+
+    t.equal(
+      micromark('a $a(#a #b)', options()),
+      '<p>a </p>',
+      'should support attributes w/ id shortcut',
+    );
+
+    t.equal(
+      micromark('a $a(#a#b)', options()),
+      '<p>a </p>',
+      'should support attributes w/ id shortcut w/o whitespace between',
+    );
+
+    t.equal(
+      micromark('a $a(#a.b.c#d e f=g #h.i.j)', options()),
+      '<p>a </p>',
+      'should support attributes w/ shortcuts combined w/ other attributes',
+    );
+
+    t.equal(
+      micromark('$a(..b)', options()),
+      '<p>(..b)</p>',
+      'should not support an empty shortcut (`.`)',
+    );
+
+    t.equal(
+      micromark('$a(.#b)', options()),
+      '<p>(.#b)</p>',
+      'should not support an empty shortcut (`#`)',
+    );
+
+    t.equal(
+      micromark('$a(.)', options()),
+      '<p>(.)</p>',
+      'should not support an empty shortcut (`}`)',
+    );
+
+    t.equal(
+      micromark('$a(.a=b)', options()),
+      '<p>(.a=b)</p>',
+      'should not support certain characters in shortcuts (`=`)',
+    );
+
+    t.equal(
+      micromark('$a(.a"b)', options()),
+      '<p>(.a&quot;b)</p>',
+      'should not support certain characters in shortcuts (`"`)',
+    );
+
+    t.equal(
+      micromark('$a(.a<b)', options()),
+      '<p>(.a&lt;b)</p>',
+      'should not support certain characters in shortcuts (`<`)',
+    );
+
+    t.equal(
+      micromark('a $a(.a💚b)', options()),
+      '<p>a </p>',
+      'should support most characters in shortcuts',
+    );
+
+    t.equal(
+      micromark('a $a(_)', options()),
+      '<p>a </p>',
+      'should support an underscore in attribute names',
+    );
+
+    t.equal(
+      micromark('a $a(xml:lang)', options()),
+      '<p>a </p>',
+      'should support a colon in attribute names',
+    );
+
+    t.equal(
+      micromark('a $a(a="b" c="d e f")', options()),
+      '<p>a </p>',
+      'should support double quoted attributes',
+    );
+
+    t.equal(
+      micromark("a $a(a='b' c='d e f')", options()),
+      '<p>a </p>',
+      'should support single quoted attributes',
+    );
+
+    t.equal(
+      micromark('a $a(a = b c\t=\t\'d\' f  =\r"g")', options()),
+      '<p>a </p>',
+      'should support whitespace around initializers',
+    );
+
+    t.equal(
+      micromark('$a(b==)', options()),
+      '<p>(b==)</p>',
+      'should not support `=` to start an unquoted attribute value',
+    );
+
+    t.equal(
+      micromark('$a(b=)', options()),
+      '<p>(b=)</p>',
+      'should not support a missing attribute value after `=`',
+    );
+
+    t.equal(
+      micromark("$a(b=c')", options()),
+      "<p>(b=c')</p>",
+      'should not support an apostrophe in an unquoted attribute value',
+    );
+
+    t.equal(
+      micromark('$a(b=c`)', options()),
+      '<p>(b=c`)</p>',
+      'should not support a grave accent in an unquoted attribute value',
+    );
+
+    t.equal(
+      micromark('a $a(b=a💚b)', options()),
+      '<p>a </p>',
+      'should support most other characters in unquoted attribute values',
+    );
+
+    t.equal(
+      micromark('$a(b="c', options()),
+      '<p>(b=&quot;c</p>',
+      'should not support an EOF in a quoted attribute value',
+    );
+
+    t.equal(
+      micromark('a $a(b="a💚b")', options()),
+      '<p>a </p>',
+      'should support most other characters in quoted attribute values',
+    );
+
+    t.equal(
+      micromark('$a(b="\nc\r  d")', options()),
+      '<p></p>',
+      'should support EOLs in quoted attribute values',
+    );
+
+    t.equal(
+      micromark('$a(b="c"', options()),
+      '<p>(b=&quot;c&quot;</p>',
+      'should not support an EOF after a quoted attribute value',
+    );
+
+    t.end();
+  });
+
+  t.test('leaf', (t) => {
+    t.equal(micromark('$b', options()), '', 'should support a directive');
+
+    t.equal(
+      micromark(':', options()),
+      '<p>:</p>',
+      'should not support one colon',
+    );
+
+    t.equal(
+      micromark('::', options()),
+      '<p>::</p>',
+      'should not support two colons not followed by an alpha',
+    );
+
+    t.equal(
+      micromark('$a', options()),
+      '',
+      'should support two colons followed by an alpha',
+    );
+
+    t.equal(
+      micromark('$9', options()),
+      '<p>$9</p>',
+      'should not support two colons followed by a digit',
+    );
+
+    t.equal(
+      micromark('$-', options()),
+      '<p>$-</p>',
+      'should not support two colons followed by a dash',
+    );
+
+    t.equal(
+      micromark('$a9', options()),
+      '',
+      'should support a digit in a name',
+    );
+
+    t.equal(
+      micromark('$a-b', options()),
+      '',
+      'should support a dash in a name',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a[', options()),
+    //   '<p>$a[</p>',
+    //   'should not support a name followed by an unclosed `[`',
+    // );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a{', options()),
+    //   '<p>$a{</p>',
+    //   'should not support a name followed by an unclosed `{`',
+    // );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a[b', options()),
+    //   '<p>$a[b</p>',
+    //   'should not support a name followed by an unclosed `[` w/ content',
+    // );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a{b', options()),
+    //   '<p>$a{b</p>',
+    //   'should not support a name followed by an unclosed `{` w/ content',
+    // );
+
+    t.equal(micromark('$a[]', options()), '', 'should support an empty label');
+
+    t.equal(
+      micromark('$a[ \t]', options()),
+      '',
+      'should support a whitespace only label',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a[\n]', options()),
+    //   '<p>$a[\n]</p>',
+    //   'should not support an eol in an label',
+    // );
+
+    t.equal(
+      micromark('$a[a b c]', options()),
+      '',
+      'should support content in an label',
+    );
+
+    t.equal(
+      micromark('$a[a *b* c]', options()),
+      '',
+      'should support markdown in an label',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a[]asd', options()),
+    //   '<p>$a[]asd</p>',
+    //   'should not support content after a label',
+    // );
+
+    t.equal(
+      micromark('$a()', options()),
+      '',
+      'should support empty attributes',
+    );
+
+    t.equal(
+      micromark('$a( \t)', options()),
+      '',
+      'should support whitespace only attributes',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a(\n)', options()),
+    //   '<p>$a(\n)</p>',
+    //   'should not support an eol in attributes',
+    // );
+
+    t.equal(
+      micromark('$a(a b c)', options()),
+      '',
+      'should support attributes w/o values',
+    );
+
+    t.equal(
+      micromark('$a(a=b c=d)', options()),
+      '',
+      'should support attributes w/ unquoted values',
+    );
+
+    t.equal(
+      micromark('$a(.a .b)', options()),
+      '',
+      'should support attributes w/ class shortcut',
+    );
+
+    t.equal(
+      micromark('$a(#a #b)', options()),
+      '',
+      'should support attributes w/ id shortcut',
+    );
+
+    t.equal(
+      micromark('$a(.a💚b)', options()),
+      '',
+      'should support most characters in shortcuts',
+    );
+
+    t.equal(
+      micromark('$a(a="b" c="d e f")', options()),
+      '',
+      'should support double quoted attributes',
+    );
+
+    t.equal(
+      micromark("$a(a='b' c='d e f')", options()),
+      '',
+      'should support single quoted attributes',
+    );
+
+    t.equal(
+      micromark("$a(a = b c\t=\t'd')", options()),
+      '',
+      'should support whitespace around initializers',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a(f  =\rg)', options()),
+    //   '<p>$a(f  =\rg)</p>',
+    //   'should not support EOLs around initializers',
+    // );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a(b==)', options()),
+    //   '<p>$a(b==)</p>',
+    //   'should not support `=` to start an unquoted attribute value',
+    // );
+
+    t.equal(
+      micromark('$a(b=a💚b)', options()),
+      '',
+      'should support most other characters in unquoted attribute values',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a(b="c', options()),
+    //   '<p>$a(b=&quot;c</p>',
+    //   'should not support an EOF in a quoted attribute value',
+    // );
+
+    t.equal(
+      micromark('$a(b="a💚b")', options()),
+      '',
+      'should support most other characters in quoted attribute values',
+    );
+
+    // == Resolved as text directive
+    // t.equal(
+    //   micromark('$a(b="\nc\r  d")', options()),
+    //   '<p>$a(b=&quot;\nc\rd&quot;)</p>',
+    //   'should not support EOLs in quoted attribute values',
+    // );
+
+    // t.equal(
+    //   micromark('$a(b="c"', options()),
+    //   '<p>$a(b=&quot;c&quot;</p>',
+    //   'should not support an EOF after a quoted attribute value',
+    // );
+
+    t.equal(
+      micromark('$a(b=c) \t ', options()),
+      '',
+      'should support whitespace after directives',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n>a', options()),
+      '<blockquote>\n<p>a</p>\n</blockquote>',
+      'should support a block quote after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n```js\na', options()),
+      '<pre><code class="language-js">a\n</code></pre>\n',
+      'should support code (fenced) after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n    a', options()),
+      '<pre><code>a\n</code></pre>',
+      'should support code (indented) after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n[a]: b', options()),
+      '',
+      'should support a definition after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n# a', options()),
+      '<h1>a</h1>',
+      'should support a heading (atx) after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\na\n=', options()),
+      '<h1>a</h1>',
+      'should support a heading (setext) after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n<!-->', options()),
+      '<!-->',
+      'should support html after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n* a', options()),
+      '<ul>\n<li>a</li>\n</ul>',
+      'should support a list after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\na', options()),
+      '<p>a</p>',
+      'should support a paragraph after a leaf',
+    );
+
+    t.equal(
+      micromark('$a(b=c)\n***', options()),
+      '<hr />',
+      'should support a thematic break after a leaf',
+    );
+
+    t.equal(
+      micromark('>a\n$a(b=c)', options()),
+      '<blockquote>\n<p>a</p>\n</blockquote>\n',
+      'should support a block quote before a leaf',
+    );
+
+    t.equal(
+      micromark('```js\na\n```\n$a(b=c)', options()),
+      '<pre><code class="language-js">a\n</code></pre>\n',
+      'should support code (fenced) before a leaf',
+    );
+
+    t.equal(
+      micromark('    a\n$a(b=c)', options()),
+      '<pre><code>a\n</code></pre>\n',
+      'should support code (indented) before a leaf',
+    );
+
+    t.equal(
+      micromark('[a]: b\n$a(b=c)', options()),
+      '',
+      'should support a definition before a leaf',
+    );
+
+    t.equal(
+      micromark('# a\n$a(b=c)', options()),
+      '<h1>a</h1>\n',
+      'should support a heading (atx) before a leaf',
+    );
+
+    t.equal(
+      micromark('a\n=\n$a(b=c)', options()),
+      '<h1>a</h1>\n',
+      'should support a heading (setext) before a leaf',
+    );
+
+    t.equal(
+      micromark('<!-->\n$a(b=c)', options()),
+      '<!-->\n',
+      'should support html before a leaf',
+    );
+
+    t.equal(
+      micromark('* a\n$a(b=c)', options()),
+      '<ul>\n<li>a</li>\n</ul>\n',
+      'should support a list before a leaf',
+    );
+
+    t.equal(
+      micromark('a\n$a(b=c)', options()),
+      '<p>a</p>\n',
+      'should support a paragraph before a leaf',
+    );
+
+    t.equal(
+      micromark('***\n$a(b=c)', options()),
+      '<hr />\n',
+      'should support a thematic break before a leaf',
+    );
+
+    t.equal(
+      micromark('> $a\nb', options({ '*': h })),
+      '<blockquote><a></a>\n</blockquote>\n<p>b</p>',
+      'should not support lazyness (1)',
+    );
+
+    t.equal(
+      micromark('> a\n$b', options({ '*': h })),
+      '<blockquote>\n<p>a</p>\n</blockquote>\n<b></b>',
+      'should not support lazyness (2)',
+    );
+
+    t.end();
+  });
+
+  t.end();
+});
+
+test('micromark-extension-directive (compile)', (t) => {
+  t.equal(
+    micromark(
+      [
+        'a $abbr',
+        'a $abbr[HTML]',
+        'a $abbr(title="HyperText Markup Language")',
+        'a $abbr[HTML](title="HyperText Markup Language")',
+      ].join('\n\n'),
+      options({ abbr }),
+    ),
+    [
+      '<p>a <abbr></abbr></p>',
+      '<p>a <abbr>HTML</abbr></p>',
+      '<p>a <abbr title="HyperText Markup Language"></abbr></p>',
+      '<p>a <abbr title="HyperText Markup Language">HTML</abbr></p>',
+    ].join('\n'),
+    'should support a directives (abbr)',
+  );
+
+  t.equal(
+    micromark(
+      [
+        'Text:',
+        'a $youtube',
+        'a $youtube[Cat in a box a]',
+        'a $youtube(v=1)',
+        'a $youtube[Cat in a box b](v=2)',
+        'Leaf:',
+        '$youtube',
+        '$youtube[Cat in a box c]',
+        '$youtube(v=3)',
+        '$youtube[Cat in a box d](v=4)',
+      ].join('\n\n'),
+      options({ youtube }),
+    ),
+    [
+      '<p>Text:</p>',
+      '<p>a </p>',
+      '<p>a </p>',
+      '<p>a <iframe src="https://www.youtube.com/embed/1" allowfullscreen></iframe></p>',
+      '<p>a <iframe src="https://www.youtube.com/embed/2" allowfullscreen title="Cat in a box b"></iframe></p>',
+      '<p>Leaf:</p>',
+      '<iframe src="https://www.youtube.com/embed/3" allowfullscreen></iframe>',
+      '<iframe src="https://www.youtube.com/embed/4" allowfullscreen title="Cat in a box d"></iframe>',
+    ].join('\n'),
+    'should support directives (youtube)',
+  );
+
+  t.equal(
+    micromark('a $youtube[Cat in a box]\n$br a', options({ youtube, '*': h })),
+    '<p>a <youtube>Cat in a box</youtube>\n<br> a</p>',
+    'should support fall through directives (`*`)',
+  );
+
+  t.equal(
+    micromark('a $a[$img(src="x" alt=y)](href="z")', options({ '*': h })),
+    '<p>a <a href="z"><img src="x" alt="y"></a></p>',
+    'should support fall through directives (`*`)',
+  );
+
+  t.end();
+});
+
+test('content', (t) => {
+  t.equal(
+    micromark('a $abbr[x\\&y&amp;z]', options({ abbr })),
+    '<p>a <abbr>x&amp;y&amp;z</abbr></p>',
+    'should support character escapes and character references in label',
+  );
+
+  t.equal(
+    micromark('a $abbr[x\\[y\\]z]', options({ abbr })),
+    '<p>a <abbr>x[y]z</abbr></p>',
+    'should support escaped brackets in a label',
+  );
+
+  t.equal(
+    micromark('a $abbr[x[y]z]', options({ abbr })),
+    '<p>a <abbr>x[y]z</abbr></p>',
+    'should support balanced brackets in a label',
+  );
+
+  t.equal(
+    micromark(
+      'a $abbr[1[2[3[4[5[6[7[8[9[10[11[12[13[14[15[16[17[18[19[20[21[22[23[24[25[26[27[28[29[30[31[32[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]',
+      options({ abbr }),
+    ),
+    '<p>a <abbr>1[2[3[4[5[6[7[8[9[10[11[12[13[14[15[16[17[18[19[20[21[22[23[24[25[26[27[28[29[30[31[32[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]</abbr></p>',
+    'should support balanced brackets in a label, 32 levels deep',
+  );
+
+  t.equal(
+    micromark(
+      '$abbr[1[2[3[4[5[6[7[8[9[10[11[12[13[14[15[16[17[18[19[20[21[22[23[24[25[26[27[28[29[30[31[32[33[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]',
+      options({ abbr }),
+    ),
+    '<p><abbr></abbr>[1[2[3[4[5[6[7[8[9[10[11[12[13[14[15[16[17[18[19[20[21[22[23[24[25[26[27[28[29[30[31[32[33[x]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]</p>',
+    'should *not* support balanced brackets in a label, 33 levels deep',
+  );
+
+  t.equal(
+    micromark('$abbr[a\nb\rc]', options({ abbr })),
+    '<p><abbr>a\nb\rc</abbr></p>',
+    'should support EOLs in a label',
+  );
+
+  t.equal(
+    micromark('$abbr[\na\r]', options({ abbr })),
+    '<p><abbr>\na\r</abbr></p>',
+    'should support EOLs at the edges of a label (1)',
+  );
+
+  t.equal(
+    micromark('$abbr[\n]', options({ abbr })),
+    '<p><abbr>\n</abbr></p>',
+    'should support EOLs at the edges of a label (2)',
+  );
+
+  // == does not work but I don't know why.. -- 2022.08.12 Yuki Takei
+  // t.equal(
+  //   micromark('$abbr[a\n$abbr[b]\nc]', options({ abbr })),
+  //   '<p>a <abbr>a\n<abbr>b</abbr>\nc</abbr> a</p>',
+  //   'should support EOLs around nested directives',
+  // );
+
+  t.equal(
+    micromark('$abbr[$abbr[\n]]', options({ abbr })),
+    '<p><abbr><abbr>\n</abbr></abbr></p>',
+    'should support EOLs inside nested directives (1)',
+  );
+
+  t.equal(
+    micromark('$abbr[$abbr[a\nb]]', options({ abbr })),
+    '<p><abbr><abbr>a\nb</abbr></abbr></p>',
+    'should support EOLs inside nested directives (2)',
+  );
+
+  t.equal(
+    micromark('$abbr[$abbr[\nb\n]]', options({ abbr })),
+    '<p><abbr><abbr>\nb\n</abbr></abbr></p>',
+    'should support EOLs inside nested directives (3)',
+  );
+
+  t.equal(
+    micromark('$abbr[$abbr[\\\n]]', options({ abbr })),
+    '<p><abbr><abbr><br />\n</abbr></abbr></p>',
+    'should support EOLs inside nested directives (4)',
+  );
+
+  t.equal(
+    micromark('a $abbr[a *b* **c** d]', options({ abbr })),
+    '<p>a <abbr>a <em>b</em> <strong>c</strong> d</abbr></p>',
+    'should support markdown in a label',
+  );
+
+  t.equal(
+    micromark('a $abbr(title=a&apos;b)', options({ abbr })),
+    '<p>a <abbr title="a\'b"></abbr></p>',
+    'should support character references in unquoted attribute values',
+  );
+
+  t.equal(
+    micromark('a $abbr(title="a&apos;b")', options({ abbr })),
+    '<p>a <abbr title="a\'b"></abbr></p>',
+    'should support character references in double attribute values',
+  );
+
+  t.equal(
+    micromark("a $abbr(title='a&apos;b')", options({ abbr })),
+    '<p>a <abbr title="a\'b"></abbr></p>',
+    'should support character references in single attribute values',
+  );
+
+  t.equal(
+    micromark('a $abbr(title="a&somethingelse;b")', options({ abbr })),
+    '<p>a <abbr title="a&amp;somethingelse;b"></abbr></p>',
+    'should support unknown character references in attribute values',
+  );
+
+  t.equal(
+    micromark('$span(a\nb)', options({ '*': h })),
+    '<p><span a="" b=""></span></p>',
+    'should support EOLs between attributes',
+  );
+
+  t.equal(
+    micromark('$span(\na\n)', options({ '*': h })),
+    '<p><span a=""></span></p>',
+    'should support EOLs at the edges of attributes',
+  );
+
+  t.equal(
+    micromark('$span(a\r= b)', options({ '*': h })),
+    '<p><span a="b"></span></p>',
+    'should support EOLs before initializer',
+  );
+
+  t.equal(
+    micromark('$span(a=\r\nb)', options({ '*': h })),
+    '<p><span a="b"></span></p>',
+    'should support EOLs after initializer',
+  );
+
+  t.equal(
+    micromark('$span(a=b\nc)', options({ '*': h })),
+    '<p><span a="b" c=""></span></p>',
+    'should support EOLs between an unquoted attribute value and a next attribute name',
+  );
+
+  t.equal(
+    micromark('$span(a="b\nc")', options({ '*': h })),
+    '<p><span a="b\nc"></span></p>',
+    'should support EOLs in a double quoted attribute value',
+  );
+
+  t.equal(
+    micromark("$span(a='b\nc')", options({ '*': h })),
+    '<p><span a="b\nc"></span></p>',
+    'should support EOLs in a single quoted attribute value',
+  );
+
+  t.equal(
+    micromark('a $span(#a#b)', options({ '*': h })),
+    '<p>a <span id="b"></span></p>',
+    'should support `id` shortcuts',
+  );
+
+  t.equal(
+    micromark('a $span(id=a id="b" #c#d)', options({ '*': h })),
+    '<p>a <span id="d"></span></p>',
+    'should support `id` shortcuts after `id` attributes',
+  );
+
+  t.equal(
+    micromark('a $span(.a.b)', options({ '*': h })),
+    '<p>a <span class="a b"></span></p>',
+    'should support `class` shortcuts',
+  );
+
+  t.equal(
+    micromark('a $span(class=a class="b c" .d.e)', options({ '*': h })),
+    '<p>a <span class="a b c d e"></span></p>',
+    'should support `class` shortcuts after `class` attributes',
+  );
+
+  t.end();
+});
+
+/** @type {Handle} */
+function abbr(d) {
+  if (d.type !== 'textGrowiPluginDirective') return false;
+
+  this.tag('<abbr');
+
+  if (d.attributes && 'title' in d.attributes) {
+    this.tag(` title="${this.encode(d.attributes.title)}"`);
+  }
+
+  this.tag('>');
+  this.raw(d.label || '');
+  this.tag('</abbr>');
+}
+
+/** @type {Handle} */
+function youtube(d) {
+  const attrs = d.attributes || {};
+  const v = attrs.v;
+  /** @type {string} */
+  let prop;
+
+  if (!v) return false;
+
+  const list = [
+    `src="https://www.youtube.com/embed/${this.encode(v)}"`,
+    'allowfullscreen',
+  ];
+
+  if (d.label) {
+    list.push(`title="${this.encode(d.label)}"`);
+  }
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (prop in attrs) {
+    if (prop !== 'v') {
+      list.push(`${this.encode(prop)}="${this.encode(attrs[prop])}"`);
+    }
+  }
+
+  this.tag(`<iframe ${list.join(' ')}>`);
+
+  if (d.content) {
+    this.lineEndingIfNeeded();
+    this.raw(d.content);
+    this.lineEndingIfNeeded();
+  }
+
+  this.tag('</iframe>');
+}
+
+/** @type {Handle} */
+function h(d) {
+  const content = d.content || d.label;
+  const attrs = d.attributes || {};
+  /** @type {Array.<string>} */
+  const list = [];
+  /** @type {string} */
+  let prop;
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (prop in attrs) {
+    if (own.call(attrs, prop)) {
+      list.push(`${this.encode(prop)}="${this.encode(attrs[prop])}"`);
+    }
+  }
+
+  this.tag(`<${d.name}`);
+  if (list.length > 0) this.tag(` ${list.join(' ')}`);
+  this.tag('>');
+
+  if (content) {
+    if (d.type === 'containerGrowiPluginDirective') this.lineEndingIfNeeded();
+    this.raw(content);
+    if (d.type === 'containerGrowiPluginDirective') this.lineEndingIfNeeded();
+  }
+
+  if (!htmlVoidElements.includes(d.name)) this.tag(`</${d.name}>`);
+}
+
+/**
+ * @param {HtmlOptions} [options]
+ */
+function options(options) {
+  return {
+    allowDangerousHtml: true,
+    extensions: [syntax()],
+    htmlExtensions: [html(options)],
+  };
+}

+ 71 - 0
packages/remark-growi-plugin/test/remark-growi-plugin.test.js

@@ -0,0 +1,71 @@
+/**
+ * @typedef {import('mdast').Root} Root
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { isHidden } from 'is-hidden';
+import { remark } from 'remark';
+import test from 'tape';
+import { readSync } from 'to-vfile';
+import { unified } from 'unified';
+
+import { remarkGrowiPlugin } from '../src/remark-growi-plugin.js';
+
+test('directive()', (t) => {
+  t.doesNotThrow(() => {
+    remark().use(remarkGrowiPlugin).freeze();
+  }, 'should not throw if not passed options');
+
+  t.doesNotThrow(() => {
+    unified().use(remarkGrowiPlugin).freeze();
+  }, 'should not throw if without parser or compiler');
+
+  t.end();
+});
+
+test('fixtures', (t) => {
+  const base = path.join('test', 'fixtures');
+  const entries = fs.readdirSync(base).filter(d => !isHidden(d));
+
+  t.plan(entries.length);
+
+  let index = -1;
+  while (++index < entries.length) {
+    const fixture = entries[index];
+    t.test(fixture, (st) => {
+      const file = readSync(path.join(base, fixture, 'input.md'));
+      const input = String(file);
+      const outputPath = path.join(base, fixture, 'output.md');
+      const treePath = path.join(base, fixture, 'tree.json');
+      const proc = remark().use(remarkGrowiPlugin).freeze();
+      const actual = proc.parse(file);
+      /** @type {string} */
+      let output;
+      /** @type {Root} */
+      let expected;
+
+      try {
+        expected = JSON.parse(String(fs.readFileSync(treePath)));
+      }
+      catch {
+        // New fixture.
+        fs.writeFileSync(treePath, `${JSON.stringify(actual, null, 2)}\n`);
+        expected = actual;
+      }
+
+      try {
+        output = fs.readFileSync(outputPath, 'utf8');
+      }
+      catch {
+        output = input;
+      }
+
+      st.deepEqual(actual, expected, 'tree');
+      st.equal(String(proc.processSync(file)), output, 'process');
+
+      st.end();
+    });
+  }
+});

+ 11 - 0
packages/remark-growi-plugin/tsconfig.base.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+  },
+  "include": [
+    "src"
+  ],
+  "exclude": [
+    "test"
+  ]
+}

+ 16 - 0
packages/remark-growi-plugin/tsconfig.build.json

@@ -0,0 +1,16 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "./src",
+    "outDir": "dist",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": false,
+    "noEmit": false,
+
+    "baseUrl": ".",
+    "paths": {
+    }
+  }
+}

+ 10 - 0
packages/remark-growi-plugin/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"],
+      "@growi/*": ["../*/src"]
+    }
+  }
+}

Разлика између датотеке није приказан због своје велике величине
+ 534 - 13
yarn.lock


Неке датотеке нису приказане због велике количине промена