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

Merge pull request #7597 from weseek/feat/112904-120355-enable-attachment-refs

feat: Revive attachment-refs with remark
Yuki Takei 2 лет назад
Родитель
Сommit
de5dce8149
46 измененных файлов с 1130 добавлено и 1000 удалено
  1. 1 0
      apps/app/package.json
  2. 26 0
      apps/app/src/client/services/renderer/renderer.tsx
  3. 2 0
      apps/app/src/server/crowi/index.js
  4. 1 1
      apps/app/src/server/models/page.ts
  5. 0 39
      packages-obsolete/plugin-attachment-refs/package.json
  6. 0 10
      packages-obsolete/plugin-attachment-refs/src/client-entry.js
  7. 0 28
      packages-obsolete/plugin-attachment-refs/src/client/css/index.css
  8. 0 199
      packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  9. 0 214
      packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  10. 0 24
      packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js
  11. 0 62
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  12. 0 59
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js
  13. 0 249
      packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js
  14. 0 21
      packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js
  15. 0 11
      packages-obsolete/plugin-attachment-refs/src/index.js
  16. 0 4
      packages-obsolete/plugin-attachment-refs/src/server-entry.js
  17. 0 4
      packages-obsolete/plugin-attachment-refs/src/server/routes/index.js
  18. 0 11
      packages-obsolete/plugin-attachment-refs/tsconfig.base.json
  19. 0 16
      packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json
  20. 0 18
      packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json
  21. 0 10
      packages-obsolete/plugin-attachment-refs/tsconfig.json
  22. 3 0
      packages/core/src/interfaces/attachment.ts
  23. 0 0
      packages/remark-attachment-refs/.eslintignore
  24. 0 0
      packages/remark-attachment-refs/.gitignore
  25. 4 4
      packages/remark-attachment-refs/README.md
  26. 44 0
      packages/remark-attachment-refs/package.json
  27. 29 0
      packages/remark-attachment-refs/src/client/components/AttachmentList.module.scss
  28. 74 0
      packages/remark-attachment-refs/src/client/components/AttachmentList.tsx
  29. 197 0
      packages/remark-attachment-refs/src/client/components/ExtractedAttachments.tsx
  30. 20 0
      packages/remark-attachment-refs/src/client/components/Gallery.tsx
  31. 43 0
      packages/remark-attachment-refs/src/client/components/Ref.tsx
  32. 57 0
      packages/remark-attachment-refs/src/client/components/RefImg.tsx
  33. 52 0
      packages/remark-attachment-refs/src/client/components/Refs.tsx
  34. 83 0
      packages/remark-attachment-refs/src/client/components/RefsImg.tsx
  35. 5 0
      packages/remark-attachment-refs/src/client/components/index.ts
  36. 166 0
      packages/remark-attachment-refs/src/client/components/util/refs-context.ts
  37. 2 0
      packages/remark-attachment-refs/src/client/index.ts
  38. 157 0
      packages/remark-attachment-refs/src/client/services/renderer/refs.ts
  39. 49 0
      packages/remark-attachment-refs/src/client/stores/refs.tsx
  40. 10 0
      packages/remark-attachment-refs/src/server/index.ts
  41. 12 16
      packages/remark-attachment-refs/src/server/routes/refs.ts
  42. 0 0
      packages/remark-attachment-refs/src/utils/logger/index.ts
  43. 14 0
      packages/remark-attachment-refs/tsconfig.json
  44. 31 0
      packages/remark-attachment-refs/vite.client.config.ts
  45. 35 0
      packages/remark-attachment-refs/vite.server.config.ts
  46. 13 0
      turbo.json

+ 1 - 0
apps/app/package.json

@@ -61,6 +61,7 @@
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "^6.1.0-RC.0",
     "@growi/hackmd": "^6.1.0-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.0-RC.0",
     "@growi/preset-themes": "^6.1.0-RC.0",
     "@growi/remark-drawio": "^6.1.0-RC.0",
     "@growi/remark-growi-directive": "^6.1.0-RC.0",

+ 26 - 0
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,6 +1,7 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
+import * as refsGrowiPlugin from '@growi/remark-attachment-refs/dist/client/index.mjs';
 import * as drawioPlugin from '@growi/remark-drawio';
 // eslint-disable-next-line import/extensions
 import * as lsxGrowiPlugin from '@growi/remark-lsx/dist/client/index.mjs';
@@ -33,6 +34,7 @@ import loggerFactory from '~/utils/logger';
 // import EasyGrid from './PreProcessor/EasyGrid';
 
 import '@growi/remark-lsx/dist/client/style.css';
+import '@growi/remark-attachment-refs/dist/client/style.css';
 
 
 const logger = loggerFactory('growi:cli:services:renderer');
@@ -58,6 +60,7 @@ export const generateViewOptions = (
     drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    refsGrowiPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -72,6 +75,7 @@ export const generateViewOptions = (
       commonSanitizeOption,
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      refsGrowiPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -79,6 +83,7 @@ export const generateViewOptions = (
   rehypePlugins.push(
     slug,
     [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiPlugin.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -93,6 +98,11 @@ export const generateViewOptions = (
     components.h5 = Header;
     components.h6 = Header;
     components.lsx = lsxGrowiPlugin.Lsx;
+    components.ref = refsGrowiPlugin.Ref;
+    components.refs = refsGrowiPlugin.Refs;
+    components.refimg = refsGrowiPlugin.RefImg;
+    components.refsimg = refsGrowiPlugin.RefsImg;
+    components.gallery = refsGrowiPlugin.Gallery;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
   }
@@ -153,6 +163,7 @@ export const generateSimpleViewOptions = (
     drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    refsGrowiPlugin.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -171,12 +182,14 @@ export const generateSimpleViewOptions = (
       commonSanitizeOption,
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      refsGrowiPlugin.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiPlugin.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     katex,
@@ -185,6 +198,11 @@ export const generateSimpleViewOptions = (
   // add components
   if (components != null) {
     components.lsx = lsxGrowiPlugin.LsxImmutable;
+    components.ref = refsGrowiPlugin.RefImmutable;
+    components.refs = refsGrowiPlugin.RefsImmutable;
+    components.refimg = refsGrowiPlugin.RefImgImmutable;
+    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
+    components.gallery = refsGrowiPlugin.GalleryImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
   }
 
@@ -219,6 +237,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    refsGrowiPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -232,6 +251,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     ? [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      refsGrowiPlugin.sanitizeOption,
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )]
@@ -240,6 +260,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiPlugin.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     katex,
@@ -248,6 +269,11 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   // add components
   if (components != null) {
     components.lsx = lsxGrowiPlugin.LsxImmutable;
+    components.ref = refsGrowiPlugin.RefImmutable;
+    components.refs = refsGrowiPlugin.RefsImmutable;
+    components.refimg = refsGrowiPlugin.RefImgImmutable;
+    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
+    components.gallery = refsGrowiPlugin.GalleryImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
   }
 

+ 2 - 0
apps/app/src/server/crowi/index.js

@@ -3,6 +3,7 @@ import http from 'http';
 import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
+import attachmentRoutes from '@growi/remark-attachment-refs/dist/server';
 import lsxRoutes from '@growi/remark-lsx/dist/server';
 import mongoose from 'mongoose';
 import next from 'next';
@@ -531,6 +532,7 @@ Crowi.prototype.setupTerminus = function(server) {
 
 Crowi.prototype.setupRoutesForPlugins = function() {
   lsxRoutes(this, this.express);
+  attachmentRoutes(this, this.express);
 };
 
 /**

+ 1 - 1
apps/app/src/server/models/page.ts

@@ -566,7 +566,7 @@ schema.statics.findByIdsAndViewer = async function(
 };
 
 /*
- * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
+ * Find a page by path and viewer. Pass true to useFindOne to use findOne method.
  */
 schema.statics.findByPathAndViewer = async function(
     path: string | null, user, userGroups = null, useFindOne = false, includeEmpty = false,

+ 0 - 39
packages-obsolete/plugin-attachment-refs/package.json

@@ -1,39 +0,0 @@
-{
-  "name": "@growi/plugin-attachment-refs",
-  "version": "6.0.0-RC.9",
-  "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
-  "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": "yarn eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
-    "lint": "run-p lint:*",
-    "test": ""
-  },
-  "dependencies": {
-    "browser-bunyan": "^1.6.3",
-    "bunyan": "^1.8.15",
-    "http-errors": "^2.0.0",
-    "react-images": "~1.0.0",
-    "react-motion": "^0.5.2",
-    "universal-bunyan": "^0.9.2"
-  },
-  "devDependencies": {
-    "eslint-plugin-regex": "^1.8.0",
-    "npm-run-all": "^4.1.5",
-    "react": "^18.2.0",
-    "react-dom": "^18.2.0"
-  }
-}

+ 0 - 10
packages-obsolete/plugin-attachment-refs/src/client-entry.js

@@ -1,10 +0,0 @@
-import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
-import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
-
-export default () => {
-  // add interceptors
-  global.interceptorManager.addInterceptors([
-    new RefsPreRenderInterceptor(),
-    new RefsPostRenderInterceptor(),
-  ]);
-};

+ 0 - 28
packages-obsolete/plugin-attachment-refs/src/client/css/index.css

@@ -1,28 +0,0 @@
-@keyframes attachement-refs-fadeIn {
-  0% {opacity: .2}
-  100% {opacity: .9}
-}
-
-.attachment-refs .attachement-refs-blink {
-  animation: attachement-refs-fadeIn 1s ease 0s infinite alternate;
-}
-
-
-.attachment-refs li.attachment {
-  list-style: none;
-}
-
-.attachment-refs .attachment-userpicture {
-  line-height: 1.7em;
-  vertical-align: bottom;
-}
-
-.attachment-refs .page-meta {
-  font-size: 0.95em;
-}
-
-.attachment-refs .attachment-filetype {
-  padding: 1px 5px;
-  margin: 0 0 0 4px;
-  font-weight: normal;
-}

+ 0 - 199
packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx

@@ -1,199 +0,0 @@
-import React from 'react';
-
-import { Attachment } from '@growi/ui/dist/components/Attachment';
-import axios from 'axios'; // import axios from growi dependencies
-import PropTypes from 'prop-types';
-
-// eslint-disable-next-line import/no-unresolved
-
-import RefsContext from '../util/RefsContext';
-import TagCacheManagerFactory from '../util/TagCacheManagerFactory';
-
-// eslint-disable-next-line no-unused-vars
-
-import ExtractedAttachments from './ExtractedAttachments';
-
-import styles from '../../css/index.css';
-
-const AttachmentLink = Attachment;
-
-export default class AttachmentList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isLoading: false,
-      isLoaded: false,
-      isError: false,
-      errorMessage: null,
-
-      attachments: [],
-    };
-
-    this.tagCacheManager = TagCacheManagerFactory.getInstance();
-  }
-
-  async UNSAFE_componentWillMount() {
-    const { refsContext } = this.props;
-
-    // get state object cache
-    const stateCache = this.tagCacheManager.getStateCache(refsContext);
-
-    // check cache exists
-    if (stateCache != null) {
-      // restore state
-      this.setState({
-        ...stateCache,
-        isLoading: false,
-      });
-      // parse with no effect
-      try {
-        refsContext.parse();
-      }
-      catch (err) {
-        // do nothing
-      }
-
-      return; // go to render()
-    }
-
-    // parse
-    try {
-      refsContext.parse();
-    }
-    catch (err) {
-      this.setState({
-        isError: true,
-        errorMessage: err.toString(),
-      });
-
-      // store to sessionStorage
-      this.tagCacheManager.cacheState(refsContext, this.state);
-
-      return;
-    }
-
-    this.loadContents();
-  }
-
-  async loadContents() {
-    const { refsContext } = this.props;
-
-    let res;
-    try {
-      this.setState({ isLoading: true });
-
-      if (refsContext.isSingle) {
-        res = await axios.get('/_api/plugin/ref', {
-          params: {
-            pagePath: refsContext.pagePath,
-            fileNameOrId: refsContext.fileNameOrId,
-            options: refsContext.options,
-          },
-        });
-        this.setState({
-          attachments: [res.data.attachment],
-        });
-      }
-      else {
-        res = await axios.get('/_api/plugin/refs', {
-          params: {
-            prefix: refsContext.prefix,
-            pagePath: refsContext.pagePath,
-            options: refsContext.options,
-          },
-        });
-        this.setState({
-          attachments: res.data.attachments,
-        });
-      }
-
-      this.setState({
-        isLoaded: true,
-      });
-    }
-    catch (err) {
-      this.setState({
-        isError: true,
-        errorMessage: err.response.data,
-      });
-
-      return;
-    }
-    finally {
-      this.setState({ isLoading: false });
-
-      // store to sessionStorage
-      this.tagCacheManager.cacheState(refsContext, this.state);
-    }
-
-  }
-
-  renderNoAttachmentsMessage() {
-    const { refsContext } = this.props;
-
-    let message;
-
-    if (refsContext.prefix != null) {
-      message = `${refsContext.prefix} and descendant pages have no attachments`;
-    }
-    else {
-      message = `${refsContext.pagePath} has no attachments`;
-    }
-
-    return (
-      <div className="text-muted">
-        <small>
-          <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
-          {message}
-        </small>
-      </div>
-    );
-  }
-
-  renderContents() {
-    const { refsContext } = this.props;
-
-    if (this.state.isLoading) {
-      return (
-        <div className="text-muted">
-          <i className="fa fa-spinner fa-pulse mr-1"></i>
-          <span className="attachment-refs-blink">{refsContext.tagExpression}</span>
-        </div>
-      );
-    }
-    if (this.state.errorMessage != null) {
-      return (
-        <div className="text-warning">
-          <i className="fa fa-exclamation-triangle fa-fw"></i>
-          {refsContext.tagExpression} (-&gt; <small>{this.state.errorMessage}</small>)
-        </div>
-      );
-    }
-
-    if (this.state.isLoaded) {
-      const { attachments } = this.state;
-
-      // no attachments
-      if (attachments.length === 0) {
-        return this.renderNoAttachmentsMessage();
-      }
-
-      return (refsContext.isExtractImg)
-        ? <ExtractedAttachments attachments={attachments} refsContext={refsContext} />
-        : attachments.map((attachment) => {
-          return <AttachmentLink key={attachment._id} attachment={attachment} />;
-        });
-    }
-  }
-
-  render() {
-    return <div className="attachment-refs">{this.renderContents()}</div>;
-  }
-
-}
-
-AttachmentList.propTypes = {
-  refsContext: PropTypes.instanceOf(RefsContext).isRequired,
-};

+ 0 - 214
packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx

@@ -1,214 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import Carousel, { Modal, ModalGateway } from 'react-images';
-
-import RefsContext from '../util/RefsContext';
-
-/**
- *  1. when 'fileFormat' is image, render Attachment as an image
- *  2. when 'fileFormat' is not image, render Attachment as an Attachment component
- */
-export default class ExtractedAttachments extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      showCarousel: false,
-      currentIndex: null,
-    };
-  }
-
-  imageClickedHandler(index) {
-    this.setState({
-      showCarousel: true,
-      currentIndex: index,
-    });
-  }
-
-  getAttachmentsFilteredByFormat() {
-    return this.props.attachments
-      .filter(attachment => attachment.fileFormat.startsWith('image/'));
-  }
-
-  getClassesAndStylesForNonGrid() {
-    const { refsContext } = this.props;
-    const { options } = refsContext;
-
-    const {
-      width,
-      height,
-      'max-width': maxWidth,
-      'max-height': maxHeight,
-      display = 'block',
-    } = options;
-
-    const containerStyles = {
-      width, height, maxWidth, maxHeight, display,
-    };
-
-    const imageClasses = [];
-    const imageStyles = {
-      width, height, maxWidth, maxHeight,
-    };
-
-    return {
-      containerStyles,
-      imageClasses,
-      imageStyles,
-    };
-  }
-
-  getClassesAndStylesForGrid() {
-    const { refsContext } = this.props;
-    const { options } = refsContext;
-
-    const {
-      'max-width': maxWidth,
-      'max-height': maxHeight,
-    } = options;
-
-    const containerStyles = {
-      width: refsContext.getOptGridWidth(),
-      height: refsContext.getOptGridHeight(),
-      maxWidth,
-      maxHeight,
-    };
-
-    const imageClasses = ['w-100', 'h-100'];
-    const imageStyles = {
-      objectFit: 'cover',
-      maxWidth,
-      maxHeight,
-    };
-
-    return {
-      containerStyles,
-      imageClasses,
-      imageStyles,
-    };
-  }
-
-  /**
-   * wrapper method for getClassesAndStylesForGrid/getClassesAndStylesForNonGrid
-   */
-  getClassesAndStyles() {
-    const { refsContext } = this.props;
-    const { options } = refsContext;
-
-    return (options.grid != null)
-      ? this.getClassesAndStylesForGrid()
-      : this.getClassesAndStylesForNonGrid();
-  }
-
-  renderExtractedImage(attachment, index) {
-    const { refsContext } = this.props;
-    const { options } = refsContext;
-
-    // determine alt
-    let alt = refsContext.isSingle ? options.alt : undefined; // use only when single mode
-    alt = alt || attachment.originalName; //                     use 'originalName' if options.alt is not specified
-
-    // get styles
-    const {
-      containerStyles, imageClasses, imageStyles,
-    } = this.getClassesAndStyles();
-
-    // carousel settings
-    let onClick;
-    if (options['no-carousel'] == null) {
-      // pointer cursor
-      Object.assign(containerStyles, { cursor: 'pointer' });
-      // set click handler
-      onClick = () => {
-        this.imageClickedHandler(index);
-      };
-    }
-
-    return (
-      <div key={attachment._id} style={containerStyles} onClick={onClick}>
-        <img src={attachment.filePathProxied} alt={alt} className={imageClasses.join(' ')} style={imageStyles} />
-      </div>
-    );
-  }
-
-  renderCarousel() {
-    const { options } = this.props.refsContext;
-    const withCarousel = options['no-carousel'] == null;
-
-    const { showCarousel, currentIndex } = this.state;
-
-    const images = this.getAttachmentsFilteredByFormat()
-      .map((attachment) => {
-        return { src: attachment.filePathProxied };
-      });
-
-    // overwrite react-images modal styles
-    const zIndex = 1030; // > grw-navbar
-    const modalStyles = {
-      blanket: (styleObj) => {
-        return Object.assign(styleObj, { zIndex });
-      },
-      positioner: (styleObj) => {
-        return Object.assign(styleObj, { zIndex });
-      },
-    };
-
-    return (
-      <ModalGateway>
-        { withCarousel && showCarousel && (
-          <Modal styles={modalStyles} onClose={() => { this.setState({ showCarousel: false }) }}>
-            <Carousel views={images} currentIndex={currentIndex} />
-          </Modal>
-        ) }
-      </ModalGateway>
-    );
-  }
-
-  render() {
-    const { refsContext } = this.props;
-    const { options } = refsContext;
-    const {
-      grid,
-      'grid-gap': gridGap,
-    } = options;
-
-    const styles = {};
-
-    // Grid mode
-    if (grid != null) {
-
-      const gridTemplateColumns = (refsContext.isOptGridColumnEnabled())
-        ? `repeat(${refsContext.getOptGridColumnsNum()}, 1fr)`
-        : `repeat(auto-fill, ${refsContext.getOptGridWidth()})`;
-
-      Object.assign(styles, {
-        display: 'grid',
-        gridTemplateColumns,
-        gridAutoRows: '1fr',
-        gridGap,
-      });
-
-    }
-
-    const contents = this.getAttachmentsFilteredByFormat()
-      .map((attachment, index) => this.renderExtractedImage(attachment, index));
-
-    return (
-      <React.Fragment>
-        <div style={styles}>
-          {contents}
-        </div>
-
-        { this.renderCarousel() }
-      </React.Fragment>
-    );
-  }
-
-}
-
-ExtractedAttachments.propTypes = {
-  attachments: PropTypes.arrayOf(PropTypes.object).isRequired,
-  refsContext: PropTypes.instanceOf(RefsContext).isRequired,
-};

+ 0 - 24
packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js

@@ -1,24 +0,0 @@
-import RefsContext from './RefsContext';
-
-/**
- * Context Object class for $gallery()
- */
-export default class GalleryContext extends RefsContext {
-
-  /**
-   * @param {object|TagContext|RefsContext} initArgs
-   */
-  constructor(initArgs, fromPagePath) {
-    super(initArgs);
-
-    this.options = {
-      grid: 'col-4',
-      'grid-gap': '1px',
-    };
-  }
-
-  get isExtractImg() {
-    return true;
-  }
-
-}

+ 0 - 62
packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js

@@ -1,62 +0,0 @@
-import React from 'react';
-
-import { BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-
-import AttachmentList from '../../components/AttachmentList';
-import GalleryContext from '../GalleryContext';
-import RefsContext from '../RefsContext';
-
-
-/**
- * The interceptor for refs
- *
- *  render React DOM
- */
-export default class RefsPostRenderInterceptor extends BasicInterceptor {
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'postRenderHtml'
-      || contextName === 'postRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    // forEach keys of tagContextMap
-    Object.keys(context.tagContextMap).forEach((domId) => {
-      const elem = document.getElementById(domId);
-
-      if (elem) {
-        const tagContext = context.tagContextMap[domId];
-
-        // instanciate RefsContext from context
-        const refsContext = (tagContext.method === 'gallery')
-          ? new GalleryContext(tagContext || {})
-          : new RefsContext(tagContext || {});
-        refsContext.fromPagePath = context.pagePath ?? context.currentPathname;
-
-        this.renderReactDom(refsContext, elem);
-      }
-    });
-
-    return context;
-  }
-
-  renderReactDom(refsContext, elem) {
-    ReactDOM.render(
-      <AttachmentList appContainer={this.appContainer} refsContext={refsContext} />,
-      elem,
-    );
-  }
-
-}

+ 0 - 59
packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js

@@ -1,59 +0,0 @@
-import { customTagUtils, BasicInterceptor } from '@growi/core';
-
-import TagCacheManagerFactory from '../TagCacheManagerFactory';
-
-/**
- * The interceptor for refs
- *
- *  replace refs tag to a React target element
- */
-export default class RefsPreRenderInterceptor extends BasicInterceptor {
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'preRenderHtml'
-      || contextName === 'preRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isProcessableParallel() {
-    return false;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-    const parsedHTML = context.parsedHTML;
-    this.initializeCache(contextName);
-
-    const tagPattern = /ref|refs|refimg|refsimg|gallery/;
-    const result = customTagUtils.findTagAndReplace(tagPattern, parsedHTML);
-
-    context.parsedHTML = result.html;
-    context.tagContextMap = result.tagContextMap;
-
-    return context;
-  }
-
-  /**
-   * initialize cache
-   *  when contextName is 'preRenderHtml'         -> clear cache
-   *  when contextName is 'preRenderPreviewHtml'  -> doesn't clear cache
-   *
-   * @param {string} contextName
-   */
-  initializeCache(contextName) {
-    if (contextName === 'preRenderHtml') {
-      TagCacheManagerFactory.getInstance().clearAllStateCaches();
-    }
-  }
-
-}

+ 0 - 249
packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js

@@ -1,249 +0,0 @@
-import * as url from 'url';
-
-import { customTagUtils, pathUtils } from '@growi/core';
-
-const { TagContext, ArgsParser, OptionParser } = customTagUtils;
-
-const GRID_DEFAULT_TRACK_WIDTH = 64;
-const GRID_AVAILABLE_OPTIONS_LIST = [
-  'autofill',
-  'autofill-xs',
-  'autofill-sm',
-  'autofill-md',
-  'autofill-lg',
-  'autofill-xl',
-  'col-2',
-  'col-3',
-  'col-4',
-  'col-5',
-  'col-6',
-];
-
-/**
- * Context Object class for $refs() and $refsimg()
- */
-export default class RefsContext extends TagContext {
-
-  /**
-   * @param {object|TagContext|RefsContext} initArgs
-   */
-  constructor(initArgs, fromPagePath) {
-    super(initArgs);
-
-    this.fromPagePath = fromPagePath;
-
-    // initialized after parse()
-    this.pagePath = null;
-    this.isParsed = null;
-    this.options = {};
-  }
-
-  get isSingle() {
-    return this.method.match(/^(ref|refimg)$/);
-  }
-
-  get isExtractImg() {
-    return this.method.match(/^(refimg|refsimg)$/);
-  }
-
-  parse() {
-    if (this.isParsed) {
-      return;
-    }
-
-    const parsedArgs = ArgsParser.parse(this.args);
-    this.options = Object.assign(this.options, parsedArgs.options);
-
-    if (this.isSingle) {
-      this.parseForSingle(parsedArgs);
-    }
-    else {
-      this.parseForMulti(parsedArgs);
-    }
-
-    this.isParsed = true;
-  }
-
-  /**
-   * parse method for 'ref' and 'refimg'
-   */
-  parseForSingle(parsedArgs) {
-    // determine fileNameOrId
-    // order:
-    //   1: ref(file=..., ...)
-    //   2: ref(id=..., ...)
-    //   2: refs(firstArgs, ...)
-    this.fileNameOrId = this.options.file || this.options.id
-      || ((parsedArgs.firstArgsValue === true) ? parsedArgs.firstArgsKey : undefined);
-
-    if (this.fileNameOrId == null) {
-      throw new Error('\'file\' or \'id\' is not specified. Set first argument or specify \'file\' or \'id\' option');
-    }
-
-    // determine pagePath
-    // order:
-    //   1: ref(page=..., ...)
-    //   2: constructor argument
-    const specifiedPath = this.options.page || this.fromPagePath;
-    this.pagePath = this.getAbsolutePathFor(specifiedPath);
-  }
-
-  /**
-   * parse method for 'refs' and 'refsimg'
-   */
-  parseForMulti(parsedArgs) {
-    if (this.options.prefix) {
-      this.prefix = this.resolvePath(this.options.prefix);
-    }
-    else {
-      // determine pagePath
-      // order:
-      //   1: ref(page=..., ...)
-      //   2: refs(firstArgs, ...)
-      //   3: constructor argument
-      const specifiedPath = this.options.page
-      || ((parsedArgs.firstArgsValue === true) ? parsedArgs.firstArgsKey : undefined)
-      || this.fromPagePath;
-
-      this.pagePath = this.getAbsolutePathFor(specifiedPath);
-    }
-
-    if (this.options.grid != null && this.getOptGrid() == null) {
-      throw new Error('\'grid\' option is invalid. '
-        + 'Available value is: \'autofill-( xs | sm | md | lg | xl )\' or \'col-( 2 | 3 | 4 | 5 | 6 )\'');
-    }
-  }
-
-  // resolve pagePath
-  //   when `fromPagePath`=/hoge and `specifiedPath`=./fuga,
-  //        `pagePath` to be /hoge/fuga
-  //   when `fromPagePath`=/hoge and `specifiedPath`=/fuga,
-  //        `pagePath` to be /fuga
-  //   when `fromPagePath`=/hoge and `specifiedPath`=undefined,
-  //        `pagePath` to be /hoge
-  resolvePath(pagePath) {
-    return decodeURIComponent(url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), pagePath));
-  }
-
-  getOptDepth() {
-    if (this.options.depth === undefined) {
-      return undefined;
-    }
-    return OptionParser.parseRange(this.options.depth);
-  }
-
-  getOptGrid() {
-    const { grid } = this.options;
-    return GRID_AVAILABLE_OPTIONS_LIST.find(item => item === grid);
-  }
-
-  isOptGridColumnEnabled() {
-    const optGrid = this.getOptGrid();
-    return (optGrid != null) && optGrid.startsWith('col-');
-  }
-
-  getOptGridColumnsNum() {
-    const { grid } = this.options;
-
-    let columnsNum = null;
-
-    switch (grid) {
-      case 'col-2':
-        columnsNum = 2;
-        break;
-      case 'col-3':
-        columnsNum = 3;
-        break;
-      case 'col-4':
-        columnsNum = 4;
-        break;
-      case 'col-5':
-        columnsNum = 5;
-        break;
-      case 'col-6':
-        columnsNum = 6;
-        break;
-    }
-
-    return columnsNum;
-  }
-
-  /**
-   * return auto-calculated grid width
-   * rules:
-   *  1. when column mode (e.g. col-6, col5, ...), the width specification is disabled
-   *  2. when width option is set, return it
-   *  3. otherwise, the mode should be autofill and the width will be calculated according to the size
-   */
-  getOptGridWidth() {
-    const grid = this.getOptGrid();
-    const { width } = this.options;
-
-    // when Grid column mode
-    if (this.isOptGridColumnEnabled()) {
-      // not specify and ignore width
-      return undefined;
-    }
-
-    // when width is specified
-    if (width != null) {
-      return width;
-    }
-
-    // when Grid autofill mode
-    let autofillMagnification = 1;
-
-    switch (grid) {
-      case 'autofill-xl':
-        autofillMagnification = 3;
-        break;
-      case 'autofill-lg':
-        autofillMagnification = 2;
-        break;
-      case 'autofill-sm':
-        autofillMagnification = 0.75;
-        break;
-      case 'autofill-xs':
-        autofillMagnification = 0.5;
-        break;
-      case 'autofill':
-      case 'autofill-md':
-        break;
-    }
-
-    return `${GRID_DEFAULT_TRACK_WIDTH * autofillMagnification}px`;
-  }
-
-  /**
-   * return auto-calculated grid height
-   * rules:
-   *  1. when height option is set, return it
-   *  2. otherwise, the same value to the width will be returned
-   */
-
-  getOptGridHeight() {
-    const { height } = this.options;
-
-    // when height is specified
-    if (height != null) {
-      return height;
-    }
-
-    // return the value which is same to width
-    return this.getOptGridWidth();
-  }
-
-  /**
-   * return absolute path for the specified path
-   *
-   * @param {string} relativePath relative path from `this.fromPagePath`
-   */
-  getAbsolutePathFor(relativePath) {
-    return decodeURIComponent(
-      pathUtils.normalizePath( // normalize like /foo/bar
-        url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), relativePath),
-      ),
-    );
-  }
-
-}

+ 0 - 21
packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js

@@ -1,21 +0,0 @@
-import { TagCacheManager } from '@growi/core';
-
-const STATE_CACHE_NS = 'refs-state-cache';
-
-let _instance;
-export default class TagCacheManagerFactory {
-
-  static getInstance() {
-    if (_instance == null) {
-      // create generateCacheKey implementation
-      const generateCacheKey = (refsContext) => {
-        return `${refsContext.method}__${refsContext.fromPagePath}__${refsContext.args}`;
-      };
-
-      _instance = new TagCacheManager(STATE_CACHE_NS, generateCacheKey);
-    }
-
-    return _instance;
-  }
-
-}

+ 0 - 11
packages-obsolete/plugin-attachment-refs/src/index.js

@@ -1,11 +0,0 @@
-const isProd = process.env.NODE_ENV === 'production';
-
-module.exports = {
-  pluginSchemaVersion: 4,
-  serverEntries: [
-    isProd ? 'dist/cjs/server-entry.js' : 'src/server-entry.js',
-  ],
-  clientEntries: [
-    'src/client-entry.js',
-  ],
-};

+ 0 - 4
packages-obsolete/plugin-attachment-refs/src/server-entry.js

@@ -1,4 +0,0 @@
-module.exports = (crowi, app) => {
-  // add routes
-  require('./server/routes')(crowi, app);
-};

+ 0 - 4
packages-obsolete/plugin-attachment-refs/src/server/routes/index.js

@@ -1,4 +0,0 @@
-module.exports = (crowi, app) => {
-  // add routes
-  app.use('/_api/plugin', require('./refs')(crowi, app));
-};

+ 0 - 11
packages-obsolete/plugin-attachment-refs/tsconfig.base.json

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

+ 0 - 16
packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json

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

+ 0 - 18
packages-obsolete/plugin-attachment-refs/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 - 10
packages-obsolete/plugin-attachment-refs/tsconfig.json

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

+ 3 - 0
packages/core/src/interfaces/attachment.ts

@@ -1,4 +1,5 @@
 import type { Ref } from './common';
+import { HasObjectId } from './has-object-id';
 import type { IPage } from './page';
 import type { IUser } from './user';
 
@@ -13,3 +14,5 @@ export type IAttachment = {
   downloadPathProxied: string,
   originalName: string,
 };
+
+export type IAttachmentHasId = IAttachment & HasObjectId;

+ 0 - 0
packages-obsolete/plugin-attachment-refs/.eslintignore → packages/remark-attachment-refs/.eslintignore


+ 0 - 0
packages-obsolete/plugin-attachment-refs/.gitignore → packages/remark-attachment-refs/.gitignore


+ 4 - 4
packages-obsolete/plugin-attachment-refs/README.md → packages/remark-attachment-refs/README.md

@@ -1,4 +1,4 @@
-# growi-plugin-attachment-refs
+# remark-attachment-refs
 
 Examples
 -------
@@ -11,9 +11,9 @@ Examples
 
 ![refsimg](https://user-images.githubusercontent.com/1638767/64986528-1c54eb00-d902-11e9-95dc-2784fa15746c.gif)
 
-### Image Carousel
+<!-- ### Image Carousel
 
-![lightbox](https://user-images.githubusercontent.com/1638767/64986530-1e1eae80-d902-11e9-8711-b5df3572769c.gif)
+![lightbox](https://user-images.githubusercontent.com/1638767/64986530-1e1eae80-d902-11e9-8711-b5df3572769c.gif) -->
 
 
 Usage
@@ -113,7 +113,7 @@ $refsimg(prefix=/somewhere, grid=autofill, grid-gap=1px)
   - `col-6` : Grid layout with 6 columns
 - *`grid-gap`* : Grid gap
   - e.g. `grid-gap=1px`
-- *`no-carousel`* : Omit carousel function and just show images
+<!-- - *`no-carousel`* : Omit carousel function and just show images -->
 
 
 ### `$gallery` tag

+ 44 - 0
packages/remark-attachment-refs/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "@growi/remark-attachment-refs",
+  "version": "6.1.0-RC.0",
+  "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
+  "license": "MIT",
+  "keywords": [
+    "growi",
+    "growi-plugin"
+  ],
+  "main": "dist/index.js",
+  "module": "dist/index.mjs",
+  "types": "dist/index.d.ts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "run-p build:*",
+    "build:server": "vite build -c vite.server.config.ts",
+    "build:client": "vite build -c vite.client.config.ts",
+    "clean": "npx -y shx rm -rf dist",
+    "dev": "run-p dev:*",
+    "dev:server": "vite build -c vite.server.config.ts --mode dev",
+    "dev:client": "vite build -c vite.client.config.ts --mode dev",
+    "watch": "yarn dev -w --emptyOutDir=false",
+    "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}",
+    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
+    "lint:typecheck": "tsc",
+    "lint": "run-p lint:*",
+    "test": ""
+  },
+  "dependencies": {
+    "bunyan": "^1.8.15",
+    "universal-bunyan": "^0.9.2",
+    "@growi/core": "^6.1.0-RC.0",
+    "@growi/remark-growi-directive": "^6.1.0-RC.0",
+    "@growi/ui": "^6.1.0-RC.0"
+  },
+  "devDependencies": {
+    "eslint-plugin-regex": "^1.8.0",
+    "npm-run-all": "^4.1.5",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  }
+}

+ 29 - 0
packages/remark-attachment-refs/src/client/components/AttachmentList.module.scss

@@ -0,0 +1,29 @@
+@keyframes attachement-refs-fadeIn {
+  0% {opacity: .2}
+  100% {opacity: .9}
+}
+
+.attachment-refs :global {
+  .attachement-refs-blink {
+    animation: attachement-refs-fadeIn 1s ease 0s infinite alternate;
+  }
+
+  li.attachment {
+    list-style: none;
+  }
+
+  .attachment-userpicture {
+    line-height: 1.7em;
+    vertical-align: bottom;
+  }
+
+  .page-meta {
+    font-size: 0.95em;
+  }
+
+  .attachment-filetype {
+    padding: 1px 5px;
+    margin: 0 0 0 4px;
+    font-weight: normal;
+  }
+}

+ 74 - 0
packages/remark-attachment-refs/src/client/components/AttachmentList.tsx

@@ -0,0 +1,74 @@
+import { useCallback } from 'react';
+
+import { IAttachmentHasId } from '@growi/core';
+import { Attachment } from '@growi/ui/dist/components/Attachment';
+
+import { ExtractedAttachments } from './ExtractedAttachments';
+import { RefsContext } from './util/refs-context';
+
+
+import styles from './AttachmentList.module.scss';
+
+const AttachmentLink = Attachment;
+
+type Props = {
+  refsContext: RefsContext
+  isLoading: boolean
+  error?: Error
+  attachments: IAttachmentHasId[]
+};
+
+export const AttachmentList = ({
+  refsContext,
+  isLoading,
+  error,
+  attachments,
+}: Props): JSX.Element => {
+  const renderNoAttachmentsMessage = useCallback(() => {
+    return (
+      <div className="text-muted">
+        <small>
+          <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
+          {
+            refsContext.options?.prefix != null
+              ? `${refsContext.options.prefix} and descendant pages have no attachments`
+              : `${refsContext.pagePath} has no attachments`
+          }
+        </small>
+      </div>
+    );
+  }, [refsContext]);
+
+  const renderContents = useCallback(() => {
+    if (isLoading) {
+      return (
+        <div className="text-muted">
+          <i className="fa fa-spinner fa-pulse mr-1"></i>
+          <span className="attachment-refs-blink">{refsContext.toString()}</span>
+        </div>
+      );
+    }
+    if (error != null) {
+      return (
+        <div className="text-warning">
+          <i className="fa fa-exclamation-triangle fa-fw"></i>
+          {refsContext.toString()} (-&gt; <small>{error.message}</small>)
+        </div>
+      );
+    }
+
+    // no attachments
+    if (attachments.length === 0) {
+      return renderNoAttachmentsMessage();
+    }
+
+    return (refsContext.isExtractImage)
+      ? <ExtractedAttachments attachments={attachments} refsContext={refsContext} />
+      : attachments.map((attachment) => {
+        return <AttachmentLink key={attachment._id} attachment={attachment} inUse={false} />;
+      });
+  }, [refsContext, isLoading, attachments]);
+
+  return <div className={styles['attachment-refs']}>{renderContents()}</div>;
+
+};

+ 197 - 0
packages/remark-attachment-refs/src/client/components/ExtractedAttachments.tsx

@@ -0,0 +1,197 @@
+import React, { useCallback } from 'react';
+
+import { IAttachmentHasId } from '@growi/core';
+import { Property } from 'csstype';
+// import Carousel, { Modal, ModalGateway } from 'react-images';
+
+import { RefsContext } from './util/refs-context';
+
+type Props = {
+  attachments: IAttachmentHasId[],
+  refsContext: RefsContext,
+};
+
+/**
+ *  1. when 'fileFormat' is image, render Attachment as an image
+ *  2. when 'fileFormat' is not image, render Attachment as an Attachment component
+ */
+// TODO https://redmine.weseek.co.jp/issues/121095: implement image carousel modal without using react-images
+export const ExtractedAttachments = React.memo(({
+  attachments,
+  refsContext,
+}: Props): JSX.Element => {
+
+  // const [showCarousel, setShowCarousel] = useState(false);
+  // const [currentIndex, setCurrentIndex] = useState<number | null>(null);
+
+  // const imageClickedHandler = useCallback((index: number) => {
+  //   setShowCarousel(true);
+  //   setCurrentIndex(index);
+  // }, []);
+
+  const getAttachmentsFilteredByFormat = useCallback(() => {
+    return attachments
+      .filter(attachment => attachment.fileFormat.startsWith('image/'));
+  }, []);
+
+  const getClassesAndStylesForNonGrid = useCallback(() => {
+    const { options } = refsContext;
+
+    const width = options?.width;
+    const height = options?.height;
+    const maxWidth = options?.maxWidth;
+    const maxHeight = options?.maxHeight;
+    const display = options?.display || 'block';
+
+    const containerStyles = {
+      width, height, maxWidth, maxHeight, display,
+    };
+
+    const imageClasses = [];
+    const imageStyles = {
+      width, height, maxWidth, maxHeight,
+    };
+
+    return {
+      containerStyles,
+      imageClasses,
+      imageStyles,
+    };
+  }, [refsContext]);
+
+  const getClassesAndStylesForGrid = useCallback(() => {
+    const { options } = refsContext;
+
+    const maxWidth = options?.maxWidth;
+    const maxHeight = options?.maxHeight;
+
+    const containerStyles = {
+      width: refsContext.getOptGridWidth(),
+      height: refsContext.getOptGridHeight(),
+      maxWidth,
+      maxHeight,
+    };
+
+    const imageClasses = ['w-100', 'h-100'];
+    const imageStyles = {
+      objectFit: 'cover' as Property.ObjectFit,
+      maxWidth,
+      maxHeight,
+    };
+
+    return {
+      containerStyles,
+      imageClasses,
+      imageStyles,
+    };
+  }, [refsContext]);
+
+  /**
+   * wrapper method for getClassesAndStylesForGrid/getClassesAndStylesForNonGrid
+   */
+  const getClassesAndStyles = useCallback(() => {
+    const { options } = refsContext;
+
+    return (options?.grid != null)
+      ? getClassesAndStylesForGrid()
+      : getClassesAndStylesForNonGrid();
+  }, []);
+
+  const renderExtractedImage = useCallback((attachment: IAttachmentHasId, index: number) => {
+    const { options } = refsContext;
+
+    // determine alt
+    let alt = refsContext.isSingle ? options?.alt : undefined; // use only when single mode
+    alt = alt || attachment.originalName; //                     use 'originalName' if options.alt is not specified
+
+    // get styles
+    const {
+      containerStyles, imageClasses, imageStyles,
+    } = getClassesAndStyles();
+
+    // carousel settings
+    // let onClick;
+    // if (options?.noCarousel == null) {
+    //   // pointer cursor
+    //   Object.assign(containerStyles, { cursor: 'pointer' });
+    //   // set click handler
+    //   onClick = () => {
+    //     imageClickedHandler(index);
+    //   };
+    // }
+
+    return (
+      <div key={attachment._id} style={containerStyles}
+      // onClick={onClick}
+      >
+        <img src={attachment.filePathProxied} alt={alt} className={imageClasses.join(' ')} style={imageStyles} />
+      </div>
+    );
+  }, [refsContext]);
+
+  // const renderCarousel = useCallback(() => {
+  //   const { options } = refsContext;
+  //   const withCarousel = options?.noCarousel == null;
+
+  //   const images = getAttachmentsFilteredByFormat()
+  //     .map((attachment) => {
+  //       return { src: attachment.filePathProxied };
+  //     });
+
+  //   // overwrite react-images modal styles
+  //   const zIndex = 1030; // > grw-navbar
+  //   const modalStyles = {
+  //     blanket: (styleObj) => {
+  //       return Object.assign(styleObj, { zIndex });
+  //     },
+  //     positioner: (styleObj) => {
+  //       return Object.assign(styleObj, { zIndex });
+  //     },
+  //   };
+
+  //   return (
+  //     <ModalGateway>
+  //       { withCarousel && showCarousel && (
+  //         <Modal styles={modalStyles} onClose={() => { setShowCarousel(false) }}>
+  //           <Carousel views={images} currentIndex={currentIndex} />
+  //         </Modal>
+  //       ) }
+  //     </ModalGateway>
+  //   );
+  // }, [refsContext]);
+
+  const { options } = refsContext;
+  const grid = options?.grid;
+  const gridGap = options?.gridGap;
+
+  const styles = {};
+
+  // Grid mode
+  if (grid != null) {
+
+    const gridTemplateColumns = (refsContext.isOptGridColumnEnabled())
+      ? `repeat(${refsContext.getOptGridColumnsNum()}, 1fr)`
+      : `repeat(auto-fill, ${refsContext.getOptGridWidth()})`;
+
+    Object.assign(styles, {
+      display: 'grid',
+      gridTemplateColumns,
+      gridAutoRows: '1fr',
+      gridGap,
+    });
+
+  }
+
+  const contents = getAttachmentsFilteredByFormat()
+    .map((attachment, index) => renderExtractedImage(attachment, index));
+
+  return (
+    <React.Fragment>
+      <div style={styles}>
+        {contents}
+      </div>
+
+      {/* { renderCarousel() } */}
+    </React.Fragment>
+  );
+});

+ 20 - 0
packages/remark-attachment-refs/src/client/components/Gallery.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { RefsImgSubstance, Props } from './RefsImg';
+
+const gridDefault = 'col-4';
+const gridGapDefault = '1px';
+
+export const Gallery = React.memo((props: Props): JSX.Element => {
+  const grid = props.grid || gridDefault;
+  const gridGap = props.gridGap || gridGapDefault;
+  return <RefsImgSubstance grid={grid} gridGap={gridGap} {...props} />;
+});
+
+export const GalleryImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
+  const grid = props.grid || gridDefault;
+  const gridGap = props.gridGap || gridGapDefault;
+  return <RefsImgSubstance grid={grid} gridGap={gridGap} {...props} isImmutable />;
+});
+
+Gallery.displayName = 'Gallery';

+ 43 - 0
packages/remark-attachment-refs/src/client/components/Ref.tsx

@@ -0,0 +1,43 @@
+import React, { useMemo } from 'react';
+
+import { useSWRxRef } from '../stores/refs';
+
+import { AttachmentList } from './AttachmentList';
+import { RefsContext } from './util/refs-context';
+
+
+type Props = {
+  fileNameOrId: string,
+  pagePath: string,
+  isImmutable?: boolean,
+};
+
+const RefSubstance = React.memo(({
+  fileNameOrId,
+  pagePath,
+  isImmutable,
+}: Props): JSX.Element => {
+  const refsContext = useMemo(() => {
+    return new RefsContext('ref', pagePath, { fileNameOrId });
+  }, [fileNameOrId, pagePath]);
+
+  const { data, error, isLoading } = useSWRxRef(pagePath, fileNameOrId, isImmutable);
+  const attachments = data != null ? [data] : [];
+
+  return <AttachmentList
+    refsContext={refsContext}
+    isLoading={isLoading}
+    error={error}
+    attachments={attachments}
+  />;
+});
+
+export const Ref = React.memo((props: Props): JSX.Element => {
+  return <RefSubstance {...props} />;
+});
+
+export const RefImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
+  return <RefSubstance {...props} isImmutable />;
+});
+
+Ref.displayName = 'Ref';

+ 57 - 0
packages/remark-attachment-refs/src/client/components/RefImg.tsx

@@ -0,0 +1,57 @@
+import React, { useMemo } from 'react';
+
+import { useSWRxRef } from '../stores/refs';
+
+import { AttachmentList } from './AttachmentList';
+import { RefsContext } from './util/refs-context';
+
+
+type Props = {
+  fileNameOrId: string
+  pagePath: string
+  width?: string
+  height?: string
+  maxWidth?: string
+  maxHeight?: string
+  alt?: string
+
+  isImmutable?: boolean
+};
+
+const RefImgSubstance = React.memo(({
+  fileNameOrId,
+  pagePath,
+  width,
+  height,
+  maxWidth,
+  maxHeight,
+  alt,
+  isImmutable,
+}: Props): JSX.Element => {
+  const refsContext = useMemo(() => {
+    const options = {
+      fileNameOrId, width, height, maxWidth, maxHeight, alt,
+    };
+    return new RefsContext('refimg', pagePath, options);
+  }, [fileNameOrId, pagePath, width, height, maxWidth, maxHeight, alt]);
+
+  const { data, error, isLoading } = useSWRxRef(pagePath, fileNameOrId, isImmutable);
+  const attachments = data != null ? [data] : [];
+
+  return <AttachmentList
+    refsContext={refsContext}
+    isLoading={isLoading}
+    error={error}
+    attachments={attachments}
+  />;
+});
+
+export const RefImg = React.memo((props: Props): JSX.Element => {
+  return <RefImgSubstance {...props} />;
+});
+
+export const RefImgImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
+  return <RefImgSubstance {...props} isImmutable />;
+});
+
+RefImg.displayName = 'RefImg';

+ 52 - 0
packages/remark-attachment-refs/src/client/components/Refs.tsx

@@ -0,0 +1,52 @@
+import React, { useMemo } from 'react';
+
+import { useSWRxRefs } from '../stores/refs';
+
+import { AttachmentList } from './AttachmentList';
+import { RefsContext } from './util/refs-context';
+
+
+type Props = {
+  pagePath: string,
+  prefix?: string,
+  depth?: string,
+  regexp?: string,
+
+  isImmutable?: boolean,
+};
+
+const RefsSubstance = React.memo(({
+  pagePath,
+  prefix,
+  depth,
+  regexp,
+
+  isImmutable,
+}: Props): JSX.Element => {
+  const refsContext = useMemo(() => {
+    const options = {
+      prefix, depth, regexp,
+    };
+    return new RefsContext('refs', pagePath, options);
+  }, [pagePath, prefix, depth, regexp]);
+
+  const { data, error, isLoading } = useSWRxRefs(pagePath, prefix, { depth, regexp }, isImmutable);
+  const attachments = data != null ? data : [];
+
+  return <AttachmentList
+    refsContext={refsContext}
+    isLoading={isLoading}
+    error={error}
+    attachments={attachments}
+  />;
+});
+
+export const Refs = React.memo((props: Props): JSX.Element => {
+  return <RefsSubstance {...props} />;
+});
+
+export const RefsImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
+  return <RefsSubstance {...props} isImmutable />;
+});
+
+Refs.displayName = 'Refs';

+ 83 - 0
packages/remark-attachment-refs/src/client/components/RefsImg.tsx

@@ -0,0 +1,83 @@
+import React, { useMemo } from 'react';
+
+import { useSWRxRefs } from '../stores/refs';
+
+import { AttachmentList } from './AttachmentList';
+import { RefsContext } from './util/refs-context';
+
+
+export type Props = {
+  pagePath: string
+  prefix?: string
+  depth?: string
+  regexp?: string
+  width?: string
+  height?: string
+  maxWidth?: string
+  maxHeight?: string
+  display?: string
+  grid?: string
+  gridGap?: string
+  noCarousel?: string
+
+  isImmutable?: boolean,
+};
+
+export const RefsImgSubstance = React.memo(({
+  pagePath, prefix, depth, regexp,
+  width, height, maxWidth, maxHeight,
+  display, grid, gridGap, noCarousel,
+
+  isImmutable,
+}: Props): JSX.Element => {
+  const refsContext = useMemo(() => {
+    const options = {
+      pagePath,
+      prefix,
+      depth,
+      regexp,
+      width,
+      height,
+      maxWidth,
+      maxHeight,
+      display,
+      grid,
+      gridGap,
+      noCarousel,
+    };
+    return new RefsContext('refsimg', pagePath, options);
+  }, [pagePath, prefix, depth, regexp,
+      width, height, maxWidth, maxHeight,
+      display, grid, gridGap, noCarousel]);
+
+  const { data, error, isLoading } = useSWRxRefs(pagePath, prefix, {
+    depth,
+    regexp,
+    width,
+    height,
+    maxWidth,
+    maxHeight,
+    display,
+    grid,
+    gridGap,
+    noCarousel,
+  }, isImmutable);
+  const attachments = data != null ? data : [];
+
+  return <AttachmentList
+    refsContext={refsContext}
+    isLoading={isLoading}
+    error={error}
+    attachments={attachments}
+  />;
+});
+
+export const RefsImg = React.memo((props: Props): JSX.Element => {
+  return <RefsImgSubstance {...props} />;
+});
+
+export const RefsImgImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
+  return <RefsImgSubstance {...props} isImmutable />;
+});
+
+RefsImg.displayName = 'RefsImg';

+ 5 - 0
packages/remark-attachment-refs/src/client/components/index.ts

@@ -0,0 +1,5 @@
+export { Ref, RefImmutable } from './Ref';
+export { RefImg, RefImgImmutable } from './RefImg';
+export { Refs, RefsImmutable } from './Refs';
+export { RefsImg, RefsImgImmutable } from './RefsImg';
+export { Gallery, GalleryImmutable } from './Gallery';

+ 166 - 0
packages/remark-attachment-refs/src/client/components/util/refs-context.ts

@@ -0,0 +1,166 @@
+const GRID_DEFAULT_TRACK_WIDTH = 64;
+const GRID_AVAILABLE_OPTIONS_LIST = [
+  'autofill',
+  'autofill-xs',
+  'autofill-sm',
+  'autofill-md',
+  'autofill-lg',
+  'autofill-xl',
+  'col-2',
+  'col-3',
+  'col-4',
+  'col-5',
+  'col-6',
+];
+
+type tags = 'ref' | 'refs' | 'refimg' | 'refsimg'
+
+/**
+ * Context Object class for $ref() and $refimg()
+ */
+export class RefsContext {
+
+  tag: tags;
+
+  pagePath: string;
+
+  options?: Record<string, string|undefined>;
+
+  constructor(tag: tags, pagePath: string, options: Record<string, string|undefined>) {
+    this.tag = tag;
+
+    this.pagePath = pagePath;
+
+    // remove undefined keys
+    Object.keys(options).forEach(key => options[key] === undefined && delete options[key]);
+
+    this.options = options;
+  }
+
+  getStringifiedAttributes(separator = ', '): string {
+    const attributeStrs = [`page=${this.pagePath}`];
+    if (this.options != null) {
+      const optionEntries = Object.entries(this.options).sort();
+      attributeStrs.push(
+        ...optionEntries.map(([key, val]) => `${key}=${val || 'true'}`),
+      );
+    }
+
+    return attributeStrs.join(separator);
+  }
+
+  /**
+   * for printing errors
+   * @returns
+   */
+  toString(): string {
+    return `$${this.tag}(${this.getStringifiedAttributes()})`;
+  }
+
+  get isSingle(): boolean {
+    return this.tag === 'ref' || this.tag === 'refimg';
+  }
+
+  get isExtractImage(): boolean {
+    return this.tag === 'refimg' || this.tag === 'refsimg';
+  }
+
+  getOptGrid(): string | undefined {
+    return GRID_AVAILABLE_OPTIONS_LIST.find(item => item === this.options?.grid);
+  }
+
+  isOptGridColumnEnabled(): boolean {
+    const optGrid = this.getOptGrid();
+    return (optGrid != null) && optGrid.startsWith('col-');
+  }
+
+  /**
+   * return auto-calculated grid width
+   * rules:
+   *  1. when column mode (e.g. col-6, col5, ...), the width specification is disabled
+   *  2. when width option is set, return it
+   *  3. otherwise, the mode should be autofill and the width will be calculated according to the size
+   */
+  getOptGridWidth(): string | undefined {
+    const grid = this.getOptGrid();
+    const width = this.options?.width;
+
+    // when Grid column mode
+    if (this.isOptGridColumnEnabled()) {
+      // not specify and ignore width
+      return undefined;
+    }
+
+    // when width is specified
+    if (width != null) {
+      return width;
+    }
+
+    // when Grid autofill mode
+    let autofillMagnification = 1;
+
+    switch (grid) {
+      case 'autofill-xl':
+        autofillMagnification = 3;
+        break;
+      case 'autofill-lg':
+        autofillMagnification = 2;
+        break;
+      case 'autofill-sm':
+        autofillMagnification = 0.75;
+        break;
+      case 'autofill-xs':
+        autofillMagnification = 0.5;
+        break;
+      case 'autofill':
+      case 'autofill-md':
+        break;
+    }
+
+    return `${GRID_DEFAULT_TRACK_WIDTH * autofillMagnification}px`;
+  }
+
+  /**
+   * return auto-calculated grid height
+   * rules:
+   *  1. when height option is set, return it
+   *  2. otherwise, the same value to the width will be returned
+   */
+
+  getOptGridHeight(): string | undefined {
+    const height = this.options?.height;
+
+    // when height is specified
+    if (height != null) {
+      return height;
+    }
+
+    // return the value which is same to width
+    return this.getOptGridWidth();
+  }
+
+  getOptGridColumnsNum(): number | null {
+    let columnsNum: number | null = null;
+
+    switch (this.options?.grid) {
+      case 'col-2':
+        columnsNum = 2;
+        break;
+      case 'col-3':
+        columnsNum = 3;
+        break;
+      case 'col-4':
+        columnsNum = 4;
+        break;
+      case 'col-5':
+        columnsNum = 5;
+        break;
+      case 'col-6':
+        columnsNum = 6;
+        break;
+    }
+
+    return columnsNum;
+  }
+
+}

+ 2 - 0
packages/remark-attachment-refs/src/client/index.ts

@@ -0,0 +1,2 @@
+export * from './services/renderer/refs';
+export * from './components';

+ 157 - 0
packages/remark-attachment-refs/src/client/services/renderer/refs.ts

@@ -0,0 +1,157 @@
+import { pathUtils } from '@growi/core';
+import { remarkGrowiDirectivePluginType } from '@growi/remark-growi-directive';
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { selectAll, HastNode } from 'hast-util-select';
+import { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+const REF_SINGLE_NAME_PATTERN = new RegExp(/refimg|ref/);
+const REF_MULTI_NAME_PATTERN = new RegExp(/refsimg|refs|gallery/);
+
+const REF_SUPPORTED_ATTRIBUTES = ['fileNameOrId', 'pagePath'];
+const REF_IMG_SUPPORTED_ATTRIBUTES = ['fileNameOrId', 'pagePath', 'width', 'height', 'maxWidth', 'maxHeight', 'alt'];
+const REFS_SUPPORTED_ATTRIBUTES = ['pagePath', 'prefix', 'depth', 'regexp'];
+const REFS_IMG_SUPPORTED_ATTRIBUTES = [
+  'pagePath', 'prefix', 'depth', 'regexp', 'width', 'height', 'maxWidth', 'maxHeight', 'display', 'grid', 'gridGap', 'noCarousel',
+];
+
+type DirectiveAttributes = Record<string, string>
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === remarkGrowiDirectivePluginType.Text || node.type === remarkGrowiDirectivePluginType.Leaf) {
+        if (typeof node.name !== 'string') {
+          return;
+        }
+        const data = node.data ?? (node.data = {});
+        const attributes = node.attributes as DirectiveAttributes || {};
+        const attrEntries = Object.entries(attributes);
+
+        if (REF_SINGLE_NAME_PATTERN.test(node.name)) {
+          // determine fileNameOrId
+          // order:
+          //   1: ref(file=..., ...)
+          //   2: ref(id=..., ...)
+          //   3: refs(firstArgs, ...)
+          let fileNameOrId: string = attributes.file || attributes.id;
+          if (fileNameOrId == null && attrEntries.length > 0) {
+            const [firstAttrKey, firstAttrValue] = attrEntries[0];
+            fileNameOrId = (firstAttrValue === '' && !REF_SUPPORTED_ATTRIBUTES.concat(REF_IMG_SUPPORTED_ATTRIBUTES).includes(firstAttrValue))
+              ? firstAttrKey : '';
+          }
+          attributes.fileNameOrId = fileNameOrId;
+        }
+        else if (REF_MULTI_NAME_PATTERN.test(node.name)) {
+          // set 'page' attribute if the first attribute is only value
+          // e.g.
+          //   case 1: refs(page=/path..., ...)    => page="/path"
+          //   case 2: refs(/path, ...)            => page="/path"
+          //   case 3: refs(/foo, page=/bar ...)   => page="/bar"
+          if (attributes.page == null && attrEntries.length > 0) {
+            const [firstAttrKey, firstAttrValue] = attrEntries[0];
+
+            if (firstAttrValue === '' && !REFS_SUPPORTED_ATTRIBUTES.concat(REFS_IMG_SUPPORTED_ATTRIBUTES).includes(firstAttrValue)) {
+              attributes.page = firstAttrKey;
+            }
+          }
+        }
+        else {
+          return;
+        }
+
+        // kebab case to camel case
+        attributes.maxWidth = attributes['max-width'];
+        attributes.maxHeight = attributes['max-height'];
+        attributes.gridGap = attributes['grid-gap'];
+        attributes.noCarousel = attributes['no-carousel'];
+
+        data.hName = node.name;
+        data.hProperties = attributes;
+      }
+    });
+  };
+};
+
+// return absolute path for the specified path
+const getAbsolutePathFor = (relativePath: string, basePath: string) => {
+  const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
+  const absoluteUrl = new URL(relativePath, baseUrl);
+  return decodeURIComponent(
+    pathUtils.normalizePath( // normalize like /foo/bar
+      absoluteUrl.pathname,
+    ),
+  );
+};
+
+// resolve pagePath
+//   when `fromPagePath`=/hoge and `specifiedPath`=./fuga,
+//        `pagePath` to be /hoge/fuga
+//   when `fromPagePath`=/hoge and `specifiedPath`=/fuga,
+//        `pagePath` to be /fuga
+//   when `fromPagePath`=/hoge and `specifiedPath`=undefined,
+//        `pagePath` to be /hoge
+const resolvePath = (pagePath:string, basePath: string) => {
+  const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
+  const absoluteUrl = new URL(pagePath, baseUrl);
+  return decodeURIComponent(absoluteUrl.pathname);
+};
+
+type RefRehypePluginParams = {
+  pagePath?: string,
+}
+
+export const rehypePlugin: Plugin<[RefRehypePluginParams]> = (options = {}) => {
+  if (options.pagePath == null) {
+    throw new Error('refs rehype plugin requires \'pagePath\' option');
+  }
+
+  return (tree) => {
+    if (options.pagePath == null) {
+      return;
+    }
+
+    const basePagePath = options.pagePath;
+    const elements = selectAll('ref, refimg, refs, refsimg, gallery', tree as HastNode);
+
+    elements.forEach((refElem) => {
+      if (refElem.properties == null) {
+        return;
+      }
+
+      const prefix = refElem.properties.prefix;
+      // set basePagePath when prefix is undefined or invalid
+      if (prefix != null && typeof prefix === 'string') {
+        refElem.properties.prefix = resolvePath(prefix, basePagePath);
+      }
+
+      refElem.properties.pagePath = refElem.properties.page;
+      const pagePath = refElem.properties.pagePath;
+
+      // set basePagePath when pagePath is undefined or invalid
+      if (pagePath == null || typeof pagePath !== 'string') {
+        refElem.properties.pagePath = basePagePath;
+        return;
+      }
+
+      // return when page is already determined and aboslute path
+      if (pathUtils.hasHeadingSlash(pagePath)) {
+        return;
+      }
+
+      // resolve relative path
+      refElem.properties.pagePath = getAbsolutePathFor(pagePath, basePagePath);
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['ref', 'refimg', 'refs', 'refsimg', 'gallery'],
+  attributes: {
+    ref: REF_SUPPORTED_ATTRIBUTES,
+    refimg: REF_IMG_SUPPORTED_ATTRIBUTES,
+    refs: REFS_SUPPORTED_ATTRIBUTES,
+    refsimg: REFS_IMG_SUPPORTED_ATTRIBUTES,
+    gallery: REFS_IMG_SUPPORTED_ATTRIBUTES,
+  },
+};

+ 49 - 0
packages/remark-attachment-refs/src/client/stores/refs.tsx

@@ -0,0 +1,49 @@
+import { IAttachmentHasId } from '@growi/core';
+import axios from 'axios';
+import useSWR, { SWRResponse } from 'swr';
+
+export const useSWRxRef = (
+    pagePath: string, fileNameOrId: string, isImmutable?: boolean,
+): SWRResponse<IAttachmentHasId | null, Error> => {
+  return useSWR(
+    ['/_api/attachment-refs/ref', pagePath, fileNameOrId, isImmutable],
+    ([endpoint, pagePath, fileNameOrId]) => {
+      return axios.get(endpoint, {
+        params: {
+          pagePath,
+          fileNameOrId,
+        },
+      }).then(result => result.data.attachment)
+        .catch(() => null);
+    },
+    {
+      keepPreviousData: true,
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+    },
+  );
+};
+
+export const useSWRxRefs = (
+    pagePath: string, prefix?: string, options?: Record<string, string | undefined>, isImmutable?: boolean,
+): SWRResponse<IAttachmentHasId[], Error> => {
+  return useSWR(
+    ['/_api/attachment-refs/refs', pagePath, prefix, options, isImmutable],
+    ([endpoint, pagePath, prefix, options]) => {
+      return axios.get(endpoint, {
+        params: {
+          pagePath,
+          prefix,
+          options,
+        },
+      }).then(result => result.data.attachments);
+    },
+    {
+      keepPreviousData: true,
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+    },
+  );
+};

+ 10 - 0
packages/remark-attachment-refs/src/server/index.ts

@@ -0,0 +1,10 @@
+import { routesFactory } from './routes/refs';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
+const middleware = (crowi: any, app: any): void => {
+  const refs = routesFactory(crowi);
+
+  app.use('/_api/attachment-refs', refs);
+};
+
+export default middleware;

+ 12 - 16
packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js → packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -1,8 +1,6 @@
-import loggerFactory from '../../utils/logger';
-
-const { customTagUtils } = require('@growi/core');
+import { OptionParser } from '@growi/core';
 
-const { OptionParser } = customTagUtils;
+import loggerFactory from '../../utils/logger';
 
 const logger = loggerFactory('growi-plugin:attachment-refs:routes:refs');
 
@@ -11,8 +9,8 @@ const loginRequiredFallback = (req, res) => {
   return res.status(403).send('login required');
 };
 
-
-module.exports = (crowi) => {
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const routesFactory = (crowi): any => {
   const express = crowi.require('express');
   const mongoose = crowi.require('mongoose');
 
@@ -30,12 +28,7 @@ module.exports = (crowi) => {
 
   const { PageQueryBuilder } = Page;
 
-  /**
-   * generate RegExp instance by the 'expression' arg
-   * @param {string} expression
-   * @return {RegExp}
-   */
-  function generateRegexp(expression) {
+  function generateRegexp(expression: string): RegExp {
     // https://regex101.com/r/uOrwqt/2
     const matches = expression.match(/^\/(.+)\/(.*)?$/);
 
@@ -59,6 +52,11 @@ module.exports = (crowi) => {
     }
 
     const range = OptionParser.parseRange(optionsDepth);
+
+    if (range == null) {
+      return query;
+    }
+
     const start = range.start;
     const end = range.end;
 
@@ -82,15 +80,13 @@ module.exports = (crowi) => {
   router.get('/ref', accessTokenParser, loginRequired, async(req, res) => {
     const user = req.user;
     const { pagePath, fileNameOrId } = req.query;
-    // eslint-disable-next-line no-unused-vars
-    const options = JSON.parse(req.query.options);
 
     if (pagePath == null) {
       res.status(400).send('the param \'pagePath\' must be set.');
       return;
     }
 
-    const page = await Page.findByPathAndViewer(pagePath, user);
+    const page = await Page.findByPathAndViewer(pagePath, user, undefined, true);
 
     // not found
     if (page == null) {
@@ -99,7 +95,7 @@ module.exports = (crowi) => {
     }
 
     // convert ObjectId
-    const orConditions = [{ originalName: fileNameOrId }];
+    const orConditions: any[] = [{ originalName: fileNameOrId }];
     if (ObjectId.isValid(fileNameOrId)) {
       orConditions.push({ _id: ObjectId(fileNameOrId) });
     }

+ 0 - 0
packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts → packages/remark-attachment-refs/src/utils/logger/index.ts


+ 14 - 0
packages/remark-attachment-refs/tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "jsx": "react-jsxdev",
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"]
+    }
+  },
+  "include": [
+    "src"
+  ]
+}

+ 31 - 0
packages/remark-attachment-refs/vite.client.config.ts

@@ -0,0 +1,31 @@
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [
+    dts(),
+  ],
+  build: {
+    outDir: 'dist/client',
+    sourcemap: true,
+    lib: {
+      entry: {
+        index: 'src/client/index.ts',
+      },
+      name: 'remark-attachment-refs-libs',
+      formats: ['es'],
+    },
+    rollupOptions: {
+      external: [
+        'bunyan',
+        'universal-bunyan',
+        'react',
+        'react-dom',
+        /^hast-.*/,
+        /^unist-.*/,
+        /^@growi\/.*/,
+      ],
+    },
+  },
+});

+ 35 - 0
packages/remark-attachment-refs/vite.server.config.ts

@@ -0,0 +1,35 @@
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [
+    dts(),
+  ],
+  build: {
+    outDir: 'dist/server',
+    sourcemap: true,
+    lib: {
+      entry: [
+        'src/server/index.ts',
+      ],
+      name: 'remark-attachment-refs-libs',
+      formats: ['cjs'],
+    },
+    rollupOptions: {
+      output: {
+        preserveModules: true,
+        preserveModulesRoot: 'src/server',
+      },
+      external: [
+        'bunyan',
+        'universal-bunyan',
+        'react',
+        'react-dom',
+        /^hast-.*/,
+        /^unist-.*/,
+        /^@growi\/.*/,
+      ],
+    },
+  },
+});

+ 13 - 0
turbo.json

@@ -19,6 +19,11 @@
       "cache": false
     },
 
+    "@growi/remark-attachment-refs#build": {
+      "dependsOn": ["@growi/core#build", "@growi/remark-growi-directive#build", "@growi/ui#build"],
+      "outputs": ["dist/**"],
+      "outputMode": "new-only"
+    },
     "@growi/ui#build": {
       "dependsOn": ["@growi/core#build"],
       "outputs": ["dist/**"],
@@ -58,6 +63,11 @@
       "outputMode": "new-only"
     },
 
+    "@growi/remark-attachment-refs#dev": {
+      "dependsOn": ["@growi/core#dev", "@growi/remark-growi-directive#dev", "@growi/ui#dev"],
+      "outputs": ["dist/**"],
+      "outputMode": "new-only"
+    },
     "@growi/remark-lsx#dev": {
       "dependsOn": ["@growi/core#dev", "@growi/remark-growi-directive#dev", "@growi/ui#dev"],
       "outputs": ["dist/**"],
@@ -123,6 +133,9 @@
       "persistent": true
     },
 
+    "@growi/remark-attachment-refs#lint": {
+      "dependsOn": ["@growi/core#dev", "@growi/remark-growi-directive#dev", "@growi/ui#dev"]
+    },
     "@growi/ui#lint": {
       "dependsOn": ["@growi/core#dev"]
     },