Просмотр исходного кода

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

kaori 3 лет назад
Родитель
Сommit
eeda44b910
85 измененных файлов с 6052 добавлено и 924 удалено
  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
       - packages/app/**
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/ui/**
       - packages/plugin-**
@@ -30,7 +32,9 @@ on:
       - yarn.lock
       - packages/app/**
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/ui/**
       - packages/plugin-**

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

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

+ 0 - 4
.vscode/launch.json

@@ -76,10 +76,6 @@
             "url": "webpack://_n_e/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",
             "path": "${workspaceFolder}/packages/plugin-lsx"

+ 2 - 2
package.json

@@ -66,7 +66,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
-    "jest": "^27.0.6",
+    "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
@@ -81,7 +81,7 @@
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
-    "ts-jest": "^27.0.4",
+    "ts-jest": "^28.0.7",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
     "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/plugin-attachment-refs packages/plugin-attachment-refs
 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/ui packages/ui
 

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

@@ -20,6 +20,11 @@ module.exports = {
 
       preset: 'ts-jest/presets/js-with-ts',
 
+      // transform ESM to CJS
+      transformIgnorePatterns: [
+        '/node_modules/(?!remark-gfm)/',
+      ],
+
       rootDir: '.',
       roots: ['<rootDir>'],
       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
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
+
     },
     {
       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
     'react-markdown',
     'unified',
+    'character-entities-html4',
     'comma-separated-tokens',
     'decode-named-character-reference',
     'hastscript',
@@ -33,7 +34,9 @@ const setupTranspileModules = () => {
     'longest-streak',
     'property-information',
     'space-separated-tokens',
+    'stringify-entities',
     'trim-lines',
+    'trough',
     'web-namespaces',
     'vfile',
     'zwitch',

+ 1 - 1
packages/app/package.json

@@ -67,7 +67,6 @@
     "@growi/core": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^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",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
@@ -169,6 +168,7 @@
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
+    "remark-wiki-link": "^1.0.4",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.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
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 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
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-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
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 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 LdapAuthTest from './LdapAuthTest';
+import { LdapAuthTest } from './LdapAuthTest';
 
 
 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 });
 
   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>
 
-      <SystemVersion />
+        <SystemVersion />
+      </div>
     </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 Image from 'next/image';
 
 import { useGrowiTheme } from '~/stores/context';
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
+import { getBackgroundImageSrc } from '../Theme/utils/ThemeImageProvider';
 import { ThemeProvider } from '../Theme/utils/ThemeProvider';
 
 type Props = {
@@ -25,12 +29,19 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   const { resolvedTheme } = useNextThemes();
 
   const [colorScheme, setColorScheme] = useState<Themes|undefined>(undefined);
+  const [backgroundImageSrc, setBackgroundImageSrc] = useState<string | undefined>(undefined);
 
   // set colorScheme in CSR
   useEffect(() => {
     setColorScheme(resolvedTheme as Themes);
   }, [resolvedTheme]);
 
+  // set background image
+  useEffect(() => {
+    const imgSrc = getBackgroundImageSrc(growiTheme, colorScheme);
+    setBackgroundImageSrc(imgSrc);
+  }, [growiTheme, colorScheme]);
+
   return (
     <>
       <Head>
@@ -40,6 +51,9 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
       </Head>
       <ThemeProvider theme={growiTheme}>
         <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}
         </div>
       </ThemeProvider>

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

@@ -6,12 +6,16 @@ import {
   ModalHeader,
   ModalBody,
   ModalFooter,
+  Nav,
+  NavLink,
+  TabContent,
+  TabPane,
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 
 type Props = {
   isOpen: boolean,
@@ -22,6 +26,7 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { associateLdapAccount } = usePersonalSettings();
+  const [activeTab, setActiveTab] = useState(1);
   const { isOpen, onClose } = props;
 
   const [username, setUsername] = useState('');
@@ -55,46 +60,61 @@ const AssociateModal = (props: Props): JSX.Element => {
         { t('admin:user_management.create_external_account') }
       </ModalHeader>
       <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
-            </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
-            </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
-            </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
-            </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
-            </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>
       </ModalBody>
       <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, {
+  useCallback,
   useEffect, useRef, useState,
 } from 'react';
 
 import dynamic from 'next/dynamic';
-import PropTypes from 'prop-types';
 // import { debounce } from 'throttle-debounce';
 
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot,
+  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -22,6 +22,7 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
+import { HtmlElementNode } from 'rehype-toc';
 
 // TODO: import dynamically
 // import MarkdownTable from '~/client/models/MarkdownTable';
@@ -30,9 +31,28 @@ import RevisionRenderer from './Page/RevisionRenderer';
 
 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);
 
     this.state = {
@@ -138,7 +158,7 @@ class PageSubstance extends React.Component {
     // }
   }
 
-  render() {
+  override render() {
     const {
       rendererOptions, page, isMobile, isGuestUser,
     } = 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: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   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 { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
+  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
   const pageRef = useRef(null);
 
@@ -206,6 +223,11 @@ export const Page = (props) => {
     mutateBlinkedAtBoot(true);
   }, [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
   // useEffect(() => {
   //   const handler = (beginLineNumber, endLineNumber) => {
@@ -253,7 +275,7 @@ export const Page = (props) => {
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       pageTags={pageTags}
-      slackChannels={slackChannelsData.toString()}
+      slackChannels={slackChannelsData?.toString()}
       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 { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
-import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
 // import RevisionBody from './RevisionBody';
@@ -95,7 +94,7 @@ type Props = {
   additionalClassName?: string,
 }
 
-const RevisionRenderer = (props: Props): JSX.Element => {
+const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
   const {
     rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
@@ -246,6 +245,7 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   //   />
   // );
 
-};
+});
+RevisionRenderer.displayName = 'RevisionRenderer';
 
 export default RevisionRenderer;

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

@@ -16,7 +16,6 @@
 
 .growi:not(.login-page) {
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/antarctic/bg.svg');
     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;
 $themelight: #f0f8ff;
 $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 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 => {
   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) {
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/christmas/christmas.jpg');
     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
 //
 .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 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 => {
   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: #;
 
 .growi:not(.login-page) {
-  #wrapper > .navbar {
-    background-image: url(/images/themes/halloween/halloween-navbar.jpg);
-  }
-
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     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
 //
 .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 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 => {
   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;
 // }
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //
 .theme[data-color-scheme='light'] :global {
@@ -107,7 +120,6 @@
 
   .growi:not(.login-page) {
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-light3.png');
       background-attachment: fixed;
@@ -275,7 +287,6 @@
 
   .growi:not(.login-page) {
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
       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 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 => {
   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-themelight: rgba(183, 226, 219, 1);
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+}
+
 .theme :global {
   $primary: $color-primary;
   // Background colors
@@ -89,7 +97,6 @@ $color-themelight: rgba(183, 226, 219, 1);
     background: lighten($color-themelight, 5%);
   }
 
-  #wrapper > #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/island/island.png');
     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 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 => {
   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;
 }
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //
 .theme :global {
@@ -107,7 +120,6 @@ $accentcolor: #e08dbc;
 
   .growi:not(.login-page) {
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
       background-image: url('/images/themes/spring/spring02.svg');
       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 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 => {
   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) {
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/wood/wood.jpg');
     background-attachment: fixed;
@@ -35,6 +34,19 @@
 $themecolor: #b9b177;
 $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
 //
 .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 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 => {
   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>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       <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} />
         </header>
         <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 { 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,
+  anchorsSelector?: IAnchorsSelector,
+  hrefResolver?: IHrefResolver,
 }
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+  const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
+  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+
   return (tree) => {
     if (options.pagePath == null) {
       return;
     }
 
     const pagePath = options.pagePath;
-    const anchors = selectAll('a[href]', tree as HastNode);
+    const anchors = anchorsSelector(tree as HastNode);
 
     anchors.forEach((anchor) => {
       if (anchor.properties == null) {
@@ -25,11 +46,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         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 katex from 'rehype-katex';
 import raw from 'rehype-raw';
@@ -9,6 +10,7 @@ import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
 
+
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
@@ -17,6 +19,8 @@ import loggerFactory from '~/utils/logger';
 
 import { addClass } from './rehype-plugins/add-class';
 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 EasyGrid from './PreProcessor/EasyGrid';
@@ -217,9 +221,14 @@ export type RendererOptions = Partial<ReactMarkdownOptions>;
 
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
   return {
-    remarkPlugins: [gfm],
+    remarkPlugins: [
+      gfm,
+      pukiwikiLikeLinker,
+      growiPlugin,
+    ],
     rehypePlugins: [
       slug,
+      [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
       [sanitize, {
@@ -245,7 +254,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
 export const generateViewOptions = (
     pagePath: string,
     config: RendererConfig,
-    storeTocNode: (node: HtmlElementNode) => void,
+    storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
 
   const options = generateCommonOptions(pagePath, config);
@@ -280,7 +289,11 @@ export const generateViewOptions = (
           });
         };
         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
       },
     }]);

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

@@ -1,5 +1,3 @@
-import EventEmitter from 'events';
-
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -232,10 +230,6 @@ export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<Ren
   return useStaticSWR('growiRendererConfig', initialData);
 };
 
-export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
-  return useStaticSWR('currentPageTocNode');
-};
-
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
   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 useSWRImmutable from 'swr/immutable';
 
@@ -9,7 +10,9 @@ import {
 } from '~/services/renderer/renderer';
 
 
-import { useCurrentPagePath, useCurrentPageTocNode, useRendererConfig } from './context';
+import {
+  useCurrentPagePath, useCurrentPageTocNode, useRendererConfig,
+} from './context';
 
 interface ReactMarkdownOptionsGenerator {
   (config: RendererConfig): RendererOptions
@@ -37,10 +40,9 @@ const _useOptionsBase = (
   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: rendererConfig } = useRendererConfig();
-  const { mutate: storeTocNode } = useCurrentPageTocNode();
 
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
 
@@ -50,16 +52,25 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
 
   return useSWRImmutable<RendererOptions, Error>(
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNode),
+    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
   );
 };
 
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'tocOptions';
-
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
   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> => {

+ 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"
   ],
   "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",
   "compilerOptions": {
     "rootDir": "./src",
-    "outDir": "dist/cjs",
+    "outDir": "dist",
     "declaration": true,
     "noResolve": false,
     "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": true,
     "strictNullChecks": true,
+    "strictBindCallApply": true,
     "noImplicitAny": false,
     "noImplicitOverride": true,
 

Разница между файлами не показана из-за своего большого размера
+ 250 - 514
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов