Преглед изворни кода

Merge branch 'master' into feat/page-bulk-export

Futa Arai пре 1 година
родитељ
комит
b8a912ed3c
39 измењених фајлова са 1235 додато и 122 уклоњено
  1. 3 1
      apps/app/package.json
  2. 3 3
      apps/app/public/static/locales/en_US/translation.json
  3. 3 3
      apps/app/public/static/locales/fr_FR/translation.json
  4. 3 3
      apps/app/public/static/locales/ja_JP/translation.json
  5. 3 3
      apps/app/public/static/locales/zh_CN/translation.json
  6. 1 1
      apps/app/src/client/components/TreeItem/NewPageInput/use-new-page-input.tsx
  7. 2 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  8. 8 7
      apps/app/src/features/openai/server/services/openai.ts
  9. 89 0
      apps/app/src/features/openai/server/utils/convert-markdown-to-html.ts
  10. 0 65
      apps/app/src/features/openai/server/utils/sanitize-markdown.ts
  11. 1 0
      apps/pdf-converter/dist/bin/index.d.ts
  12. 8 0
      apps/pdf-converter/dist/bin/index.js
  13. 1 0
      apps/pdf-converter/dist/bin/index.js.map
  14. 2 0
      apps/pdf-converter/dist/controllers/index.d.ts
  15. 3 0
      apps/pdf-converter/dist/controllers/index.js
  16. 1 0
      apps/pdf-converter/dist/controllers/index.js.map
  17. 11 0
      apps/pdf-converter/dist/controllers/pdf.d.ts
  18. 59 0
      apps/pdf-converter/dist/controllers/pdf.js
  19. 1 0
      apps/pdf-converter/dist/controllers/pdf.js.map
  20. 10 0
      apps/pdf-converter/dist/controllers/terminus.d.ts
  21. 28 0
      apps/pdf-converter/dist/controllers/terminus.js
  22. 1 0
      apps/pdf-converter/dist/controllers/terminus.js.map
  23. 1 0
      apps/pdf-converter/dist/index.d.ts
  24. 23 0
      apps/pdf-converter/dist/index.js
  25. 1 0
      apps/pdf-converter/dist/index.js.map
  26. 8 0
      apps/pdf-converter/dist/server.d.ts
  27. 41 0
      apps/pdf-converter/dist/server.js
  28. 1 0
      apps/pdf-converter/dist/server.js.map
  29. 81 0
      apps/pdf-converter/dist/service/pdf-convert.d.ts
  30. 240 0
      apps/pdf-converter/dist/service/pdf-convert.js
  31. 0 0
      apps/pdf-converter/dist/service/pdf-convert.js.map
  32. 149 0
      apps/pdf-converter/specs/v3/docs/swagger.json
  33. 106 0
      apps/pdf-converter/specs/v3/docs/swagger.yaml
  34. 2 1
      packages/core/src/utils/page-path-utils/index.ts
  35. 62 0
      packages/pdf-converter-client/dist/index.d.ts
  36. 31 0
      packages/pdf-converter-client/dist/index.js
  37. 1 0
      packages/pdf-converter-client/dist/index.js.map
  38. 2 2
      packages/remark-lsx/src/server/index.ts
  39. 245 31
      pnpm-lock.yaml

+ 3 - 1
apps/app/package.json

@@ -157,7 +157,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.13",
+    "next": "^14.2.15",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^1.0.7",
@@ -197,9 +197,11 @@
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rehype-katex": "^7.0.1",
+    "rehype-meta": "^4.0.1",
     "rehype-raw": "^7.0.0",
     "rehype-sanitize": "^6.0.0",
     "rehype-slug": "^6.0.0",
+    "rehype-stringify": "^10.0.1",
     "rehype-toc": "^3.0.2",
     "remark-breaks": "^4.0.0",
     "remark-directive": "^3.0.0",

+ 3 - 3
apps/app/public/static/locales/en_US/translation.json

@@ -117,7 +117,7 @@
   "Create under": "Create page under below:",
   "V5 Page Migration": "Convert To V5 Compatibility",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
-  "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <span className='growi-custom-icons'>external_link</span> ",
+  "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <span class='growi-custom-icons'>external_link</span> ",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
   "Basic Settings": "Basic Settings",
@@ -157,7 +157,7 @@
   "duplicated_path": "Duplicated path",
   "Link sharing is disabled": "Link sharing is disabled",
   "successfully_saved_the_page": "Successfully saved the page",
-  "you_can_not_create_page_with_this_name": "You can not create page with this name",
+  "you_can_not_create_page_with_this_name_or_hierarchy": "You can not create page with this name or page hierarchy",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
@@ -615,7 +615,7 @@
     "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
     "nopages_title": "Congratulations. Ready to use GROWI v5!",
     "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
-    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <span className='growi-custom-icons'>external_link</span></a>.",
+    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <span class='growi-custom-icons'>external_link</span></a>.",
     "modal": {
       "title": "Convert to new v5 compatible format",
       "converting_pages": "Converting pages",

+ 3 - 3
apps/app/public/static/locales/fr_FR/translation.json

@@ -117,7 +117,7 @@
   "Create under": "Créer la page sous:",
   "V5 Page Migration": "Convertir vers la V5",
   "GROWI.5.0_new_schema": "Nouveau schéma GROWI.5.0",
-  "See_more_detail_on_new_schema": "Plus de détails sur <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <i class='icon-share-alt'></i> ",
+  "See_more_detail_on_new_schema": "Plus de détails sur <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a><span class='growi-custom-icons'>external_link</span> ",
   "external_account_management": "Gestion des comptes externes",
   "UserGroup": "Groupe utilisateur",
   "Basic Settings": "Paramètres de base",
@@ -157,7 +157,7 @@
   "duplicated_path": "Chemin dupliqué",
   "Link sharing is disabled": "Le partage est désactivé",
   "successfully_saved_the_page": "Page sauvegardée",
-  "you_can_not_create_page_with_this_name": "Vous ne pouvez pas créer cette page",
+  "you_can_not_create_page_with_this_name_or_hierarchy": "Vous ne pouvez pas créer de page avec ce nom ou cette hiérarchie de pages",
   "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page",
   "Confirm": "Confirmer",
   "Successfully requested": "Demande envoyée.",
@@ -608,7 +608,7 @@
     "alert_desc1": "Sélectionner les pages à convertir vers le format V5 avec le bouton \"Opération de masse\".",
     "nopages_title": "GROWI V5 est maintenant utilisable!",
     "nopages_desc1": "Toutes les pages ont été converties au format V5.",
-    "detail_info": "Pour plus de détails, voir <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Convertir vers GROWI v5.0.x <span className='growi-custom-icons'>external_link</span></a>.",
+    "detail_info": "Pour plus de détails, voir <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Convertir vers GROWI v5.0.x <span class='growi-custom-icons'>external_link</span></a>.",
     "modal": {
       "title": "Convertir au format V5",
       "converting_pages": "Conversion des pages",

+ 3 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -116,7 +116,7 @@
   "Create under": "ページを以下に作成",
   "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
-  "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><span className='growi-custom-icons'>external_link</span>を参照ください。",
+  "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><span class='growi-custom-icons'>external_link</span>を参照ください。",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
   "Basic Settings": "基本設定",
@@ -158,7 +158,7 @@
   "duplicated_path": "重複したパス",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
   "successfully_saved_the_page": "ページが正常に保存されました",
-  "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
+  "you_can_not_create_page_with_this_name_or_hierarchy": "この名前、または階層でページを作成することはできません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
@@ -647,7 +647,7 @@
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
     "nopages_desc1": "今あなたが管理可能なページはすべて v5 互換形式になっているようです。",
-    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード <span className='growi-custom-icons'>external_link</span></a> を参照ください。",
+    "detail_info": "詳しくは <a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>GROWI v5.0.x へのアップグレード <span class='growi-custom-icons'>external_link</span></a> を参照ください。",
     "modal": {
       "title": "新しい v5 互換形式への変換",
       "converting_pages": "以下のページを変換します",

+ 3 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -122,7 +122,7 @@
   "Create under": "Create page under below:",
   "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
-  "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <span className='growi-custom-icons'>external_link</span> ",
+  "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <span class='growi-custom-icons'>external_link</span> ",
 	"Markdown Settings": "Markdown设置",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
@@ -163,7 +163,7 @@
   "duplicated_path": "Duplicated path",
   "Link sharing is disabled": "你不允许分享该链接",
   "successfully_saved_the_page": "成功地保存了该页面",
-  "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
+  "you_can_not_create_page_with_this_name_or_hierarchy": "您無法使用此名稱或頁面層級建立頁面",
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
@@ -617,7 +617,7 @@
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
     "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
-    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <span className='growi-custom-icons'>external_link</span></a>.的详细内容。",
+    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <span class='growi-custom-icons'>external_link</span></a>.的详细内容。",
     "modal": {
       "title": "转换为新的v5兼容格式",
       "converting_pages": "转换页面",

+ 1 - 1
apps/app/src/client/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -99,7 +99,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
 
       if (!isCreatable) {
-        toastWarning(t('you_can_not_create_page_with_this_name'));
+        toastWarning(t('you_can_not_create_page_with_this_name_or_hierarchy'));
         return;
       }
 

+ 2 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -4,7 +4,7 @@
 
 .grw-page-path-nav-layout :global {
   .grw-page-path-nav-copydropdown {
-    display: none;
+    visibility: hidden;
     @include bs.media-breakpoint-down(md) {
       display: block;
     }
@@ -15,7 +15,7 @@
   &:global {
     &:hover {
       .grw-page-path-nav-copydropdown {
-        display: block;
+        visibility: visible;
       }
     }
   }

+ 8 - 7
apps/app/src/features/openai/server/services/openai.ts

@@ -2,6 +2,7 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
+import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { PageGrant, isPopulated } from '@growi/core';
 import type { HydratedDocument, Types } from 'mongoose';
 import mongoose from 'mongoose';
@@ -20,7 +21,7 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 
 import { OpenaiServiceTypes } from '../../interfaces/ai';
-import { sanitizeMarkdown } from '../utils/sanitize-markdown';
+import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 
 import { getClient } from './client-delegator';
 // import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter';
@@ -157,9 +158,9 @@ class OpenaiService implements IOpenaiService {
   //   }
   // }
 
-  private async uploadFile(pageId: Types.ObjectId, body: string): Promise<OpenAI.Files.FileObject> {
-    const sanitizedMarkdown = await sanitizeMarkdown(body);
-    const file = await toFile(Readable.from(sanitizedMarkdown), `${pageId}.md`);
+  private async uploadFile(pageId: Types.ObjectId, pagePath: string, revisionBody: string): Promise<OpenAI.Files.FileObject> {
+    const convertedHtml = await convertMarkdownToHtml({ pagePath, revisionBody });
+    const file = await toFile(Readable.from(convertedHtml), `${pageId}.html`);
     const uploadedFile = await this.client.uploadFile(file);
     return uploadedFile;
   }
@@ -183,17 +184,17 @@ class OpenaiService implements IOpenaiService {
   async createVectorStoreFile(pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
     const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
-    const processUploadFile = async(page: PageDocument) => {
+    const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
       if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
-          const uploadedFile = await this.uploadFile(page._id, page.revision.body);
+          const uploadedFile = await this.uploadFile(page._id, page.path, page.revision.body);
           prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
           return;
         }
 
         const pagePopulatedToShowRevision = await page.populateDataToShowRevision();
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
-          const uploadedFile = await this.uploadFile(page._id, pagePopulatedToShowRevision.revision.body);
+          const uploadedFile = await this.uploadFile(page._id, page.path, pagePopulatedToShowRevision.revision.body);
           prepareVectorStoreFileRelations(vectorStore._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
         }
       }

+ 89 - 0
apps/app/src/features/openai/server/utils/convert-markdown-to-html.ts

@@ -0,0 +1,89 @@
+import { dynamicImport } from '@cspell/dynamic-import';
+import type { Root, Code } from 'mdast';
+import type * as RehypeMeta from 'rehype-meta';
+import type * as RehypeStringify from 'rehype-stringify';
+import type * as RemarkParse from 'remark-parse';
+import type * as RemarkRehype from 'remark-rehype';
+import type * as Unified from 'unified';
+import type * as UnistUtilVisit from 'unist-util-visit';
+
+interface ModuleCache {
+  unified?: typeof Unified.unified;
+  visit?: typeof UnistUtilVisit.visit;
+  remarkParse?: typeof RemarkParse.default;
+  remarkRehype?: typeof RemarkRehype.default;
+  rehypeMeta?: typeof RehypeMeta.default;
+  rehypeStringify?: typeof RehypeStringify.default;
+}
+
+let moduleCache: ModuleCache = {};
+
+const initializeModules = async(): Promise<void> => {
+  if (moduleCache.unified != null
+    && moduleCache.visit != null
+    && moduleCache.remarkParse != null
+    && moduleCache.remarkRehype != null
+    && moduleCache.rehypeMeta != null
+    && moduleCache.rehypeStringify != null
+  ) {
+    return;
+  }
+
+  const [
+    { unified },
+    { visit },
+    { default: remarkParse },
+    { default: remarkRehype },
+    { default: rehypeMeta },
+    { default: rehypeStringify },
+  ] = await Promise.all([
+    dynamicImport<typeof Unified>('unified', __dirname),
+    dynamicImport<typeof UnistUtilVisit>('unist-util-visit', __dirname),
+    dynamicImport<typeof RemarkParse>('remark-parse', __dirname),
+    dynamicImport<typeof RemarkRehype>('remark-rehype', __dirname),
+    dynamicImport<typeof RehypeMeta>('rehype-meta', __dirname),
+    dynamicImport<typeof RehypeStringify>('rehype-stringify', __dirname),
+  ]);
+
+  moduleCache = {
+    unified,
+    visit,
+    remarkParse,
+    remarkRehype,
+    rehypeMeta,
+    rehypeStringify,
+  };
+};
+
+export const convertMarkdownToHtml = async({ pagePath, revisionBody }: { pagePath: string, revisionBody: string }): Promise<string> => {
+  await initializeModules();
+
+  const {
+    unified, visit, remarkParse, remarkRehype, rehypeMeta, rehypeStringify,
+  } = moduleCache;
+
+  if (unified == null || visit == null || remarkParse == null || remarkRehype == null || rehypeMeta == null || rehypeStringify == null) {
+    throw new Error('Failed to initialize required modules');
+  }
+
+  const sanitizeMarkdown = () => {
+    return (tree: Root) => {
+      visit(tree, 'code', (node: Code) => {
+        if (node.lang === 'drawio') {
+          node.value = '<!-- drawio content replaced -->';
+        }
+      });
+    };
+  };
+
+  const processor = unified()
+    .use(remarkParse)
+    .use(sanitizeMarkdown)
+    .use(remarkRehype)
+    .use(rehypeMeta, {
+      title: pagePath,
+    })
+    .use(rehypeStringify);
+
+  return processor.processSync(revisionBody).toString();
+};

+ 0 - 65
apps/app/src/features/openai/server/utils/sanitize-markdown.ts

@@ -1,65 +0,0 @@
-import { dynamicImport } from '@cspell/dynamic-import';
-import type { Root, Code } from 'mdast';
-import type * as RemarkParse from 'remark-parse';
-import type * as RemarkStringify from 'remark-stringify';
-import type * as Unified from 'unified';
-import type * as UnistUtilVisit from 'unist-util-visit';
-
-interface ModuleCache {
-  remarkParse?: typeof RemarkParse.default;
-  remarkStringify?: typeof RemarkStringify.default;
-  unified?: typeof Unified.unified;
-  visit?: typeof UnistUtilVisit.visit;
-}
-
-let moduleCache: ModuleCache = {};
-
-const initializeModules = async(): Promise<void> => {
-  if (moduleCache.remarkParse != null && moduleCache.remarkStringify != null && moduleCache.unified != null && moduleCache.visit != null) {
-    return;
-  }
-
-  const [{ default: remarkParse }, { default: remarkStringify }, { unified }, { visit }] = await Promise.all([
-    dynamicImport<typeof RemarkParse>('remark-parse', __dirname),
-    dynamicImport<typeof RemarkStringify>('remark-stringify', __dirname),
-    dynamicImport<typeof Unified>('unified', __dirname),
-    dynamicImport<typeof UnistUtilVisit>('unist-util-visit', __dirname),
-  ]);
-
-  moduleCache = {
-    remarkParse,
-    remarkStringify,
-    unified,
-    visit,
-  };
-};
-
-export const sanitizeMarkdown = async(markdown: string): Promise<string> => {
-  await initializeModules();
-
-  const {
-    remarkParse, remarkStringify, unified, visit,
-  } = moduleCache;
-
-
-  if (remarkParse == null || remarkStringify == null || unified == null || visit == null) {
-    throw new Error('Failed to initialize required modules');
-  }
-
-  const sanitize = () => {
-    return (tree: Root) => {
-      visit(tree, 'code', (node: Code) => {
-        if (node.lang === 'drawio') {
-          node.value = '<!-- drawio content replaced -->';
-        }
-      });
-    };
-  };
-
-  const processor = unified()
-    .use(remarkParse)
-    .use(sanitize)
-    .use(remarkStringify);
-
-  return processor.processSync(markdown).toString();
-};

+ 1 - 0
apps/pdf-converter/dist/bin/index.d.ts

@@ -0,0 +1 @@
+export {};

+ 8 - 0
apps/pdf-converter/dist/bin/index.js

@@ -0,0 +1,8 @@
+import { CliCore } from '@tsed/cli-core';
+import { GenerateSwaggerCmd } from '@tsed/cli-generate-swagger';
+import Server from '../server.js';
+CliCore.bootstrap({
+    server: Server,
+    commands: [GenerateSwaggerCmd],
+});
+//# sourceMappingURL=index.js.map

+ 1 - 0
apps/pdf-converter/dist/bin/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bin/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACzC,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEhE,OAAO,MAAM,MAAM,cAAc,CAAC;AAElC,OAAO,CAAC,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM;IACd,QAAQ,EAAE,CAAC,kBAAkB,CAAC;CAC/B,CAAC,CAAC"}

+ 2 - 0
apps/pdf-converter/dist/controllers/index.d.ts

@@ -0,0 +1,2 @@
+export { default as PdfCtrl } from './pdf.js';
+export { default as TerminusCtrl } from './terminus.js';

+ 3 - 0
apps/pdf-converter/dist/controllers/index.js

@@ -0,0 +1,3 @@
+export { default as PdfCtrl } from './pdf.js';
+export { default as TerminusCtrl } from './terminus.js';
+//# sourceMappingURL=index.js.map

+ 1 - 0
apps/pdf-converter/dist/controllers/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/controllers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,UAAU,CAAC;AAC9C,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC"}

+ 11 - 0
apps/pdf-converter/dist/controllers/pdf.d.ts

@@ -0,0 +1,11 @@
+import { Logger } from '@tsed/common';
+import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
+declare class PdfCtrl {
+    private readonly pdfConvertService;
+    logger: Logger;
+    constructor(pdfConvertService: PdfConvertService);
+    syncJobStatus(jobId: string, expirationDateStr: string, growiJobStatus: JobStatusSharedWithGrowi): Promise<{
+        status: JobStatus;
+    }>;
+}
+export default PdfCtrl;

+ 59 - 0
apps/pdf-converter/dist/controllers/pdf.js

@@ -0,0 +1,59 @@
+import { __decorate, __metadata, __param } from "tslib";
+import { BodyParams, Logger } from '@tsed/common';
+import { Controller, Inject } from '@tsed/di';
+import { InternalServerError } from '@tsed/exceptions';
+import { Post, Returns, Enum, Description, } from '@tsed/schema';
+import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
+let PdfCtrl = class PdfCtrl {
+    pdfConvertService;
+    logger;
+    constructor(pdfConvertService) {
+        this.pdfConvertService = pdfConvertService;
+    }
+    async syncJobStatus(jobId, expirationDateStr, growiJobStatus) {
+        const expirationDate = new Date(expirationDateStr);
+        try {
+            await this.pdfConvertService.registerOrUpdateJob(jobId, expirationDate, growiJobStatus);
+            const status = this.pdfConvertService.getJobStatus(jobId); // get status before cleanup
+            this.pdfConvertService.cleanUpJobList();
+            return { status };
+        }
+        catch (err) {
+            this.logger.error('Failed to register or update job', err);
+            throw new InternalServerError(err);
+        }
+    }
+};
+__decorate([
+    Inject(),
+    __metadata("design:type", Logger)
+], PdfCtrl.prototype, "logger", void 0);
+__decorate([
+    Post('/sync-job'),
+    (Returns(202).ContentType('application/json').Schema({
+        type: 'object',
+        properties: {
+            status: { type: 'string', enum: Object.values(JobStatus) },
+        },
+        required: ['status'],
+    })),
+    Returns(500),
+    Description(`
+    Sync job pdf convert status with GROWI.
+    Register or update job inside pdf-converter with given jobId, expirationDate, and status.
+    Return resulting status of job to GROWI.
+  `),
+    __param(0, BodyParams('jobId')),
+    __param(1, BodyParams('expirationDate')),
+    __param(2, BodyParams('status')),
+    __param(2, Enum(Object.values(JobStatusSharedWithGrowi))),
+    __metadata("design:type", Function),
+    __metadata("design:paramtypes", [String, String, String]),
+    __metadata("design:returntype", Promise)
+], PdfCtrl.prototype, "syncJobStatus", null);
+PdfCtrl = __decorate([
+    Controller('/pdf'),
+    __metadata("design:paramtypes", [PdfConvertService])
+], PdfCtrl);
+export default PdfCtrl;
+//# sourceMappingURL=pdf.js.map

+ 1 - 0
apps/pdf-converter/dist/controllers/pdf.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/controllers/pdf.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAC9C,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EACL,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GACjC,MAAM,cAAc,CAAC;AAEtB,OAAO,iBAAiB,EAAE,EAAE,wBAAwB,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAEnG,IACM,OAAO,GADb,MACM,OAAO;IAKkB;IAF3B,MAAM,CAAS;IAEjB,YAA6B,iBAAoC;QAApC,sBAAiB,GAAjB,iBAAiB,CAAmB;IAAG,CAAC;IAgB/D,AAAN,KAAK,CAAC,aAAa,CACI,KAAa,EACJ,iBAAyB,EACc,cAAwC;QAE7G,MAAM,cAAc,GAAG,IAAI,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACnD,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,iBAAiB,CAAC,mBAAmB,CAAC,KAAK,EAAE,cAAc,EAAE,cAAc,CAAC,CAAC;YACxF,MAAM,MAAM,GAAG,IAAI,CAAC,iBAAiB,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,4BAA4B;YACvF,IAAI,CAAC,iBAAiB,CAAC,cAAc,EAAE,CAAC;YACxC,OAAO,EAAE,MAAM,EAAE,CAAC;QACpB,CAAC;QACD,OAAO,GAAG,EAAE,CAAC;YACX,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,kCAAkC,EAAE,GAAG,CAAC,CAAC;YAC3D,MAAM,IAAI,mBAAmB,CAAC,GAAG,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;CAEF,CAAA;AApCG;IADD,MAAM,EAAE;8BACC,MAAM;uCAAC;AAkBX;IAdL,IAAI,CAAC,WAAW,CAAC;IACjB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC,MAAM,CAAC;QACpD,IAAI,EAAE,QAAQ;QACd,UAAU,EAAE;YACV,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE;SAC3D;QACD,QAAQ,EAAE,CAAC,QAAQ,CAAC;KACrB,CAAC,CAAC;IACF,OAAO,CAAC,GAAG,CAAC;IACZ,WAAW,CAAC;;;;GAIZ,CAAC;IAEC,WAAA,UAAU,CAAC,OAAO,CAAC,CAAA;IACnB,WAAA,UAAU,CAAC,gBAAgB,CAAC,CAAA;IAC5B,WAAA,UAAU,CAAC,QAAQ,CAAC,CAAA;IAAE,WAAA,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,wBAAwB,CAAC,CAAC,CAAA;;;;4CAarE;AArCG,OAAO;IADZ,UAAU,CAAC,MAAM,CAAC;qCAM+B,iBAAiB;GAL7D,OAAO,CAuCZ;AAED,eAAe,OAAO,CAAC"}

+ 10 - 0
apps/pdf-converter/dist/controllers/terminus.d.ts

@@ -0,0 +1,10 @@
+import { Logger } from '@tsed/common';
+import PdfConvertService from '../service/pdf-convert.js';
+declare class TerminusCtrl {
+    private readonly pdfConvertService;
+    logger: Logger;
+    constructor(pdfConvertService: PdfConvertService);
+    $onSignal(): Promise<void>;
+    $onShutdown(): void;
+}
+export default TerminusCtrl;

+ 28 - 0
apps/pdf-converter/dist/controllers/terminus.js

@@ -0,0 +1,28 @@
+import { __decorate, __metadata } from "tslib";
+import { Logger } from '@tsed/common';
+import { Inject, Injectable } from '@tsed/di';
+import PdfConvertService from '../service/pdf-convert.js';
+let TerminusCtrl = class TerminusCtrl {
+    pdfConvertService;
+    logger;
+    constructor(pdfConvertService) {
+        this.pdfConvertService = pdfConvertService;
+    }
+    async $onSignal() {
+        this.logger.info('Server is starting cleanup');
+        await this.pdfConvertService.closePuppeteerCluster();
+    }
+    $onShutdown() {
+        this.logger.info('Cleanup finished, server is shutting down');
+    }
+};
+__decorate([
+    Inject(),
+    __metadata("design:type", Logger)
+], TerminusCtrl.prototype, "logger", void 0);
+TerminusCtrl = __decorate([
+    Injectable(),
+    __metadata("design:paramtypes", [PdfConvertService])
+], TerminusCtrl);
+export default TerminusCtrl;
+//# sourceMappingURL=terminus.js.map

+ 1 - 0
apps/pdf-converter/dist/controllers/terminus.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"terminus.js","sourceRoot":"","sources":["../../src/controllers/terminus.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE9C,OAAO,iBAAiB,MAAM,2BAA2B,CAAC;AAE1D,IACM,YAAY,GADlB,MACM,YAAY;IAKa;IAF3B,MAAM,CAAS;IAEjB,YAA6B,iBAAoC;QAApC,sBAAiB,GAAjB,iBAAiB,CAAmB;IAAG,CAAC;IAErE,KAAK,CAAC,SAAS;QACb,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;QAC/C,MAAM,IAAI,CAAC,iBAAiB,CAAC,qBAAqB,EAAE,CAAC;IACvD,CAAC;IAED,WAAW;QACT,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,2CAA2C,CAAC,CAAC;IAChE,CAAC;CAEF,CAAA;AAbG;IADD,MAAM,EAAE;8BACC,MAAM;4CAAC;AAHb,YAAY;IADjB,UAAU,EAAE;qCAMqC,iBAAiB;GAL7D,YAAY,CAgBjB;AAED,eAAe,YAAY,CAAC"}

+ 1 - 0
apps/pdf-converter/dist/index.d.ts

@@ -0,0 +1 @@
+export {};

+ 23 - 0
apps/pdf-converter/dist/index.js

@@ -0,0 +1,23 @@
+import { $log } from '@tsed/common';
+import { PlatformExpress } from '@tsed/platform-express';
+import Server from './server.js';
+function hasProcessFlag(flag) {
+    return process.argv.join('').indexOf(flag) > -1;
+}
+async function bootstrap() {
+    try {
+        $log.debug('Start server...');
+        const platform = await PlatformExpress.bootstrap(Server);
+        await platform.listen();
+        $log.debug('Server initialized');
+        if (hasProcessFlag('ci')) {
+            $log.info('"--ci" flag is detected. Exit process.');
+            process.exit();
+        }
+    }
+    catch (error) {
+        $log.error(error);
+    }
+}
+bootstrap();
+//# sourceMappingURL=index.js.map

+ 1 - 0
apps/pdf-converter/dist/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACpC,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAEzD,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,SAAS,cAAc,CAAC,IAAY;IAClC,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAClD,CAAC;AAED,KAAK,UAAU,SAAS;IACtB,IAAI,CAAC;QACH,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;QAC9B,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QAEzD,MAAM,QAAQ,CAAC,MAAM,EAAE,CAAC;QACxB,IAAI,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAC;QAEjC,IAAI,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,IAAI,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAC;YACpD,OAAO,CAAC,IAAI,EAAE,CAAC;QACjB,CAAC;IACH,CAAC;IACD,OAAO,KAAK,EAAE,CAAC;QACb,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACpB,CAAC;AACH,CAAC;AAED,SAAS,EAAE,CAAC"}

+ 8 - 0
apps/pdf-converter/dist/server.d.ts

@@ -0,0 +1,8 @@
+import { PlatformApplication } from '@tsed/common';
+import '@tsed/swagger';
+import '@tsed/terminus';
+import '@tsed/platform-express';
+declare class Server {
+    app: PlatformApplication;
+}
+export default Server;

+ 41 - 0
apps/pdf-converter/dist/server.js

@@ -0,0 +1,41 @@
+import { __decorate, __metadata } from "tslib";
+import { PlatformApplication } from '@tsed/common';
+import { Configuration, Inject } from '@tsed/di';
+import express from 'express';
+import '@tsed/swagger';
+import '@tsed/terminus';
+import * as Controllers from './controllers/index.js';
+import '@tsed/platform-express';
+const PORT = Number(process.env.PORT || 3010);
+let Server = class Server {
+    app;
+};
+__decorate([
+    Inject(),
+    __metadata("design:type", PlatformApplication)
+], Server.prototype, "app", void 0);
+Server = __decorate([
+    Configuration({
+        port: PORT,
+        acceptMimes: ['application/json'],
+        mount: {
+            '/': [...Object.values(Controllers)],
+        },
+        middlewares: [
+            'json-parser',
+            express.json({ limit: '50mb' }),
+            express.urlencoded({ extended: true, limit: '50mb' }),
+        ],
+        swagger: [
+            {
+                path: '/v3/docs',
+                specVersion: '3.0.1',
+            },
+        ],
+        terminus: {
+            signals: ['SIGINT', 'SIGTERM'],
+        },
+    })
+], Server);
+export default Server;
+//# sourceMappingURL=server.js.map

+ 1 - 0
apps/pdf-converter/dist/server.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,OAAO,MAAM,SAAS,CAAC;AAC9B,OAAO,eAAe,CAAC;AACvB,OAAO,gBAAgB,CAAC;AAExB,OAAO,KAAK,WAAW,MAAM,wBAAwB,CAAC;AAEtD,OAAO,wBAAwB,CAAC;AAEhC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;AAuB9C,IAAM,MAAM,GAAZ,MAAM,MAAM;IAGR,GAAG,CAAsB;CAE5B,CAAA;AAFG;IADD,MAAM,EAAE;8BACF,mBAAmB;mCAAC;AAHvB,MAAM;IArBX,aAAa,CAAC;QACb,IAAI,EAAE,IAAI;QACV,WAAW,EAAE,CAAC,kBAAkB,CAAC;QACjC,KAAK,EAAE;YACL,GAAG,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;SACrC;QACD,WAAW,EAAE;YACX,aAAa;YACb,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;YAC/B,OAAO,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;SACtD;QACD,OAAO,EAAE;YACP;gBACE,IAAI,EAAE,UAAU;gBAChB,WAAW,EAAE,OAAO;aACrB;SACF;QACD,QAAQ,EAAE;YACR,OAAO,EAAE,CAAC,QAAQ,EAAE,SAAS,CAAC;SAC/B;KACF,CAAC;GACI,MAAM,CAKX;AAED,eAAe,MAAM,CAAC"}

+ 81 - 0
apps/pdf-converter/dist/service/pdf-convert.d.ts

@@ -0,0 +1,81 @@
+import { Logger, OnInit } from '@tsed/common';
+export declare const JobStatusSharedWithGrowi: {
+    readonly HTML_EXPORT_IN_PROGRESS: "HTML_EXPORT_IN_PROGRESS";
+    readonly HTML_EXPORT_DONE: "HTML_EXPORT_DONE";
+    readonly FAILED: "FAILED";
+};
+export declare const JobStatus: {
+    readonly PDF_EXPORT_DONE: "PDF_EXPORT_DONE";
+    readonly HTML_EXPORT_IN_PROGRESS: "HTML_EXPORT_IN_PROGRESS";
+    readonly HTML_EXPORT_DONE: "HTML_EXPORT_DONE";
+    readonly FAILED: "FAILED";
+};
+export type JobStatusSharedWithGrowi = typeof JobStatusSharedWithGrowi[keyof typeof JobStatusSharedWithGrowi];
+export type JobStatus = typeof JobStatus[keyof typeof JobStatus];
+declare class PdfConvertService implements OnInit {
+    private puppeteerCluster;
+    private maxConcurrency;
+    private convertRetryLimit;
+    private tmpOutputRootDir;
+    private tmpHtmlDir;
+    private jobList;
+    logger: Logger;
+    $onInit(): Promise<void>;
+    /**
+     * Register or update job inside jobList with given jobId, expirationDate, and status.
+     * If job is new, start reading html files and convert them to pdf.
+     * @param jobId id of PageBulkExportJob
+     * @param expirationDate expiration date of job
+     * @param status status of job
+     */
+    registerOrUpdateJob(jobId: string, expirationDate: Date, status: JobStatusSharedWithGrowi): Promise<void>;
+    /**
+     * Get job status
+     * @param jobId id of PageBulkExportJob
+     * @returns job status
+     */
+    getJobStatus(jobId: string): JobStatus;
+    /**
+     * Clean up job list by removing expired jobs, finished jobs, and failed jobs
+     */
+    cleanUpJobList(): void;
+    /**
+     * Close puppeteer cluster
+     */
+    closePuppeteerCluster(): Promise<void>;
+    private isJobCompleted;
+    /**
+     * Read html files from shared fs path, convert them to pdf, and save them to shared fs path.
+     * Repeat this until all html files are converted to pdf or job fails.
+     * @param jobId id of PageBulkExportJob
+     */
+    private readHtmlAndConvertToPdfUntilFinish;
+    /**
+     * Get readable stream that reads html files from shared fs path
+     * @param jobId id of PageBulkExportJob
+     * @returns readable stream
+     */
+    private getHtmlReadable;
+    /**
+     * Get writable stream that converts html to pdf, and save it to shared fs path
+     * @returns writable stream
+     */
+    private getPdfWritable;
+    /**
+     * Convert html to pdf. Retry up to convertRetryLimit if failed.
+     * @param htmlString html to convert to pdf
+     * @returns converted pdf
+     */
+    private convertHtmlToPdf;
+    /**
+     * Initialize puppeteer cluster
+     */
+    private initPuppeteerCluster;
+    /**
+     * Get parent path from given path
+     * @param path target path
+     * @returns parent path
+     */
+    private getParentPath;
+}
+export default PdfConvertService;

+ 240 - 0
apps/pdf-converter/dist/service/pdf-convert.js

@@ -0,0 +1,240 @@
+import { __decorate, __metadata } from "tslib";
+import fs from 'fs';
+import path from 'path';
+import { Readable, Writable } from 'stream';
+import { pipeline as pipelinePromise } from 'stream/promises';
+import { Logger } from '@tsed/common';
+import { Inject, Service } from '@tsed/di';
+import { Cluster } from 'puppeteer-cluster';
+export const JobStatusSharedWithGrowi = {
+    HTML_EXPORT_IN_PROGRESS: 'HTML_EXPORT_IN_PROGRESS',
+    HTML_EXPORT_DONE: 'HTML_EXPORT_DONE',
+    FAILED: 'FAILED',
+};
+export const JobStatus = {
+    ...JobStatusSharedWithGrowi,
+    PDF_EXPORT_DONE: 'PDF_EXPORT_DONE',
+};
+let PdfConvertService = class PdfConvertService {
+    puppeteerCluster;
+    maxConcurrency = 1;
+    convertRetryLimit = 5;
+    tmpOutputRootDir = '/tmp/page-bulk-export';
+    tmpHtmlDir = `${this.tmpOutputRootDir}/html`;
+    jobList = {};
+    logger;
+    async $onInit() {
+        if (process.env.SWAGGER_GENERATION === 'true')
+            return;
+        await this.initPuppeteerCluster();
+    }
+    /**
+     * Register or update job inside jobList with given jobId, expirationDate, and status.
+     * If job is new, start reading html files and convert them to pdf.
+     * @param jobId id of PageBulkExportJob
+     * @param expirationDate expiration date of job
+     * @param status status of job
+     */
+    async registerOrUpdateJob(jobId, expirationDate, status) {
+        const isJobNew = !(jobId in this.jobList);
+        if (isJobNew) {
+            this.jobList[jobId] = { expirationDate, status };
+        }
+        else {
+            const jobInfo = this.jobList[jobId];
+            jobInfo.expirationDate = expirationDate;
+            if (!this.isJobCompleted(jobId)) {
+                jobInfo.status = status;
+            }
+        }
+        if (status === JobStatus.FAILED) {
+            this.jobList[jobId].currentStream?.destroy(new Error('job failed'));
+        }
+        if (isJobNew && status !== JobStatus.FAILED) {
+            this.readHtmlAndConvertToPdfUntilFinish(jobId);
+        }
+    }
+    /**
+     * Get job status
+     * @param jobId id of PageBulkExportJob
+     * @returns job status
+     */
+    getJobStatus(jobId) {
+        if (!(jobId in this.jobList))
+            return JobStatus.FAILED;
+        return this.jobList[jobId].status;
+    }
+    /**
+     * Clean up job list by removing expired jobs, finished jobs, and failed jobs
+     */
+    cleanUpJobList() {
+        const now = new Date();
+        for (const jobId of Object.keys(this.jobList)) {
+            const job = this.jobList[jobId];
+            if (now > job.expirationDate || this.isJobCompleted(jobId)) {
+                job.currentStream?.destroy(new Error('job expired'));
+                delete this.jobList[jobId];
+            }
+        }
+    }
+    /**
+     * Close puppeteer cluster
+     */
+    async closePuppeteerCluster() {
+        if (this.puppeteerCluster == null) {
+            this.logger.info('No puppeteer cluster running for closure');
+            return;
+        }
+        this.logger.info('Closing puppeteer cluster...');
+        await this.puppeteerCluster.idle();
+        await this.puppeteerCluster.close();
+    }
+    isJobCompleted(jobId) {
+        if (this.jobList[jobId] == null)
+            return true;
+        return this.jobList[jobId].status === JobStatus.PDF_EXPORT_DONE || this.jobList[jobId].status === JobStatus.FAILED;
+    }
+    /**
+     * Read html files from shared fs path, convert them to pdf, and save them to shared fs path.
+     * Repeat this until all html files are converted to pdf or job fails.
+     * @param jobId id of PageBulkExportJob
+     */
+    async readHtmlAndConvertToPdfUntilFinish(jobId) {
+        while (!this.isJobCompleted(jobId)) {
+            // eslint-disable-next-line no-await-in-loop
+            await new Promise(resolve => setTimeout(resolve, 10 * 1000));
+            try {
+                if (new Date() > this.jobList[jobId].expirationDate) {
+                    throw new Error('Job expired');
+                }
+                const htmlReadable = this.getHtmlReadable(jobId);
+                const pdfWritable = this.getPdfWritable();
+                this.jobList[jobId].currentStream = htmlReadable;
+                // eslint-disable-next-line no-await-in-loop
+                await pipelinePromise(htmlReadable, pdfWritable);
+                this.jobList[jobId].currentStream = undefined;
+            }
+            catch (err) {
+                this.logger.error('Failed to convert html to pdf', err);
+                this.jobList[jobId].status = JobStatus.FAILED;
+                this.jobList[jobId].currentStream?.destroy(new Error('Failed to convert html to pdf'));
+                break;
+            }
+        }
+    }
+    /**
+     * Get readable stream that reads html files from shared fs path
+     * @param jobId id of PageBulkExportJob
+     * @returns readable stream
+     */
+    getHtmlReadable(jobId) {
+        const htmlFileEntries = fs.readdirSync(path.join(this.tmpHtmlDir, jobId), { recursive: true, withFileTypes: true }).filter(entry => entry.isFile());
+        let index = 0;
+        const jobList = this.jobList;
+        return new Readable({
+            objectMode: true,
+            async read() {
+                if (index >= htmlFileEntries.length) {
+                    if (jobList[jobId].status === JobStatus.HTML_EXPORT_DONE && htmlFileEntries.length === 0) {
+                        jobList[jobId].status = JobStatus.PDF_EXPORT_DONE;
+                    }
+                    this.push(null);
+                    return;
+                }
+                const entry = htmlFileEntries[index];
+                const htmlFilePath = path.join(entry.parentPath, entry.name);
+                const htmlString = await fs.promises.readFile(htmlFilePath, 'utf-8');
+                this.push({ htmlString, htmlFilePath });
+                index += 1;
+            },
+        });
+    }
+    /**
+     * Get writable stream that converts html to pdf, and save it to shared fs path
+     * @returns writable stream
+     */
+    getPdfWritable() {
+        return new Writable({
+            objectMode: true,
+            write: async (pageInfo, encoding, callback) => {
+                const fileOutputPath = pageInfo.htmlFilePath.replace(new RegExp(`^${this.tmpHtmlDir}`), this.tmpOutputRootDir).replace(/\.html$/, '.pdf');
+                const fileOutputParentPath = this.getParentPath(fileOutputPath);
+                try {
+                    const pdfBody = await this.convertHtmlToPdf(pageInfo.htmlString);
+                    await fs.promises.mkdir(fileOutputParentPath, { recursive: true });
+                    await fs.promises.writeFile(fileOutputPath, pdfBody);
+                    await fs.promises.rm(pageInfo.htmlFilePath, { force: true });
+                }
+                catch (err) {
+                    callback(err);
+                    return;
+                }
+                callback();
+            },
+        });
+    }
+    /**
+     * Convert html to pdf. Retry up to convertRetryLimit if failed.
+     * @param htmlString html to convert to pdf
+     * @returns converted pdf
+     */
+    async convertHtmlToPdf(htmlString) {
+        const executeConvert = async (retries) => {
+            try {
+                return this.puppeteerCluster.execute(htmlString);
+            }
+            catch (err) {
+                if (retries > 0) {
+                    this.logger.error('Failed to convert markdown to pdf. Retrying...', err);
+                    return executeConvert(retries - 1);
+                }
+                throw err;
+            }
+        };
+        const result = await executeConvert(this.convertRetryLimit);
+        return result;
+    }
+    /**
+     * Initialize puppeteer cluster
+     */
+    async initPuppeteerCluster() {
+        this.puppeteerCluster = await Cluster.launch({
+            concurrency: Cluster.CONCURRENCY_PAGE,
+            maxConcurrency: this.maxConcurrency,
+            workerCreationDelay: 10000,
+        });
+        await this.puppeteerCluster.task(async ({ page, data: htmlString }) => {
+            await page.setContent(htmlString, { waitUntil: 'domcontentloaded' });
+            await page.emulateMediaType('screen');
+            const pdfResult = await page.pdf({
+                margin: {
+                    top: '100px', right: '50px', bottom: '100px', left: '50px',
+                },
+                printBackground: true,
+                format: 'A4',
+            });
+            return pdfResult;
+        });
+    }
+    /**
+     * Get parent path from given path
+     * @param path target path
+     * @returns parent path
+     */
+    getParentPath(path) {
+        const parentPath = path.split('/').slice(0, -1).join('/');
+        if (parentPath === '' || parentPath === '/') {
+            return '/';
+        }
+        return parentPath;
+    }
+};
+__decorate([
+    Inject(),
+    __metadata("design:type", Logger)
+], PdfConvertService.prototype, "logger", void 0);
+PdfConvertService = __decorate([
+    Service()
+], PdfConvertService);
+export default PdfConvertService;
+//# sourceMappingURL=pdf-convert.js.map

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
apps/pdf-converter/dist/service/pdf-convert.js.map


+ 149 - 0
apps/pdf-converter/specs/v3/docs/swagger.json

@@ -0,0 +1,149 @@
+{
+  "openapi": "3.0.1",
+  "info": {
+    "title": "Api documentation",
+    "version": "1.0.0"
+  },
+  "paths": {
+    "/pdf/sync-job": {
+      "post": {
+        "responses": {
+          "202": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "status": {
+                      "type": "string",
+                      "enum": [
+                        "HTML_EXPORT_IN_PROGRESS",
+                        "HTML_EXPORT_DONE",
+                        "FAILED",
+                        "PDF_EXPORT_DONE"
+                      ]
+                    }
+                  },
+                  "required": [
+                    "status"
+                  ]
+                }
+              }
+            },
+            "description": "Accepted"
+          },
+          "500": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/InternalServerError"
+                }
+              }
+            },
+            "description": "Internal Server Error"
+          }
+        },
+        "description": "\n    Sync job pdf convert status with GROWI.\n    Register or update job inside pdf-converter with given jobId, expirationDate, and status.\n    Return resulting status of job to GROWI.\n  ",
+        "parameters": [],
+        "requestBody": {
+          "required": false,
+          "content": {
+            "application/json": {
+              "schema": {
+                "properties": {
+                  "jobId": {
+                    "type": "string"
+                  },
+                  "expirationDate": {
+                    "type": "string"
+                  },
+                  "status": {
+                    "type": "string",
+                    "enum": [
+                      "HTML_EXPORT_IN_PROGRESS",
+                      "HTML_EXPORT_DONE",
+                      "FAILED"
+                    ]
+                  }
+                },
+                "type": "object"
+              }
+            }
+          }
+        },
+        "tags": [
+          "PdfCtrl"
+        ],
+        "operationId": "pdfCtrlSyncJobStatus"
+      }
+    }
+  },
+  "tags": [
+    {
+      "name": "PdfCtrl"
+    }
+  ],
+  "components": {
+    "schemas": {
+      "InternalServerError": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "minLength": 1,
+            "description": "The error name",
+            "example": "INTERNAL_SERVER_ERROR",
+            "default": "INTERNAL_SERVER_ERROR"
+          },
+          "message": {
+            "type": "string",
+            "minLength": 1,
+            "description": "An error message"
+          },
+          "status": {
+            "type": "number",
+            "description": "The status code of the exception",
+            "example": 500,
+            "default": 500
+          },
+          "errors": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/GenericError"
+            },
+            "description": "A list of related errors"
+          },
+          "stack": {
+            "type": "string",
+            "description": "The stack trace (only in development mode)"
+          }
+        },
+        "required": [
+          "name",
+          "message",
+          "status"
+        ]
+      },
+      "GenericError": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "minLength": 1,
+            "description": "The error name"
+          },
+          "message": {
+            "type": "string",
+            "minLength": 1,
+            "description": "An error message"
+          }
+        },
+        "additionalProperties": true,
+        "required": [
+          "name",
+          "message"
+        ]
+      }
+    }
+  }
+}

+ 106 - 0
apps/pdf-converter/specs/v3/docs/swagger.yaml

@@ -0,0 +1,106 @@
+openapi: 3.0.1
+info:
+  title: Api documentation
+  version: 1.0.0
+paths:
+  /pdf/sync-job:
+    post:
+      responses:
+        '202':
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    enum:
+                      - HTML_EXPORT_IN_PROGRESS
+                      - HTML_EXPORT_DONE
+                      - FAILED
+                      - PDF_EXPORT_DONE
+                required:
+                  - status
+          description: Accepted
+        '500':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/InternalServerError'
+          description: Internal Server Error
+      description: |2-
+
+            Sync job pdf convert status with GROWI.
+            Register or update job inside pdf-converter with given jobId, expirationDate, and status.
+            Return resulting status of job to GROWI.
+          
+      parameters: []
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              properties:
+                jobId:
+                  type: string
+                expirationDate:
+                  type: string
+                status:
+                  type: string
+                  enum:
+                    - HTML_EXPORT_IN_PROGRESS
+                    - HTML_EXPORT_DONE
+                    - FAILED
+              type: object
+      tags:
+        - PdfCtrl
+      operationId: pdfCtrlSyncJobStatus
+tags:
+  - name: PdfCtrl
+components:
+  schemas:
+    InternalServerError:
+      type: object
+      properties:
+        name:
+          type: string
+          minLength: 1
+          description: The error name
+          example: INTERNAL_SERVER_ERROR
+          default: INTERNAL_SERVER_ERROR
+        message:
+          type: string
+          minLength: 1
+          description: An error message
+        status:
+          type: number
+          description: The status code of the exception
+          example: 500
+          default: 500
+        errors:
+          type: array
+          items:
+            $ref: '#/components/schemas/GenericError'
+          description: A list of related errors
+        stack:
+          type: string
+          description: The stack trace (only in development mode)
+      required:
+        - name
+        - message
+        - status
+    GenericError:
+      type: object
+      properties:
+        name:
+          type: string
+          minLength: 1
+          description: The error name
+        message:
+          type: string
+          minLength: 1
+          description: An error message
+      additionalProperties: true
+      required:
+        - name
+        - message

+ 2 - 1
packages/core/src/utils/page-path-utils/index.ts

@@ -1,6 +1,6 @@
 import escapeStringRegexp from 'escape-string-regexp';
 
-import { IUser } from '~/interfaces';
+import type { IUser } from '~/interfaces';
 
 import { isValidObjectId } from '../objectid-utils';
 import { addTrailingSlash } from '../path-utils';
@@ -117,6 +117,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /^\/(_search|_private-legacy-pages)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share|attachment)(\/.*|$)/,
   /^\/user(?:\/[^/]+)?$/, // https://regex101.com/r/9Eh2S1/1
+  /^(\/.+){130,}$/, // avoid deep layer path. see: https://regex101.com/r/L0kzOD/1
 ];
 export const isCreatablePage = (path: string): boolean => {
   return !restrictedPatternsToCreate.some(pattern => path.match(pattern));

+ 62 - 0
packages/pdf-converter-client/dist/index.d.ts

@@ -0,0 +1,62 @@
+import type { AxiosRequestConfig, AxiosResponse } from 'axios';
+export type PdfCtrlSyncJobStatus202Status = typeof PdfCtrlSyncJobStatus202Status[keyof typeof PdfCtrlSyncJobStatus202Status];
+export declare const PdfCtrlSyncJobStatus202Status: {
+    readonly HTML_EXPORT_IN_PROGRESS: "HTML_EXPORT_IN_PROGRESS";
+    readonly HTML_EXPORT_DONE: "HTML_EXPORT_DONE";
+    readonly FAILED: "FAILED";
+    readonly PDF_EXPORT_DONE: "PDF_EXPORT_DONE";
+};
+export type PdfCtrlSyncJobStatus202 = {
+    status: PdfCtrlSyncJobStatus202Status;
+};
+export type PdfCtrlSyncJobStatusBodyStatus = typeof PdfCtrlSyncJobStatusBodyStatus[keyof typeof PdfCtrlSyncJobStatusBodyStatus];
+export declare const PdfCtrlSyncJobStatusBodyStatus: {
+    readonly HTML_EXPORT_IN_PROGRESS: "HTML_EXPORT_IN_PROGRESS";
+    readonly HTML_EXPORT_DONE: "HTML_EXPORT_DONE";
+    readonly FAILED: "FAILED";
+};
+export type PdfCtrlSyncJobStatusBody = {
+    expirationDate?: string;
+    jobId?: string;
+    status?: PdfCtrlSyncJobStatusBodyStatus;
+};
+export interface GenericError {
+    /**
+     * An error message
+     * @minLength 1
+     */
+    message: string;
+    /**
+     * The error name
+     * @minLength 1
+     */
+    name: string;
+    [key: string]: unknown;
+}
+export interface InternalServerError {
+    /** A list of related errors */
+    errors?: GenericError[];
+    /**
+     * An error message
+     * @minLength 1
+     */
+    message: string;
+    /**
+     * The error name
+     * @minLength 1
+     */
+    name: string;
+    /** The stack trace (only in development mode) */
+    stack?: string;
+    /** The status code of the exception */
+    status: number;
+}
+/**
+*
+  Sync job pdf convert status with GROWI.
+  Register or update job inside pdf-converter with given jobId, expirationDate, and status.
+  Return resulting status of job to GROWI.
+
+*/
+export declare const pdfCtrlSyncJobStatus: <TData = AxiosResponse<PdfCtrlSyncJobStatus202, any>>(pdfCtrlSyncJobStatusBody?: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig) => Promise<TData>;
+export type PdfCtrlSyncJobStatusResult = AxiosResponse<PdfCtrlSyncJobStatus202>;

+ 31 - 0
packages/pdf-converter-client/dist/index.js

@@ -0,0 +1,31 @@
+/**
+ * Generated by orval v7.2.0 🍺
+ * Do not edit manually.
+ * Api documentation
+ * OpenAPI spec version: 1.0.0
+ */
+import axios from 'axios';
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const PdfCtrlSyncJobStatus202Status = {
+    HTML_EXPORT_IN_PROGRESS: 'HTML_EXPORT_IN_PROGRESS',
+    HTML_EXPORT_DONE: 'HTML_EXPORT_DONE',
+    FAILED: 'FAILED',
+    PDF_EXPORT_DONE: 'PDF_EXPORT_DONE',
+};
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const PdfCtrlSyncJobStatusBodyStatus = {
+    HTML_EXPORT_IN_PROGRESS: 'HTML_EXPORT_IN_PROGRESS',
+    HTML_EXPORT_DONE: 'HTML_EXPORT_DONE',
+    FAILED: 'FAILED',
+};
+/**
+*
+  Sync job pdf convert status with GROWI.
+  Register or update job inside pdf-converter with given jobId, expirationDate, and status.
+  Return resulting status of job to GROWI.
+
+*/
+export const pdfCtrlSyncJobStatus = (pdfCtrlSyncJobStatusBody, options) => {
+    return axios.post(`/pdf/sync-job`, pdfCtrlSyncJobStatusBody, options);
+};
+//# sourceMappingURL=index.js.map

+ 1 - 0
packages/pdf-converter-client/dist/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,MAAM,OAAO,CAAA;AAQzB,2DAA2D;AAC3D,MAAM,CAAC,MAAM,6BAA6B,GAAG;IAC3C,uBAAuB,EAAE,yBAAyB;IAClD,gBAAgB,EAAE,kBAAkB;IACpC,MAAM,EAAE,QAAQ;IAChB,eAAe,EAAE,iBAAiB;CAC1B,CAAC;AASX,2DAA2D;AAC3D,MAAM,CAAC,MAAM,8BAA8B,GAAG;IAC5C,uBAAuB,EAAE,yBAAyB;IAClD,gBAAgB,EAAE,kBAAkB;IACpC,MAAM,EAAE,QAAQ;CACR,CAAC;AA6CT;;;;;;EAMC;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAChC,wBAAmD,EAAE,OAA4B,EAClE,EAAE;IACjB,OAAO,KAAK,CAAC,IAAI,CACf,eAAe,EACf,wBAAwB,EAAC,OAAO,CACjC,CAAC;AACJ,CAAC,CAAA"}

+ 2 - 2
packages/remark-lsx/src/server/index.ts

@@ -14,8 +14,8 @@ const filterXSS = new FilterXSS();
 
 const lsxValidator = [
   query('pagePath').notEmpty().isString(),
-  query('offset').optional().isInt(),
-  query('limit').optional().isInt(),
+  query('offset').optional().isInt().toInt(),
+  query('limit').optional().isInt().toInt(),
   query('options')
     .optional()
     .customSanitizer((options) => {

+ 245 - 31
pnpm-lock.yaml

@@ -59,7 +59,7 @@ importers:
         version: 4.3.1(vite@5.4.6(@types/node@20.14.0)(sass@1.77.6)(terser@5.36.0))
       '@vitest/coverage-v8':
         specifier: ^2.1.1
-        version: 2.1.1(vitest@2.1.1)
+        version: 2.1.1(vitest@2.1.1(@types/node@20.14.0)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.36.0))
       '@vitest/ui':
         specifier: ^2.1.1
         version: 2.1.1(vitest@2.1.1)
@@ -74,10 +74,10 @@ importers:
         version: 8.41.0
       eslint-config-next:
         specifier: ^12.1.6
-        version: 12.1.6(eslint@8.41.0)(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4)
+        version: 12.1.6(eslint@8.41.0)(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4)
       eslint-config-weseek:
         specifier: ^2.1.1
-        version: 2.1.1(@babel/core@7.24.6)(@babel/eslint-parser@7.24.7(@babel/core@7.24.6)(eslint@8.41.0))(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4))(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint-plugin-vue@7.20.0(eslint@8.41.0))(eslint@8.41.0)
+        version: 2.1.1(@babel/core@7.24.6)(@babel/eslint-parser@7.24.7(@babel/core@7.24.6)(eslint@8.41.0))(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4))(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5(eslint-plugin-import@2.26.0)(eslint@8.41.0))(eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0))(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint-plugin-vue@7.20.0(eslint@8.41.0))(eslint@8.41.0)
       eslint-import-resolver-typescript:
         specifier: ^3.2.5
         version: 3.2.5(eslint-plugin-import@2.26.0)(eslint@8.41.0)
@@ -185,7 +185,7 @@ importers:
         version: 2.1.1(@types/node@20.14.0)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.36.0)
       vitest-mock-extended:
         specifier: ^2.0.2
-        version: 2.0.2(typescript@5.0.4)(vitest@2.1.1)
+        version: 2.0.2(typescript@5.0.4)(vitest@2.1.1(@types/node@20.14.0)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.36.0))
       vue-tsc:
         specifier: ^2.1.10
         version: 2.1.10(typescript@5.0.4)
@@ -481,20 +481,20 @@ importers:
         specifier: ^4.2.0
         version: 4.2.0
       next:
-        specifier: ^14.2.13
-        version: 14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        specifier: ^14.2.15
+        version: 14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       next-dynamic-loading-props:
         specifier: ^0.1.1
         version: 0.1.1(react@18.2.0)
       next-i18next:
         specifier: ^15.3.1
-        version: 15.3.1(i18next@23.16.5)(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
+        version: 15.3.1(i18next@23.16.5)(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
       next-superjson:
         specifier: ^1.0.7
         version: 1.0.7(@swc/helpers@0.5.11)(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@1.13.3)
       next-themes:
         specifier: ^0.2.1
-        version: 0.2.1(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+        version: 0.2.1(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       nocache:
         specifier: ^4.0.0
         version: 4.0.0
@@ -600,6 +600,9 @@ importers:
       rehype-katex:
         specifier: ^7.0.1
         version: 7.0.1
+      rehype-meta:
+        specifier: ^4.0.1
+        version: 4.0.1
       rehype-raw:
         specifier: ^7.0.0
         version: 7.0.0
@@ -609,6 +612,9 @@ importers:
       rehype-slug:
         specifier: ^6.0.0
         version: 6.0.0
+      rehype-stringify:
+        specifier: ^10.0.1
+        version: 10.0.1
       rehype-toc:
         specifier: ^3.0.2
         version: 3.0.2
@@ -831,7 +837,7 @@ importers:
         version: 3.1.0
       eslint-plugin-jest:
         specifier: ^26.5.3
-        version: 26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.0)(typescript@5.4.2)))(typescript@5.4.2)
+        version: 26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.0)(typescript@5.4.2)))(typescript@5.4.2)
       fslightbox-react:
         specifier: ^1.7.6
         version: 1.7.6(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -3004,6 +3010,9 @@ packages:
   '@next/env@14.2.13':
     resolution: {integrity: sha512-s3lh6K8cbW1h5Nga7NNeXrbe0+2jIIYK9YaA9T7IufDWnZpozdFUp6Hf0d5rNWUKu4fEuSX2rCKlGjCrtylfDw==}
 
+  '@next/env@14.2.15':
+    resolution: {integrity: sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==}
+
   '@next/eslint-plugin-next@12.1.6':
     resolution: {integrity: sha512-yNUtJ90NEiYFT6TJnNyofKMPYqirKDwpahcbxBgSIuABwYOdkGwzos1ZkYD51Qf0diYwpQZBeVqElTk7Q2WNqw==}
 
@@ -3013,54 +3022,108 @@ packages:
     cpu: [arm64]
     os: [darwin]
 
+  '@next/swc-darwin-arm64@14.2.15':
+    resolution: {integrity: sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
   '@next/swc-darwin-x64@14.2.13':
     resolution: {integrity: sha512-Dv1RBGs2TTjkwEnFMVL5XIfJEavnLqqwYSD6LXgTPdEy/u6FlSrLBSSfe1pcfqhFEXRAgVL3Wpjibe5wXJzWog==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [darwin]
 
+  '@next/swc-darwin-x64@14.2.15':
+    resolution: {integrity: sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
   '@next/swc-linux-arm64-gnu@14.2.13':
     resolution: {integrity: sha512-yB1tYEFFqo4ZNWkwrJultbsw7NPAAxlPXURXioRl9SdW6aIefOLS+0TEsKrWBtbJ9moTDgU3HRILL6QBQnMevg==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
+  '@next/swc-linux-arm64-gnu@14.2.15':
+    resolution: {integrity: sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
   '@next/swc-linux-arm64-musl@14.2.13':
     resolution: {integrity: sha512-v5jZ/FV/eHGoWhMKYrsAweQ7CWb8xsWGM/8m1mwwZQ/sutJjoFaXchwK4pX8NqwImILEvQmZWyb8pPTcP7htWg==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
+  '@next/swc-linux-arm64-musl@14.2.15':
+    resolution: {integrity: sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
   '@next/swc-linux-x64-gnu@14.2.13':
     resolution: {integrity: sha512-aVc7m4YL7ViiRv7SOXK3RplXzOEe/qQzRA5R2vpXboHABs3w8vtFslGTz+5tKiQzWUmTmBNVW0UQdhkKRORmGA==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
+  '@next/swc-linux-x64-gnu@14.2.15':
+    resolution: {integrity: sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
   '@next/swc-linux-x64-musl@14.2.13':
     resolution: {integrity: sha512-4wWY7/OsSaJOOKvMsu1Teylku7vKyTuocvDLTZQq0TYv9OjiYYWt63PiE1nTuZnqQ4RPvME7Xai+9enoiN0Wrg==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
+  '@next/swc-linux-x64-musl@14.2.15':
+    resolution: {integrity: sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
   '@next/swc-win32-arm64-msvc@14.2.13':
     resolution: {integrity: sha512-uP1XkqCqV2NVH9+g2sC7qIw+w2tRbcMiXFEbMihkQ8B1+V6m28sshBwAB0SDmOe0u44ne1vFU66+gx/28RsBVQ==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [win32]
 
+  '@next/swc-win32-arm64-msvc@14.2.15':
+    resolution: {integrity: sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
   '@next/swc-win32-ia32-msvc@14.2.13':
     resolution: {integrity: sha512-V26ezyjPqQpDBV4lcWIh8B/QICQ4v+M5Bo9ykLN+sqeKKBxJVDpEc6biDVyluTXTC40f5IqCU0ttth7Es2ZuMw==}
     engines: {node: '>= 10'}
     cpu: [ia32]
     os: [win32]
 
+  '@next/swc-win32-ia32-msvc@14.2.15':
+    resolution: {integrity: sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==}
+    engines: {node: '>= 10'}
+    cpu: [ia32]
+    os: [win32]
+
   '@next/swc-win32-x64-msvc@14.2.13':
     resolution: {integrity: sha512-WwzOEAFBGhlDHE5Z73mNU8CO8mqMNLqaG+AO9ETmzdCQlJhVtWZnOl2+rqgVQS+YHunjOWptdFmNfbpwcUuEsw==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [win32]
 
+  '@next/swc-win32-x64-msvc@14.2.15':
+    resolution: {integrity: sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
 
@@ -4540,10 +4603,12 @@ packages:
   abstract-leveldown@6.2.3:
     resolution: {integrity: sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   abstract-leveldown@6.3.0:
     resolution: {integrity: sha512-TU5nlYgta8YrBMNpc9FwQzRbiXsj49gsALsXadbGHt9CROPzX5fB0rWDR5mtdpOOKa5XqRFpbj1QroPAoPzVjQ==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   abstract-logging@2.0.1:
     resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -6138,6 +6203,7 @@ packages:
   deferred-leveldown@5.3.0:
     resolution: {integrity: sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   define-data-property@1.1.4:
     resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
@@ -7327,6 +7393,9 @@ packages:
   hast-util-from-parse5@8.0.1:
     resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==}
 
+  hast-util-from-selector@3.0.1:
+    resolution: {integrity: sha512-CA2dwcsAS6a7DNZq8HT5fNP4FzUq2PUpQpKnAtOCmfTk429jR0RtasLSMlFA1FNKd8lgfeCIAFl3/vD95be8Lg==}
+
   hast-util-has-property@3.0.0:
     resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
 
@@ -7351,6 +7420,9 @@ packages:
   hast-util-select@6.0.2:
     resolution: {integrity: sha512-hT/SD/d/Meu+iobvgkffo1QecV8WeKWxwsNMzcTJsKw1cKTQKSR/7ArJeURLNJF9HDjp9nVoORyNNJxrvBye8Q==}
 
+  hast-util-to-html@9.0.3:
+    resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==}
+
   hast-util-to-jsx-runtime@2.3.0:
     resolution: {integrity: sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==}
 
@@ -7372,6 +7444,9 @@ packages:
   hastscript@8.0.0:
     resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==}
 
+  hastscript@9.0.0:
+    resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==}
+
   he@1.2.0:
     resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
     hasBin: true
@@ -8265,6 +8340,7 @@ packages:
 
   level-js@5.0.2:
     resolution: {integrity: sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==}
+    deprecated: Superseded by browser-level (https://github.com/Level/community#faq)
 
   level-packager@5.1.1:
     resolution: {integrity: sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==}
@@ -8281,10 +8357,12 @@ packages:
   leveldown@5.6.0:
     resolution: {integrity: sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==}
     engines: {node: '>=8.6.0'}
+    deprecated: Superseded by classic-level (https://github.com/Level/community#faq)
 
   levelup@4.4.0:
     resolution: {integrity: sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==}
     engines: {node: '>=6'}
+    deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
 
   leven@3.1.0:
     resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
@@ -9169,6 +9247,24 @@ packages:
       sass:
         optional: true
 
+  next@14.2.15:
+    resolution: {integrity: sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==}
+    engines: {node: '>=18.17.0'}
+    hasBin: true
+    peerDependencies:
+      '@opentelemetry/api': ^1.1.0
+      '@playwright/test': ^1.41.2
+      react: ^18.2.0
+      react-dom: ^18.2.0
+      sass: ^1.3.0
+    peerDependenciesMeta:
+      '@opentelemetry/api':
+        optional: true
+      '@playwright/test':
+        optional: true
+      sass:
+        optional: true
+
   nice-try@1.0.4:
     resolution: {integrity: sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==}
 
@@ -10281,6 +10377,9 @@ packages:
   rehype-katex@7.0.1:
     resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
 
+  rehype-meta@4.0.1:
+    resolution: {integrity: sha512-nLwA17+GbtBYi3C1KSrFR8JlqXv76mz185U//xDEAYgzE3g/bSD6WKSXva1W95ttzouUCJwA09X3AQZIi3R+Nw==}
+
   rehype-raw@7.0.0:
     resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
 
@@ -10294,6 +10393,9 @@ packages:
   rehype-slug@6.0.0:
     resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
 
+  rehype-stringify@10.0.1:
+    resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
+
   rehype-toc@3.0.2:
     resolution: {integrity: sha512-DMt376+4i1KJGgHJL7Ezd65qKkJ7Eqp6JSB47BJ90ReBrohI9ufrornArM6f4oJjP2E2DVZZHufWucv/9t7GUQ==}
     engines: {node: '>=10'}
@@ -12333,6 +12435,9 @@ packages:
   zwitch@2.0.2:
     resolution: {integrity: sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==}
 
+  zwitch@2.0.4:
+    resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
 snapshots:
 
   '@adobe/css-tools@4.4.0': {}
@@ -14874,6 +14979,8 @@ snapshots:
 
   '@next/env@14.2.13': {}
 
+  '@next/env@14.2.15': {}
+
   '@next/eslint-plugin-next@12.1.6':
     dependencies:
       glob: 7.1.7
@@ -14881,30 +14988,57 @@ snapshots:
   '@next/swc-darwin-arm64@14.2.13':
     optional: true
 
+  '@next/swc-darwin-arm64@14.2.15':
+    optional: true
+
   '@next/swc-darwin-x64@14.2.13':
     optional: true
 
+  '@next/swc-darwin-x64@14.2.15':
+    optional: true
+
   '@next/swc-linux-arm64-gnu@14.2.13':
     optional: true
 
+  '@next/swc-linux-arm64-gnu@14.2.15':
+    optional: true
+
   '@next/swc-linux-arm64-musl@14.2.13':
     optional: true
 
+  '@next/swc-linux-arm64-musl@14.2.15':
+    optional: true
+
   '@next/swc-linux-x64-gnu@14.2.13':
     optional: true
 
+  '@next/swc-linux-x64-gnu@14.2.15':
+    optional: true
+
   '@next/swc-linux-x64-musl@14.2.13':
     optional: true
 
+  '@next/swc-linux-x64-musl@14.2.15':
+    optional: true
+
   '@next/swc-win32-arm64-msvc@14.2.13':
     optional: true
 
+  '@next/swc-win32-arm64-msvc@14.2.15':
+    optional: true
+
   '@next/swc-win32-ia32-msvc@14.2.13':
     optional: true
 
+  '@next/swc-win32-ia32-msvc@14.2.15':
+    optional: true
+
   '@next/swc-win32-x64-msvc@14.2.13':
     optional: true
 
+  '@next/swc-win32-x64-msvc@14.2.15':
+    optional: true
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     dependencies:
       eslint-scope: 5.1.1
@@ -16480,10 +16614,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2)':
+  '@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.4.2)':
     dependencies:
       '@eslint-community/regexpp': 4.5.1
-      '@typescript-eslint/parser': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
+      '@typescript-eslint/parser': 5.59.7(eslint@8.41.0)(typescript@5.0.4)
       '@typescript-eslint/scope-manager': 5.59.7
       '@typescript-eslint/type-utils': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
       '@typescript-eslint/utils': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
@@ -16684,7 +16818,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@vitest/coverage-v8@2.1.1(vitest@2.1.1)':
+  '@vitest/coverage-v8@2.1.1(vitest@2.1.1(@types/node@20.14.0)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.36.0))':
     dependencies:
       '@ampproject/remapping': 2.3.0
       '@bcoe/v8-coverage': 0.2.3
@@ -17314,6 +17448,14 @@ snapshots:
       '@types/babel__core': 7.20.5
       '@types/babel__traverse': 7.0.7
 
+  babel-plugin-superjson-next@0.4.5(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@1.13.3):
+    dependencies:
+      '@babel/helper-module-imports': 7.24.6
+      '@babel/types': 7.25.6
+      hoist-non-react-statics: 3.3.2
+      next: 14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      superjson: 1.13.3
+
   babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.6):
     dependencies:
       '@babel/core': 7.24.6
@@ -18990,7 +19132,7 @@ snapshots:
 
   escape-string-regexp@5.0.0: {}
 
-  eslint-config-airbnb-base@13.1.0(eslint-plugin-import@2.26.0)(eslint@8.41.0):
+  eslint-config-airbnb-base@13.1.0(eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0))(eslint@8.41.0):
     dependencies:
       eslint: 8.41.0
       eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0)
@@ -18998,17 +19140,17 @@ snapshots:
       object.assign: 4.1.5
       object.entries: 1.1.5
 
-  eslint-config-airbnb@17.1.0(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint@8.41.0):
+  eslint-config-airbnb@17.1.0(eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0))(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint@8.41.0):
     dependencies:
       eslint: 8.41.0
-      eslint-config-airbnb-base: 13.1.0(eslint-plugin-import@2.26.0)(eslint@8.41.0)
+      eslint-config-airbnb-base: 13.1.0(eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0))(eslint@8.41.0)
       eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0)
       eslint-plugin-jsx-a11y: 6.5.1(eslint@8.41.0)
       eslint-plugin-react: 7.30.1(eslint@8.41.0)
       object.assign: 4.1.5
       object.entries: 1.1.5
 
-  eslint-config-next@12.1.6(eslint@8.41.0)(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4):
+  eslint-config-next@12.1.6(eslint@8.41.0)(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4):
     dependencies:
       '@next/eslint-plugin-next': 12.1.6
       '@rushstack/eslint-patch': 1.1.3
@@ -19016,25 +19158,25 @@ snapshots:
       eslint: 8.41.0
       eslint-import-resolver-node: 0.3.6
       eslint-import-resolver-typescript: 2.7.1(eslint-plugin-import@2.26.0)(eslint@8.41.0)
-      eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.26.0)(eslint@8.41.0))(eslint@8.41.0)
+      eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@2.7.1)(eslint@8.41.0)
       eslint-plugin-jsx-a11y: 6.5.1(eslint@8.41.0)
       eslint-plugin-react: 7.30.1(eslint@8.41.0)
       eslint-plugin-react-hooks: 4.6.0(eslint@8.41.0)
-      next: 14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
     optionalDependencies:
       typescript: 5.0.4
     transitivePeerDependencies:
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-config-weseek@2.1.1(@babel/core@7.24.6)(@babel/eslint-parser@7.24.7(@babel/core@7.24.6)(eslint@8.41.0))(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4))(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint-plugin-vue@7.20.0(eslint@8.41.0))(eslint@8.41.0):
+  eslint-config-weseek@2.1.1(@babel/core@7.24.6)(@babel/eslint-parser@7.24.7(@babel/core@7.24.6)(eslint@8.41.0))(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4))(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5(eslint-plugin-import@2.26.0)(eslint@8.41.0))(eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0))(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint-plugin-vue@7.20.0(eslint@8.41.0))(eslint@8.41.0):
     dependencies:
       '@babel/core': 7.24.6
       '@babel/eslint-parser': 7.24.7(@babel/core@7.24.6)(eslint@8.41.0)
       '@typescript-eslint/eslint-plugin': 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4)
       '@typescript-eslint/parser': 5.59.7(eslint@8.41.0)(typescript@5.0.4)
       eslint: 8.41.0
-      eslint-config-airbnb: 17.1.0(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint@8.41.0)
+      eslint-config-airbnb: 17.1.0(eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0))(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint@8.41.0)
       eslint-import-resolver-typescript: 3.2.5(eslint-plugin-import@2.26.0)(eslint@8.41.0)
       eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0)
       eslint-plugin-react: 7.30.1(eslint@8.41.0)
@@ -19054,7 +19196,7 @@ snapshots:
     dependencies:
       debug: 4.3.7(supports-color@5.5.0)
       eslint: 8.41.0
-      eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint@8.41.0)
+      eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@2.7.1)(eslint@8.41.0)
       glob: 7.2.3
       is-glob: 4.0.3
       resolve: 1.22.8
@@ -19087,7 +19229,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-module-utils@2.7.3(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.2.5):
+  eslint-module-utils@2.7.3(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.2.5(eslint-plugin-import@2.26.0)(eslint@8.41.0)):
     dependencies:
       debug: 3.2.7
       find-up: 2.1.0
@@ -19098,7 +19240,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.26.0)(eslint@8.41.0))(eslint@8.41.0):
+  eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@2.7.1)(eslint@8.41.0):
     dependencies:
       array-includes: 3.1.5
       array.prototype.flat: 1.3.2
@@ -19129,7 +19271,7 @@ snapshots:
       doctrine: 2.1.0
       eslint: 8.41.0
       eslint-import-resolver-node: 0.3.6
-      eslint-module-utils: 2.7.3(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.2.5)
+      eslint-module-utils: 2.7.3(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.2.5(eslint-plugin-import@2.26.0)(eslint@8.41.0))
       has: 1.0.3
       is-core-module: 2.15.1
       is-glob: 4.0.3
@@ -19155,12 +19297,12 @@ snapshots:
       - typescript
     optional: true
 
-  eslint-plugin-jest@26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.0)(typescript@5.4.2)))(typescript@5.4.2):
+  eslint-plugin-jest@26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.0)(typescript@5.4.2)))(typescript@5.4.2):
     dependencies:
       '@typescript-eslint/utils': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
       eslint: 8.41.0
     optionalDependencies:
-      '@typescript-eslint/eslint-plugin': 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2)
+      '@typescript-eslint/eslint-plugin': 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.4.2)
       jest: 29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.0)(typescript@5.4.2))
     transitivePeerDependencies:
       - supports-color
@@ -20060,6 +20202,13 @@ snapshots:
       vfile-location: 5.0.3
       web-namespaces: 2.0.1
 
+  hast-util-from-selector@3.0.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      css-selector-parser: 3.0.5
+      devlop: 1.1.0
+      hastscript: 9.0.0
+
   hast-util-has-property@3.0.0:
     dependencies:
       '@types/hast': 3.0.4
@@ -20119,6 +20268,20 @@ snapshots:
       unist-util-visit: 5.0.0
       zwitch: 2.0.2
 
+  hast-util-to-html@9.0.3:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      comma-separated-tokens: 2.0.2
+      hast-util-whitespace: 3.0.0
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.0
+      property-information: 6.1.1
+      space-separated-tokens: 2.0.1
+      stringify-entities: 4.0.4
+      zwitch: 2.0.4
+
   hast-util-to-jsx-runtime@2.3.0:
     dependencies:
       '@types/estree': 1.0.6
@@ -20180,6 +20343,14 @@ snapshots:
       property-information: 6.1.1
       space-separated-tokens: 2.0.1
 
+  hastscript@9.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      comma-separated-tokens: 2.0.2
+      hast-util-parse-selector: 4.0.0
+      property-information: 6.1.1
+      space-separated-tokens: 2.0.1
+
   he@1.2.0: {}
 
   header-case@2.0.4:
@@ -22425,7 +22596,7 @@ snapshots:
     dependencies:
       react: 18.2.0
 
-  next-i18next@15.3.1(i18next@23.16.5)(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
+  next-i18next@15.3.1(i18next@23.16.5)(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
     dependencies:
       '@babel/runtime': 7.25.4
       '@types/hoist-non-react-statics': 3.3.5
@@ -22433,7 +22604,7 @@ snapshots:
       hoist-non-react-statics: 3.3.2
       i18next: 23.16.5
       i18next-fs-backend: 2.3.2
-      next: 14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-i18next: 15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
 
@@ -22453,9 +22624,9 @@ snapshots:
       - '@swc/helpers'
       - superjson
 
-  next-themes@0.2.1(next@14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+  next-themes@0.2.1(next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
     dependencies:
-      next: 14.2.13(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
 
@@ -22486,6 +22657,33 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
+  next@14.2.15(@babel/core@7.24.6)(@playwright/test@1.46.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6):
+    dependencies:
+      '@next/env': 14.2.15
+      '@swc/helpers': 0.5.5
+      busboy: 1.6.0
+      caniuse-lite: 1.0.30001680
+      graceful-fs: 4.2.11
+      postcss: 8.4.31
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      styled-jsx: 5.1.1(@babel/core@7.24.6)(react@18.2.0)
+    optionalDependencies:
+      '@next/swc-darwin-arm64': 14.2.15
+      '@next/swc-darwin-x64': 14.2.15
+      '@next/swc-linux-arm64-gnu': 14.2.15
+      '@next/swc-linux-arm64-musl': 14.2.15
+      '@next/swc-linux-x64-gnu': 14.2.15
+      '@next/swc-linux-x64-musl': 14.2.15
+      '@next/swc-win32-arm64-msvc': 14.2.15
+      '@next/swc-win32-ia32-msvc': 14.2.15
+      '@next/swc-win32-x64-msvc': 14.2.15
+      '@playwright/test': 1.46.0
+      sass: 1.77.6
+    transitivePeerDependencies:
+      - '@babel/core'
+      - babel-plugin-macros
+
   nice-try@1.0.4: {}
 
   no-case@3.0.4:
@@ -23732,6 +23930,14 @@ snapshots:
       unist-util-visit-parents: 6.0.1
       vfile: 6.0.3
 
+  rehype-meta@4.0.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      hast-util-from-selector: 3.0.1
+      hast-util-select: 6.0.2
+      hastscript: 9.0.0
+      vfile: 6.0.3
+
   rehype-raw@7.0.0:
     dependencies:
       '@types/hast': 3.0.4
@@ -23757,6 +23963,12 @@ snapshots:
       hast-util-to-string: 3.0.1
       unist-util-visit: 5.0.0
 
+  rehype-stringify@10.0.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      hast-util-to-html: 9.0.3
+      unified: 11.0.5
+
   rehype-toc@3.0.2:
     dependencies:
       '@jsdevtools/rehype-toc': 3.0.2
@@ -25673,7 +25885,7 @@ snapshots:
       sass: 1.77.6
       terser: 5.36.0
 
-  vitest-mock-extended@2.0.2(typescript@5.0.4)(vitest@2.1.1):
+  vitest-mock-extended@2.0.2(typescript@5.0.4)(vitest@2.1.1(@types/node@20.14.0)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.36.0)):
     dependencies:
       ts-essentials: 10.0.2(typescript@5.0.4)
       typescript: 5.0.4
@@ -26154,3 +26366,5 @@ snapshots:
   zwitch@1.0.5: {}
 
   zwitch@2.0.2: {}
+
+  zwitch@2.0.4: {}

Неке датотеке нису приказане због велике количине промена