Browse Source

Merge branch 'support/apply-nextjs-2' into imprv/101883-show-user-management-detail-page-new

kaori 3 years ago
parent
commit
eeda44b910
85 changed files with 6052 additions and 924 deletions
  1. 4 0
      .github/workflows/ci-app-prod.yml
  2. 12 4
      .github/workflows/ci-app.yml
  3. 0 4
      .vscode/launch.json
  4. 2 2
      package.json
  5. 0 1
      packages/app/docker/Dockerfile
  6. 6 0
      packages/app/jest.config.js
  7. 3 0
      packages/app/next.config.js
  8. 1 1
      packages/app/package.json
  9. 0 3
      packages/app/resource/locales/en_US/sandbox.md
  10. 0 3
      packages/app/resource/locales/ja_JP/sandbox.md
  11. 0 3
      packages/app/resource/locales/zh_CN/sandbox.md
  12. 0 151
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  13. 129 0
      packages/app/src/components/Admin/Security/LdapAuthTest.tsx
  14. 1 1
      packages/app/src/components/Admin/Security/LdapAuthTestModal.jsx
  15. 18 16
      packages/app/src/components/Layout/AdminLayout.tsx
  16. 15 1
      packages/app/src/components/Layout/RawLayout.tsx
  17. 56 36
      packages/app/src/components/Me/AssociateModal.tsx
  18. 41 19
      packages/app/src/components/Page.tsx
  19. 3 3
      packages/app/src/components/Page/RevisionRenderer.tsx
  20. 12 1
      packages/app/src/components/Theme/ThemeAntarctic.module.scss
  21. 9 0
      packages/app/src/components/Theme/ThemeAntarctic.tsx
  22. 12 1
      packages/app/src/components/Theme/ThemeChristmas.module.scss
  23. 9 0
      packages/app/src/components/Theme/ThemeChristmas.tsx
  24. 12 5
      packages/app/src/components/Theme/ThemeHalloween.module.scss
  25. 9 0
      packages/app/src/components/Theme/ThemeHalloween.tsx
  26. 13 2
      packages/app/src/components/Theme/ThemeHufflepuff.module.scss
  27. 13 0
      packages/app/src/components/Theme/ThemeHufflepuff.tsx
  28. 8 1
      packages/app/src/components/Theme/ThemeIsland.module.scss
  29. 9 0
      packages/app/src/components/Theme/ThemeIsland.tsx
  30. 13 1
      packages/app/src/components/Theme/ThemeSpring.module.scss
  31. 9 0
      packages/app/src/components/Theme/ThemeSpring.tsx
  32. 13 1
      packages/app/src/components/Theme/ThemeWood.module.scss
  33. 9 0
      packages/app/src/components/Theme/ThemeWood.tsx
  34. 34 0
      packages/app/src/components/Theme/utils/ThemeImageProvider.tsx
  35. 7 0
      packages/app/src/interfaces/ldap.ts
  36. 1 1
      packages/app/src/pages/[[...path]].page.tsx
  37. 27 0
      packages/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  38. 25 8
      packages/app/src/services/renderer/rehype-plugins/relative-links.ts
  39. 80 0
      packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts
  40. 16 3
      packages/app/src/services/renderer/renderer.ts
  41. 6 6
      packages/app/src/stores/context.tsx
  42. 18 7
      packages/app/src/stores/renderer.tsx
  43. 92 0
      packages/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  44. 0 1
      packages/plugin-pukiwiki-like-linker/.gitignore
  45. 0 36
      packages/plugin-pukiwiki-like-linker/README.md
  46. 0 29
      packages/plugin-pukiwiki-like-linker/package.json
  47. 0 6
      packages/plugin-pukiwiki-like-linker/src/client-entry.js
  48. 0 8
      packages/plugin-pukiwiki-like-linker/src/index.js
  49. 0 22
      packages/plugin-pukiwiki-like-linker/src/resource/js/util/PreProcessor/PukiwikiLikeLinker.js
  50. 0 18
      packages/plugin-pukiwiki-like-linker/tsconfig.build.esm.json
  51. 0 3
      packages/plugin-pukiwiki-like-linker/tsconfig.json
  52. 0 0
      packages/remark-growi-plugin/.eslintignore
  53. 13 0
      packages/remark-growi-plugin/.eslintrc.cjs
  54. 2 0
      packages/remark-growi-plugin/.gitignore
  55. 69 0
      packages/remark-growi-plugin/package.json
  56. 420 0
      packages/remark-growi-plugin/readme.md
  57. 6 0
      packages/remark-growi-plugin/src/index.js
  58. 32 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/complex-types.d.ts
  59. 4 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/consts.js
  60. 328 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/index.js
  61. 403 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/readme.md
  62. 7 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/index.js
  63. 138 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-leaf.js
  64. 108 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-text.js
  65. 336 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js
  66. 139 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-label.js
  67. 50 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-name.js
  68. 195 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/html.js
  69. 18 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/syntax.js
  70. 288 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/readme.md
  71. 35 0
      packages/remark-growi-plugin/src/remark-growi-plugin.js
  72. 11 0
      packages/remark-growi-plugin/test/fixtures/leaf/input.md
  73. 11 0
      packages/remark-growi-plugin/test/fixtures/leaf/output.md
  74. 266 0
      packages/remark-growi-plugin/test/fixtures/leaf/tree.json
  75. 7 0
      packages/remark-growi-plugin/test/fixtures/text/input.md
  76. 7 0
      packages/remark-growi-plugin/test/fixtures/text/output.md
  77. 429 0
      packages/remark-growi-plugin/test/fixtures/text/tree.json
  78. 585 0
      packages/remark-growi-plugin/test/mdast-util-growi-plugin.test.js
  79. 1064 0
      packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js
  80. 71 0
      packages/remark-growi-plugin/test/remark-growi-plugin.test.js
  81. 1 1
      packages/remark-growi-plugin/tsconfig.base.json
  82. 1 1
      packages/remark-growi-plugin/tsconfig.build.json
  83. 10 0
      packages/remark-growi-plugin/tsconfig.json
  84. 1 0
      tsconfig.base.json
  85. 250 514
      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

+ 0 - 4
.vscode/launch.json

@@ -76,10 +76,6 @@
             "url": "webpack://_n_e/plugin-attachment-refs",
             "url": "webpack://_n_e/plugin-attachment-refs",
             "path": "${workspaceFolder}/packages/plugin-attachment-refs"
             "path": "${workspaceFolder}/packages/plugin-attachment-refs"
           },
           },
-          {
-            "url": "webpack://_n_e/plugin-pukiwiki-like-linker",
-            "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
-          },
           {
           {
             "url": "webpack://_n_e/plugin-lsx",
             "url": "webpack://_n_e/plugin-lsx",
             "path": "${workspaceFolder}/packages/plugin-lsx"
             "path": "${workspaceFolder}/packages/plugin-lsx"

+ 2 - 2
package.json

@@ -66,7 +66,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-react-hooks": "^4.6.0",
-    "jest": "^27.0.6",
+    "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
     "lerna": "^4.0.0",
@@ -81,7 +81,7 @@
     "shipjs": "^0.24.1",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "stylelint-config-recess-order": "^3.0.0",
-    "ts-jest": "^27.0.4",
+    "ts-jest": "^28.0.7",
     "ts-node": "^10.9.1",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~4.7",
     "typescript": "~4.7",

+ 0 - 1
packages/app/docker/Dockerfile

@@ -101,7 +101,6 @@ COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-lsx packages/plugin-lsx
-COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
 COPY packages/slack packages/slack
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
 COPY packages/ui packages/ui
 
 

+ 6 - 0
packages/app/jest.config.js

@@ -20,6 +20,11 @@ module.exports = {
 
 
       preset: 'ts-jest/presets/js-with-ts',
       preset: 'ts-jest/presets/js-with-ts',
 
 
+      // transform ESM to CJS
+      transformIgnorePatterns: [
+        '/node_modules/(?!remark-gfm)/',
+      ],
+
       rootDir: '.',
       rootDir: '.',
       roots: ['<rootDir>'],
       roots: ['<rootDir>'],
       testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
       testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
@@ -29,6 +34,7 @@ module.exports = {
       // Automatically clear mock calls and instances between every test
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
       moduleNameMapper: MODULE_NAME_MAPPING,
+
     },
     },
     {
     {
       displayName: 'server',
       displayName: 'server',

+ 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',

+ 1 - 1
packages/app/package.json

@@ -67,7 +67,6 @@
     "@growi/core": "^5.1.3-RC.0",
     "@growi/core": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
     "@growi/plugin-lsx": "^5.1.3-RC.0",
     "@growi/plugin-lsx": "^5.1.3-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.3-RC.0",
     "@growi/slack": "^5.1.3-RC.0",
     "@growi/slack": "^5.1.3-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
@@ -169,6 +168,7 @@
     "remark-emoji": "^3.0.2",
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
     "remark-math": "^5.1.1",
+    "remark-wiki-link": "^1.0.4",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",

+ 0 - 3
packages/app/resource/locales/en_US/sandbox.md

@@ -245,9 +245,6 @@ You can create links using `[Display text](URL)`.
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
 
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 This is the most flexible linker.
 Both the page description and link address can be displayed on the page.
 Both the page description and link address can be displayed on the page.
 
 

+ 0 - 3
packages/app/resource/locales/ja_JP/sandbox.md

@@ -244,9 +244,6 @@ ___
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
 
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 最も柔軟な Linker です。
 最も柔軟な Linker です。
 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます。
 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます。
 
 

+ 0 - 3
packages/app/resource/locales/zh_CN/sandbox.md

@@ -245,9 +245,6 @@ You can create links using `[Display text](URL)`.
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
 
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 This is the most flexible linker.
 Both the page description and link address can be displayed on the page.
 Both the page description and link address can be displayed on the page.
 
 

+ 0 - 151
packages/app/src/components/Admin/Security/LdapAuthTest.jsx

@@ -1,151 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
-
-class LdapAuthTest extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      logs: '',
-      errorMessage: null,
-      successMessage: null,
-    };
-
-    this.addLogs = this.addLogs.bind(this);
-    this.testLdapCredentials = this.testLdapCredentials.bind(this);
-  }
-
-  /**
-   * add logs
-   */
-  addLogs(log) {
-    const newLog = `${new Date()} - ${log}\n\n`;
-    this.setState({
-      logs: `${newLog}${this.state.logs}`,
-    });
-  }
-
-  /**
-   * Test ldap auth
-   */
-  async testLdapCredentials() {
-    try {
-      const response = await apiPost('/login/testLdap', {
-        loginForm: {
-          username: this.props.username,
-          password: this.props.password,
-        },
-      });
-
-      // add logs
-      if (response.err) {
-        toastError(response.err);
-        this.addLogs(response.err);
-      }
-
-      if (response.status === 'warning') {
-        this.addLogs(response.message);
-        this.setState({ errorMessage: response.message, successMessage: null });
-      }
-
-      if (response.status === 'success') {
-        toastSuccess(response.message);
-        this.setState({ successMessage: response.message, errorMessage: null });
-      }
-
-      if (response.ldapConfiguration) {
-        const prettified = JSON.stringify(response.ldapConfiguration.server, undefined, 4);
-        this.addLogs(`LDAP Configuration : ${prettified}`);
-      }
-      if (response.ldapAccountInfo) {
-        const prettified = JSON.stringify(response.ldapAccountInfo, undefined, 4);
-        this.addLogs(`Retrieved LDAP Account : ${prettified}`);
-      }
-
-    }
-    // Catch server communication error
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <React.Fragment>
-        {this.state.successMessage != null && <div className="alert alert-success">{this.state.successMessage}</div>}
-        {this.state.errorMessage != null && <div className="alert alert-warning">{this.state.errorMessage}</div>}
-        <div className="form-group row">
-          <label htmlFor="username" className="col-3 col-form-label">{t('username')}</label>
-          <div className="col-6">
-            <input
-              className="form-control"
-              name="username"
-              value={this.props.username}
-              onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
-              autoComplete="off"
-            />
-          </div>
-        </div>
-        <div className="form-group row">
-          <label htmlFor="password" className="col-3 col-form-label">{t('Password')}</label>
-          <div className="col-6">
-            <input
-              className="form-control"
-              type="password"
-              name="password"
-              value={this.props.password}
-              onChange={(e) => { this.props.onChangePassword(e.target.value) }}
-              autoComplete="off"
-            />
-          </div>
-        </div>
-
-        <div className="form-group">
-          <label><h5>Logs</h5></label>
-          <textarea id="taLogs" className="col form-control" rows="4" value={this.state.logs} readOnly />
-        </div>
-
-        <div>
-          <button type="button" className="btn btn-outline-secondary offset-5 col-2" onClick={this.testLdapCredentials}>Test</button>
-        </div>
-      </React.Fragment>
-
-    );
-  }
-
-}
-
-const LdapAuthTestFc = (props) => {
-  const { t } = useTranslation();
-  return <LdapAuthTest t={t} {...props} />;
-};
-
-
-LdapAuthTest.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
-
-  username: PropTypes.string.isRequired,
-  password: PropTypes.string.isRequired,
-  onChangeUsername: PropTypes.func.isRequired,
-  onChangePassword: PropTypes.func.isRequired,
-};
-
-const LdapAuthTestWrapper = withUnstatedContainers(LdapAuthTestFc, [AdminLdapSecurityContainer]);
-
-export default LdapAuthTestWrapper;

+ 129 - 0
packages/app/src/components/Admin/Security/LdapAuthTest.tsx

@@ -0,0 +1,129 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import loggerFactory from '~/utils/logger';
+import { IResTestLdap } from '~/interfaces/ldap';
+
+const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
+
+type LdapAuthTestProps = {
+  username: string,
+  password: string,
+  onChangeUsername: (username: string) => void,
+  onChangePassword: (password: string) => void,
+}
+
+export const LdapAuthTest = (props: LdapAuthTestProps): JSX.Element => {
+  const {
+    username, password, onChangeUsername, onChangePassword,
+  } = props;
+  const { t } = useTranslation();
+  const [logs, setLogs] = useState('');
+  const [errorMessage, setErrorMessage] = useState('');
+  const [successMessage, setSuccessMessage] = useState('');
+
+  /**
+   * add logs
+   */
+  const addLogs = (log) => {
+    const newLog = `${new Date()} - ${log}\n\n`;
+    setLogs(`${newLog}${logs}`);
+  };
+
+  /**
+   * Test ldap auth
+   */
+  const testLdapCredentials = async() => {
+    try {
+      const response = await apiPost<IResTestLdap>('/login/testLdap', {
+        loginForm: {
+          username,
+          password,
+        },
+      });
+
+      const {
+        err, message, status, ldapConfiguration, ldapAccountInfo,
+      } = response;
+
+      // add logs
+      if (err) {
+        toastError(err);
+        addLogs(err);
+      }
+
+      if (status === 'warning') {
+        addLogs(message);
+        setErrorMessage(message);
+        setSuccessMessage('');
+      }
+
+      if (status === 'success') {
+        toastSuccess(message);
+        setSuccessMessage(message);
+        setErrorMessage('');
+      }
+
+      if (ldapConfiguration) {
+        const prettified = JSON.stringify(ldapConfiguration.server, undefined, 4);
+        addLogs(`LDAP Configuration : ${prettified}`);
+      }
+      if (ldapAccountInfo) {
+        const prettified = JSON.stringify(ldapAccountInfo, undefined, 4);
+        addLogs(`Retrieved LDAP Account : ${prettified}`);
+      }
+
+    }
+    // Catch server communication error
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+
+  return (
+    <React.Fragment>
+      {successMessage !== '' && <div className="alert alert-success">{successMessage}</div>}
+      {errorMessage !== '' && <div className="alert alert-warning">{errorMessage}</div>}
+      <div className="form-group row">
+        <label htmlFor="username" className="col-3 col-form-label">{t('username')}</label>
+        <div className="col-6">
+          <input
+            className="form-control"
+            name="username"
+            value={username}
+            onChange={(e) => { onChangeUsername(e.target.value) }}
+            autoComplete="off"
+          />
+        </div>
+      </div>
+      <div className="form-group row">
+        <label htmlFor="password" className="col-3 col-form-label">{t('Password')}</label>
+        <div className="col-6">
+          <input
+            className="form-control"
+            type="password"
+            name="password"
+            value={password}
+            onChange={(e) => { onChangePassword(e.target.value) }}
+            autoComplete="off"
+          />
+        </div>
+      </div>
+
+      <div className="form-group">
+        <label><h5>Logs</h5></label>
+        <textarea id="taLogs" className="col form-control" rows={4} value={logs} readOnly />
+      </div>
+
+      <div>
+        <button type="button" className="btn btn-outline-secondary offset-5 col-2" onClick={testLdapCredentials}>Test</button>
+      </div>
+    </React.Fragment>
+
+  );
+};

+ 1 - 1
packages/app/src/components/Admin/Security/LdapAuthTestModal.jsx

@@ -9,7 +9,7 @@ import {
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import LdapAuthTest from './LdapAuthTest';
+import { LdapAuthTest } from './LdapAuthTest';
 
 
 
 
 class LdapAuthTestModal extends React.Component {
 class LdapAuthTestModal extends React.Component {

+ 18 - 16
packages/app/src/components/Layout/AdminLayout.tsx

@@ -31,26 +31,28 @@ const AdminLayout = ({
   const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
   const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 
 
   return (
   return (
-    <RawLayout title={title} className={`admin-page ${styles['admin-page']}`}>
-      <GrowiNavbar />
-
-      <header className="py-0">
-        <h1 className="title">{title}</h1>
-      </header>
-      <div id="main" className="main">
-        <div className="container-fluid">
-          <div className="row">
-            <div className="col-lg-3">
-              <AdminNavigation selected={selectedNavOpt} />
-            </div>
-            <div className="col-lg-9">
-              {title != null ? children : <AdminNotFoundPage />}
+    <RawLayout title={title}>
+      <div className={`admin-page ${styles['admin-page']}`}>
+        <GrowiNavbar />
+
+        <header className="py-0 position-relative">
+          <h1 className="title">{title}</h1>
+        </header>
+        <div id="main" className="main">
+          <div className="container-fluid">
+            <div className="row">
+              <div className="col-lg-3">
+                <AdminNavigation selected={selectedNavOpt} />
+              </div>
+              <div className="col-lg-9">
+                {children}
+              </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
-      </div>
 
 
-      <SystemVersion />
+        <SystemVersion />
+      </div>
     </RawLayout>
     </RawLayout>
   );
   );
 };
 };

+ 15 - 1
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,10 +1,14 @@
-import React, { ReactNode, useEffect, useState } from 'react';
+import React, {
+  ReactNode, useCallback, useEffect, useState,
+} from 'react';
 
 
 import Head from 'next/head';
 import Head from 'next/head';
+import Image from 'next/image';
 
 
 import { useGrowiTheme } from '~/stores/context';
 import { useGrowiTheme } from '~/stores/context';
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
 
+import { getBackgroundImageSrc } from '../Theme/utils/ThemeImageProvider';
 import { ThemeProvider } from '../Theme/utils/ThemeProvider';
 import { ThemeProvider } from '../Theme/utils/ThemeProvider';
 
 
 type Props = {
 type Props = {
@@ -25,12 +29,19 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   const { resolvedTheme } = useNextThemes();
   const { resolvedTheme } = useNextThemes();
 
 
   const [colorScheme, setColorScheme] = useState<Themes|undefined>(undefined);
   const [colorScheme, setColorScheme] = useState<Themes|undefined>(undefined);
+  const [backgroundImageSrc, setBackgroundImageSrc] = useState<string | undefined>(undefined);
 
 
   // set colorScheme in CSR
   // set colorScheme in CSR
   useEffect(() => {
   useEffect(() => {
     setColorScheme(resolvedTheme as Themes);
     setColorScheme(resolvedTheme as Themes);
   }, [resolvedTheme]);
   }, [resolvedTheme]);
 
 
+  // set background image
+  useEffect(() => {
+    const imgSrc = getBackgroundImageSrc(growiTheme, colorScheme);
+    setBackgroundImageSrc(imgSrc);
+  }, [growiTheme, colorScheme]);
+
   return (
   return (
     <>
     <>
       <Head>
       <Head>
@@ -40,6 +51,9 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
       </Head>
       </Head>
       <ThemeProvider theme={growiTheme}>
       <ThemeProvider theme={growiTheme}>
         <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
         <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
+          {backgroundImageSrc != null && <div className="grw-bg-image-wrapper">
+            <Image className='grw-bg-image' alt='background-image' src={backgroundImageSrc} layout='fill' quality="100" />
+          </div>}
           {children}
           {children}
         </div>
         </div>
       </ThemeProvider>
       </ThemeProvider>

+ 56 - 36
packages/app/src/components/Me/AssociateModal.tsx

@@ -6,12 +6,16 @@ import {
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
+  Nav,
+  NavLink,
+  TabContent,
+  TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 
 
 type Props = {
 type Props = {
   isOpen: boolean,
   isOpen: boolean,
@@ -22,6 +26,7 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { associateLdapAccount } = usePersonalSettings();
   const { associateLdapAccount } = usePersonalSettings();
+  const [activeTab, setActiveTab] = useState(1);
   const { isOpen, onClose } = props;
   const { isOpen, onClose } = props;
 
 
   const [username, setUsername] = useState('');
   const [username, setUsername] = useState('');
@@ -55,46 +60,61 @@ const AssociateModal = (props: Props): JSX.Element => {
         { t('admin:user_management.create_external_account') }
         { t('admin:user_management.create_external_account') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
-          <li className="nav-item active">
-            <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
+        <div>
+          <Nav tabs className='mb-2'>
+            <NavLink
+              className={activeTab === 1 ? 'active' : ''}
+              onClick={() => setActiveTab(1)}
+            >
               <i className="fa fa-sitemap"></i> LDAP
               <i className="fa fa-sitemap"></i> LDAP
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 2 ? 'active' : ''}
+              onClick={() => setActiveTab(2)}
+            >
               <i className="fa fa-github"></i> (TBD) GitHub
               <i className="fa fa-github"></i> (TBD) GitHub
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 3 ? 'active' : ''}
+              onClick={() => setActiveTab(3)}
+            >
               <i className="fa fa-google"></i> (TBD) Google OAuth
               <i className="fa fa-google"></i> (TBD) Google OAuth
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 4 ? 'active' : ''}
+              onClick={() => setActiveTab(4)}
+            >
               <i className="fa fa-facebook"></i> (TBD) Facebook
               <i className="fa fa-facebook"></i> (TBD) Facebook
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 5 ? 'active' : ''}
+              onClick={() => setActiveTab(5)}
+            >
               <i className="fa fa-twitter"></i> (TBD) Twitter
               <i className="fa fa-twitter"></i> (TBD) Twitter
-            </a>
-          </li>
-        </ul>
-        <div className="tab-content">
-          <div id="passport-ldap" className="tab-pane active">
-            <LdapAuthTest
-              username={username}
-              password={password}
-              onChangeUsername={username => setUsername(username)}
-              onChangePassword={password => setPassword(password)}
-            />
-          </div>
-          <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
+            </NavLink>
+          </Nav>
+          <TabContent activeTab={activeTab}>
+            <TabPane tabId={1}>
+              <LdapAuthTest
+                username={username}
+                password={password}
+                onChangeUsername={username => setUsername(username)}
+                onChangePassword={password => setPassword(password)}
+              />
+            </TabPane>
+            <TabPane tabId={2}>
+              TBD
+            </TabPane>
+            <TabPane tabId={3}>
+              TBD
+            </TabPane>
+            <TabPane tabId={4}>
+              TBD
+            </TabPane>
+            <TabPane tabId={5}>
+              TBD
+            </TabPane>
+          </TabContent>
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter className="border-top-0">
       <ModalFooter className="border-top-0">

+ 41 - 19
packages/app/src/components/Page.jsx → packages/app/src/components/Page.tsx

@@ -1,15 +1,15 @@
 import React, {
 import React, {
+  useCallback,
   useEffect, useRef, useState,
   useEffect, useRef, useState,
 } from 'react';
 } from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-import PropTypes from 'prop-types';
 // import { debounce } from 'throttle-debounce';
 // import { debounce } from 'throttle-debounce';
 
 
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot,
+  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -22,6 +22,7 @@ import {
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
+import { HtmlElementNode } from 'rehype-toc';
 
 
 // TODO: import dynamically
 // TODO: import dynamically
 // import MarkdownTable from '~/client/models/MarkdownTable';
 // import MarkdownTable from '~/client/models/MarkdownTable';
@@ -30,9 +31,28 @@ import RevisionRenderer from './Page/RevisionRenderer';
 
 
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
 
 
-class PageSubstance extends React.Component {
+type PageSubstanceProps = {
+  rendererOptions: any,
+  page: any,
+  pageTags?: string[],
+  editorMode: string,
+  isGuestUser: boolean,
+  isMobile?: boolean,
+  isSlackEnabled: boolean,
+  slackChannels: string,
+};
+
+class PageSubstance extends React.Component<PageSubstanceProps> {
+
+  gridEditModal: any;
+
+  linkEditModal: any;
+
+  handsontableModal: any;
+
+  drawioModal: any;
 
 
-  constructor(props) {
+  constructor(props: PageSubstanceProps) {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
@@ -138,7 +158,7 @@ class PageSubstance extends React.Component {
     // }
     // }
   }
   }
 
 
-  render() {
+  override render() {
     const {
     const {
       rendererOptions, page, isMobile, isGuestUser,
       rendererOptions, page, isMobile, isGuestUser,
     } = this.props;
     } = this.props;
@@ -171,29 +191,26 @@ class PageSubstance extends React.Component {
 
 
 }
 }
 
 
-PageSubstance.propTypes = {
-  rendererOptions: PropTypes.object.isRequired,
+export const Page = (props) => {
+  // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
+  // The toc node passed by customizeTOC is assigned to tocRef.current.
+  const tocRef = useRef<HtmlElementNode>();
 
 
-  page: PropTypes.any.isRequired,
-  pageTags:  PropTypes.arrayOf(PropTypes.string),
-  editorMode: PropTypes.string.isRequired,
-  isGuestUser: PropTypes.bool.isRequired,
-  isMobile: PropTypes.bool,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-};
+  const storeTocNodeHandler = useCallback((toc: HtmlElementNode) => {
+    tocRef.current = toc;
+  }, []);
 
 
-export const Page = (props) => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors();
-  const { data: rendererOptions } = useViewOptions();
+  const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
+  const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
   const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
+  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
 
   const pageRef = useRef(null);
   const pageRef = useRef(null);
 
 
@@ -206,6 +223,11 @@ export const Page = (props) => {
     mutateBlinkedAtBoot(true);
     mutateBlinkedAtBoot(true);
   }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
   }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
 
 
+  useEffect(() => {
+    mutateCurrentPageTocNode(tocRef.current);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
+
   // // set handler to open DrawioModal
   // // set handler to open DrawioModal
   // useEffect(() => {
   // useEffect(() => {
   //   const handler = (beginLineNumber, endLineNumber) => {
   //   const handler = (beginLineNumber, endLineNumber) => {
@@ -253,7 +275,7 @@ export const Page = (props) => {
       isMobile={isMobile}
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       isSlackEnabled={isSlackEnabled}
       pageTags={pageTags}
       pageTags={pageTags}
-      slackChannels={slackChannelsData.toString()}
+      slackChannels={slackChannelsData?.toString()}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
     />
   );
   );

+ 3 - 3
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -8,7 +8,6 @@ import { CustomWindow } from '~/interfaces/global';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
-import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // import RevisionBody from './RevisionBody';
 // import RevisionBody from './RevisionBody';
@@ -95,7 +94,7 @@ type Props = {
   additionalClassName?: string,
   additionalClassName?: string,
 }
 }
 
 
-const RevisionRenderer = (props: Props): JSX.Element => {
+const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
 
   const {
   const {
     rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
     rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
@@ -246,6 +245,7 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   //   />
   //   />
   // );
   // );
 
 
-};
+});
+RevisionRenderer.displayName = 'RevisionRenderer';
 
 
 export default RevisionRenderer;
 export default RevisionRenderer;

+ 12 - 1
packages/app/src/components/Theme/ThemeAntarctic.module.scss

@@ -16,7 +16,6 @@
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/antarctic/bg.svg');
     background-image: url('/images/themes/antarctic/bg.svg');
     background-attachment: fixed;
     background-attachment: fixed;
@@ -34,6 +33,18 @@
   }
   }
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+  }
+}
+
 $themecolor: #000080;
 $themecolor: #000080;
 $themelight: #f0f8ff;
 $themelight: #f0f8ff;
 $accentcolor: #ffd700;
 $accentcolor: #ffd700;

+ 9 - 0
packages/app/src/components/Theme/ThemeAntarctic.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeAntarctic.module.scss';
 import styles from './ThemeAntarctic.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/antarctic/bg.svg';
+  }
+};
+
 const ThemeAntarctic = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeAntarctic = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 12 - 1
packages/app/src/components/Theme/ThemeChristmas.module.scss

@@ -27,7 +27,6 @@ $color-link-wiki-hover: lighten($color-link-wiki, 15%);
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/christmas/christmas.jpg');
     background-image: url('/images/themes/christmas/christmas.jpg');
     background-attachment: fixed;
     background-attachment: fixed;
@@ -36,6 +35,18 @@ $color-link-wiki-hover: lighten($color-link-wiki, 15%);
   }
   }
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {

+ 9 - 0
packages/app/src/components/Theme/ThemeChristmas.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeChristmas.module.scss';
 import styles from './ThemeChristmas.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/christmas/christmas.jpg';
+  }
+};
+
 const ThemeChristmas = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeChristmas = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 12 - 5
packages/app/src/components/Theme/ThemeHalloween.module.scss

@@ -21,17 +21,24 @@ $light: lighten($secondary, 10%);
 // $dark: #;
 // $dark: #;
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
-  #wrapper > .navbar {
-    background-image: url(/images/themes/halloween/halloween-navbar.jpg);
-  }
-
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/halloween/halloween.jpg');
     background-image: url('/images/themes/halloween/halloween.jpg');
   }
   }
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-navbar {
+    background-image: url('/images/themes/halloween/halloween-navbar.jpg') !important;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {

+ 9 - 0
packages/app/src/components/Theme/ThemeHalloween.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeHalloween.module.scss';
 import styles from './ThemeHalloween.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/halloween/halloween.jpg';
+  }
+};
+
 const ThemeHalloween = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeHalloween = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 13 - 2
packages/app/src/components/Theme/ThemeHufflepuff.module.scss

@@ -18,6 +18,19 @@
 //   border-bottom: $accentcolor 4px solid;
 //   border-bottom: $accentcolor 4px solid;
 // }
 // }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme[data-color-scheme='light'] :global {
 .theme[data-color-scheme='light'] :global {
@@ -107,7 +120,6 @@
 
 
   .growi:not(.login-page) {
   .growi:not(.login-page) {
     // add background-image
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-light3.png');
       background-image: url('/images/themes/hufflepuff/badger-light3.png');
       background-attachment: fixed;
       background-attachment: fixed;
@@ -275,7 +287,6 @@
 
 
   .growi:not(.login-page) {
   .growi:not(.login-page) {
     // add background-image
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
       background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
       background-attachment: fixed;
       background-attachment: fixed;

+ 13 - 0
packages/app/src/components/Theme/ThemeHufflepuff.tsx

@@ -1,7 +1,20 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeHufflepuff.module.scss';
 import styles from './ThemeHufflepuff.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    case Themes.light:
+      return '/images/themes/hufflepuff/badger-light3.png';
+    case Themes.dark:
+      return '/images/themes/hufflepuff/badger-dark.jpg';
+    default:
+      return '/images/themes/hufflepuff/badger-light3.png';
+  }
+};
+
 const ThemeHufflepuff = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeHufflepuff = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 8 - 1
packages/app/src/components/Theme/ThemeIsland.module.scss

@@ -6,6 +6,14 @@
 $color-primary: #97cbc3;
 $color-primary: #97cbc3;
 $color-themelight: rgba(183, 226, 219, 1);
 $color-themelight: rgba(183, 226, 219, 1);
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+}
+
 .theme :global {
 .theme :global {
   $primary: $color-primary;
   $primary: $color-primary;
   // Background colors
   // Background colors
@@ -89,7 +97,6 @@ $color-themelight: rgba(183, 226, 219, 1);
     background: lighten($color-themelight, 5%);
     background: lighten($color-themelight, 5%);
   }
   }
 
 
-  #wrapper > #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/island/island.png');
     background-image: url('/images/themes/island/island.png');
     background-attachment: fixed;
     background-attachment: fixed;

+ 9 - 0
packages/app/src/components/Theme/ThemeIsland.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeIsland.module.scss';
 import styles from './ThemeIsland.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/island/island.png';
+  }
+};
+
 const ThemeIsland = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeIsland = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 13 - 1
packages/app/src/components/Theme/ThemeSpring.module.scss

@@ -25,6 +25,19 @@ $accentcolor: #e08dbc;
   border-bottom: $accentcolor 4px solid;
   border-bottom: $accentcolor 4px solid;
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {
@@ -107,7 +120,6 @@ $accentcolor: #e08dbc;
 
 
   .growi:not(.login-page) {
   .growi:not(.login-page) {
     // add background-image
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-image: url('/images/themes/spring/spring02.svg');
       background-image: url('/images/themes/spring/spring02.svg');
       background-attachment: fixed;
       background-attachment: fixed;

+ 9 - 0
packages/app/src/components/Theme/ThemeSpring.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeSpring.module.scss';
 import styles from './ThemeSpring.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/spring/spring02.svg';
+  }
+};
+
 const ThemeSpring = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeSpring = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 13 - 1
packages/app/src/components/Theme/ThemeWood.module.scss

@@ -14,7 +14,6 @@
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/wood/wood.jpg');
     background-image: url('/images/themes/wood/wood.jpg');
     background-attachment: fixed;
     background-attachment: fixed;
@@ -35,6 +34,19 @@
 $themecolor: #b9b177;
 $themecolor: #b9b177;
 $themelight: #f5f3ee;
 $themelight: #f5f3ee;
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: center center;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {

+ 9 - 0
packages/app/src/components/Theme/ThemeWood.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeWood.module.scss';
 import styles from './ThemeWood.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/wood/wood.jpg';
+  }
+};
+
 const ThemeWood = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeWood = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 34 - 0
packages/app/src/components/Theme/utils/ThemeImageProvider.tsx

@@ -0,0 +1,34 @@
+import { GrowiThemes } from '~/interfaces/theme';
+import { Themes } from '~/stores/use-next-themes';
+
+import { getBackgroundImageSrc as getAntarcticBackgroundImageSrc } from '../ThemeAntarctic';
+import { getBackgroundImageSrc as getChristmasBackgroundImageSrc } from '../ThemeChristmas';
+import { getBackgroundImageSrc as getHalloweenBackgroundImageSrc } from '../ThemeHalloween';
+import { getBackgroundImageSrc as getHuffulePuffBackgroundImageSrc } from '../ThemeHufflepuff';
+import { getBackgroundImageSrc as getIslandBackgroundImageSrc } from '../ThemeIsland';
+import { getBackgroundImageSrc as getSpringBackgroundImageSrc } from '../ThemeSpring';
+import { getBackgroundImageSrc as getWoodBackgroundImageSrc } from '../ThemeWood';
+
+export const getBackgroundImageSrc = (theme: GrowiThemes | undefined, colorScheme: Themes | undefined): string | undefined => {
+  if (theme == null || colorScheme == null) {
+    return undefined;
+  }
+  switch (theme) {
+    case GrowiThemes.ANTARCTIC:
+      return getAntarcticBackgroundImageSrc(colorScheme);
+    case GrowiThemes.CHRISTMAS:
+      return getChristmasBackgroundImageSrc(colorScheme);
+    case GrowiThemes.HALLOWEEN:
+      return getHalloweenBackgroundImageSrc(colorScheme);
+    case GrowiThemes.ISLAND:
+      return getIslandBackgroundImageSrc(colorScheme);
+    case GrowiThemes.HUFFLEPUFF:
+      return getHuffulePuffBackgroundImageSrc(colorScheme);
+    case GrowiThemes.SPRING:
+      return getSpringBackgroundImageSrc(colorScheme);
+    case GrowiThemes.WOOD:
+      return getWoodBackgroundImageSrc(colorScheme);
+    default:
+      return undefined;
+  }
+};

+ 7 - 0
packages/app/src/interfaces/ldap.ts

@@ -0,0 +1,7 @@
+export interface IResTestLdap {
+  err?: any,
+  message: string,
+  status: string,
+  ldapConfiguration?: any,
+  ldapAccountInfo?: any,
+}

+ 1 - 1
packages/app/src/pages/[[...path]].page.tsx

@@ -283,7 +283,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
       </Head>
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
-        <header className="py-0">
+        <header className="py-0 position-relative">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
         </header>
         <div className="d-edit-none">
         <div className="d-edit-none">

+ 27 - 0
packages/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts

@@ -0,0 +1,27 @@
+import { pathUtils } from '@growi/core';
+import { selectAll } from 'hast-util-select';
+import { Plugin } from 'unified';
+
+import {
+  IAnchorsSelector, IHrefResolver, relativeLinks, RelativeLinksPluginParams,
+} from './relative-links';
+
+const customAnchorsSelector: IAnchorsSelector = (node) => {
+  return selectAll('a[href].pukiwiki-like-linker', node);
+};
+
+const customHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+  // generate relative pathname
+  const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
+  const relativeUrl = new URL(relativeHref, baseUrl);
+
+  return relativeUrl.pathname;
+};
+
+export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+  return relativeLinks.bind(this)({
+    ...options,
+    anchorsSelector: customAnchorsSelector,
+    hrefResolver: customHrefResolver,
+  });
+};

+ 25 - 8
packages/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,19 +1,40 @@
-import { selectAll, HastNode } from 'hast-util-select';
+import { selectAll, HastNode, Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
 import isAbsolute from 'is-absolute-url';
 import { Plugin } from 'unified';
 import { Plugin } from 'unified';
 
 
-type RelativeLinksPluginParams = {
+export type IAnchorsSelector = (node: HastNode) => Element[];
+export type IHrefResolver = (relativeHref: string, basePath: string) => string;
+
+const defaultAnchorsSelector: IAnchorsSelector = (node) => {
+  return selectAll('a[href]', node);
+};
+
+const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+  // generate relative pathname
+  const baseUrl = new URL(basePath, 'https://example.com');
+  const relativeUrl = new URL(relativeHref, baseUrl);
+
+  return relativeUrl.pathname;
+};
+
+
+export type RelativeLinksPluginParams = {
   pagePath?: string,
   pagePath?: string,
+  anchorsSelector?: IAnchorsSelector,
+  hrefResolver?: IHrefResolver,
 }
 }
 
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+  const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
+  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+
   return (tree) => {
   return (tree) => {
     if (options.pagePath == null) {
     if (options.pagePath == null) {
       return;
       return;
     }
     }
 
 
     const pagePath = options.pagePath;
     const pagePath = options.pagePath;
-    const anchors = selectAll('a[href]', tree as HastNode);
+    const anchors = anchorsSelector(tree as HastNode);
 
 
     anchors.forEach((anchor) => {
     anchors.forEach((anchor) => {
       if (anchor.properties == null) {
       if (anchor.properties == null) {
@@ -25,11 +46,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         return;
         return;
       }
       }
 
 
-      // generate relative pathname
-      const baseUrl = new URL(pagePath, 'https://example.com');
-      const relativeUrl = new URL(href, baseUrl);
-
-      anchor.properties.href = relativeUrl.pathname;
+      anchor.properties.href = hrefResolver(href, pagePath);
     });
     });
   };
   };
 };
 };

+ 80 - 0
packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts

@@ -0,0 +1,80 @@
+import { fromMarkdown, toMarkdown } from 'mdast-util-wiki-link';
+import { syntax } from 'micromark-extension-wiki-link';
+import { Plugin } from 'unified';
+
+
+type FromMarkdownExtension = {
+  enter: {
+    wikiLink: (token: string) => void,
+  },
+  exit: {
+    wikiLinkTarget: (token: string) => void,
+    wikiLinkAlias: (token: string) => void,
+    wikiLink: (token: string) => void,
+  }
+}
+
+type FromMarkdownData = {
+  value: string | null,
+  data: {
+    alias: string | null,
+    hProperties: Record<string, unknown>,
+  }
+}
+
+function swapTargetAndAlias(fromMarkdownExtension: FromMarkdownExtension): FromMarkdownExtension {
+  return {
+    enter: fromMarkdownExtension.enter,
+    exit: {
+      wikiLinkTarget: fromMarkdownExtension.exit.wikiLinkTarget,
+      wikiLinkAlias: fromMarkdownExtension.exit.wikiLinkAlias,
+      wikiLink(token: string) {
+        const wikiLink: FromMarkdownData = this.stack[this.stack.length - 1];
+
+        // swap target and alias
+        //    The default Wiki Link behavior: [[${target}${aliasDivider}${alias}]]
+        //    After swapping:                 [[${alias}${aliasDivider}${target}]]
+        const target = wikiLink.value;
+        const alias = wikiLink.data.alias;
+        if (target != null && alias != null) {
+          wikiLink.value = alias;
+          wikiLink.data.alias = target;
+        }
+
+        // invoke original wikiLink method
+        const orgWikiLink = fromMarkdownExtension.exit.wikiLink.bind(this);
+        orgWikiLink(token);
+      },
+    },
+  };
+}
+
+/**
+ * Implemented with reference to https://github.com/landakram/remark-wiki-link/blob/master/src/index.js
+ */
+export const pukiwikiLikeLinker: Plugin = function() {
+  const data = this.data();
+
+  function add(field: string, value) {
+    if (data[field] != null) {
+      const array = data[field];
+      if (Array.isArray(array)) {
+        array.push(value);
+      }
+    }
+    else {
+      data[field] = [value];
+    }
+  }
+
+  add('micromarkExtensions', syntax({
+    aliasDivider: '>',
+  }));
+  add('fromMarkdownExtensions', swapTargetAndAlias(fromMarkdown({
+    wikiLinkClassName: 'pukiwiki-like-linker',
+    newClassName: ' ',
+    pageResolver: value => [value],
+    hrefTemplate: permalink => permalink,
+  })));
+  add('toMarkdownExtensions', toMarkdown({}));
+};

+ 16 - 3
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';
@@ -17,6 +19,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import { addClass } from './rehype-plugins/add-class';
 import { addClass } from './rehype-plugins/add-class';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinks } from './rehype-plugins/relative-links';
+import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
+import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 
 
 // import CsvToTable from './PreProcessor/CsvToTable';
 // import CsvToTable from './PreProcessor/CsvToTable';
 // import EasyGrid from './PreProcessor/EasyGrid';
 // import EasyGrid from './PreProcessor/EasyGrid';
@@ -217,9 +221,14 @@ export type RendererOptions = Partial<ReactMarkdownOptions>;
 
 
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
   return {
   return {
-    remarkPlugins: [gfm],
+    remarkPlugins: [
+      gfm,
+      pukiwikiLikeLinker,
+      growiPlugin,
+    ],
     rehypePlugins: [
     rehypePlugins: [
       slug,
       slug,
+      [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
       raw,
       [sanitize, {
       [sanitize, {
@@ -245,7 +254,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
 export const generateViewOptions = (
 export const generateViewOptions = (
     pagePath: string,
     pagePath: string,
     config: RendererConfig,
     config: RendererConfig,
-    storeTocNode: (node: HtmlElementNode) => void,
+    storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
 ): RendererOptions => {
 
 
   const options = generateCommonOptions(pagePath, config);
   const options = generateCommonOptions(pagePath, config);
@@ -280,7 +289,11 @@ export const generateViewOptions = (
           });
           });
         };
         };
         replacer([toc]); // replace <ol> to <ul>
         replacer([toc]); // replace <ol> to <ul>
-        storeTocNode(toc); // store tocNode to global state with swr
+
+        // For storing tocNode to global state with swr
+        // search: tocRef.current
+        storeTocNode(toc);
+
         return false; // not show toc in body
         return false; // not show toc in body
       },
       },
     }]);
     }]);

+ 6 - 6
packages/app/src/stores/context.tsx

@@ -1,5 +1,3 @@
-import EventEmitter from 'events';
-
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
@@ -232,10 +230,6 @@ export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<Ren
   return useStaticSWR('growiRendererConfig', initialData);
   return useStaticSWR('growiRendererConfig', initialData);
 };
 };
 
 
-export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
-  return useStaticSWR('currentPageTocNode');
-};
-
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR('isBlinkedAtBoot', initialData);
   return useStaticSWR('isBlinkedAtBoot', initialData);
 };
 };
@@ -278,3 +272,9 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
     },
     },
   );
   );
 };
 };
+
+export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  return useStaticSWR(['currentPageTocNode', currentPagePath]);
+};

+ 18 - 7
packages/app/src/stores/renderer.tsx

@@ -1,3 +1,4 @@
+import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -9,7 +10,9 @@ import {
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
 
 
 
 
-import { useCurrentPagePath, useCurrentPageTocNode, useRendererConfig } from './context';
+import {
+  useCurrentPagePath, useCurrentPageTocNode, useRendererConfig,
+} from './context';
 
 
 interface ReactMarkdownOptionsGenerator {
 interface ReactMarkdownOptionsGenerator {
   (config: RendererConfig): RendererOptions
   (config: RendererConfig): RendererOptions
@@ -37,10 +40,9 @@ const _useOptionsBase = (
   return useSWRImmutable<RendererOptions, Error>(key);
   return useSWRImmutable<RendererOptions, Error>(key);
 };
 };
 
 
-export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
+export const useViewOptions = (storeTocNodeHandler: (toc: HtmlElementNode) => void): SWRResponse<RendererOptions, Error> => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: rendererConfig } = useRendererConfig();
   const { data: rendererConfig } = useRendererConfig();
-  const { mutate: storeTocNode } = useCurrentPageTocNode();
 
 
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
 
 
@@ -50,16 +52,25 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
 
 
   return useSWRImmutable<RendererOptions, Error>(
   return useSWRImmutable<RendererOptions, Error>(
     key,
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNode),
+    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
   );
   );
 };
 };
 
 
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'tocOptions';
-
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
   const { data: tocNode } = useCurrentPageTocNode();
   const { data: tocNode } = useCurrentPageTocNode();
 
 
-  return _useOptionsBase(key, config => generateTocOptions(config, tocNode));
+  const isAllDataValid = rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['tocOptions', currentPagePath, tocNode, rendererConfig]
+    : null;
+
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, path, tocNode, rendererConfig) => generateTocOptions(rendererConfig, tocNode),
+  );
 };
 };
 
 
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {

+ 92 - 0
packages/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts

@@ -0,0 +1,92 @@
+import { HastNode, selectAll } from 'hast-util-select';
+import parse from 'remark-parse';
+import rehype from 'remark-rehype';
+import { unified } from 'unified';
+import { visit } from 'unist-util-visit';
+
+import { relativeLinksByPukiwikiLikeLinker } from '../../../../src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker';
+import { pukiwikiLikeLinker } from '../../../../src/services/renderer/remark-plugins/pukiwiki-like-linker';
+
+describe('pukiwikiLikeLinker', () => {
+
+  /* eslint-disable indent */
+  describe.each`
+    input                                   | expectedHref                | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
+    ${'[[./page]]'}                         | ${'./page'}                 | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'./page'}                 | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
+  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
+  /* eslint-enable indent */
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker);
+
+      // when:
+      const ast = processor.parse(input);
+
+      expect(ast).not.toBeNull();
+
+      visit(ast, 'wikiLink', (node: any) => {
+        expect(node.data.alias).toEqual(expectedValue);
+        expect(node.data.permalink).toEqual(expectedHref);
+        expect(node.data.hName).toEqual('a');
+        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
+        expect(node.data.hProperties.href).toEqual(expectedHref);
+        expect(node.data.hChildren[0].value).toEqual(expectedValue);
+      });
+
+    });
+  });
+
+});
+
+
+describe('relativeLinksByPukiwikiLikeLinker', () => {
+
+  /* eslint-disable indent */
+  describe.each`
+    input                                   | expectedHref                | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
+    ${'[[./page]]'}                         | ${'/user/admin/page'}       | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}       | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
+  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
+  /* eslint-enable indent */
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker)
+        .use(rehype)
+        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
+
+      // when:
+      const mdast = processor.parse(input);
+      const hast = processor.runSync(mdast);
+
+      expect(hast).not.toBeNull();
+      expect((hast as any).children[0].type).toEqual('element');
+
+      const anchors = selectAll('a', hast as HastNode);
+
+      expect(anchors.length).toEqual(1);
+
+      const anchor = anchors[0];
+
+      expect(anchor.tagName).toEqual('a');
+      expect((anchor.properties as any).className.startsWith('pukiwiki-like-linker')).toBeTruthy();
+      expect(anchor.properties?.href).toEqual(expectedHref);
+
+      expect(anchor.children[0]).not.toBeNull();
+      expect(anchor.children[0].type).toEqual('text');
+      expect(anchor.children[0].value).toEqual(expectedValue);
+
+    });
+  });
+
+});

+ 0 - 1
packages/plugin-pukiwiki-like-linker/.gitignore

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

+ 0 - 36
packages/plugin-pukiwiki-like-linker/README.md

@@ -1,36 +0,0 @@
-# growi-plugin-pukiwiki-like-linker
-[GROWI][growi] Plugin to add PukiwikiLikeLinker
-
-Overview
-----------
-
-Add the feature to use `[[alias>./relative/path]]` expression in markdown.
-
-### Replacement examples
-
-When you write at `/Level1/Level2` page:
-
-```html
-<!-- Markdown -->
-[[./Level3]]
-<!-- HTML -->
-<a href="/Level1/Level2/Level3">./Level3</a>
-
-
-<!-- Markdown -->
-[[../AnotherLevel2]]
-<!-- HTML -->
-<a href="/Level1/AnotherLevel2">../AnotherLevel2</a>
-
-
-<!-- Markdown -->
-Level 3 page is [[here>./Level3]]
-<!-- HTML -->
-Level 3 page is <a href="/Level1/Level2/Level3">here</a>
-
-
-<!-- Markdown -->
-[[example.com>https://example.com/]]
-<!-- HTML -->
-<a href="https://example.com/">example.com</a>
-```

+ 0 - 29
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,29 +0,0 @@
-{
-  "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.1.3-RC.0",
-  "description": "GROWI plugin to add PukiwikiLikeLinker",
-  "license": "MIT",
-  "keywords": [
-    "growi",
-    "growi-plugin"
-  ],
-  "main": "dist/cjs/index.js",
-  "module": "dist/esm/index.js",
-  "files": [
-    "dist"
-  ],
-  "scripts": {
-    "build": "run-p build:*",
-    "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
-    "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
-    "clean": "npx -y shx rm -rf dist",
-    "lint:js": "eslint **/*.{js,ts}",
-    "lint": "run-p lint:*",
-    "test": ""
-  },
-  "devDependencies": {
-    "browser-bunyan": "^1.6.3",
-    "eslint-plugin-regex": "^1.8.0",
-    "tsc-alias": "^1.2.9"
-  }
-}

+ 0 - 6
packages/plugin-pukiwiki-like-linker/src/client-entry.js

@@ -1,6 +0,0 @@
-import PukiwikiLikeLinker from './resource/js/util/PreProcessor/PukiwikiLikeLinker';
-
-export default () => {
-  // add preprocessor to head of array
-  window.growiRenderer.preProcessors.unshift(new PukiwikiLikeLinker());
-};

+ 0 - 8
packages/plugin-pukiwiki-like-linker/src/index.js

@@ -1,8 +0,0 @@
-module.exports = {
-  pluginSchemaVersion: 4,
-  serverEntries: [
-  ],
-  clientEntries: [
-    'src/client-entry.js',
-  ],
-};

+ 0 - 22
packages/plugin-pukiwiki-like-linker/src/resource/js/util/PreProcessor/PukiwikiLikeLinker.js

@@ -1,22 +0,0 @@
-const path = require('path');
-
-export default class PukiwikiLikeLinker {
-
-  process(markdown, context) {
-    const currentPath = context.pagePath ?? context.currentPathname;
-
-    return markdown
-      // see: https://regex101.com/r/k2dwz3/3
-      .replace(/\[\[(([^(\]\])]+)>)?(.+?)\]\]/g, (all, group1, group2, group3) => {
-        // create url
-        // use 'group3' as is if starts from 'http(s)', otherwise join to currentPath
-        const url = (group3.match(/^(\/|https?:\/\/)/)) ? group3 : path.join(currentPath, group3);
-        // determine alias string
-        // if 'group2' is undefined, use group3
-        const alias = group2 || group3;
-
-        return `<a href="${url}">${alias}</a>`;
-      });
-  }
-
-}

+ 0 - 18
packages/plugin-pukiwiki-like-linker/tsconfig.build.esm.json

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

+ 0 - 3
packages/plugin-pukiwiki-like-linker/tsconfig.json

@@ -1,3 +0,0 @@
-{
-  "extends": "../../tsconfig.base.json"
-}

+ 0 - 0
packages/plugin-pukiwiki-like-linker/.eslintignore → packages/remark-growi-plugin/.eslintignore


+ 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 === DirectiveType.Text ||
+        node.type === DirectiveType.Leaf
+      ) {
+        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 === DirectiveType.Text ||
+        node.type === DirectiveType.Leaf
+      ) {
+        if (node.name !== 'youtube') return
+
+        const data = node.data || (node.data = {})
+        const attributes = node.attributes || {}
+        const id = attributes.id
+
+        if (node.type === DirectiveType.Text) 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 === DirectiveType.Text ||
+        node.type === DirectiveType.Leaf
+      ) {
+        if (node.name !== 'note') return
+
+        const data = node.data || (node.data = {})
+        const tagName = node.type === DirectiveType.Text ? '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/

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

@@ -0,0 +1,6 @@
+import { DirectiveType } from './mdast-util-growi-plugin/consts.js';
+import { remarkGrowiPlugin } from './remark-growi-plugin.js';
+
+export { DirectiveType as RemarkGrowiPluginType };
+
+export default remarkGrowiPlugin;

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

@@ -0,0 +1,32 @@
+import type { PhrasingContent } from 'mdast';
+import type { Parent } from 'unist';
+
+import { DirectiveType } from './consts.js';
+
+
+type DirectiveAttributes = Record<string, string>
+
+interface DirectiveFields {
+  name: string
+  attributes?: DirectiveAttributes
+}
+
+export interface TextDirective extends Parent, DirectiveFields {
+  type: DirectiveType.Text
+  children: PhrasingContent[]
+}
+
+export interface LeafDirective extends Parent, DirectiveFields {
+  type: DirectiveType.Leaf
+  children: PhrasingContent[]
+}
+
+declare module 'mdast' {
+  interface StaticPhrasingContentMap {
+    [DirectiveType.Text]: TextDirective
+  }
+
+  interface BlockContentMap {
+    [DirectiveType.Leaf]: LeafDirective
+  }
+}

+ 4 - 0
packages/remark-growi-plugin/src/mdast-util-growi-plugin/consts.js

@@ -0,0 +1,4 @@
+export const DirectiveType = Object.freeze({
+  Text: 'textGrowiPluginDirective',
+  Leaf: 'leafGrowiPluginDirective',
+});

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

@@ -0,0 +1,328 @@
+/**
+ * @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';
+
+import { DirectiveType } from './consts.js';
+
+const own = {}.hasOwnProperty;
+
+const shortcut = /^[^\t\n\r "#'.<=>`}]+$/;
+
+handleDirective.peek = peekDirective;
+
+/** @type {FromMarkdownExtension} */
+export const directiveFromMarkdown = {
+  canContainEols: [DirectiveType.Text],
+  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: [DirectiveType.Leaf],
+    },
+    {
+      character: '\n',
+      inConstruct: [DirectiveType.Leaf],
+    },
+    {
+      before: '[^$]',
+      character: '$',
+      after: '[A-Za-z]',
+      inConstruct: ['phrasing'],
+    },
+    { atBreak: true, character: '$', after: '$' },
+  ],
+  handlers: {
+    [DirectiveType.Leaf]: handleDirective,
+    [DirectiveType.Text]: handleDirective,
+  },
+};
+
+/** @type {FromMarkdownHandle} */
+function enterLeaf(token) {
+  enter.call(this, DirectiveType.Leaf, token);
+}
+
+/** @type {FromMarkdownHandle} */
+function enterText(token) {
+  enter.call(this, DirectiveType.Text, 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 === DirectiveType.Text ? [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 === DirectiveType.Leaf) {
+    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: DirectiveType.Text,
+          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: DirectiveType.Text
+  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: DirectiveType.Text,
+  name: 'name',
+  attributes: {id: 'x', class: 'y z', key: 'value'},
+  children: [{type: 'text', value: 'Label'}]
+}
+```
+
+#### `LeafDirective`
+
+```idl
+interface LeafDirective <: Parent {
+  type: DirectiveType.Leaf
+  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: DirectiveType.Leaf,
+  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);
+  }
+}

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

@@ -0,0 +1,195 @@
+/**
+ * @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 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';
+
+import { DirectiveType } from '../../mdast-util-growi-plugin/consts.js';
+
+const own = {}.hasOwnProperty;
+
+/**
+  * @param {HtmlOptions} [options]
+  * @returns {HtmlExtension}
+  */
+export function directiveHtml(options = {}) {
+  return {
+    enter: {
+
+      directiveLeaf() {
+        return enter.call(this, DirectiveType.Leaf);
+      },
+      directiveLeafAttributes: enterAttributes,
+      directiveLeafLabel: enterLabel,
+
+      directiveText() {
+        return enter.call(this, DirectiveType.Text);
+      },
+      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 !== DirectiveType.Text) {
+      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 !== DirectiveType.Text) 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` (`DirectiveType.Text|DirectiveType.Leaf`)
+*   `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
+    }
+  }
+}

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

@@ -0,0 +1,585 @@
+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 { DirectiveType } from '../src/mdast-util-growi-plugin/consts.js';
+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: DirectiveType.Text,
+          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: DirectiveType.Leaf,
+      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: DirectiveType.Text,
+              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: DirectiveType.Text,
+              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: DirectiveType.Text,
+              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: DirectiveType.Text },
+          { 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: DirectiveType.Text, 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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Text,
+            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: DirectiveType.Leaf }, { extensions: [directiveToMarkdown] }),
+    '$\n',
+    'should try to serialize a directive (leaf) w/o `name`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      // @ts-expect-error: `children` missing.
+      { type: DirectiveType.Leaf, name: 'a' },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a\n',
+    'should serialize a directive (leaf) w/ `name`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: DirectiveType.Leaf,
+        name: 'a',
+        children: [{ type: 'text', value: 'b' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a[b]\n',
+    'should serialize a directive (leaf) w/ `children`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: DirectiveType.Leaf,
+        name: 'a',
+        children: [{ type: 'text', value: 'b' }],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$a[b]\n',
+    'should serialize a directive (leaf) w/ `children`',
+  );
+
+  t.deepEqual(
+    toMarkdown(
+      {
+        type: DirectiveType.Leaf,
+        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: DirectiveType.Leaf,
+        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: DirectiveType.Text, name: 'red', children: [] },
+          { type: 'text', value: '$' },
+        ],
+      },
+      { extensions: [directiveToMarkdown] },
+    ),
+    '$red$\n',
+    'should escape a `:` after a text directive',
+  );
+
+  t.end();
+});

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

@@ -0,0 +1,1064 @@
+/**
+ * @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 { DirectiveType } from '../src/mdast-util-growi-plugin/consts.js';
+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 !== DirectiveType.Text) 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();
+    });
+  }
+});

+ 1 - 1
packages/plugin-pukiwiki-like-linker/tsconfig.base.json → packages/remark-growi-plugin/tsconfig.base.json

@@ -6,6 +6,6 @@
     "src"
     "src"
   ],
   ],
   "exclude": [
   "exclude": [
-    "src/test"
+    "test"
   ]
   ]
 }
 }

+ 1 - 1
packages/plugin-pukiwiki-like-linker/tsconfig.build.cjs.json → packages/remark-growi-plugin/tsconfig.build.json

@@ -2,7 +2,7 @@
   "extends": "./tsconfig.base.json",
   "extends": "./tsconfig.base.json",
   "compilerOptions": {
   "compilerOptions": {
     "rootDir": "./src",
     "rootDir": "./src",
-    "outDir": "dist/cjs",
+    "outDir": "dist",
     "declaration": true,
     "declaration": true,
     "noResolve": false,
     "noResolve": false,
     "preserveConstEnums": true,
     "preserveConstEnums": true,

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

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

+ 1 - 0
tsconfig.base.json

@@ -14,6 +14,7 @@
     /* Strict Type-Checking Options */
     /* Strict Type-Checking Options */
     // "strict": true,
     // "strict": true,
     "strictNullChecks": true,
     "strictNullChecks": true,
+    "strictBindCallApply": true,
     "noImplicitAny": false,
     "noImplicitAny": false,
     "noImplicitOverride": true,
     "noImplicitOverride": true,
 
 

File diff suppressed because it is too large
+ 250 - 514
yarn.lock


Some files were not shown because too many files changed in this diff