diff --git a/README.md b/README.md index aa97d5e..4989edd 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,76 @@ Qiita CLI、Qiita Preview から記事の削除はできません。 [Qiita](https://qiita.com)上で記事の削除を行なえます。 +## Qiita CLI でスライドを管理する + +Qiita CLI では、[Marp](https://marp.app/) 形式の markdown ファイルでスライドを作成し、Qiita に投稿できます。 + +> [!NOTE] +> スライド機能は、Qiita のベータ版を利用しているユーザーのみ利用できます。 +> それ以外のユーザーが`pull`、`preview`、`publish`コマンドを実行した場合、スライドの同期は行われず、`preview`にもスライドは表示されません。`publish --all`はスライドを投稿せずに記事だけを投稿し、ファイル名を指定してスライドを投稿しようとした場合はエラーになります。 + +### スライドファイルの配置について + +1 つのスライドの内容は、1 つの markdown ファイル(◯◯.md)で管理します。 +スライドファイルは記事ファイルと同じく`public`ディレクトリ内に含める必要があります。 +frontmatter に`marp: true`があるファイルがスライド、ないファイルが記事として扱われます。 + +```console +. +└─ public + ├── newArticle001.md + └── slides + ├── newSlide001.md + └── newSlide002.md +``` + +ファイル名を指定せずに作成したスライドと、Qiita 上にだけあるスライドは`public/slides`ディレクトリに保存されます。 + +### スライドの作成 + +Qiita Preview 上の「新規スライド作成」ボタン、または以下のコマンドで新規スライドを作成できます。 + +```console +npx qiita new --slide スライドのファイルのベース名 +``` + +作成されたスライドファイルの中身は次のようになっています。 + +```markdown +--- +title: newSlide001 # スライドのタイトル +id: null # スライドを投稿した際に自動的にスライドのUUIDに変わります +updated_at: null # スライドを投稿した際に自動的にスライドの更新日時に変わります +description: "" # スライドの説明 +ignorePublish: false # true: `publish`コマンドにおいて無視されます(Qiitaに投稿されません) / false: `publish`コマンドで処理されます(Qiitaに投稿されます) +marp: true +theme: default # Marp のテーマ +--- + +# Title + +--- + +# Page 2 +``` + +`title`、`id`、`updated_at`、`description`、`ignorePublish`以外の項目(`theme`、`paginate`など)は、Marp のディレクティブとしてそのままスライドに反映されます。 +本文は`---`でページを区切ります。 + +### スライドの投稿・更新 + +Qiita Preview 上の「スライドを投稿する」ボタン、または記事と同じ`publish`コマンドで投稿・更新ができます。 + +```console +npx qiita publish スライドのファイルのベース名 +``` + +`npx qiita publish --all`を実行すると、記事とあわせて全てのスライドを反映させます。`ignorePublish: true`のスライドは対象外です。 + +### スライドの同期 + +`pull`、`preview`コマンドを実行すると、記事とあわせて Qiita 上のスライドも`public`ディレクトリに同期されます。 + ## GitHub で記事を管理する ### GitHub の設定について @@ -226,14 +296,14 @@ npx qiita help ### pull -記事ファイルを Qiita と同期します。 -Qiita 上で更新を行い、手元で変更を行っていない記事ファイルのみ同期されます。 +記事ファイル、スライドファイルを Qiita と同期します。 +Qiita 上で更新を行い、手元で変更を行っていない記事ファイル、スライドファイルのみ同期されます。 ```console npx qiita pull ``` -`--force`オプションを用いることで、強制的に Qiita 上の内容を記事ファイルに反映させます。 +`--force`オプションを用いることで、強制的に Qiita 上の内容を記事ファイル、スライドファイルに反映させます。 ```console npx qiita pull --force diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts index 577382b..36daa7e 100644 --- a/src/commands/help.test.ts +++ b/src/commands/help.test.ts @@ -1,75 +1,24 @@ -import { config } from "../lib/config"; -import { getHelpText, help } from "./help"; - -jest.mock("../lib/config"); - -const mockConfig = jest.mocked(config); - -describe("getHelpText", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("when the experimental slide feature is disabled (default)", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: false, - }); - }); - - it("does not mention slides on the publish and pull lines", async () => { - const helpText = await getHelpText(); - - expect(helpText).toContain("publish ... 記事を投稿、更新\n"); - expect(helpText).toContain( - "publish --all 全ての記事を投稿、更新\n", - ); - expect(helpText).toContain( - "pull 記事ファイルをQiitaと同期\n", - ); - }); - - it("tells how to enable the slide feature on the new --slide line", async () => { - const helpText = await getHelpText(); - - expect(helpText).toContain("実験的機能"); - expect(helpText).toContain("experimentalSlideFeatureEnabled"); - }); +import { help, helpText } from "./help"; + +describe("helpText", () => { + it("mentions slides on the new, publish and pull lines", () => { + expect(helpText).toContain( + "new --slide [] ...\n 新しいスライドを追加(Qiitaのベータ版を利用しているユーザーのみ)\n", + ); + expect(helpText).toContain( + "publish ... 記事、スライドを投稿、更新\n", + ); + expect(helpText).toContain( + "publish --all 全ての記事、スライドを投稿、更新\n", + ); + expect(helpText).toContain( + "pull 記事、スライドファイルをQiitaと同期\n", + ); }); - describe("when the experimental slide feature is enabled", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: true, - }); - }); - - it("mentions slides on the publish and pull lines", async () => { - const helpText = await getHelpText(); - - expect(helpText).toContain( - "publish ... 記事、スライドを投稿、更新\n", - ); - expect(helpText).toContain( - "publish --all 全ての記事、スライドを投稿、更新\n", - ); - expect(helpText).toContain( - "pull 記事、スライドファイルをQiitaと同期\n", - ); - }); - - it("does not explain how to enable the already enabled feature", async () => { - const helpText = await getHelpText(); - - expect(helpText).not.toContain("実験的機能"); - expect(helpText).not.toContain("experimentalSlideFeatureEnabled"); - }); + it("does not describe the slide feature as experimental", () => { + expect(helpText).not.toContain("実験的機能"); + expect(helpText).not.toContain("experimentalSlideFeatureEnabled"); }); }); @@ -77,13 +26,6 @@ describe("help", () => { let logSpy: jest.SpyInstance; beforeEach(() => { - jest.clearAllMocks(); - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: false, - }); logSpy = jest.spyOn(console, "log").mockImplementation(); }); @@ -91,9 +33,9 @@ describe("help", () => { logSpy.mockRestore(); }); - it("prints the help text", async () => { - await help(); + it("prints the help text", () => { + help(); - expect(logSpy).toHaveBeenCalledWith(await getHelpText()); + expect(logSpy).toHaveBeenCalledWith(helpText); }); }); diff --git a/src/commands/help.ts b/src/commands/help.ts index abbe132..49493d5 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,38 +1,16 @@ -import { config } from "../lib/config"; - -const buildHelpText = ({ - experimentalSlideFeatureEnabled, -}: { - experimentalSlideFeatureEnabled: boolean; -}) => { - const newSlide = experimentalSlideFeatureEnabled - ? ` new --slide [] ... - 新しいスライドを追加` - : ` new --slide [] ... - 新しいスライドを追加(実験的機能。qiita.config.jsonで - experimentalSlideFeatureEnabledをtrueにすると使用可能)`; - const publish = experimentalSlideFeatureEnabled - ? "記事、スライドを投稿、更新" - : "記事を投稿、更新"; - const publishAll = experimentalSlideFeatureEnabled - ? "全ての記事、スライドを投稿、更新" - : "全ての記事を投稿、更新"; - const pull = experimentalSlideFeatureEnabled - ? "記事、スライドファイルをQiitaと同期" - : "記事ファイルをQiitaと同期"; - - return `USAGE: +export const helpText = `USAGE: qiita [] COMMAND: init 記事をGitHubで管理するための初期設定 login Qiita APIの認証認可 new [] ... 新しい記事を追加 -${newSlide} + new --slide [] ... + 新しいスライドを追加(Qiitaのベータ版を利用しているユーザーのみ) preview コンテンツをブラウザでプレビュー - publish ... ${publish} - publish --all ${publishAll} - pull ${pull} + publish ... 記事、スライドを投稿、更新 + publish --all 全ての記事、スライドを投稿、更新 + pull 記事、スライドファイルをQiitaと同期 posting-campaigns 開催中の記事投稿キャンペーン一覧を表示(最大100件) version Qiita CLIのバージョンを表示 help ヘルプを表示 @@ -52,16 +30,7 @@ OPTIONS: 詳細についてはReadme(https://github.com/increments/qiita-cli)をご覧ください `; -}; - -export const getHelpText = async () => { - const userConfig = await config.getUserConfig(); - - return buildHelpText({ - experimentalSlideFeatureEnabled: userConfig.experimentalSlideFeatureEnabled, - }); -}; -export const help = async () => { - console.log(await getHelpText()); +export const help = () => { + console.log(helpText); }; diff --git a/src/commands/index.test.ts b/src/commands/index.test.ts index e84dcdf..e35dd7b 100644 --- a/src/commands/index.test.ts +++ b/src/commands/index.test.ts @@ -1,5 +1,5 @@ import { handleError } from "../lib/error-handler"; -import { getHelpText } from "./help"; +import { helpText } from "./help"; import { exec } from "./index"; jest.mock("../lib/error-handler"); @@ -14,7 +14,6 @@ jest.mock("./publish"); jest.mock("./pull"); jest.mock("./version"); -const mockGetHelpText = jest.mocked(getHelpText); const mockHandleError = jest.mocked(handleError); describe("exec", () => { @@ -45,27 +44,13 @@ describe("exec", () => { describe("when the command is unknown", () => { it("prints the help text and exits with 1", async () => { - mockGetHelpText.mockResolvedValue("help text"); - await expect(exec("unknown-command", [])).rejects.toThrow( ProcessExitError, ); - expect(errorSpy).toHaveBeenCalledWith("help text"); + expect(errorSpy).toHaveBeenCalledWith(helpText); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockHandleError).not.toHaveBeenCalled(); }); - - it("handles the error and still exits with 1 when getHelpText fails", async () => { - const error = new Error("config is broken"); - mockGetHelpText.mockRejectedValue(error); - - await expect(exec("unknown-command", [])).rejects.toThrow( - ProcessExitError, - ); - - expect(mockHandleError).toHaveBeenCalledWith(error); - expect(exitSpy).toHaveBeenCalledWith(1); - }); }); }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 4ae55f0..39ae024 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,6 @@ import { handleError } from "../lib/error-handler"; import { packageUpdateNotice } from "../lib/package-update-notice"; -import { getHelpText, help } from "./help"; +import { help, helpText } from "./help"; import { init } from "./init"; import { login } from "./login"; import { newArticles } from "./newArticles"; @@ -32,11 +32,7 @@ export const exec = async (commandName: string, commandArgs: string[]) => { if (!isCommand(commandName)) { console.error(`Unknown command '${commandName}'`); console.error(); - try { - console.error(await getHelpText()); - } catch (err) { - await handleError(err as Error); - } + console.error(helpText); process.exit(1); } diff --git a/src/commands/newArticles.test.ts b/src/commands/newArticles.test.ts index d2eff2e..13cc495 100644 --- a/src/commands/newArticles.test.ts +++ b/src/commands/newArticles.test.ts @@ -2,16 +2,13 @@ import type { FileSystemRepo } from "../lib/file-system-repo"; import type { SlideFileSystemRepo } from "../lib/slide-file-system-repo"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; -import { config } from "../lib/config"; import { newArticles } from "./newArticles"; jest.mock("../lib/get-file-system-repo"); jest.mock("../lib/get-slide-file-system-repo"); -jest.mock("../lib/config"); const mockGetFileSystemRepo = jest.mocked(getFileSystemRepo); const mockGetSlideFileSystemRepo = jest.mocked(getSlideFileSystemRepo); -const mockConfig = jest.mocked(config); describe("newArticles", () => { const fileSystemRepo = { @@ -22,14 +19,6 @@ describe("newArticles", () => { createSlide: jest.fn(), } as unknown as jest.Mocked; - class ProcessExitError extends Error { - constructor(public readonly code: string | number | null | undefined) { - super(`process.exit(${code})`); - } - } - - let exitSpy: jest.SpyInstance; - let errorSpy: jest.SpyInstance; let logSpy: jest.SpyInstance; beforeEach(() => { @@ -40,80 +29,35 @@ describe("newArticles", () => { fileSystemRepo.createItem.mockResolvedValue("article"); slideFileSystemRepo.createSlide.mockResolvedValue("deck"); - exitSpy = jest - .spyOn(process, "exit") - .mockImplementation((code?: string | number | null) => { - throw new ProcessExitError(code); - }); logSpy = jest.spyOn(console, "log").mockImplementation(); - errorSpy = jest.spyOn(console, "error").mockImplementation(); }); afterEach(() => { - exitSpy.mockRestore(); logSpy.mockRestore(); - errorSpy.mockRestore(); }); describe("--slide", () => { - describe("when the experimental slide feature is disabled (default)", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: false, - }); - }); - - it("exits with an error and does not create a slide", async () => { - await expect(newArticles(["--slide"])).rejects.toThrow( - ProcessExitError, - ); + it("creates a slide", async () => { + await newArticles(["--slide"]); - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("experimentalSlideFeatureEnabled"), - ); - expect(slideFileSystemRepo.createSlide).not.toHaveBeenCalled(); - }); + expect(slideFileSystemRepo.createSlide).toHaveBeenCalledWith(undefined); + expect(fileSystemRepo.createItem).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith("created: deck.md"); }); - describe("when the experimental slide feature is enabled", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: true, - }); - }); - - it("creates a slide", async () => { - await newArticles(["--slide"]); + it("creates a slide with the given basename", async () => { + await newArticles(["--slide", "deck"]); - expect(slideFileSystemRepo.createSlide).toHaveBeenCalledWith(undefined); - expect(logSpy).toHaveBeenCalledWith("created: deck.md"); - expect(exitSpy).not.toHaveBeenCalled(); - }); + expect(slideFileSystemRepo.createSlide).toHaveBeenCalledWith("deck"); }); }); describe("without --slide", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: false, - }); - }); - - it("creates an article regardless of the experimental slide feature flag", async () => { + it("creates an article", async () => { await newArticles([]); expect(fileSystemRepo.createItem).toHaveBeenCalledWith(undefined); - expect(exitSpy).not.toHaveBeenCalled(); + expect(slideFileSystemRepo.createSlide).not.toHaveBeenCalled(); }); }); }); diff --git a/src/commands/newArticles.ts b/src/commands/newArticles.ts index 9db6037..a3d7d57 100644 --- a/src/commands/newArticles.ts +++ b/src/commands/newArticles.ts @@ -1,6 +1,4 @@ import arg from "arg"; -import process from "node:process"; -import { config } from "../lib/config"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; @@ -37,15 +35,6 @@ export const newArticles = async (argv: string[]) => { ); if (args["--slide"]) { - const userConfig = await config.getUserConfig(); - if (!userConfig.experimentalSlideFeatureEnabled) { - console.error( - 'Error: the slide feature is experimental and disabled by default. Set "experimentalSlideFeatureEnabled": true in qiita.config.json to enable it.', - ); - process.exit(1); - return; - } - const slideFileSystemRepo = await getSlideFileSystemRepo(); await createWithBasenames(args._, (basename) => slideFileSystemRepo.createSlide(basename), diff --git a/src/commands/preview.test.ts b/src/commands/preview.test.ts index a33ceea..195a3dd 100644 --- a/src/commands/preview.test.ts +++ b/src/commands/preview.test.ts @@ -1,7 +1,7 @@ import type { FileSystemRepo } from "../lib/file-system-repo"; import type { SlideFileSystemRepo } from "../lib/slide-file-system-repo"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; -import { getSlideFileSystemRepoIfEnabled } from "../lib/get-slide-file-system-repo"; +import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; import { syncSlidesFromQiita } from "../lib/sync-slides-from-qiita"; @@ -16,9 +16,7 @@ jest.mock("../lib/sync-slides-from-qiita"); jest.mock("../server/app"); const mockGetFileSystemRepo = jest.mocked(getFileSystemRepo); -const mockGetSlideFileSystemRepoIfEnabled = jest.mocked( - getSlideFileSystemRepoIfEnabled, -); +const mockGetSlideFileSystemRepo = jest.mocked(getSlideFileSystemRepo); const mockGetQiitaApiInstance = jest.mocked(getQiitaApiInstance); const mockSyncArticlesFromQiita = jest.mocked(syncArticlesFromQiita); const mockSyncSlidesFromQiita = jest.mocked(syncSlidesFromQiita); @@ -45,43 +43,45 @@ describe("preview", () => { jest.clearAllMocks(); mockGetFileSystemRepo.mockResolvedValue(fileSystemRepo); - mockGetSlideFileSystemRepoIfEnabled.mockResolvedValue(null); + mockGetSlideFileSystemRepo.mockResolvedValue(slideFileSystemRepo); mockGetQiitaApiInstance.mockResolvedValue(qiitaApi); mockSyncArticlesFromQiita.mockResolvedValue(); - mockSyncSlidesFromQiita.mockResolvedValue(); + mockSyncSlidesFromQiita.mockResolvedValue(true); mockStartServer.mockResolvedValue(server); mockStartLocalChangeWatcher.mockImplementation(); }); - describe("when the slide repository is unavailable", () => { - it("watches only the article root and does not sync slides", async () => { - await preview(); + it("syncs slides and watches the shared root once", async () => { + await preview(); - expect(mockSyncSlidesFromQiita).not.toHaveBeenCalled(); - expect(mockStartLocalChangeWatcher).toHaveBeenCalledWith({ - server, - watchPaths: ["/data/public"], - }); + expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ + slideFileSystemRepo, + qiitaApi, + }); + expect(mockStartLocalChangeWatcher).toHaveBeenCalledWith({ + server, + watchPaths: ["/data/public"], + }); + }); + + it("starts the server with the slide feature enabled", async () => { + await preview(); + + expect(mockStartServer).toHaveBeenCalledWith({ + slideFeatureAvailable: true, }); }); - describe("when the slide repository is available", () => { + describe("when the user is not a Qiita beta user", () => { beforeEach(() => { - mockGetSlideFileSystemRepoIfEnabled.mockResolvedValue( - slideFileSystemRepo, - ); + mockSyncSlidesFromQiita.mockResolvedValue(false); }); - it("syncs slides and watches the shared root once", async () => { + it("starts the server with the slide feature disabled", async () => { await preview(); - expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ - slideFileSystemRepo, - qiitaApi, - }); - expect(mockStartLocalChangeWatcher).toHaveBeenCalledWith({ - server, - watchPaths: ["/data/public"], + expect(mockStartServer).toHaveBeenCalledWith({ + slideFeatureAvailable: false, }); }); }); diff --git a/src/commands/preview.ts b/src/commands/preview.ts index b56cbf3..f7f44e8 100644 --- a/src/commands/preview.ts +++ b/src/commands/preview.ts @@ -1,5 +1,5 @@ import { getFileSystemRepo } from "../lib/get-file-system-repo"; -import { getSlideFileSystemRepoIfEnabled } from "../lib/get-slide-file-system-repo"; +import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; import { getUrlAddress } from "../lib/getUrlAddress"; import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; @@ -9,15 +9,15 @@ import { startLocalChangeWatcher, startServer } from "../server/app"; export const preview = async () => { const qiitaApi = await getQiitaApiInstance(); const fileSystemRepo = await getFileSystemRepo(); - const slideFileSystemRepo = await getSlideFileSystemRepoIfEnabled(); + const slideFileSystemRepo = await getSlideFileSystemRepo(); await syncArticlesFromQiita({ fileSystemRepo, qiitaApi }); + const slideFeatureAvailable = await syncSlidesFromQiita({ + slideFileSystemRepo, + qiitaApi, + }); - if (slideFileSystemRepo) { - await syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi }); - } - - const server = await startServer(); + const server = await startServer({ slideFeatureAvailable }); const address = server.address(); const url = getUrlAddress(address); diff --git a/src/commands/publish.test.ts b/src/commands/publish.test.ts index 7258aa2..ddd95a4 100644 --- a/src/commands/publish.test.ts +++ b/src/commands/publish.test.ts @@ -1,7 +1,7 @@ import type { FileSystemRepo } from "../lib/file-system-repo"; import type { SlideFileSystemRepo } from "../lib/slide-file-system-repo"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; -import { getSlideFileSystemRepoIfEnabled } from "../lib/get-slide-file-system-repo"; +import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; import { syncSlidesFromQiita } from "../lib/sync-slides-from-qiita"; @@ -33,9 +33,7 @@ jest.mock( ); const mockGetFileSystemRepo = jest.mocked(getFileSystemRepo); -const mockGetSlideFileSystemRepoIfEnabled = jest.mocked( - getSlideFileSystemRepoIfEnabled, -); +const mockGetSlideFileSystemRepo = jest.mocked(getSlideFileSystemRepo); const mockGetQiitaApiInstance = jest.mocked(getQiitaApiInstance); const mockSyncArticlesFromQiita = jest.mocked(syncArticlesFromQiita); const mockSyncSlidesFromQiita = jest.mocked(syncSlidesFromQiita); @@ -144,10 +142,10 @@ theme: gaia jest.clearAllMocks(); mockGetFileSystemRepo.mockResolvedValue(fileSystemRepo); - mockGetSlideFileSystemRepoIfEnabled.mockResolvedValue(slideFileSystemRepo); + mockGetSlideFileSystemRepo.mockResolvedValue(slideFileSystemRepo); mockGetQiitaApiInstance.mockResolvedValue(qiitaApi); mockSyncArticlesFromQiita.mockResolvedValue(); - mockSyncSlidesFromQiita.mockResolvedValue(); + mockSyncSlidesFromQiita.mockResolvedValue(true); fileSystemRepo.loadPublishTargets.mockResolvedValue([]); fileSystemRepo.loadItemByBasename.mockResolvedValue(null); slideFileSystemRepo.loadPublishTargets.mockResolvedValue([]); @@ -429,6 +427,15 @@ theme: gaia }); }); + describe("when the basename matches neither an article nor a slide", () => { + it("exits with a not found error", async () => { + await expect(publish(["deck"])).rejects.toThrow(ProcessExitError); + + expect(errorSpy).toHaveBeenCalledWith("Error: 'deck' is not found"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + describe("when there is nothing to publish", () => { it("logs and exits 0", async () => { await expect(publish(["--all"])).rejects.toThrow(ProcessExitError); @@ -438,21 +445,79 @@ theme: gaia }); }); - describe("when the slide repository is unavailable", () => { + describe("when the user is not a Qiita beta user", () => { + const skipMessage = + "Skip publishing slides: the slide feature is only available to Qiita beta users"; + beforeEach(() => { - mockGetSlideFileSystemRepoIfEnabled.mockResolvedValue(null); + mockSyncSlidesFromQiita.mockResolvedValue(false); + fileSystemRepo.publishItem.mockResolvedValue({ + item: buildResponseItem(), + posted: true, + }); }); - it("does not sync slides", async () => { - await expect(publish(["--all"])).rejects.toThrow(ProcessExitError); + describe("with --all and local slides to publish", () => { + it("publishes only the articles and reports that the slides were skipped", async () => { + fileSystemRepo.loadPublishTargets.mockResolvedValue([buildItem()]); + slideFileSystemRepo.loadPublishTargets.mockResolvedValue([ + buildSlide(), + ]); + + await publish(["--all"]); - expect(mockSyncSlidesFromQiita).not.toHaveBeenCalled(); + expect(fileSystemRepo.publishItem).toHaveBeenCalledTimes(1); + expect(slideFileSystemRepo.publishSlide).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith(skipMessage); + expect(exitSpy).not.toHaveBeenCalled(); + }); }); - it("reports a slide basename as not found", async () => { - await expect(publish(["deck"])).rejects.toThrow(ProcessExitError); + describe("with --all and no local slides to publish", () => { + it("does not report that the slides were skipped", async () => { + fileSystemRepo.loadPublishTargets.mockResolvedValue([buildItem()]); - expect(errorSpy).toHaveBeenCalledWith("Error: 'deck' is not found"); + await publish(["--all"]); + + expect(fileSystemRepo.publishItem).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalledWith(skipMessage); + }); + }); + + describe("when a slide is given by basename", () => { + it("exits with an error and does not call the API", async () => { + slideFileSystemRepo.loadSlideByBasename.mockResolvedValue(buildSlide()); + + await expect(publish(["deck"])).rejects.toThrow(ProcessExitError); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: 'deck' is a slide, but the slide feature is only available to Qiita beta users", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(slideFileSystemRepo.publishSlide).not.toHaveBeenCalled(); + }); + }); + + describe("when an article is given by basename", () => { + it("publishes it", async () => { + fileSystemRepo.loadItemByBasename.mockResolvedValue(buildItem()); + + await publish(["article"]); + + expect(fileSystemRepo.publishItem).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe("before publishing", () => { + it("syncs the slides from Qiita", async () => { + await expect(publish(["--all"])).rejects.toThrow(ProcessExitError); + + expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ + slideFileSystemRepo, + qiitaApi, + }); }); }); }); diff --git a/src/commands/publish.ts b/src/commands/publish.ts index ebdec78..0815d17 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -5,7 +5,7 @@ import { QiitaSlide } from "../lib/entities/qiita-slide"; import type { FileSystemRepo } from "../lib/file-system-repo"; import type { SlideFileSystemRepo } from "../lib/slide-file-system-repo"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; -import { getSlideFileSystemRepoIfEnabled } from "../lib/get-slide-file-system-repo"; +import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; import { syncSlidesFromQiita } from "../lib/sync-slides-from-qiita"; @@ -18,7 +18,7 @@ import { interface PublishRepos { fileSystemRepo: FileSystemRepo; - slideFileSystemRepo: SlideFileSystemRepo | null; + slideFileSystemRepo: SlideFileSystemRepo; } interface PublishTargets { @@ -26,33 +26,49 @@ interface PublishTargets { slides: QiitaSlide[]; } -const loadAllPublishTargets = async ({ - fileSystemRepo, - slideFileSystemRepo, -}: PublishRepos): Promise => ({ - items: await fileSystemRepo.loadPublishTargets(), - slides: slideFileSystemRepo - ? await slideFileSystemRepo.loadPublishTargets() - : [], -}); +const slideFeatureUnavailableReason = + "the slide feature is only available to Qiita beta users"; + +const loadAllPublishTargets = async ( + { fileSystemRepo, slideFileSystemRepo }: PublishRepos, + slideFeatureAvailable: boolean, +): Promise => { + const items = await fileSystemRepo.loadPublishTargets(); + const slides = await slideFileSystemRepo.loadPublishTargets(); + + if (slideFeatureAvailable) { + return { items, slides }; + } + + if (slides.length > 0) { + console.log(`Skip publishing slides: ${slideFeatureUnavailableReason}`); + } + return { items, slides: [] }; +}; // Articles and slides share one basename namespace on the command line, so // resolving a basename is the only step that has to know about both stores. const resolveTargetsByBasenames = async ( basenames: string[], { fileSystemRepo, slideFileSystemRepo }: PublishRepos, + slideFeatureAvailable: boolean, ): Promise => { const items: QiitaItem[] = []; const slides: QiitaSlide[] = []; for (const basename of basenames) { const item = await fileSystemRepo.loadItemByBasename(basename); - const slide = - (await slideFileSystemRepo?.loadSlideByBasename(basename)) ?? null; + const slide = await slideFileSystemRepo.loadSlideByBasename(basename); if (item !== null) { items.push(item); } else if (slide !== null) { + if (!slideFeatureAvailable) { + console.error( + `Error: '${basename}' is a slide, but ${slideFeatureUnavailableReason}`, + ); + process.exit(1); + } slides.push(slide); } else { console.error(`Error: '${basename}' is not found`); @@ -88,19 +104,18 @@ export const publish = async (argv: string[]) => { const qiitaApi = await getQiitaApiInstance(); const fileSystemRepo = await getFileSystemRepo(); - const slideFileSystemRepo = await getSlideFileSystemRepoIfEnabled(); + const slideFileSystemRepo = await getSlideFileSystemRepo(); await syncArticlesFromQiita({ fileSystemRepo, qiitaApi }); - if (slideFileSystemRepo) { - await syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi }); - } + const slideFeatureAvailable = await syncSlidesFromQiita({ + slideFileSystemRepo, + qiitaApi, + }); + const repos = { fileSystemRepo, slideFileSystemRepo }; const { items: targetItems, slides: targetSlides } = args["--all"] - ? await loadAllPublishTargets({ fileSystemRepo, slideFileSystemRepo }) - : await resolveTargetsByBasenames(args._, { - fileSystemRepo, - slideFileSystemRepo, - }); + ? await loadAllPublishTargets(repos, slideFeatureAvailable) + : await resolveTargetsByBasenames(args._, repos, slideFeatureAvailable); // Validate const force = args["--force"] ?? false; @@ -142,7 +157,7 @@ export const publish = async (argv: string[]) => { const slidePromises = targetSlides.map(async (slide) => { const { slide: responseSlide, posted } = - await slideFileSystemRepo!.publishSlide(slide, qiitaApi); + await slideFileSystemRepo.publishSlide(slide, qiitaApi); console.log( `${posted ? "Posted" : "Updated"} (slide): ${slide.name} -> ${responseSlide.uuid}`, diff --git a/src/commands/pull.test.ts b/src/commands/pull.test.ts index 1ea57bb..c70e6c0 100644 --- a/src/commands/pull.test.ts +++ b/src/commands/pull.test.ts @@ -1,5 +1,5 @@ import { getFileSystemRepo } from "../lib/get-file-system-repo"; -import { getSlideFileSystemRepoIfEnabled } from "../lib/get-slide-file-system-repo"; +import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; import { syncSlidesFromQiita } from "../lib/sync-slides-from-qiita"; @@ -12,17 +12,15 @@ jest.mock("../lib/sync-articles-from-qiita"); jest.mock("../lib/sync-slides-from-qiita"); const mockGetQiitaApiInstance = jest.mocked(getQiitaApiInstance); const mockGetFileSystemRepo = jest.mocked(getFileSystemRepo); -const mockGetSlideFileSystemRepoIfEnabled = jest.mocked( - getSlideFileSystemRepoIfEnabled, -); +const mockGetSlideFileSystemRepo = jest.mocked(getSlideFileSystemRepo); const mockSyncArticlesFromQiita = jest.mocked(syncArticlesFromQiita); const mockSyncSlidesFromQiita = jest.mocked(syncSlidesFromQiita); describe("pull", () => { const qiitaApi = {} as ReturnType; const fileSystemRepo = {} as ReturnType; - const slideFileSystemRepo = {} as NonNullable< - Awaited> + const slideFileSystemRepo = {} as Awaited< + ReturnType >; beforeEach(() => { @@ -30,9 +28,9 @@ describe("pull", () => { mockGetQiitaApiInstance.mockReturnValue(qiitaApi); mockGetFileSystemRepo.mockReturnValue(fileSystemRepo); - mockGetSlideFileSystemRepoIfEnabled.mockResolvedValue(null); + mockGetSlideFileSystemRepo.mockResolvedValue(slideFileSystemRepo); mockSyncArticlesFromQiita.mockImplementation(); - mockSyncSlidesFromQiita.mockImplementation(); + mockSyncSlidesFromQiita.mockResolvedValue(true); jest.spyOn(console, "log").mockImplementation(); }); @@ -47,40 +45,47 @@ describe("pull", () => { expect(mockSyncArticlesFromQiita).toHaveBeenCalledTimes(1); }); - describe("when the slide repository is unavailable", () => { - it("does not sync slides", async () => { - await pull([]); + it("pulls slides as well", async () => { + await pull([]); - expect(mockSyncSlidesFromQiita).not.toHaveBeenCalled(); + expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ + slideFileSystemRepo, + qiitaApi, + forceUpdate: undefined, }); + expect(mockSyncSlidesFromQiita).toHaveBeenCalledTimes(1); }); - describe("when the slide repository is available", () => { + it("reports that slides are synced", async () => { + await pull([]); + + expect(console.log).toHaveBeenCalledWith("Sync local slides from Qiita"); + }); + + describe("when the user cannot use the slide feature", () => { beforeEach(() => { - mockGetSlideFileSystemRepoIfEnabled.mockResolvedValue( - slideFileSystemRepo, - ); + mockSyncSlidesFromQiita.mockResolvedValue(false); }); - it("pulls slides as well", async () => { + it("reports nothing about slides", async () => { await pull([]); - expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ - slideFileSystemRepo, - qiitaApi, - forceUpdate: undefined, - }); - expect(mockSyncSlidesFromQiita).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenNthCalledWith( + 1, + "Sync local articles from Qiita", + ); + expect(console.log).toHaveBeenNthCalledWith(2, "Successful!"); }); + }); - it("pulls slides with forceUpdate", async () => { - await pull(["--force"]); + it("pulls slides with forceUpdate", async () => { + await pull(["--force"]); - expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ - slideFileSystemRepo, - qiitaApi, - forceUpdate: true, - }); + expect(mockSyncSlidesFromQiita).toHaveBeenCalledWith({ + slideFileSystemRepo, + qiitaApi, + forceUpdate: true, }); }); diff --git a/src/commands/pull.ts b/src/commands/pull.ts index b65e4f3..a557a6e 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -1,6 +1,6 @@ import arg from "arg"; import { getFileSystemRepo } from "../lib/get-file-system-repo"; -import { getSlideFileSystemRepoIfEnabled } from "../lib/get-slide-file-system-repo"; +import { getSlideFileSystemRepo } from "../lib/get-slide-file-system-repo"; import { getQiitaApiInstance } from "../lib/get-qiita-api-instance"; import { syncArticlesFromQiita } from "../lib/sync-articles-from-qiita"; import { syncSlidesFromQiita } from "../lib/sync-slides-from-qiita"; @@ -16,14 +16,15 @@ export const pull = async (argv: string[]) => { const qiitaApi = await getQiitaApiInstance(); const fileSystemRepo = await getFileSystemRepo(); - const slideFileSystemRepo = await getSlideFileSystemRepoIfEnabled(); + const slideFileSystemRepo = await getSlideFileSystemRepo(); const forceUpdate = args["--force"]; await syncArticlesFromQiita({ fileSystemRepo, qiitaApi, forceUpdate }); console.log("Sync local articles from Qiita"); - if (slideFileSystemRepo) { - await syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi, forceUpdate }); + if ( + await syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi, forceUpdate }) + ) { console.log("Sync local slides from Qiita"); } diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 798c808..7e4bb33 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -237,25 +237,6 @@ describe("config", () => { includePrivate: true, host: "localhost", port: 9999, - experimentalSlideFeatureEnabled: false, - }); - }); - - describe("and it enables the experimental slide feature", () => { - beforeEach(() => { - const userConfigData = { - includePrivate: true, - host: "localhost", - port: 9999, - experimentalSlideFeatureEnabled: true, - }; - resetFiles(); - setFile(userConfigFilePath, JSON.stringify(userConfigData, null, 2)); - }); - - it("returns experimentalSlideFeatureEnabled: true", async () => { - const userConfig = await config.getUserConfig(); - expect(userConfig.experimentalSlideFeatureEnabled).toBe(true); }); }); }); @@ -271,7 +252,6 @@ describe("config", () => { includePrivate: false, host: "localhost", port: 8888, - experimentalSlideFeatureEnabled: false, }); }); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 3852215..4eb33a6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -16,7 +16,6 @@ type UserConfig = { includePrivate: boolean; host: string; port: number; - experimentalSlideFeatureEnabled: boolean; }; class Config { @@ -111,7 +110,6 @@ class Config { includePrivate: false, host: "localhost", port: 8888, - experimentalSlideFeatureEnabled: false, } as UserConfig; if (fsSync.existsSync(this.getUserConfigFilePath())) { diff --git a/src/lib/error-handler.test.ts b/src/lib/error-handler.test.ts index 9897288..73bae0b 100644 --- a/src/lib/error-handler.test.ts +++ b/src/lib/error-handler.test.ts @@ -1,12 +1,11 @@ import { QiitaBadRequestError, + QiitaBetaFeatureRequiredError, QiitaForbiddenOrBadRequestError, QiitaNotFoundError, } from "../qiita-api"; -import { config } from "./config"; import { handleError } from "./error-handler"; -jest.mock("./config"); // chalk is ESM-only; stub it so the dynamic import() works under ts-jest's // CommonJS transform. jest.mock( @@ -20,23 +19,12 @@ jest.mock( { virtual: true }, ); -const mockConfig = jest.mocked(config); - describe("handleError", () => { let errorSpy: jest.SpyInstance; const printedMessages = () => errorSpy.mock.calls.map(([message]) => message as string).join("\n"); - const mockExperimentalSlideFeatureEnabled = (enabled: boolean) => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: enabled, - }); - }; - beforeEach(() => { jest.clearAllMocks(); errorSpy = jest.spyOn(console, "error").mockImplementation(); @@ -46,67 +34,38 @@ describe("handleError", () => { errorSpy.mockRestore(); }); - describe("when the experimental slide feature is disabled (default)", () => { - beforeEach(() => { - mockExperimentalSlideFeatureEnabled(false); - }); - - it("mentions only articles on a bad request", async () => { - await handleError(new QiitaBadRequestError("bad request")); - - expect(printedMessages()).toContain( - " 記事ファイルに不備がないかご確認ください", - ); - }); - - it("mentions only articles on a forbidden or bad request", async () => { - await handleError(new QiitaForbiddenOrBadRequestError("forbidden")); + it("mentions articles and slides on a bad request", async () => { + await handleError(new QiitaBadRequestError("bad request")); - expect(printedMessages()).toContain( - " 記事ファイルに不備がないかご確認ください", - ); - }); - - it("mentions only articles when not found", async () => { - await handleError(new QiitaNotFoundError("not found")); - - expect(printedMessages()).toContain("記事が見つかりませんでした"); - expect(printedMessages()).toContain( - " Qiita上で記事が削除されていないかご確認ください", - ); - }); + expect(printedMessages()).toContain( + " 記事、スライドファイルに不備がないかご確認ください", + ); }); - describe("when the experimental slide feature is enabled", () => { - beforeEach(() => { - mockExperimentalSlideFeatureEnabled(true); - }); - - it("mentions articles and slides on a bad request", async () => { - await handleError(new QiitaBadRequestError("bad request")); + it("mentions articles and slides on a forbidden or bad request", async () => { + await handleError(new QiitaForbiddenOrBadRequestError("forbidden")); - expect(printedMessages()).toContain( - " 記事、スライドファイルに不備がないかご確認ください", - ); - }); + expect(printedMessages()).toContain( + " 記事、スライドファイルに不備がないかご確認ください", + ); + }); - it("mentions articles and slides on a forbidden or bad request", async () => { - await handleError(new QiitaForbiddenOrBadRequestError("forbidden")); + it("mentions articles and slides when not found", async () => { + await handleError(new QiitaNotFoundError("not found")); - expect(printedMessages()).toContain( - " 記事、スライドファイルに不備がないかご確認ください", - ); - }); + expect(printedMessages()).toContain("記事、スライドが見つかりませんでした"); + expect(printedMessages()).toContain( + " Qiita上で記事、スライドが削除されていないかご確認ください", + ); + }); - it("mentions articles and slides when not found", async () => { - await handleError(new QiitaNotFoundError("not found")); + it("explains that the slide feature is limited to beta users", async () => { + await handleError( + new QiitaBetaFeatureRequiredError("Beta feature required"), + ); - expect(printedMessages()).toContain( - "記事、スライドが見つかりませんでした", - ); - expect(printedMessages()).toContain( - " Qiita上で記事、スライドが削除されていないかご確認ください", - ); - }); + expect(printedMessages()).toContain( + "スライド機能はQiitaのベータ版を利用しているユーザーのみ利用できます", + ); }); }); diff --git a/src/lib/error-handler.ts b/src/lib/error-handler.ts index 230c3fd..2de3634 100644 --- a/src/lib/error-handler.ts +++ b/src/lib/error-handler.ts @@ -1,5 +1,6 @@ import { QiitaBadRequestError, + QiitaBetaFeatureRequiredError, QiitaFetchError, QiitaForbiddenError, QiitaForbiddenOrBadRequestError, @@ -10,12 +11,6 @@ import { QiitaUnknownError, QiitaUnprocessableEntityError, } from "../qiita-api"; -import { config } from "./config"; - -const contentLabel = async () => - (await config.getUserConfig()).experimentalSlideFeatureEnabled - ? "記事、スライド" - : "記事"; export const handleError = async (error: Error) => { const chalk = (await import("chalk")).default; @@ -35,9 +30,7 @@ export const handleError = async (error: Error) => { case QiitaBadRequestError.name: console.error(chalk.red.bold("Qiita APIへのリクエストに失敗しました")); console.error( - chalk.red( - ` ${await contentLabel()}ファイルに不備がないかご確認ください`, - ), + chalk.red(" 記事、スライドファイルに不備がないかご確認ください"), ); break; case QiitaUnauthorizedError.name: @@ -56,26 +49,31 @@ export const handleError = async (error: Error) => { ); console.error(chalk.red("")); break; + case QiitaBetaFeatureRequiredError.name: + console.error( + chalk.red.bold( + "スライド機能はQiitaのベータ版を利用しているユーザーのみ利用できます", + ), + ); + break; case QiitaForbiddenOrBadRequestError.name: console.error(chalk.red.bold("Qiita APIへのリクエストに失敗しました")); console.error( - chalk.red( - ` ${await contentLabel()}ファイルに不備がないかご確認ください`, - ), + chalk.red(" 記事、スライドファイルに不備がないかご確認ください"), ); console.error( chalk.red(" または、Qiitaのアクセストークンが正しいかご確認ください"), ); console.error(chalk.red("")); break; - case QiitaNotFoundError.name: { - const label = await contentLabel(); - console.error(chalk.red.bold(`${label}が見つかりませんでした`)); + case QiitaNotFoundError.name: + console.error(chalk.red.bold("記事、スライドが見つかりませんでした")); console.error( - chalk.red(` Qiita上で${label}が削除されていないかご確認ください`), + chalk.red( + " Qiita上で記事、スライドが削除されていないかご確認ください", + ), ); break; - } case QiitaUnprocessableEntityError.name: console.error(chalk.red.bold("Qiita APIへのリクエストに失敗しました")); console.error(chalk.red(` ${error.message}`)); diff --git a/src/lib/get-slide-file-system-repo.test.ts b/src/lib/get-slide-file-system-repo.test.ts deleted file mode 100644 index 83f2597..0000000 --- a/src/lib/get-slide-file-system-repo.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import fs from "node:fs/promises"; -import { config } from "./config"; -import { getSlideFileSystemRepoIfEnabled } from "./get-slide-file-system-repo"; -import { SlideFileSystemRepo } from "./slide-file-system-repo"; - -jest.mock("node:fs/promises"); -jest.mock("./config"); - -const mockConfig = jest.mocked(config); -const mockFs = fs as jest.Mocked; - -describe("getSlideFileSystemRepoIfEnabled", () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockConfig.getItemsRootDir.mockReturnValue("data_root_dir"); - }); - - describe("when the experimental slide feature is disabled (default)", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: false, - }); - }); - - it("returns null without creating the slide directory", async () => { - expect(await getSlideFileSystemRepoIfEnabled()).toBeNull(); - expect(mockFs.mkdir).not.toHaveBeenCalled(); - }); - }); - - describe("when the experimental slide feature is enabled", () => { - beforeEach(() => { - mockConfig.getUserConfig.mockResolvedValue({ - includePrivate: false, - host: "localhost", - port: 8888, - experimentalSlideFeatureEnabled: true, - }); - }); - - it("returns a repository rooted at public/ and creates public/slides/", async () => { - const slideFileSystemRepo = await getSlideFileSystemRepoIfEnabled(); - - expect(slideFileSystemRepo).toBeInstanceOf(SlideFileSystemRepo); - expect(slideFileSystemRepo?.getRootPath()).toBe("data_root_dir/public"); - expect(mockFs.mkdir).toHaveBeenCalledWith("data_root_dir/public/slides", { - recursive: true, - }); - }); - }); -}); diff --git a/src/lib/get-slide-file-system-repo.ts b/src/lib/get-slide-file-system-repo.ts index 8bd5cc8..006e8d6 100644 --- a/src/lib/get-slide-file-system-repo.ts +++ b/src/lib/get-slide-file-system-repo.ts @@ -5,12 +5,3 @@ export const getSlideFileSystemRepo = async () => await SlideFileSystemRepo.build({ dataRootDir: config.getItemsRootDir(), }); - -// Building the repo creates public/slides/, so it must not happen while the -// experimental feature is off. -export const getSlideFileSystemRepoIfEnabled = async () => { - const userConfig = await config.getUserConfig(); - if (!userConfig.experimentalSlideFeatureEnabled) return null; - - return await getSlideFileSystemRepo(); -}; diff --git a/src/lib/slide-file-system-repo.test.ts b/src/lib/slide-file-system-repo.test.ts index 0eaeb2e..04a81fd 100644 --- a/src/lib/slide-file-system-repo.test.ts +++ b/src/lib/slide-file-system-repo.test.ts @@ -47,10 +47,16 @@ const buildRemoteSlide = (overrides: Partial = {}): Slide => ({ ...overrides, }); +const enoent = (filePath: unknown) => + Object.assign(new Error(`ENOENT: ${filePath}`), { code: "ENOENT" }); + const mockFileSystem = (files: Record) => { const mockFs = fs as jest.Mocked; mockFs.readdir.mockImplementation(async (dirPath) => { const prefix = `${dirPath}/`; + if (!Object.keys(files).some((filePath) => filePath.startsWith(prefix))) { + throw enoent(dirPath); + } return Object.keys(files) .filter((filePath) => filePath.startsWith(prefix)) .map((filePath) => filePath.slice(prefix.length)) as any[]; @@ -58,7 +64,7 @@ const mockFileSystem = (files: Record) => { mockFs.readFile.mockImplementation(async (filePath) => { const content = files[filePath as string]; if (content === undefined) { - throw new Error(`ENOENT: ${filePath}`); + throw enoent(filePath); } return content; }); @@ -110,6 +116,14 @@ describe("SlideFileSystemRepo", () => { }); }); + describe("build()", () => { + it("does not create the slides directory", async () => { + await SlideFileSystemRepo.build({ dataRootDir }); + + expect(fs.mkdir).not.toHaveBeenCalled(); + }); + }); + describe("getRootPath()", () => { it("returns the root path", () => { const dataRootDir = "./tmp"; @@ -308,6 +322,23 @@ marp: true }); }); + it("returns empty when the slides directory does not exist", async () => { + mockFileSystem({}); + const instance = new SlideFileSystemRepo({ dataRootDir }); + + expect(await instance.loadSlides()).toStrictEqual([]); + }); + + it("rethrows errors other than ENOENT", async () => { + const mockFs = fs as jest.Mocked; + mockFs.readdir.mockRejectedValue( + Object.assign(new Error("EACCES"), { code: "EACCES" }), + ); + const instance = new SlideFileSystemRepo({ dataRootDir }); + + await expect(instance.loadSlides()).rejects.toThrow("EACCES"); + }); + it("returns all slides", () => { const dataRootDir = "data_root_dir"; const subDir = "public"; @@ -362,6 +393,19 @@ marp: true }); }); + it("creates the slides directory before writing the file", async () => { + mockFileSystem({}); + const instance = new SlideFileSystemRepo({ dataRootDir }); + + await instance.createSlide("deck"); + + const mockFs = fs as jest.Mocked; + expect(mockFs.mkdir).toHaveBeenCalledWith(rootPath, { recursive: true }); + expect(mockFs.mkdir.mock.invocationCallOrder[0]).toBeLessThan( + mockFs.writeFile.mock.invocationCallOrder[0], + ); + }); + it("saves slide with the given basename", () => { const mockFs = fs as jest.Mocked; mockFs.readdir.mockResolvedValueOnce([]); @@ -418,6 +462,13 @@ body`); }); describe("loadPublishTargets()", () => { + it("returns empty when the slides directory does not exist", async () => { + mockFileSystem({}); + const instance = new SlideFileSystemRepo({ dataRootDir }); + + expect(await instance.loadPublishTargets()).toStrictEqual([]); + }); + it("returns the unpublished slides and the ones that differ from the mirror", async () => { mockFileSystem({ [`${rootPath}/draft.md`]: localFile.replace( @@ -748,6 +799,30 @@ ignorePublish: false }); }); + it("creates the slides and mirror directories", async () => { + mockFileSystem({}); + const instance = new SlideFileSystemRepo({ dataRootDir }); + + await instance.saveSlides([buildRemoteSlide()]); + + const mockFs = fs as jest.Mocked; + expect(mockFs.mkdir).toHaveBeenCalledWith(`${rootPath}/slides`, { + recursive: true, + }); + expect(mockFs.mkdir).toHaveBeenCalledWith(remotePath, { + recursive: true, + }); + }); + + it("creates no directory when there are no slides", async () => { + mockFileSystem({}); + const instance = new SlideFileSystemRepo({ dataRootDir }); + + await instance.saveSlides([]); + + expect(fs.mkdir).not.toHaveBeenCalled(); + }); + it("names a slide that has no local file after its uuid under slides/", () => { mockFileSystem({}); const instance = new SlideFileSystemRepo({ dataRootDir }); diff --git a/src/lib/slide-file-system-repo.ts b/src/lib/slide-file-system-repo.ts index ba2b2d9..bcab3d2 100644 --- a/src/lib/slide-file-system-repo.ts +++ b/src/lib/slide-file-system-repo.ts @@ -185,18 +185,7 @@ export class SlideFileSystemRepo { } public static async build({ dataRootDir }: { dataRootDir: string }) { - const slideFileSystemRepo = new SlideFileSystemRepo({ dataRootDir }); - await slideFileSystemRepo.setUp(); - - return slideFileSystemRepo; - } - - private async setUp() { - await fs.mkdir(this.getRootPath(), { recursive: true }); - await fs.mkdir(this.getRemotePath(), { recursive: true }); - await fs.mkdir(path.join(this.getRootPath(), DEFAULT_SUBDIR), { - recursive: true, - }); + return new SlideFileSystemRepo({ dataRootDir }); } public getRootPath() { @@ -229,16 +218,27 @@ export class SlideFileSystemRepo { } private async getSlideFilenames(remote: boolean = false) { - return ( - await fs.readdir( + let filenames: string[]; + try { + filenames = await fs.readdir( this.getRootOrRemotePath(remote), SlideFileSystemRepo.fileSystemOptions(), - ) - ).filter( + ); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; + throw err; + } + + return filenames.filter( (filename) => /\.md$/.test(filename) && !filename.startsWith(".remote/"), ); } + private async writeSlideFile(filePath: string, data: string) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, data, SlideFileSystemRepo.fileSystemOptions()); + } + private async getNewBasename() { const prefix = "newSlide"; const filenames = await this.getSlideFilenames(); @@ -291,10 +291,9 @@ export class SlideFileSystemRepo { if (!fileContent.id) { return; } - await fs.writeFile( + await this.writeSlideFile( this.getFilePath(basename || fileContent.id, remote), fileContent.toSaveFormat(), - SlideFileSystemRepo.fileSystemOptions(), ); } @@ -429,12 +428,13 @@ export class SlideFileSystemRepo { const filenames = await this.getSlideFilenames(); if (filenames.includes(this.getFilename(basename))) return; - const filepath = this.getFilePath(basename); const newFileContent = SlideFileContent.empty({ title: path.basename(basename), }); - const data = newFileContent.toSaveFormat(); - await fs.writeFile(filepath, data, SlideFileSystemRepo.fileSystemOptions()); + await this.writeSlideFile( + this.getFilePath(basename), + newFileContent.toSaveFormat(), + ); return basename; } @@ -447,10 +447,9 @@ export class SlideFileSystemRepo { return; } - await fs.writeFile( + await this.writeSlideFile( this.getFilePath(basename), fileContent.clone({ id, updatedAt }).toSaveFormat(), - SlideFileSystemRepo.fileSystemOptions(), ); } diff --git a/src/lib/sync-slides-from-qiita.test.ts b/src/lib/sync-slides-from-qiita.test.ts index 332c130..49aef55 100644 --- a/src/lib/sync-slides-from-qiita.test.ts +++ b/src/lib/sync-slides-from-qiita.test.ts @@ -1,3 +1,7 @@ +import { + QiitaBetaFeatureRequiredError, + QiitaForbiddenError, +} from "../qiita-api"; import type { Slide, QiitaApi } from "../qiita-api"; import type { SlideFileSystemRepo } from "./slide-file-system-repo"; import { syncSlidesFromQiita } from "./sync-slides-from-qiita"; @@ -30,7 +34,9 @@ describe("syncSlidesFromQiita", () => { }); it("saves every page until an empty one is returned", async () => { - await syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi }); + await expect( + syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi }), + ).resolves.toBe(true); expect(mockAuthenticatedUserSlides).toHaveBeenNthCalledWith(1, 1, 100); expect(mockAuthenticatedUserSlides).toHaveBeenNthCalledWith(2, 2, 100); @@ -50,4 +56,32 @@ describe("syncSlidesFromQiita", () => { expect(mockSaveSlides).toHaveBeenCalledWith(slides, true); }); }); + + describe("when the slide API requires the beta feature", () => { + beforeEach(() => { + mockAuthenticatedUserSlides.mockRejectedValue( + new QiitaBetaFeatureRequiredError("Beta feature required"), + ); + }); + + it("skips syncing slides", async () => { + await expect( + syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi }), + ).resolves.toBe(false); + + expect(mockSaveSlides).not.toHaveBeenCalled(); + }); + }); + + describe("when the slide API fails for another reason", () => { + it("throws the error", async () => { + mockAuthenticatedUserSlides.mockRejectedValue( + new QiitaForbiddenError("Forbidden"), + ); + + await expect( + syncSlidesFromQiita({ slideFileSystemRepo, qiitaApi }), + ).rejects.toThrow(QiitaForbiddenError); + }); + }); }); diff --git a/src/lib/sync-slides-from-qiita.ts b/src/lib/sync-slides-from-qiita.ts index ad65f93..02d3a8b 100644 --- a/src/lib/sync-slides-from-qiita.ts +++ b/src/lib/sync-slides-from-qiita.ts @@ -1,3 +1,4 @@ +import { QiitaBetaFeatureRequiredError } from "../qiita-api"; import type { QiitaApi } from "../qiita-api"; import type { SlideFileSystemRepo } from "./slide-file-system-repo"; @@ -12,11 +13,18 @@ export const syncSlidesFromQiita = async ({ }) => { const per = 100; for (let page = 1; page <= 100; page += 1) { - const slides = await qiitaApi.authenticatedUserSlides(page, per); + let slides; + try { + slides = await qiitaApi.authenticatedUserSlides(page, per); + } catch (err) { + if (err instanceof QiitaBetaFeatureRequiredError) return false; + throw err; + } if (slides.length <= 0) { break; } await slideFileSystemRepo.saveSlides(slides, forceUpdate); } + return true; }; diff --git a/src/qiita-api/errors.ts b/src/qiita-api/errors.ts index d0f011a..48d62a1 100644 --- a/src/qiita-api/errors.ts +++ b/src/qiita-api/errors.ts @@ -26,6 +26,13 @@ export class QiitaForbiddenError extends Error { } } +export class QiitaBetaFeatureRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "QiitaBetaFeatureRequiredError"; + } +} + export class QiitaNotFoundError extends Error { constructor(message: string) { super(message); diff --git a/src/qiita-api/index.test.ts b/src/qiita-api/index.test.ts new file mode 100644 index 0000000..05326be --- /dev/null +++ b/src/qiita-api/index.test.ts @@ -0,0 +1,53 @@ +import { + QiitaApi, + QiitaBetaFeatureRequiredError, + QiitaForbiddenError, +} from "./index"; + +describe("QiitaApi", () => { + const qiitaApi = new QiitaApi({ token: "token" }); + const mockFetch = jest.spyOn(global, "fetch"); + + afterAll(() => { + mockFetch.mockRestore(); + }); + + describe("when the response is 403", () => { + it("throws QiitaBetaFeatureRequiredError if a beta feature is required", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + message: "Beta feature required", + type: "beta_feature_required", + }), + { status: 403 }, + ), + ); + + await expect(qiitaApi.authenticatedUserSlides()).rejects.toThrow( + QiitaBetaFeatureRequiredError, + ); + }); + + it("throws QiitaForbiddenError otherwise", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ message: "Forbidden", type: "forbidden" }), + { status: 403 }, + ), + ); + + await expect(qiitaApi.authenticatedUserSlides()).rejects.toThrow( + QiitaForbiddenError, + ); + }); + + it("throws QiitaForbiddenError if the body is not JSON", async () => { + mockFetch.mockResolvedValue(new Response("Forbidden", { status: 403 })); + + await expect(qiitaApi.authenticatedUserSlides()).rejects.toThrow( + QiitaForbiddenError, + ); + }); + }); +}); diff --git a/src/qiita-api/index.ts b/src/qiita-api/index.ts index f58f458..788871d 100644 --- a/src/qiita-api/index.ts +++ b/src/qiita-api/index.ts @@ -1,6 +1,7 @@ import { URL, URLSearchParams } from "node:url"; import { QiitaBadRequestError, + QiitaBetaFeatureRequiredError, QiitaFetchError, QiitaForbiddenError, QiitaInternalServerError, @@ -136,6 +137,9 @@ export class QiitaApi { case 401: throw new QiitaUnauthorizedError(errorMessage); case 403: + if (QiitaApi.isBetaFeatureRequired(responseBody)) { + throw new QiitaBetaFeatureRequiredError(errorMessage); + } throw new QiitaForbiddenError(errorMessage); case 404: throw new QiitaNotFoundError(errorMessage); @@ -150,6 +154,14 @@ export class QiitaApi { } } + private static isBetaFeatureRequired(responseBody: string) { + try { + return JSON.parse(responseBody).type === "beta_feature_required"; + } catch { + return false; + } + } + private generateApiUrl(path: string) { const baseUrl = path === "/api/preview" ? this.getPreviewUrl() : this.getBaseUrl(); diff --git a/src/server/app.ts b/src/server/app.ts index 79f1c45..5363364 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -13,7 +13,11 @@ import { SlidesRouter } from "./api/slides"; import { config } from "../lib/config"; import { getUrlAddress } from "../lib/getUrlAddress"; -export async function startServer() { +export async function startServer({ + slideFeatureAvailable, +}: { + slideFeatureAvailable: boolean; +}) { const app = express(); const userConfig = await config.getUserConfig(); @@ -27,7 +31,7 @@ export async function startServer() { app.use(express.static(path.join(__dirname, "../public"))); app.use("/api/items", ItemsRouter); - if (userConfig.experimentalSlideFeatureEnabled) { + if (slideFeatureAvailable) { app.use("/api/slides", SlidesRouter); } else { app.use("/api/slides", (req, res) => {