Przeglądaj źródła

Merge branch 'support/apply-nextjs-PageComment-integrate' into support/isLoading-PageComment

jam411 3 lat temu
rodzic
commit
f138400c93
100 zmienionych plików z 4288 dodań i 505 usunięć
  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. 4 0
      packages/app/next.config.js
  8. 2 1
      packages/app/package.json
  9. 1 0
      packages/app/public/static/locales/en_US/translation.json
  10. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  11. 1 0
      packages/app/public/static/locales/zh_CN/translation.json
  12. 0 3
      packages/app/resource/locales/en_US/sandbox.md
  13. 0 3
      packages/app/resource/locales/ja_JP/sandbox.md
  14. 0 3
      packages/app/resource/locales/zh_CN/sandbox.md
  15. 0 151
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  16. 129 0
      packages/app/src/components/Admin/Security/LdapAuthTest.tsx
  17. 1 1
      packages/app/src/components/Admin/Security/LdapAuthTestModal.jsx
  18. 18 16
      packages/app/src/components/Layout/AdminLayout.tsx
  19. 15 1
      packages/app/src/components/Layout/RawLayout.tsx
  20. 56 36
      packages/app/src/components/Me/AssociateModal.tsx
  21. 42 20
      packages/app/src/components/Page.tsx
  22. 0 18
      packages/app/src/components/Page/RedirectedAlert.tsx
  23. 3 3
      packages/app/src/components/Page/RevisionRenderer.tsx
  24. 2 0
      packages/app/src/components/PageAlert/PageAlerts.tsx
  25. 46 0
      packages/app/src/components/PageAlert/PageRedirectedAlert.tsx
  26. 13 6
      packages/app/src/components/PageComment.tsx
  27. 6 1
      packages/app/src/components/PageComment/Comment.tsx
  28. 3 0
      packages/app/src/components/PageComment/CommentControl.tsx
  29. 11 25
      packages/app/src/components/PageComment/CommentEditor.tsx
  30. 9 4
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  31. 2 1
      packages/app/src/components/PageComment/CommentPreview.tsx
  32. 4 3
      packages/app/src/components/PageComment/ReplyComments.tsx
  33. 3 4
      packages/app/src/components/PageContentFooter.tsx
  34. 1 0
      packages/app/src/components/PageEditor/Editor.tsx
  35. 1 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  36. 12 1
      packages/app/src/components/Theme/ThemeAntarctic.module.scss
  37. 9 0
      packages/app/src/components/Theme/ThemeAntarctic.tsx
  38. 12 1
      packages/app/src/components/Theme/ThemeChristmas.module.scss
  39. 9 0
      packages/app/src/components/Theme/ThemeChristmas.tsx
  40. 12 5
      packages/app/src/components/Theme/ThemeHalloween.module.scss
  41. 9 0
      packages/app/src/components/Theme/ThemeHalloween.tsx
  42. 13 2
      packages/app/src/components/Theme/ThemeHufflepuff.module.scss
  43. 13 0
      packages/app/src/components/Theme/ThemeHufflepuff.tsx
  44. 8 1
      packages/app/src/components/Theme/ThemeIsland.module.scss
  45. 9 0
      packages/app/src/components/Theme/ThemeIsland.tsx
  46. 13 1
      packages/app/src/components/Theme/ThemeSpring.module.scss
  47. 9 0
      packages/app/src/components/Theme/ThemeSpring.tsx
  48. 13 1
      packages/app/src/components/Theme/ThemeWood.module.scss
  49. 9 0
      packages/app/src/components/Theme/ThemeWood.tsx
  50. 34 0
      packages/app/src/components/Theme/utils/ThemeImageProvider.tsx
  51. 7 0
      packages/app/src/interfaces/ldap.ts
  52. 0 4
      packages/app/src/interfaces/services/renderer.ts
  53. 3 2
      packages/app/src/pages/[[...path]].page.tsx
  54. 6 4
      packages/app/src/pages/me.page.tsx
  55. 7 5
      packages/app/src/server/routes/ogp.ts
  56. 2 1
      packages/app/src/server/service/s2s-messaging/nchan.ts
  57. 3 5
      packages/app/src/services/renderer/rehype-plugins/add-class.ts
  58. 27 0
      packages/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  59. 52 0
      packages/app/src/services/renderer/rehype-plugins/relative-links.ts
  60. 80 0
      packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts
  61. 30 16
      packages/app/src/services/renderer/renderer.ts
  62. 6 10
      packages/app/src/stores/context.tsx
  63. 30 0
      packages/app/src/stores/page-redirect.tsx
  64. 34 9
      packages/app/src/stores/renderer.tsx
  65. 5 2
      packages/app/src/utils/download.ts
  66. 92 0
      packages/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  67. 0 1
      packages/plugin-pukiwiki-like-linker/.gitignore
  68. 0 36
      packages/plugin-pukiwiki-like-linker/README.md
  69. 0 29
      packages/plugin-pukiwiki-like-linker/package.json
  70. 0 6
      packages/plugin-pukiwiki-like-linker/src/client-entry.js
  71. 0 8
      packages/plugin-pukiwiki-like-linker/src/index.js
  72. 0 22
      packages/plugin-pukiwiki-like-linker/src/resource/js/util/PreProcessor/PukiwikiLikeLinker.js
  73. 0 18
      packages/plugin-pukiwiki-like-linker/tsconfig.build.esm.json
  74. 0 3
      packages/plugin-pukiwiki-like-linker/tsconfig.json
  75. 0 0
      packages/remark-growi-plugin/.eslintignore
  76. 13 0
      packages/remark-growi-plugin/.eslintrc.cjs
  77. 2 0
      packages/remark-growi-plugin/.gitignore
  78. 69 0
      packages/remark-growi-plugin/package.json
  79. 420 0
      packages/remark-growi-plugin/readme.md
  80. 6 0
      packages/remark-growi-plugin/src/index.js
  81. 32 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/complex-types.d.ts
  82. 4 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/consts.js
  83. 328 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/index.js
  84. 403 0
      packages/remark-growi-plugin/src/mdast-util-growi-plugin/readme.md
  85. 7 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/index.js
  86. 138 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-leaf.js
  87. 108 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/directive-text.js
  88. 336 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js
  89. 139 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-label.js
  90. 50 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-name.js
  91. 195 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/html.js
  92. 18 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/syntax.js
  93. 288 0
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/readme.md
  94. 35 0
      packages/remark-growi-plugin/src/remark-growi-plugin.js
  95. 11 0
      packages/remark-growi-plugin/test/fixtures/leaf/input.md
  96. 11 0
      packages/remark-growi-plugin/test/fixtures/leaf/output.md
  97. 266 0
      packages/remark-growi-plugin/test/fixtures/leaf/tree.json
  98. 7 0
      packages/remark-growi-plugin/test/fixtures/text/input.md
  99. 7 0
      packages/remark-growi-plugin/test/fixtures/text/output.md
  100. 429 0
      packages/remark-growi-plugin/test/fixtures/text/tree.json

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

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

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

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

+ 0 - 4
.vscode/launch.json

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

+ 2 - 2
package.json

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

+ 0 - 1
packages/app/docker/Dockerfile

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

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

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

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

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

+ 2 - 1
packages/app/package.json

@@ -67,7 +67,6 @@
     "@growi/core": "^5.1.3-RC.0",
     "@growi/core": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
     "@growi/plugin-lsx": "^5.1.3-RC.0",
     "@growi/plugin-lsx": "^5.1.3-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.3-RC.0",
     "@growi/slack": "^5.1.3-RC.0",
     "@growi/slack": "^5.1.3-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
@@ -115,6 +114,7 @@
     "i18next-chained-backend": "^3.0.2",
     "i18next-chained-backend": "^3.0.2",
     "i18next-http-backend": "^1.4.1",
     "i18next-http-backend": "^1.4.1",
     "i18next-localstorage-backend": "^3.1.3",
     "i18next-localstorage-backend": "^3.1.3",
+    "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
@@ -168,6 +168,7 @@
     "remark-emoji": "^3.0.2",
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
     "remark-math": "^5.1.1",
+    "remark-wiki-link": "^1.0.4",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",

+ 1 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -19,6 +19,7 @@
   "Move/Rename": "Move/Rename",
   "Move/Rename": "Move/Rename",
   "Redirected": "Redirected",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Unlinked": "Unlinked",
+  "unlink_redirection": "Unlink redirection",
   "Done": "Done",
   "Done": "Done",
   "Cancel": "Cancel",
   "Cancel": "Cancel",
   "Create": "Create",
   "Create": "Create",

+ 1 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -19,6 +19,7 @@
   "Move/Rename": "移動/名前変更",
   "Move/Rename": "移動/名前変更",
   "Redirected": "リダイレクトされました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Unlinked": "リダイレクト削除",
+  "unlink_redirection": "リダイレクト削除",
   "Done": "完了",
   "Done": "完了",
   "Cancel": "キャンセル",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Create": "作成",

+ 1 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -20,6 +20,7 @@
 	"Move/Rename": "移动/重命名",
 	"Move/Rename": "移动/重命名",
 	"Redirected": "重定向",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Unlinked": "Unlinked",
+  "unlink_redirection": "取消链接重定向",
   "Done": "Done",
   "Done": "Done",
   "Cancel": "取消",
   "Cancel": "取消",
 	"Create": "创建",
 	"Create": "创建",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 42 - 20
packages/app/src/components/Page.jsx → packages/app/src/components/Page.tsx

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

+ 0 - 18
packages/app/src/components/Page/RedirectedAlert.tsx

@@ -1,18 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-const RedirectedAlert = React.memo((): JSX.Element => {
-  const { t } = useTranslation();
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('redirectFrom');
-
-  return (
-    <>
-      <strong>{ t('Redirected') }:</strong> { t('page_page.notice.redirected')} <code>{fromPath}</code> {t('page_page.notice.redirected_period')}
-    </>
-  );
-});
-RedirectedAlert.displayName = 'RedirectedAlert';
-
-export default RedirectedAlert;

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

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

+ 2 - 0
packages/app/src/components/PageAlert/PageAlerts.tsx

@@ -7,6 +7,7 @@ import { useIsNotFound } from '~/stores/context';
 import { FixPageGrantAlert } from './FixPageGrantAlert';
 import { FixPageGrantAlert } from './FixPageGrantAlert';
 import { OldRevisionAlert } from './OldRevisionAlert';
 import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageGrantAlert } from './PageGrantAlert';
+import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 
 
 // dynamic import because TrashPageAlert uses localStorageMiddleware
 // dynamic import because TrashPageAlert uses localStorageMiddleware
@@ -25,6 +26,7 @@ export const PageAlerts = (): JSX.Element => {
         <TrashPageAlert />
         <TrashPageAlert />
         <PageStaleAlert />
         <PageStaleAlert />
         <OldRevisionAlert />
         <OldRevisionAlert />
+        <PageRedirectedAlert />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 46 - 0
packages/app/src/components/PageAlert/PageRedirectedAlert.tsx

@@ -0,0 +1,46 @@
+import React, { useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { useRedirectFrom } from '~/stores/page-redirect';
+
+export const PageRedirectedAlert = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: redirectFrom, unlink } = useRedirectFrom();
+
+  const [isUnlinked, setIsUnlinked] = useState(false);
+
+  const unlinkButtonClickHandler = useCallback(async() => {
+    try {
+      await unlink();
+      setIsUnlinked(true);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [unlink]);
+
+  if (redirectFrom == null) {
+    return <></>;
+  }
+
+  if (isUnlinked) {
+    return (<div className="alert alert-info d-edit-none py-3 px-4">
+      <strong>{ t('Unlinked') }: </strong> { t('page_page.notice.unlinked') }
+    </div>);
+  }
+
+  return (
+    <div className="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
+      <span>
+        <strong>{ t('Redirected') }:</strong> { t('page_page.notice.redirected')} <code>{redirectFrom}</code> {t('page_page.notice.redirected_period')}
+      </span>
+      <button type="button" id="unlink-page-button" onClick={unlinkButtonClickHandler} className="btn btn-outline-dark btn-sm float-right">
+        <i className="ti ti-unlink" aria-hidden="true"></i>
+        {t('unlink_redirection')}
+      </button>
+    </div>
+  );
+});
+PageRedirectedAlert.displayName = 'PageRedirectedAlert';

+ 13 - 6
packages/app/src/components/PageComment.tsx

@@ -2,7 +2,7 @@ import React, {
   FC, useEffect, useState, useMemo, memo, useCallback,
   FC, useEffect, useState, useMemo, memo, useCallback,
 } from 'react';
 } from 'react';
 
 
-import { Nullable } from '@growi/core';
+import dynamic from 'next/dynamic';
 import { Button } from 'reactstrap';
 import { Button } from 'reactstrap';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
@@ -15,7 +15,7 @@ import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 
 import { Comment } from './PageComment/Comment';
 import { Comment } from './PageComment/Comment';
-import { CommentEditor } from './PageComment/CommentEditor';
+import { CommentEditorProps } from './PageComment/CommentEditor';
 import { CommentEditorLazyRenderer } from './PageComment/CommentEditorLazyRenderer';
 import { CommentEditorLazyRenderer } from './PageComment/CommentEditorLazyRenderer';
 import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
 import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
 import { ReplyComments } from './PageComment/ReplyComments';
 import { ReplyComments } from './PageComment/ReplyComments';
@@ -25,15 +25,19 @@ import styles from './PageComment.module.scss';
 import CommentEditorStyles from './PageComment/CommentEditor.module.scss';
 import CommentEditorStyles from './PageComment/CommentEditor.module.scss';
 
 
 
 
-type Props = {
-  pageId?: Nullable<string>
+// TODO: Update Skelton
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+
+
+type PageCommentProps = {
+  pageId?: string,
   isReadOnly: boolean,
   isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
   titleAlign?: 'center' | 'left' | 'right',
   highlightKeywords?: string[],
   highlightKeywords?: string[],
   hideIfEmpty?: boolean,
   hideIfEmpty?: boolean,
 }
 }
 
 
-export const PageComment: FC<Props> = memo((props:Props): JSX.Element => {
+export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
 
 
   const {
   const {
     pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
     pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
@@ -235,7 +239,10 @@ export const PageComment: FC<Props> = memo((props:Props): JSX.Element => {
               })}
               })}
             </div>
             </div>
             {/* TODO: Check if identical-page */}
             {/* TODO: Check if identical-page */}
-            <CommentEditorLazyRenderer pageId={pageId} rendererOptions={rendererOptions}/>
+            <CommentEditorLazyRenderer
+              pageId={pageId}
+              rendererOptions={rendererOptions}
+            />
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 6 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
@@ -15,10 +16,14 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 import Username from '../User/Username';
 import Username from '../User/Username';
 
 
 import { CommentControl } from './CommentControl';
 import { CommentControl } from './CommentControl';
-import { CommentEditor } from './CommentEditor';
+import { CommentEditorProps } from './CommentEditor';
 
 
 import styles from './Comment.module.scss';
 import styles from './Comment.module.scss';
 
 
+// TODO: Update Skelton
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+
+
 type CommentProps = {
 type CommentProps = {
   comment: ICommentHasId,
   comment: ICommentHasId,
   isReadOnly: boolean,
   isReadOnly: boolean,

+ 3 - 0
packages/app/src/components/PageComment/CommentControl.tsx

@@ -1,11 +1,13 @@
 import React from 'react';
 import React from 'react';
 
 
+
 type CommentControlProps = {
 type CommentControlProps = {
   onClickEditBtn: () => void,
   onClickEditBtn: () => void,
   onClickDeleteBtn: () => void,
   onClickDeleteBtn: () => void,
 }
 }
 
 
 export const CommentControl = (props: CommentControlProps): JSX.Element => {
 export const CommentControl = (props: CommentControlProps): JSX.Element => {
+
   const { onClickEditBtn, onClickDeleteBtn } = props;
   const { onClickEditBtn, onClickDeleteBtn } = props;
 
 
   return (
   return (
@@ -19,4 +21,5 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
       </button>
       </button>
     </div>
     </div>
   );
   );
+
 };
 };

+ 11 - 25
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -3,7 +3,6 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
-import dynamic from 'next/dynamic';
 import {
 import {
   Button, TabContent, TabPane,
   Button, TabContent, TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -17,10 +16,10 @@ import {
   useIsUploadableFile, useIsUploadableImage,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useIsMobile } from '~/stores/ui';
 
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
 import NotAvailableForGuest from '../NotAvailableForGuest';
+import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
 import { SlackNotification } from '../SlackNotification';
 
 
 import { CommentPreview } from './CommentPreview';
 import { CommentPreview } from './CommentPreview';
@@ -41,7 +40,7 @@ const navTabMapping = {
   },
   },
 };
 };
 
 
-type PropsType = {
+export type CommentEditorProps = {
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   isForNewComment?: boolean,
   isForNewComment?: boolean,
   replyTo?: string,
   replyTo?: string,
@@ -57,7 +56,7 @@ type EditorRef = {
   terminateUploadingState: () => void,
   terminateUploadingState: () => void,
 }
 }
 
 
-export const CommentEditor = (props: PropsType): JSX.Element => {
+export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
 
   const {
   const {
     rendererOptions, isForNewComment, replyTo,
     rendererOptions, isForNewComment, replyTo,
@@ -69,7 +68,6 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPageId } = useCurrentPageId();
   const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
   const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
   const { data: revisionId } = useRevisionId();
   const { data: revisionId } = useRevisionId();
-  const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
   const { data: isSlackConfigured } = useIsSlackConfigured();
@@ -77,9 +75,7 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
 
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
-  // TODO: Refactor comment and markdown variable names or logic after presentation
   const [comment, setComment] = useState(commentBody ?? '');
   const [comment, setComment] = useState(commentBody ?? '');
-  const [markdown, setMarkdown] = useState('');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
   const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
@@ -88,8 +84,7 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
 
 
   const handleSelect = useCallback((activeTab: string) => {
   const handleSelect = useCallback((activeTab: string) => {
     setActiveTab(activeTab);
     setActiveTab(activeTab);
-    setMarkdown(comment);
-  }, [comment, setMarkdown]);
+  }, []);
 
 
   useEffect(() => {
   useEffect(() => {
     if (slackChannels === undefined) { return }
     if (slackChannels === undefined) { return }
@@ -98,7 +93,6 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
 
 
   const initializeEditor = useCallback(() => {
   const initializeEditor = useCallback(() => {
     setComment('');
     setComment('');
-    setMarkdown('');
     setActiveTab('comment_editor');
     setActiveTab('comment_editor');
     setError(undefined);
     setError(undefined);
     // reset value
     // reset value
@@ -176,7 +170,6 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
   }, []);
   }, []);
 
 
   const uploadHandler = useCallback(async(file) => {
   const uploadHandler = useCallback(async(file) => {
-
     if (editorRef.current == null) { return }
     if (editorRef.current == null) { return }
 
 
     const pagePath = currentPagePath;
     const pagePath = currentPagePath;
@@ -186,6 +179,7 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
     formData.append('file', file);
     formData.append('file', file);
     formData.append('path', pagePath ?? '');
     formData.append('path', pagePath ?? '');
     formData.append('page_id', pageId ?? '');
     formData.append('page_id', pageId ?? '');
+
     try {
     try {
       // TODO: typescriptize res
       // TODO: typescriptize res
       const res = await apiPostForm(endpoint, formData) as any;
       const res = await apiPostForm(endpoint, formData) as any;
@@ -207,7 +201,6 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
     }
     }
   }, [apiErrorHandler, currentPageId, currentPagePath]);
   }, [apiErrorHandler, currentPageId, currentPagePath]);
 
 
-
   const getCommentHtml = useCallback(() => {
   const getCommentHtml = useCallback(() => {
     if (currentPagePath == null) {
     if (currentPagePath == null) {
       return <></>;
       return <></>;
@@ -216,11 +209,11 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
     return (
     return (
       <CommentPreview
       <CommentPreview
         rendererOptions={rendererOptions}
         rendererOptions={rendererOptions}
-        markdown={markdown}
+        markdown={comment}
         path={currentPagePath}
         path={currentPagePath}
       />
       />
     );
     );
-  }, [currentPagePath, markdown, rendererOptions]);
+  }, [currentPagePath, comment, rendererOptions]);
 
 
   const renderBeforeReady = useCallback((): JSX.Element => {
   const renderBeforeReady = useCallback((): JSX.Element => {
     return (
     return (
@@ -239,7 +232,6 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
   }, []);
   }, []);
 
 
   const renderReady = () => {
   const renderReady = () => {
-
     const commentPreview = getCommentHtml();
     const commentPreview = getCommentHtml();
 
 
     const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
     const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
@@ -259,10 +251,6 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
       </Button>
       </Button>
     );
     );
 
 
-    const Editor = dynamic(() => import('../PageEditor/Editor'), { ssr: false });
-    // TODO: typescriptize Editor
-    const AnyEditor = Editor as any;
-
     const isUploadable = isUploadableImage || isUploadableFile;
     const isUploadable = isUploadableImage || isUploadableFile;
 
 
     return (
     return (
@@ -271,18 +259,16 @@ export const CommentEditor = (props: PropsType): JSX.Element => {
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
             <TabPane tabId="comment_editor">
-              {/* <AnyEditor
+              <Editor
                 ref={editorRef}
                 ref={editorRef}
                 value={comment}
                 value={comment}
-                lineNumbers={false}
-                isMobile={isMobile}
-                // isUploadable={isUploadable}
-                // isUploadableFile={isUploadableFile}
+                isUploadable={isUploadable}
+                isUploadableFile={isUploadableFile}
                 onChange={setComment}
                 onChange={setComment}
                 onUpload={uploadHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
                 isComment
-              /> */}
+              />
               {/*
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 See a review comment in https://github.com/weseek/growi/pull/3473
                 See a review comment in https://github.com/weseek/growi/pull/3473

+ 9 - 4
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,15 +1,19 @@
 import React from 'react';
 import React from 'react';
 
 
-import { Nullable } from '@growi/core';
+import dynamic from 'next/dynamic';
 
 
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
 
 
 import { useSWRxPageComment } from '../../stores/comment';
 import { useSWRxPageComment } from '../../stores/comment';
 
 
-import { CommentEditor } from './CommentEditor';
+import { CommentEditorProps } from './CommentEditor';
+
+// TODO: Update Skelton
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+
 
 
 type Props = {
 type Props = {
-  pageId?: Nullable<string>,
+  pageId?: string,
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
 }
 }
 
 
@@ -22,9 +26,10 @@ export const CommentEditorLazyRenderer = (props: Props): JSX.Element => {
   return (
   return (
     <CommentEditor
     <CommentEditor
       rendererOptions={rendererOptions}
       rendererOptions={rendererOptions}
+      isForNewComment
       replyTo={undefined}
       replyTo={undefined}
       onCommentButtonClicked={mutate}
       onCommentButtonClicked={mutate}
-      isForNewComment
     />
     />
   );
   );
+
 };
 };

+ 2 - 1
packages/app/src/components/PageComment/CommentPreview.tsx

@@ -2,6 +2,7 @@ import { RendererOptions } from '~/services/renderer/renderer';
 
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
 
 
+
 type CommentPreviewPorps = {
 type CommentPreviewPorps = {
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   markdown: string,
   markdown: string,
@@ -10,7 +11,7 @@ type CommentPreviewPorps = {
 
 
 export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
 export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
 
 
-  const { markdown, path, rendererOptions } = props;
+  const { rendererOptions, markdown, path } = props;
 
 
   return (
   return (
     <div className="page-comment-preview-body">
     <div className="page-comment-preview-body">

+ 4 - 3
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -12,6 +12,7 @@ import { Comment } from './Comment';
 
 
 import styles from './ReplyComments.module.scss';
 import styles from './ReplyComments.module.scss';
 
 
+
 type ReplycommentsProps = {
 type ReplycommentsProps = {
   isReadOnly: boolean,
   isReadOnly: boolean,
   replyList: ICommentHasIdList,
   replyList: ICommentHasIdList,
@@ -38,11 +39,11 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
     return (
     return (
       <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
       <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
         <Comment
         <Comment
-          rendererOptions={rendererOptions}
-          deleteBtnClicked={deleteBtnClicked}
           comment={reply}
           comment={reply}
-          onComment={onComment}
           isReadOnly={isReadOnly}
           isReadOnly={isReadOnly}
+          deleteBtnClicked={deleteBtnClicked}
+          onComment={onComment}
+          rendererOptions={rendererOptions}
           currentPagePath={currentPagePath}
           currentPagePath={currentPagePath}
           currentRevisionId={currentRevisionId}
           currentRevisionId={currentRevisionId}
           currentRevisionCreatedAt={currentRevisionCreatedAt}
           currentRevisionCreatedAt={currentRevisionCreatedAt}

+ 3 - 4
packages/app/src/components/PageContentFooter.tsx

@@ -8,10 +8,10 @@ import { Skelton } from './Skelton';
 
 
 import styles from './PageContentFooter.module.scss';
 import styles from './PageContentFooter.module.scss';
 
 
-export const PageContentFooter = memo((): JSX.Element => {
+const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'),
+  { ssr: false, loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} /> });
 
 
-  const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'),
-    { ssr: false, loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} /> });
+export const PageContentFooter = memo((): JSX.Element => {
 
 
   const { data: page } = useSWRxCurrentPage();
   const { data: page } = useSWRxCurrentPage();
 
 
@@ -20,7 +20,6 @@ export const PageContentFooter = memo((): JSX.Element => {
   }
   }
 
 
   return (
   return (
-    // TODO: page-content-footer, scss module import and global import.
     <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
     <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
       <div className="grw-container-convertible">
       <div className="grw-container-convertible">
         <div className="page-meta">
         <div className="page-meta">

+ 1 - 0
packages/app/src/components/PageEditor/Editor.tsx

@@ -36,6 +36,7 @@ type EditorPropsType = {
   onSave?: () => Promise<void>,
   onSave?: () => Promise<void>,
   onPasteFiles?: (event: Event) => void,
   onPasteFiles?: (event: Event) => void,
   onCtrlEnter?: (event: Event) => void,
   onCtrlEnter?: (event: Event) => void,
+  isComment?: boolean,
 }
 }
 
 
 type DropzoneRef = {
 type DropzoneRef = {

+ 1 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -81,7 +81,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
   const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
   const RevisionLoader = dynamic(() => import('../Page/RevisionLoader'), { ssr: false });
   const RevisionLoader = dynamic(() => import('../Page/RevisionLoader'), { ssr: false });
   const PageComment = dynamic(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
   const PageComment = dynamic(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
-  // TODO: Commentout for eslint error
+  // Commentout for eslint error, PageContentFooter is imported in PgaeComment
   // const PageContentFooter = dynamic(() => import('../PageContentFooter'), { ssr: false });
   // const PageContentFooter = dynamic(() => import('../PageContentFooter'), { ssr: false });
 
 
   const scrollElementRef = useRef(null);
   const scrollElementRef = useRef(null);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 0 - 4
packages/app/src/interfaces/services/renderer.ts

@@ -1,5 +1,3 @@
-import { HastNode } from 'hast-util-select';
-
 import { XssOptionConfig } from '~/services/xss/xssOption';
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
 
 // export type GrowiHydratedEnv = {
 // export type GrowiHydratedEnv = {
@@ -20,5 +18,3 @@ export type RendererConfig = {
   plantumlUri: string | null,
   plantumlUri: string | null,
   blockdiagUri: string | null,
   blockdiagUri: string | null,
 } & XssOptionConfig;
 } & XssOptionConfig;
-
-export type RehypePlugin = (option: any) => (node: HastNode) => void

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

@@ -35,6 +35,7 @@ import { PageModel, PageDocument } from '~/server/models/page';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
+import { useRedirectFrom } from '~/stores/page-redirect';
 import {
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -191,12 +192,12 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
 
   // page
   // page
-  useCurrentPagePath(props.currentPathname);
   useIsLatestRevision(props.isLatestRevision);
   useIsLatestRevision(props.isLatestRevision);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   useIsForbidden(props.isForbidden);
   useIsForbidden(props.isForbidden);
   useIsNotFound(props.isNotFound);
   useIsNotFound(props.isNotFound);
   useIsNotCreatable(props.IsNotCreatable);
   useIsNotCreatable(props.IsNotCreatable);
+  useRedirectFrom(props.redirectFrom);
   // useIsTrashPage(_isTrashPage(props.currentPagePath));
   // useIsTrashPage(_isTrashPage(props.currentPagePath));
   // useShared();
   // useShared();
   // useShareLinkId(props.shareLinkId);
   // useShareLinkId(props.shareLinkId);
@@ -290,7 +291,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
       </Head>
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
-        <header className="py-0">
+        <header className="py-0 position-relative">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
         </header>
         <div className="d-edit-none">
         <div className="d-edit-none">

+ 6 - 4
packages/app/src/pages/me.page.tsx

@@ -8,6 +8,7 @@ import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
@@ -131,10 +132,10 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 //  * @param props
 //  * @param props
 //  * @param namespacesRequired
 //  * @param namespacesRequired
 //  */
 //  */
-// async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
-//   const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
-//   props._nextI18Next = nextI18NextConfig._nextI18Next;
-// }
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
   const req = context.req as CrowiRequest<IUserHasId & any>;
@@ -157,6 +158,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
 
   await injectUserUISettings(context, props);
   await injectUserUISettings(context, props);
   await injectServerConfigurations(context, props);
   await injectServerConfigurations(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
 
 
   return {
   return {
     props,
     props,

+ 7 - 5
packages/app/src/server/routes/ogp.ts

@@ -1,15 +1,17 @@
+import * as fs from 'fs';
+import path from 'path';
+
+import { DevidedPagePath } from '@growi/core';
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import {
 import {
   Request, Response, NextFunction,
   Request, Response, NextFunction,
 } from 'express';
 } from 'express';
 import { param, validationResult, ValidationError } from 'express-validator';
 import { param, validationResult, ValidationError } from 'express-validator';
 
 
-import path from 'path';
-import * as fs from 'fs';
-
-import { DevidedPagePath } from '@growi/core';
-import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
+
 import { convertStreamToBuffer } from '../util/stream';
 import { convertStreamToBuffer } from '../util/stream';
 
 
 const logger = loggerFactory('growi:routes:ogp');
 const logger = loggerFactory('growi:routes:ogp');

+ 2 - 1
packages/app/src/server/service/s2s-messaging/nchan.ts

@@ -1,9 +1,10 @@
 import path from 'path';
 import path from 'path';
 
 
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import WebSocket from 'ws';
 import WebSocket from 'ws';
 
 
-import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';

+ 3 - 5
packages/app/src/services/renderer/rehype-plugins/add-class.ts

@@ -1,9 +1,7 @@
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // Re-implemeted in TypeScript.
 // Re-implemeted in TypeScript.
-
 import { selectAll, HastNode, Element } from 'hast-util-select';
 import { selectAll, HastNode, Element } from 'hast-util-select';
-
-import { RehypePlugin } from '~/interfaces/services/renderer';
+import { Plugin } from 'unified';
 
 
 export type SelectorName = string; // e.g. 'h1'
 export type SelectorName = string; // e.g. 'h1'
 export type ClassName = string; // e.g. 'header'
 export type ClassName = string; // e.g. 'header'
@@ -32,8 +30,8 @@ const adder = (entry: AdditionsEntry) => {
   return (node: HastNode) => selectAll(selectorName, node).forEach(writer);
   return (node: HastNode) => selectAll(selectorName, node).forEach(writer);
 };
 };
 
 
-export const addClass: RehypePlugin = (additions) => {
+export const addClass: Plugin<[Additions]> = (additions) => {
   const adders = Object.entries(additions).map(adder);
   const adders = Object.entries(additions).map(adder);
 
 
-  return node => adders.forEach(a => a(node));
+  return node => adders.forEach(a => a(node as HastNode));
 };
 };

+ 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,
+  });
+};

+ 52 - 0
packages/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -0,0 +1,52 @@
+import { selectAll, HastNode, Element } from 'hast-util-select';
+import isAbsolute from 'is-absolute-url';
+import { Plugin } from 'unified';
+
+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 = anchorsSelector(tree as HastNode);
+
+    anchors.forEach((anchor) => {
+      if (anchor.properties == null) {
+        return;
+      }
+
+      const href = anchor.properties.href;
+      if (href == null || typeof href !== 'string' || isAbsolute(href)) {
+        return;
+      }
+
+      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({}));
+};

+ 30 - 16
packages/app/src/services/renderer/renderer.ts

@@ -1,3 +1,4 @@
+import growiPlugin from '@growi/remark-growi-plugin';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import katex from 'rehype-katex';
 import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import raw from 'rehype-raw';
@@ -9,13 +10,18 @@ import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
 import math from 'remark-math';
 
 
+
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { RendererConfig } from '~/interfaces/services/renderer';
-import { addClass } from '~/services/renderer/rehype-plugins/add-class';
 import loggerFactory from '~/utils/logger';
 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 CsvToTable from './PreProcessor/CsvToTable';
 // import EasyGrid from './PreProcessor/EasyGrid';
 // import EasyGrid from './PreProcessor/EasyGrid';
 // import Linker from './PreProcessor/Linker';
 // import Linker from './PreProcessor/Linker';
@@ -212,15 +218,18 @@ const logger = loggerFactory('growi:util:GrowiRenderer');
 
 
 export type RendererOptions = Partial<ReactMarkdownOptions>;
 export type RendererOptions = Partial<ReactMarkdownOptions>;
 
 
-export interface ReactMarkdownOptionsGenerator {
-  (config: RendererConfig): RendererOptions
-}
 
 
-const generateCommonOptions: ReactMarkdownOptionsGenerator = (config: RendererConfig): RendererOptions => {
+const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
   return {
   return {
-    remarkPlugins: [gfm],
+    remarkPlugins: [
+      gfm,
+      pukiwikiLikeLinker,
+      growiPlugin,
+    ],
     rehypePlugins: [
     rehypePlugins: [
       slug,
       slug,
+      [relativeLinksByPukiwikiLikeLinker, { pagePath }],
+      [relativeLinks, { pagePath }],
       raw,
       raw,
       [sanitize, {
       [sanitize, {
         ...sanitizeDefaultSchema,
         ...sanitizeDefaultSchema,
@@ -243,11 +252,12 @@ const generateCommonOptions: ReactMarkdownOptionsGenerator = (config: RendererCo
 };
 };
 
 
 export const generateViewOptions = (
 export const generateViewOptions = (
+    pagePath: string,
     config: RendererConfig,
     config: RendererConfig,
-    storeTocNode: (node: HtmlElementNode) => void,
+    storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
 ): RendererOptions => {
 
 
-  const options = generateCommonOptions(config);
+  const options = generateCommonOptions(pagePath, config);
 
 
   const { remarkPlugins, rehypePlugins, components } = options;
   const { remarkPlugins, rehypePlugins, components } = options;
 
 
@@ -279,7 +289,11 @@ export const generateViewOptions = (
           });
           });
         };
         };
         replacer([toc]); // replace <ol> to <ul>
         replacer([toc]); // replace <ol> to <ul>
-        storeTocNode(toc); // store tocNode to global state with swr
+
+        // For storing tocNode to global state with swr
+        // search: tocRef.current
+        storeTocNode(toc);
+
         return false; // not show toc in body
         return false; // not show toc in body
       },
       },
     }]);
     }]);
@@ -312,7 +326,7 @@ export const generateViewOptions = (
 
 
 export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementNode | undefined): RendererOptions => {
 export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementNode | undefined): RendererOptions => {
 
 
-  const options = generateCommonOptions(config);
+  const options = generateCommonOptions(undefined, config);
 
 
   const { remarkPlugins, rehypePlugins } = options;
   const { remarkPlugins, rehypePlugins } = options;
 
 
@@ -334,8 +348,8 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   return options;
   return options;
 };
 };
 
 
-export const generatePreviewOptions: ReactMarkdownOptionsGenerator = (config: RendererConfig): RendererOptions => {
-  const options = generateCommonOptions(config);
+export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
+  const options = generateCommonOptions(undefined, config);
 
 
   // // Add configurers for preview
   // // Add configurers for preview
   // renderer.addConfigurers([
   // renderer.addConfigurers([
@@ -350,8 +364,8 @@ export const generatePreviewOptions: ReactMarkdownOptionsGenerator = (config: Re
   return options;
   return options;
 };
 };
 
 
-export const generateCommentPreviewOptions: ReactMarkdownOptionsGenerator = (config: RendererConfig): RendererOptions => {
-  const options = generateCommonOptions(config);
+export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
+  const options = generateCommonOptions(undefined, config);
   const { remarkPlugins } = options;
   const { remarkPlugins } = options;
 
 
   // add remark plugins
   // add remark plugins
@@ -372,8 +386,8 @@ export const generateCommentPreviewOptions: ReactMarkdownOptionsGenerator = (con
   return options;
   return options;
 };
 };
 
 
-export const generateOthersOptions: ReactMarkdownOptionsGenerator = (config: RendererConfig): RendererOptions => {
-  const options = generateCommonOptions(config);
+export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
+  const options = generateCommonOptions(undefined, config);
 
 
   // renderer.addConfigurers([
   // renderer.addConfigurers([
   //   new TableConfigurer(),
   //   new TableConfigurer(),

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

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

+ 30 - 0
packages/app/src/stores/page-redirect.tsx

@@ -0,0 +1,30 @@
+import { SWRResponseWithUtils, withUtils } from '@growi/core/src/utils/with-utils';
+import { SWRResponse } from 'swr';
+
+import { apiPost } from '~/client/util/apiv1-client';
+
+import { useCurrentPagePath } from './context';
+import { useStaticSWR } from './use-static-swr';
+
+type RedirectFromUtil = {
+  unlink(): Promise<void>
+}
+export const useRedirectFrom = (initialData?: string): SWRResponseWithUtils<RedirectFromUtil, string> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const swrResponse: SWRResponse<string, Error> = useStaticSWR('redirectFrom', initialData);
+  const utils = {
+    unlink: async() => {
+      if (currentPagePath == null) {
+        return;
+      }
+      try {
+        await apiPost('/pages.unlink', { path: currentPagePath });
+        swrResponse.mutate('');
+      }
+      catch (err) {
+        throw err;
+      }
+    },
+  };
+  return withUtils(swrResponse, utils);
+};

+ 34 - 9
packages/app/src/stores/renderer.tsx

@@ -1,14 +1,22 @@
+import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
+import { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
-  ReactMarkdownOptionsGenerator, RendererOptions,
+  RendererOptions,
   generatePreviewOptions, generateCommentPreviewOptions, generateOthersOptions,
   generatePreviewOptions, generateCommentPreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
 
 
 
 
-import { useCurrentPageTocNode, useRendererConfig } from './context';
+import {
+  useCurrentPagePath, useCurrentPageTocNode, useRendererConfig,
+} from './context';
+
+interface ReactMarkdownOptionsGenerator {
+  (config: RendererConfig): RendererOptions
+}
 
 
 // The base hook with common processes
 // The base hook with common processes
 const _useOptionsBase = (
 const _useOptionsBase = (
@@ -32,20 +40,37 @@ const _useOptionsBase = (
   return useSWRImmutable<RendererOptions, Error>(key);
   return useSWRImmutable<RendererOptions, Error>(key);
 };
 };
 
 
-export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'viewOptions';
+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;
 
 
-  return _useOptionsBase(key, config => generateViewOptions(config, storeTocNode));
+  const key = isAllDataValid
+    ? ['viewOptions', currentPagePath, rendererConfig]
+    : null;
+
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
+  );
 };
 };
 
 
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'tocOptions';
-
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
   const { data: tocNode } = useCurrentPageTocNode();
   const { data: tocNode } = useCurrentPageTocNode();
 
 
-  return _useOptionsBase(key, config => generateTocOptions(config, tocNode));
+  const isAllDataValid = rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['tocOptions', currentPagePath, tocNode, rendererConfig]
+    : null;
+
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, path, tocNode, rendererConfig) => generateTocOptions(rendererConfig, tocNode),
+  );
 };
 };
 
 
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {

+ 5 - 2
packages/app/src/utils/download.ts

@@ -1,9 +1,12 @@
 import path from 'path';
 import path from 'path';
+import { Readable, Writable, Transform } from 'stream';
+
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import fs from 'graceful-fs';
 import fs from 'graceful-fs';
 import mkdirp from 'mkdirp';
 import mkdirp from 'mkdirp';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
-import { Readable, Writable, Transform } from 'stream';
-import axios from '~/utils/axios';
+
 
 
 export async function downloadTo(url: string, outDir: string, fileName: string, transform: Transform|null = null): Promise<void> {
 export async function downloadTo(url: string, outDir: string, fileName: string, transform: Transform|null = null): Promise<void> {
   // get
   // get

+ 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
+    }
+  }
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików