Futa Arai 3 лет назад
Родитель
Сommit
9dd7343c35

+ 13 - 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';
@@ -58,6 +59,7 @@ export const generateViewOptions = (
     drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    refsGrowiPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -72,6 +74,7 @@ export const generateViewOptions = (
       commonSanitizeOption,
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      refsGrowiPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -79,6 +82,7 @@ export const generateViewOptions = (
   rehypePlugins.push(
     slug,
     [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiPlugin.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -93,6 +97,7 @@ export const generateViewOptions = (
     components.h5 = Header;
     components.h6 = Header;
     components.lsx = lsxGrowiPlugin.Lsx;
+    components.ref = refsGrowiPlugin.Ref;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
   }
@@ -153,6 +158,7 @@ export const generateSimpleViewOptions = (
     drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    refsGrowiPlugin.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -171,12 +177,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 +193,7 @@ export const generateSimpleViewOptions = (
   // add components
   if (components != null) {
     components.lsx = lsxGrowiPlugin.LsxImmutable;
+    components.ref = refsGrowiPlugin.Ref;
     components.drawio = drawioPlugin.DrawioViewer;
   }
 
@@ -219,6 +228,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    refsGrowiPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -232,6 +242,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     ? [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      refsGrowiPlugin.sanitizeOption,
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )]
@@ -240,6 +251,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 +260,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   // add components
   if (components != null) {
     components.lsx = lsxGrowiPlugin.LsxImmutable;
+    components.ref = refsGrowiPlugin.Ref;
     components.drawio = drawioPlugin.DrawioViewer;
   }
 

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

+ 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 - 30
packages/remark-attachment-refs/client.txt

@@ -1,30 +0,0 @@
-// 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',
-//         'http-errors',
-//         'universal-bunyan',
-//         'react',
-//         'react-dom',
-//         /^@growi\/.*/,
-//       ],
-//     },
-//   },
-// });

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

@@ -16,9 +16,11 @@
   "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",

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

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

@@ -0,0 +1,79 @@
+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[]
+  isExtractImg?: boolean
+};
+
+export const AttachmentList = ({
+  refsContext,
+  isLoading,
+  error,
+  attachments,
+  isExtractImg = false,
+}: Props): JSX.Element => {
+  const renderNoAttachmentsMessage = () => {
+    let message;
+
+    if (refsContext.options?.prefix != null) {
+      message = `${refsContext.options.prefix} and descendant pages have no attachments`;
+    }
+    else {
+      message = `${refsContext.options?.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>
+    );
+  };
+
+  const renderContents = () => {
+    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 (isExtractImg)
+      ? <ExtractedAttachments attachments={attachments} refsContext={refsContext} />
+      : attachments.map((attachment) => {
+        return <AttachmentLink key={attachment._id} attachment={attachment} inUse={false} />;
+      });
+  };
+
+  return <div className={styles['attachment-refs']}>{renderContents()}</div>;
+
+};

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

@@ -0,0 +1,194 @@
+import React, { useState, 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
+ */
+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?.['max-width'];
+    const maxHeight = options?.['max-height'];
+    const display = options?.display || 'block';
+
+    const containerStyles = {
+      width, height, maxWidth, maxHeight, display,
+    };
+
+    const imageClasses = [];
+    const imageStyles = {
+      width, height, maxWidth, maxHeight,
+    };
+
+    return {
+      containerStyles,
+      imageClasses,
+      imageStyles,
+    };
+  }, []);
+
+  const getClassesAndStylesForGrid = useCallback(() => {
+    const { options } = refsContext;
+
+    const maxWidth = options?.['max-width'];
+    const maxHeight = options?.['max-height'];
+
+    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,
+    };
+  }, []);
+
+  /**
+   * 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?.['no-carousel'] == 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>
+    );
+  }, []);
+
+  const renderCarousel = useCallback(() => {
+    const { options } = refsContext;
+    const withCarousel = options?.['no-carousel'] == 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>
+    );
+  }, []);
+
+  const { options } = refsContext;
+  const grid = options?.grid;
+  const gridGap = options?.['grid-gap'];
+
+  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>
+  );
+});

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

@@ -0,0 +1,36 @@
+import { 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,
+};
+
+export const Ref = ({
+  fileNameOrId,
+  pagePath,
+  isImmutable,
+}: Props): JSX.Element => {
+  const refsContext = useMemo(() => {
+    const options = {
+      fileNameOrId, pagePath,
+    };
+    return new RefsContext('ref', options);
+  }, [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}
+  />;
+};

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

@@ -0,0 +1 @@
+export { Ref } from './Ref';

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

@@ -0,0 +1,158 @@
+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;
+
+  options?: Record<string, string|undefined>;
+
+  constructor(tag: tags, options: Record<string, string|undefined>) {
+    this.tag = tag;
+
+    // remove undefined keys
+    Object.keys(options).forEach(key => options[key] === undefined && delete options[key]);
+
+    this.options = options;
+  }
+
+  getStringifiedAttributes(separator = ', '): string {
+    const attributeStrs: string[] = [];
+    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';
+  }
+
+  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';

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

@@ -0,0 +1,142 @@
+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 isAbsolute from 'is-absolute-url';
+import { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+const REF_NAME_PATTERN = new RegExp(/refimg|ref/);
+const REFS_NAME_PATTERN = new RegExp(/refsimg|refs/);
+const REF_SUPPORTED_ATTRIBUTES = ['file', 'id', 'page'];
+const REF_IMG_SUPPORTED_ATTRIBUTES = ['file', 'id', 'page', 'width', 'height', 'max-width', 'max-height', 'alt'];
+const REFS_SUPPORTED_ATTRIBUTES = ['page', 'prefix', 'depth', 'regexp'];
+const REFS_IMG_SUPPORTED_ATTRIBUTES = [
+  'page', 'prefix', 'depth', 'regexp', 'width', 'height', 'max-width', 'max-height', 'display', 'grid', 'grid-gap', 'no-carousel',
+];
+
+const { hasHeadingSlash } = pathUtils;
+
+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_NAME_PATTERN.test(node.name)) {
+          // determine fileNameOrId
+          // order:
+          //   1: ref(file=..., ...)
+          //   2: ref(id=..., ...)
+          //   3: refs(firstArgs, ...)
+          let fileNameOrId: string | undefined = 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 : undefined;
+          }
+          if (fileNameOrId == null) {
+            throw new Error('\'file\' or \'id\' is not specified. Set first argument or specify \'file\' or \'id\' option');
+          }
+          else {
+            attributes.fileNameOrId = fileNameOrId;
+          }
+        }
+        else if (REFS_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;
+        }
+
+        data.hName = node.name;
+        data.hProperties = attributes;
+      }
+    });
+  };
+};
+
+const pathResolver = (href: string, basePath: string): string => {
+  // exclude absolute URL
+  if (isAbsolute(href)) {
+    // remove scheme
+    return href.replace(/^(.+?):\/\//, '/');
+  }
+
+  // generate relative pathname
+  const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
+  const relativeUrl = new URL(href, baseUrl);
+
+  return relativeUrl.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', tree as HastNode);
+
+    elements.forEach((refElem) => {
+      if (refElem.properties == null) {
+        return;
+      }
+
+      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 (hasHeadingSlash(pagePath)) {
+        return;
+      }
+
+      // resolve relative path
+      refElem.properties.pagePath = decodeURI(pathResolver(pagePath, basePagePath));
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['ref', 'refimg', 'refs', 'refsimg'],
+  attributes: {
+    ref: REF_SUPPORTED_ATTRIBUTES,
+    refimg: REF_IMG_SUPPORTED_ATTRIBUTES,
+    refs: REFS_SUPPORTED_ATTRIBUTES,
+    refsimg: REFS_IMG_SUPPORTED_ATTRIBUTES,
+  },
+};

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

@@ -0,0 +1,63 @@
+import * as url from 'url';
+
+import { IAttachmentHasId, pathUtils } from '@growi/core';
+import axios from 'axios';
+import useSWR, { SWRResponse } from 'swr';
+
+/**
+   * return absolute path for the specified path
+   *
+   * @param {string} relativePath relative path from fromPagePath`
+   */
+function getAbsolutePathFor(relativePath: string): string {
+  return decodeURIComponent(
+    pathUtils.normalizePath( // normalize like /foo/bar
+      url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), relativePath),
+    ),
+  );
+}
+
+export const useSWRxRef = (
+    pagePath: string, fileNameOrId: string, isImmutable?: boolean,
+): SWRResponse<IAttachmentHasId, Error> => {
+  return useSWR(
+    ['/_api/ref', pagePath, fileNameOrId, isImmutable],
+    ([endpoint, pagePath, fileNameOrId]) => {
+      return axios.get(endpoint, {
+        params: {
+          pagePath,
+          fileNameOrId,
+        },
+      }).then(result => result.data.attachment);
+    },
+    {
+      keepPreviousData: true,
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+    },
+  );
+};
+
+export const useSWRxRefs = (
+    prefix: string, pagePath: string, options?: Record<string, string | undefined>, isImmutable?: boolean,
+): SWRResponse<IAttachmentHasId, Error> => {
+  return useSWR(
+    ['/_api/refs', prefix, pagePath, options, isImmutable],
+    ([endpoint, prefix, pagePath, options]) => {
+      return axios.get(endpoint, {
+        params: {
+          prefix,
+          pagePath,
+          options,
+        },
+      }).then(result => result.data.attachments);
+    },
+    {
+      keepPreviousData: true,
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+    },
+  );
+};

+ 5 - 5
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -1,8 +1,8 @@
 import { OptionParser } from '@growi/core';
 
-// import loggerFactory from '../../utils/logger';
+import loggerFactory from '../../utils/logger';
 
-// const logger = loggerFactory('growi-plugin:attachment-refs:routes:refs');
+const logger = loggerFactory('growi-plugin:attachment-refs:routes:refs');
 
 
 const loginRequiredFallback = (req, res) => {
@@ -118,12 +118,12 @@ export const routesFactory = (crowi): any => {
       return;
     }
 
-    // logger.debug(`attachment '${attachment.id}' is found from fileNameOrId '${fileNameOrId}'`);
+    logger.debug(`attachment '${attachment.id}' is found from fileNameOrId '${fileNameOrId}'`);
 
     // forbidden
     const isAccessible = await Page.isAccessiblePageByViewer(attachment.page, user);
     if (!isAccessible) {
-      // logger.debug(`attachment '${attachment.id}' is forbidden for user '${user && user.username}'`);
+      logger.debug(`attachment '${attachment.id}' is forbidden for user '${user && user.username}'`);
       res.status(403).send(`page '${attachment.page}' is forbidden.`);
       return;
     }
@@ -191,7 +191,7 @@ export const routesFactory = (crowi): any => {
     const results = await pageQuery.select('id').exec();
     const pageIds = results.map(result => result.id);
 
-    // logger.debug('retrieve attachments for pages:', pageIds);
+    logger.debug('retrieve attachments for pages:', pageIds);
 
     // create query to find
     let query = Attachment

+ 11 - 0
packages/remark-attachment-refs/src/utils/logger/index.ts

@@ -0,0 +1,11 @@
+import Logger from 'bunyan';
+import { createLogger } from 'universal-bunyan';
+
+const loggerFactory = function(name: string): Logger {
+  return createLogger({
+    name,
+    config: { default: 'info' },
+  });
+};
+
+export default loggerFactory;

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

@@ -0,0 +1,30 @@
+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',
+        'http-errors',
+        'universal-bunyan',
+        'react',
+        'react-dom',
+        /^@growi\/.*/,
+      ],
+    },
+  },
+});