Skip to content

Publish slides from the CLI - #419

Merged
tomoasleep merged 7 commits into
mainfrom
publish-pull-remote-slide
Sep 25, 2026
Merged

tomoasleep merged 7 commits into
mainfrom
publish-pull-remote-slide

Conversation

@tomoasleep

@tomoasleep tomoasleep commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

What

slides/ 配下の markdown を qiita publish で投稿・更新できるようにする。

  • QiitaApi に postSlide / patchSlide を追加
  • SlideFileSystemRepo#publishSlide が投稿・更新後に uuid をローカル frontmatter へ書き戻し、.remote ミラーを更新
  • validatePublishSlide を追加し、frontmatter の型チェック・値のバリデーション・「リモートより古い」チェックを、validatePublishItem と同じ形で1段ずつ報告
  • qiita publish <basename> / qiita publish --all がスライドも対象になる
  • Switch help text generation to an async, config-aware builder #414 で入れた help のフラグ分岐に、スライドの文言を流し込む
  • スライドでも記事と同じく frontmatter の ignorePublish: true で qiita publish --all の対象から外せるようにする。ignorePublish は Marp ディレクティブとして扱わず、投稿本文に含めない
  • 400 / 403 / 404 のエラー文言を、フラグが有効なときは「記事、スライドファイルに不備がないか」「記事、スライドが見つかりませんでした」のように切り替える

すべて experimentalSlideFeatureEnabled が有効なときだけ動く。無効なら現行 main と完全に同じ挙動。

Base の #418(pull/preview側の同期・ミラー機構)に依存する: publishSlide は .remote ミラーの更新に、validatePublishSlide の「リモートより古い」判定は isOlderThanRemote に、それぞれ #418 の実装を使っている。

動作確認

自動テスト

yarn lint / yarn build / yarn test(24 suites, 213 tests)がいずれも成功。

Issue で名指しされている「過去に round 1 が PR から外れた原因の2バグ」の回帰テストは以下に入れてある。

  • src/lib/slide-file-system-repo.test.ts: publishSlide() が description: null を "" に正規化して送ること / 投稿本文に Marp ディレクティブ(---\nmarp: true\n---)が含まれること / 書き込み順序(uuid 書き戻し → ミラー)で uuid 名の重複ファイルが作られないこと / 投稿直後にミラーと「差分なし」と判定されること
  • src/commands/publish.test.ts: 新規 POST / 既存 PATCH / 記事とスライドの混在 / basename 衝突 / --all の対象 / isOlderThanRemote ±--force / フラグ off のときスライド関連が一切呼ばれないこと
  • src/commands/help.test.ts: フラグ on/off で publish / pull / new --slide の説明が切り替わること
  • src/lib/slide-file-system-repo.test.ts: ignorePublish: true のスライドが publish 対象から外れること / 投稿本文に ignorePublish が含まれないこと / 同期してもローカルの ignorePublish がミラーとローカルの両方で保たれること
  • src/lib/error-handler.test.ts: フラグ on/off で 400 / 403 / 404 の文言が切り替わること

dev 環境での手動確認

ローカルの increments/Qiita 開発サーバー(QIITA_DOMAIN=qiita.localhost、開発用 fixture の public トークン)に対して実施した。ビルド済み CLI(node dist/main.js)を使っている。

# 操作 結果
1 qiita new --slide dev443-deck slides/dev443-deck.md が marp: true / theme: default 付きで生成
2 theme: gaia / paginate: true に書き換えて qiita publish dev443-deck Posted (slide): dev443-deck -> 35d69ce4f26ca19aac1e。frontmatter に id / updated_at が書き戻り、marp / theme / paginate は残存。slides/.remote/35d69ce4f26ca19aac1e.md が作られ、ローカルは dev443-deck.md のまま(uuid 名の重複ファイルは作られない)
3 サーバー側に保存された markdown を API で確認 "markdown": "---\nmarp: true\ntheme: gaia\npaginate: true\n---\n# ..."。Marp ディレクティブがそのまま保存されている(以前の実装でテーマが落ちていたバグの回帰確認)
4 直後に qiita publish --all Nothing to publish。実サーバー相手でも投稿直後に「差分あり」にならない(往復正規化の回帰確認)
5 本文を編集して qiita publish dev443-deck Updated (slide): ... で PATCH
6 description: null にして qiita publish dev443-deck 400 にならず成功。サーバー側の description_markdown は ""(以前の実装で 400 になっていたバグの回帰確認)
7 ローカル編集 + API で直接 PATCH → qiita pull の状態で qiita publish dev443-deck dev443-deck: 内容がQiita上のスライドより古い可能性があります で中断
8 qiita publish dev443-deck --force PATCH が飛んで成功
9 未編集の状態で qiita publish --all → 1 件だけ編集して qiita publish --all 前者は Nothing to publish、後者は編集した 1 件だけ Updated (slide)
10 フラグ off / on それぞれで qiita help off ではスライドが publish / pull の説明に出ず、on では「記事、スライドを投稿、更新」等になり「実験的機能」の注記が消える
11 API が 404 を返す状態で、フラグ off / on それぞれで qiita pull off は 記事が見つかりませんでした / Qiita上で記事が削除されていないかご確認ください(現行 main と同じ)。on は 記事、スライドが見つかりませんでした / Qiita上で記事、スライドが削除されていないかご確認ください

レビュー対応で入れた ignorePublish 対応は、dev 環境の Rails が起動できず手動確認できていない(自動テストのみで確認)。400 / 403 の文言も 404 と同じ切り替え処理なので、自動テストで確認している。

qiita preview での表示 と Qiita 本体での表示

theme: gaia と paginate: true が両方で同じように効いていることを Playwright で確認した(toMarkdown() を投稿本文に使う判断の実証)。

qiita preview(ローカル) Qiita 本体(dev 環境)
dev-env-slide-preview dev-env-slide-on-qiita

Why

フラグが有効でも Qiita 側のスライド API が有効になっていないユーザーだと、qiita publish が起動時点で QiitaNotFoundError になりうる。これまではスライドの個別ページを開いたときだけ 404 で、記事のプレビュー自体は動いていたので、そこは挙動が変わる。opt-in の実験的フラグなので syncSlidesFromQiita を try/catch で握り潰すのは避けたが、判断が分かれるところなので明示しておく。

How

投稿本文は Marp ディレクティブを含めて送る

Qiita 側は渡された markdown をそのまま保存してレンダリングするので、rawBody(Marp ディレクティブを除いた本文だけ)を送ると theme: などが落ちて、preview で見えていたものと投稿結果が食い違う。toMarkdown()(#418 で toPreviewMarkdown() からリネーム済み)を投稿にも使うようにした。

description は必ず String で送る

Qiita 側は「キーが存在するのに String でない」と 400 を返す。QiitaApi#postSlide / #patchSlide のシグネチャで description: string を要求して、null が渡らないことを型で担保する形にした。

キーごと省略する案もあったが、PATCH ではリモートの値が保持されてローカルと恒久的に食い違い、「差分あり」が消えなくなるのでやめた(ローカルを source of truth にする)。

POST 後は先に frontmatter へ uuid を書き戻す

saveSlide はリモートの uuid でローカルファイルを引くので、書き戻す前にミラー更新をすると「対応するローカルファイルが無い」と判断されて slides/<uuid>.md が新規に作られてしまう。記事の updateItemUuid → saveItem と同じ順序にしてある。この不変条件は SlideFileSystemRepo#publishSlide の中に閉じているので、呼び出し側から壊せない。

投稿シーケンスは #415 と同じ置き場所に揃える

#415 が item 側に作った形(FileSystemRepo#publishItem / loadPublishTargets() / validatePublishItem)にそのまま対応させ、slide 側も SlideFileSystemRepo#publishSlide / loadPublishTargets() / validatePublishSlide に置いた。

basename の衝突検出だけは repository に下げず publish.ts に残した。 item と slide の両方を知る必要があるが、これは「CLI 引数の名前空間解決」であってドメインロジックではないため。結果として repository / validator 層に slide は漏れていない。

QiitaSlide#published は id !== null のまま

記事は .remote/ の有無で判定しているが、スライドで同じにすると、フラグ off・オフライン・API 未有効の状態で投稿済みスライドが全部「未投稿」に見える。既存の表示挙動を変えないほうがリスクが低いのでそのままにした。

Refs

確認中に見つけた既存の挙動(この PR のスコープ外)

description: の行を キーごと削除 したスライドは、qiita publish が descriptionは文字列で入力してください で止まる。SlideFileContent.read が data.description(キーが無ければ undefined)をそのまま渡すのに対し、checkSlideFrontmatterType は null か string しか許さないため。

description: null と明示的に書いた場合は通る。これは #409 で入った組み合わせによるもので、この PR では両方とも触っていない。qiita preview でも同じエラーになる既存挙動なので、直すなら別 issue にしたい。

🤖 Generated by Claude Code

🤖 Generated with Claude Code

@tomoasleep
tomoasleep added this pull request to stack #420 September 14, 2026 02:44
@tomoasleep
tomoasleep force-pushed the publish-pull-remote-slide branch from 6ae8e9a to a347150 Compare September 14, 2026 05:25
@tomoasleep
tomoasleep force-pushed the publish-pull-remote-slide branch from a347150 to 6c435c8 Compare September 14, 2026 06:49
Base automatically changed from sync-slides-remote to main September 16, 2026 07:48
Add QiitaApi#postSlide / #patchSlide (description typed as string,
since the API rejects a present-but-null description with 400) and
SlideFileSystemRepo#publishSlide, which posts or patches a slide, then
writes the uuid back to the local frontmatter before refreshing the
mirror — the uuid has to land first or the sync can't tell which local
file the returned slide belongs to and creates a second one.
validatePublishSlide reports the frontmatter type check, the value
validation and the "older than the remote" check one tier at a time,
as validatePublishItem does for articles, and `qiita publish` resolves
a basename against both stores and validates articles and slides
together.

Also fills in the publish/pull help text left empty by #414: with the
experimental slide feature on, they say they handle slides too, and
the new --slide line drops the "how to enable it" note that no longer
applies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tomoasleep
tomoasleep force-pushed the publish-pull-remote-slide branch from 6c435c8 to d842612 Compare September 16, 2026 07:49
@tomoasleep
tomoasleep requested review from a team and P-SiZK and removed request for a team September 17, 2026 08:55

@P-SiZK P-SiZK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

コメントしました

Comment thread src/commands/publish.ts
Comment thread src/lib/slide-file-system-repo.ts Outdated
Comment thread src/lib/slide-file-system-repo.ts Outdated
Comment thread src/commands/publish.ts

const { items: targetItems, slides: targetSlides } = args["--all"]
? await loadAllPublishTargets({ fileSystemRepo, slideFileSystemRepo })
: await resolveTargetsByBasenames(args._, {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveTargetsByBasenames は、記事とスライドの両方で basename が被るものがあったときにエラーになり、リネームを求めますが、loadAllPublishTargets ではエラーにならないという不整合があります。

basename が被っているときに、どっちを指定したかったのかが明確でないので、エラーを出しているのだと思いますが、loadAllPublishTargets から見た時に不正な構造ではないなら、リネームを求めるのは微妙に思えます。

引数などで、更新したいものがスライドなのか記事なのかを指定できるようにするか、loadAllPublishTargets の方でもエラーを出して一貫性を保つようにすると良さそうです

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘ありがとうございます。今回はコードを変えずに、別 PR で対応させてください。

単独指定で重複をエラーにしているのは、記事とスライドのどちらを指定したのかが曖昧になるからです。--all は両方とも投稿するので曖昧さがなく、エラーにしていません。とはいえ、--all から見て不正な構造ではないのにリネームを求めるのは一貫していない、というご指摘はその通りだと思います。

そこで、記事・スライドを明示して指定できる publish コマンドを別 PR で用意する予定です。既存の new --slide に揃えて publish --slide <basename> のようにするか、qiita slide publish <basename> のような体系にするかを含めて検討します。

🤖 Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

引数などで、更新したいものがスライドなのか記事なのかを指定できるようにするか、

基本的にこれの方針なのですが、渡し方は他のコマンドを通しても一貫したものにしたい (Qiita CLI で preview / publish だけではなく gh CLI などのように API ラッパーとして拡張していく可能性が考えられ、その際に直感的なコマンド体系にしたい) ので、どのような渡し方にするかはこの場で決めないことにしました。

Comment thread src/commands/help.test.ts
Comment thread src/commands/publish.test.ts Outdated
Comment thread src/lib/slide-file-system-repo.test.ts Outdated
tomoasleep and others added 6 commits September 24, 2026 16:06
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 400/403/404 messages told users to check their article files even
when a slide caused the error. Switch the wording on
experimentalSlideFeatureEnabled, as the help text does, and keep the
messages unchanged while the feature is disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Articles already honor ignorePublish, but a slide treated the key as a
Marp directive and posted it as part of the slide markdown. Reserve the
key, keep the local value across a sync since Qiita does not store it,
and exclude such slides from the publish targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@tomoasleep tomoasleep left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Review 🤖

問題はありません。

その他確認した項目

何を確認したか

  • PR: #419
  • diff: main...publish-pull-remote-slide
  • CI state: success(6/6 checks pass)
  • 既存レビュー: レビュアー P-SiZK による行コメント7件。いずれも本日 tomoasleep がコミット付きで返信・対応済み。行に紐づかないトップレベルコメントは0件。

見たが問題にしなかった観点

7件の行コメントすべてで、返信の説明内容と実際のコミット差分が一致していることを確認した。

  • エラーメッセージ・ignorePublish 対応・冗長コメント削除・help テストの対称化・template literal 化・async/await 統一の各コメントは、いずれも対応コミットの diff が返信内容と一致していた。
  • basename 重複時の挙動が単独指定と --all で非対称という指摘は、今回はコードを変更せず別 issue に切り出す判断になっている。切り出し先の issue には指摘内容が正確に引用され、設計の選択肢と完了条件まで書かれており、実害もリネームを求められる程度の UX 上の不便に留まるため、この判断は妥当と考える。
  • basename 衝突検出のみ publish.ts に残し repository / validator に slide を漏らさない設計、正常系・異常系・フラグ on/off 両方をカバーするテスト、feature flag 無効時は現行 main と完全に同一という分岐についても、新たな問題は見当たらなかった。

tomoasleep に確認するべき点

特になし。

補足

CI は 6/6 pass、conflict なし(MERGEABLE)。branch protection によりレビュー承認待ちで mergeStateStatus は BLOCKED(P-SiZK の approve 待ち)。


head: 4b0e56a

@tomoasleep
tomoasleep requested a review from P-SiZK September 25, 2026 03:56

@P-SiZK P-SiZK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@tomoasleep
tomoasleep merged commit 5544e6b into main Sep 25, 2026
6 checks passed
@tomoasleep
tomoasleep deleted the publish-pull-remote-slide branch September 25, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants