@onozaty/growi-uploader 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -1
  2. package/dist/index.mjs +53 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # growi-uploader
2
2
 
3
+ [![Test](https://github.com/onozaty/growi-uploader/actions/workflows/test.yaml/badge.svg)](https://github.com/onozaty/growi-uploader/actions/workflows/test.yaml)
4
+ [![codecov](https://codecov.io/gh/onozaty/growi-uploader/graph/badge.svg?token=X0YN1OP5PB)](https://codecov.io/gh/onozaty/growi-uploader)
3
5
  [![npm version](https://badge.fury.io/js/@onozaty%2Fgrowi-uploader.svg)](https://www.npmjs.com/package/@onozaty/growi-uploader)
4
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
7
 
@@ -161,6 +163,8 @@ Files referenced in markdown links are automatically detected as attachments:
161
163
  **Local Directory:**
162
164
  ```
163
165
  guide.md
166
+ assets/
167
+ banner.png
164
168
  images/
165
169
  logo.png
166
170
  screenshot.png
@@ -170,9 +174,27 @@ images/
170
174
  ```markdown
171
175
  ![Logo](./images/logo.png)
172
176
  ![Screenshot](images/screenshot.png)
177
+ ![Banner](/assets/banner.png)
173
178
  ```
174
179
 
175
- Both `logo.png` and `screenshot.png` will be uploaded as attachments to the `/guide` page, even though they don't follow the `_attachment_` naming convention.
180
+ All referenced files (`logo.png`, `screenshot.png`, and `banner.png`) will be uploaded as attachments to the `/guide` page, even though they don't follow the `_attachment_` naming convention.
181
+
182
+ **Path resolution:**
183
+ - Markdown escape sequences are unescaped (`\(` → `(`)
184
+ - URL encoding (percent-encoding) is decoded (`%20` → space, `%E7%94%BB%E5%83%8F` → `画像`)
185
+ - Relative paths (`./`, `../`, or no prefix): Resolved from the Markdown file's directory
186
+ - Absolute paths (starting with `/`): Resolved from the source directory root
187
+ - Example: `/assets/banner.png` → `<source-dir>/assets/banner.png`
188
+
189
+ **Supported link formats:**
190
+ ```markdown
191
+ ![Logo](./images/logo.png) # Standard relative path
192
+ ![Logo](images/logo.png) # Relative path without ./
193
+ ![Image](./images/%E7%94%BB%E5%83%8F.png) # URL-encoded Japanese filename
194
+ [File](./docs/my%20file.pdf) # URL-encoded space
195
+ [File](<./path/file (1).png>) # Special chars with angle brackets
196
+ ![Image](./path/file\\(1\\).png) # Special chars with escaping
197
+ ```
176
198
 
177
199
  **Excluded from detection:**
178
200
  - `.md` files (treated as page links)
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { lookup } from "mime-types";
8
8
  import { glob } from "glob";
9
9
 
10
10
  //#region package.json
11
- var version = "1.1.0";
11
+ var version = "1.3.0";
12
12
 
13
13
  //#endregion
14
14
  //#region src/config.ts
@@ -103,10 +103,11 @@ const formatErrorMessage = (error) => {
103
103
  * Create a new page or update an existing page in GROWI
104
104
  *
105
105
  * @param file Markdown file to upload
106
+ * @param content Markdown content
106
107
  * @param shouldUpdate If true, update existing pages; if false, skip existing pages
107
108
  * @returns Result containing page ID, revision ID, and action taken
108
109
  */
109
- const createOrUpdatePage = async (file, shouldUpdate) => {
110
+ const createOrUpdatePage = async (file, content, shouldUpdate) => {
110
111
  try {
111
112
  const actualResponse = (await getPage({ path: file.growiPath })).data;
112
113
  if (actualResponse.page) {
@@ -120,7 +121,7 @@ const createOrUpdatePage = async (file, shouldUpdate) => {
120
121
  return {
121
122
  pageId,
122
123
  revisionId: (await putPage({
123
- body: file.content,
124
+ body: content,
124
125
  pageId,
125
126
  revisionId
126
127
  })).data.page?.revision,
@@ -138,7 +139,7 @@ const createOrUpdatePage = async (file, shouldUpdate) => {
138
139
  try {
139
140
  const response = await postPage({
140
141
  path: file.growiPath,
141
- body: file.content
142
+ body: content
142
143
  });
143
144
  return {
144
145
  pageId: response.data.page?._id,
@@ -226,19 +227,35 @@ const uploadAttachment = async (attachment, pageId, sourceDir) => {
226
227
  */
227
228
  const extractLinkedAttachments = (content, markdownFilePath, sourceDir) => {
228
229
  const attachments = [];
229
- const linkRegex = /!?\[([^\]]*)\]\((?:<([^>]+)>|((?:[^)\\]+|\\.)*))\)/g;
230
+ const linkRegex = new RegExp([
231
+ "!?",
232
+ "\\[(?<alt>[^\\]]*)\\]",
233
+ "\\(",
234
+ "(?:",
235
+ "<(?<anglePath>[^>]+)>",
236
+ "|",
237
+ "(?<regularPath>(?:[^)\\\\]+|\\\\.)*)",
238
+ ")",
239
+ "\\)"
240
+ ].join(""), "g");
230
241
  let match;
231
242
  while ((match = linkRegex.exec(content)) !== null) {
232
- const angleBracketPath = match[2];
233
- const regularPath = match[3];
234
- const linkPath = angleBracketPath || regularPath;
243
+ const { anglePath, regularPath } = match.groups;
244
+ const linkPath = anglePath || regularPath;
235
245
  if (!linkPath) continue;
236
- const originalLinkPath = angleBracketPath ? `<${angleBracketPath}>` : regularPath;
246
+ const originalLinkPath = anglePath ? `<${anglePath}>` : regularPath;
237
247
  if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) continue;
238
248
  if (linkPath.endsWith(".md")) continue;
239
- if (linkPath.startsWith("/")) continue;
240
249
  const unescapedPath = linkPath.replace(/\\([\\`*_{}[\]()#+\-.!])/g, "$1");
241
- const absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), unescapedPath);
250
+ let decodedPath;
251
+ try {
252
+ decodedPath = decodeURIComponent(unescapedPath);
253
+ } catch {
254
+ decodedPath = unescapedPath;
255
+ }
256
+ let absolutePath;
257
+ if (linkPath.startsWith("/")) absolutePath = resolve(sourceDir, decodedPath.slice(1));
258
+ else absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), decodedPath);
242
259
  if (!existsSync(absolutePath)) continue;
243
260
  const normalizedPath = relative(sourceDir, absolutePath).replace(/\\/g, "/");
244
261
  attachments.push({
@@ -294,7 +311,6 @@ const scanMarkdownFiles = async (sourceDir, basePath = "/") => {
294
311
  return {
295
312
  localPath: file,
296
313
  growiPath: growiPath.startsWith("/") ? growiPath : `/${growiPath}`,
297
- content,
298
314
  attachments
299
315
  };
300
316
  }));
@@ -303,6 +319,14 @@ const scanMarkdownFiles = async (sourceDir, basePath = "/") => {
303
319
  //#endregion
304
320
  //#region src/markdown.ts
305
321
  /**
322
+ * Escape special regex characters for safe use in RegExp
323
+ * Escapes: . * + ? ^ $ { } ( ) | [ ] \ < >
324
+ * Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
325
+ */
326
+ const escapeRegex = (str) => {
327
+ return str.replace(/[.*+?^${}()|[\]\\<>]/g, "\\$&");
328
+ };
329
+ /**
306
330
  * Replace attachment links in Markdown content with GROWI format
307
331
  *
308
332
  * Supports two detection patterns:
@@ -322,16 +346,16 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
322
346
  const growiPath = `/attachment/${attachment.attachmentId}`;
323
347
  const patterns = [];
324
348
  if (attachment.detectionPattern === "naming") {
325
- const escapedFileName = `${pageName}_attachment_${attachment.fileName}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
349
+ const escapedFileName = escapeRegex(`${pageName}_attachment_${attachment.fileName}`);
326
350
  patterns.push(escapedFileName, `\\./${escapedFileName}`);
327
351
  }
328
- if (attachment.detectionPattern === "link" && attachment.originalLinkPaths) for (const linkPath of attachment.originalLinkPaths) {
329
- const escapedPath = linkPath.replace(/[.*+?^${}()|[\]\\<>]/g, "\\$&");
352
+ if (attachment.originalLinkPaths) for (const linkPath of attachment.originalLinkPaths) {
353
+ const escapedPath = escapeRegex(linkPath);
330
354
  patterns.push(escapedPath);
331
355
  if (linkPath.startsWith("./")) {
332
- const escapedWithoutDot = linkPath.substring(2).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
356
+ const escapedWithoutDot = escapeRegex(linkPath.substring(2));
333
357
  patterns.push(escapedWithoutDot);
334
- } else if (!linkPath.startsWith("../")) patterns.push(`\\./${escapedPath}`);
358
+ } else if (!linkPath.startsWith("../") && !linkPath.startsWith("<")) patterns.push(`\\./${escapedPath}`);
335
359
  }
336
360
  for (const pattern of patterns) {
337
361
  const imgRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${pattern}\\)`, "g");
@@ -355,18 +379,24 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
355
379
  * Replace .md extension in page links with GROWI format
356
380
  *
357
381
  * Converts Markdown page links to GROWI-compatible format by removing .md extension.
382
+ * For absolute paths (starting with /), prepends basePath to the link.
358
383
  * External URLs (http://, https://) are excluded from replacement.
359
384
  *
360
385
  * Supported patterns:
361
386
  * - Relative path: [text](./page.md) → [text](./page)
362
387
  * - Filename only: [text](page.md) → [text](page)
388
+ * - Absolute path: [text](/docs/page.md) → [text](/basePath/docs/page)
363
389
  * - With anchor: [text](./page.md#section) → [text](./page#section)
364
390
  *
365
391
  * @param markdown Original Markdown content
392
+ * @param basePath Base path for GROWI pages (default: "/")
366
393
  * @returns Object with replaced content and whether any replacement occurred
367
394
  */
368
- const replaceMarkdownExtension = (markdown) => {
369
- const result = markdown.replace(/(\[[^\]]*\]\((?!https?:\/\/)[^)]*?)\.md((?:#[^)]*)?\))/g, "$1$2");
395
+ const replaceMarkdownExtension = (markdown, basePath = "/") => {
396
+ const result = markdown.replace(/(\[[^\]]*\]\((?!https?:\/\/))([^)]*?)\.md((?:#[^)]*)?\))/g, (match, prefix, path, suffix) => {
397
+ if (path.startsWith("/") && basePath !== "/") return `${prefix}${basePath.endsWith("/") ? basePath.slice(0, -1) : basePath}${path}${suffix}`;
398
+ return `${prefix}${path}${suffix}`;
399
+ });
370
400
  return {
371
401
  content: result,
372
402
  replaced: result !== markdown
@@ -401,7 +431,8 @@ const uploadFiles = async (files, sourceDir, config) => {
401
431
  linkReplacementErrors: 0
402
432
  };
403
433
  for (const file of files) {
404
- const result = await createOrUpdatePage(file, config.update);
434
+ const content = readFileSync(join(sourceDir, file.localPath), "utf-8");
435
+ const result = await createOrUpdatePage(file, content, config.update);
405
436
  if (result.action === "created") {
406
437
  stats.pagesCreated++;
407
438
  console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (created)`);
@@ -416,7 +447,7 @@ const uploadFiles = async (files, sourceDir, config) => {
416
447
  console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${result.errorMessage || "unknown error"})`);
417
448
  }
418
449
  if (result.pageId && (result.action === "created" || result.action === "updated")) {
419
- let currentContent = file.content;
450
+ let currentContent = content;
420
451
  let currentRevisionId = result.revisionId;
421
452
  if (file.attachments.length > 0) {
422
453
  let hasAttachments = false;
@@ -454,7 +485,7 @@ const uploadFiles = async (files, sourceDir, config) => {
454
485
  }
455
486
  }
456
487
  if (currentRevisionId) {
457
- const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent);
488
+ const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent, config.basePath);
458
489
  if (linkReplaced) {
459
490
  const updateResult = await updatePageContent(result.pageId, currentRevisionId, linkedContent);
460
491
  if (updateResult.success && updateResult.revisionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onozaty/growi-uploader",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A content uploader for GROWI",
5
5
  "type": "module",
6
6
  "bin": {