コミットを比較

..

98 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 2f87669699 #399 2026-07-18 21:59:22 +09:00
みてるぞ 1c906d7432 #399 2026-07-18 21:19:53 +09:00
みてるぞ f662dc9dc0 #399 2026-07-18 20:22:53 +09:00
みてるぞ 206c6bc0a0 #399 2026-07-18 20:10:15 +09:00
みてるぞ 8f66ee8059 #399 2026-07-18 20:03:26 +09:00
みてるぞ 83e3db3314 #399 2026-07-18 20:00:40 +09:00
みてるぞ eae6c30064 #399 2026-07-18 19:26:27 +09:00
みてるぞ 240e078f0b #399 2026-07-18 18:33:11 +09:00
みてるぞ 74b1ada0dd #399 2026-07-18 17:44:56 +09:00
みてるぞ 23d8adf65d #399 2026-07-18 17:35:28 +09:00
みてるぞ e0debed94e #399 2026-07-18 17:31:57 +09:00
みてるぞ 583ce22a7e #399 2026-07-18 17:07:56 +09:00
みてるぞ 7c808a6f76 #399 2026-07-18 15:15:10 +09:00
みてるぞ a5ae7c6f2d #399 2026-07-18 14:55:11 +09:00
みてるぞ 03dc4d0661 #399 2026-07-18 13:32:22 +09:00
みてるぞ 06b9c1cb50 #399 2026-07-18 13:07:49 +09:00
みてるぞ 688b4af575 #399 2026-07-18 12:53:50 +09:00
みてるぞ 040cc3f25d #399 2026-07-18 12:15:32 +09:00
みてるぞ b3e67d8cca #399 2026-07-18 11:55:09 +09:00
みてるぞ b00a62a0ac #399 2026-07-18 09:11:11 +09:00
みてるぞ e6c3c635b8 #399 2026-07-18 02:29:55 +09:00
みてるぞ 9552081133 #399 2026-07-18 02:08:20 +09:00
みてるぞ 2d2a9b4bd6 #399 2026-07-18 01:37:11 +09:00
みてるぞ 09ac2576bb #399 2026-07-18 01:28:22 +09:00
みてるぞ 9eca670934 #399 2026-07-18 01:12:55 +09:00
みてるぞ c31d84115d #399 2026-07-18 00:31:02 +09:00
みてるぞ ff970f8171 #399 2026-07-18 00:01:12 +09:00
みてるぞ 0ae41b6266 #399 2026-07-17 23:14:37 +09:00
みてるぞ eb4bf5e35c #399 2026-07-17 22:21:18 +09:00
みてるぞ e6b7e33b83 #399 2026-07-17 21:53:24 +09:00
みてるぞ 3820d3d4d5 #399 2026-07-17 21:06:26 +09:00
みてるぞ dd2d199d04 #399 2026-07-16 23:39:13 +09:00
みてるぞ 0a8ffc38b8 #399 2026-07-16 23:23:32 +09:00
みてるぞ 3f75994bd4 #399 2026-07-16 23:12:14 +09:00
みてるぞ f91b78bd47 #399 2026-07-16 22:31:32 +09:00
みてるぞ 7f8cce39bc #399 2026-07-16 22:05:40 +09:00
みてるぞ 5c5a9fa1b0 #399 2026-07-16 22:00:16 +09:00
みてるぞ 66c738cbff #399 2026-07-16 21:45:02 +09:00
みてるぞ cde0a2deae #399 2026-07-16 20:54:42 +09:00
みてるぞ 43a3772976 #399 2026-07-16 20:35:07 +09:00
みてるぞ 90d8d3ff08 #399 2026-07-16 20:05:09 +09:00
みてるぞ d1de631eed #399 2026-07-16 19:35:21 +09:00
みてるぞ 6f3ccb0ffa #399 2026-07-16 19:18:35 +09:00
みてるぞ 95c3f08a44 #399 2026-07-16 18:52:07 +09:00
みてるぞ ef95b20a7e #399 2026-07-16 18:33:45 +09:00
みてるぞ f76fbe6711 #399 2026-07-16 18:16:20 +09:00
みてるぞ 0224c4d2f4 #399 2026-07-16 12:39:35 +09:00
みてるぞ ead825e3ee #399 2026-07-16 07:56:45 +09:00
みてるぞ 3d88fdecb1 #399 2026-07-16 01:19:36 +09:00
みてるぞ 2e1b4449ba #399 2026-07-16 00:44:11 +09:00
みてるぞ 4eb483c049 #399 2026-07-16 00:24:24 +09:00
みてるぞ e197f27e9a #399 2026-07-15 23:59:01 +09:00
みてるぞ 34f81325d2 #399 2026-07-15 23:42:05 +09:00
みてるぞ 9af4670c6c #399 2026-07-15 23:29:19 +09:00
みてるぞ 5183b0b354 #399 2026-07-15 23:06:28 +09:00
みてるぞ d0914fce3b #399 2026-07-15 22:51:01 +09:00
みてるぞ 0ac7332458 #399 2026-07-15 22:41:06 +09:00
みてるぞ 07ce19e32d #399 2026-07-15 22:26:58 +09:00
みてるぞ 5f1d619139 #399 2026-07-15 22:04:47 +09:00
みてるぞ 3cd22855b3 #399 2026-07-15 21:42:00 +09:00
みてるぞ 55c69866a7 #399 2026-07-15 21:37:03 +09:00
みてるぞ 04d2f0f5a9 #399 2026-07-15 20:59:00 +09:00
みてるぞ 025f49cbcb #399 2026-07-15 20:51:24 +09:00
みてるぞ d2f1b1ed48 #399 2026-07-15 20:25:51 +09:00
みてるぞ 6e5aa1e30f #399 2026-07-15 19:57:09 +09:00
みてるぞ 21d6c1408a #399 2026-07-15 19:43:39 +09:00
みてるぞ eebbd5d069 #399 2026-07-15 19:33:18 +09:00
みてるぞ 58828597d7 #399 2026-07-15 08:03:25 +09:00
みてるぞ b0c24f319a #399 2026-07-14 20:03:11 +09:00
みてるぞ f9463f383f #399 2026-07-14 19:37:04 +09:00
みてるぞ 6d037192c4 #399 2026-07-14 19:12:23 +09:00
みてるぞ d035da99ad #399 2026-07-14 18:39:24 +09:00
みてるぞ bc660676ef Merge remote-tracking branch 'origin/main' into feature/399 2026-07-14 12:40:32 +09:00
みてるぞ 97b132e5ab #399 2026-07-13 01:31:45 +09:00
みてるぞ be68841bd3 #399 2026-07-13 01:17:11 +09:00
みてるぞ ac82adc6b3 #399 2026-07-13 00:58:02 +09:00
みてるぞ aa96ec95d1 #399 2026-07-12 14:03:05 +09:00
みてるぞ df17f20907 #399 2026-07-12 13:14:36 +09:00
みてるぞ 769966648b #399 2026-07-12 12:43:31 +09:00
みてるぞ 7bcb76516c #399 2026-07-12 12:23:53 +09:00
みてるぞ 8970edc59f #399 2026-07-12 11:17:23 +09:00
みてるぞ 9b1ea56e36 #399 2026-07-12 10:27:13 +09:00
みてるぞ 155edfe018 #399 2026-07-12 02:22:36 +09:00
みてるぞ 23f1ffb04b #399 2026-07-12 02:08:32 +09:00
みてるぞ ccfe65a6a4 #399 2026-07-12 02:01:25 +09:00
みてるぞ 06d6e512e4 #399 2026-07-12 01:56:24 +09:00
みてるぞ 5bd097bcfe #399 2026-07-12 01:47:34 +09:00
みてるぞ f636d2a177 #399 2026-07-12 01:39:31 +09:00
みてるぞ 10dc776313 #399 2026-07-12 01:17:45 +09:00
みてるぞ a9e16735f8 #399 2026-07-12 01:11:07 +09:00
みてるぞ 440a6c9961 #399 2026-07-12 00:54:21 +09:00
みてるぞ 4535a9d260 #399 2026-07-12 00:46:53 +09:00
みてるぞ 440d3d38be #399 2026-07-11 23:21:59 +09:00
みてるぞ cb33d9ca25 #399 2026-07-11 23:08:03 +09:00
みてるぞ 19bf24432a #399 2026-07-11 23:07:51 +09:00
みてるぞ ac41385962 Merge remote-tracking branch 'origin/main' into feature/399 2026-07-11 22:36:03 +09:00
みてるぞ 08bf92ff79 #399 2026-07-11 21:46:10 +09:00
みてるぞ c51d7b98ba #399 2026-07-11 21:11:36 +09:00
20個のファイルの変更307行の追加391行の削除
+24 -157
ファイルの表示
@@ -124,7 +124,7 @@ npm run preview
- For arrays, never put whitespace or a line break immediately before `]`. - For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default. - Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent - If an array would exceed the line limit, break after `[` and indent
elements 4 spaces deeper than the statement's base indentation. elements by 4 spaces.
### Ruby delimiter and wrapping rules ### Ruby delimiter and wrapping rules
@@ -140,32 +140,6 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。 99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)` - Ruby の method chain や call argument を折り返す際、call-site の `)`
block close のやうに独立させない。 block close のやうに独立させない。
- Ruby では、行末の `\` を用途を問はず一切使用しない。
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
- Ruby の block body は、その基準位置から 2 空白深くする。
- Ruby の wrapped expression、method argument、array element、hash pair などの
continuation indentation は、その statement の基準位置から 4 空白深くする。
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
括弧内ではバックスラッシュなしで改行する。
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
method へ抽出する、のいづれかへ書き換へる。
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
並べない。
- RSpec の `describe``context``it` 等の description が長い場合は、意味を
保ったまま一行へ収まる文言へ短縮する。
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
検討する。
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
深くするため、結果として行頭から 6 空白になる。
Bad: Bad:
@@ -217,63 +191,6 @@ end
Bad: Bad:
```rb
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ payload.to_json }")
```
Good:
```rb
payload = {
error: e.class.name,
message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }")
```
Bad:
```rb
message = "first "\
"second"
```
Good:
```rb
message = format(
'%<first>s %<second>s',
first: 'first',
second: 'second')
```
Bad:
```rb
result = first_value + \
second_value
```
Bad:
```rb
it(
'returns 409 when stale changes '
'do not conflict'
) do
```
Good:
```rb
it 'returns mergeable 409 for stale non-conflicting changes' do
```
Bad:
```rb ```rb
records.each { records.each {
do_work(_1) } do_work(_1) }
@@ -308,37 +225,20 @@ records.each {
formatting as the local reference shape: component and callback bodies use formatting as the local reference shape: component and callback bodies use
2-space block indentation, single-line bodies do not gain unnecessary 2-space block indentation, single-line bodies do not gain unnecessary
braces, wrapped expressions use 4-space continuation indentation, multi- braces, wrapped expressions use 4-space continuation indentation, multi-
stage ternaries use explicit parentheses, and TypeScript / TSX indentation stage ternaries use explicit parentheses, and only complete leading runs
and alignment whitespace compress every complete run of 8 spaces to tabs. of 8 spaces are compressed to tabs.
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
- A tab does not represent one indentation level. - A tab does not represent one indentation level.
- In TypeScript and TSX, first determine visible indentation using 2-space - Do not replace 2-space or 4-space indentation with tabs.
block indentation and 4-space continuation indentation, then compress every - First determine visible indentation using 2-space block indentation and
complete run of 8 spaces used for indentation or column alignment to tabs. 4-space continuation indentation, then compress only complete leading runs
- In TypeScript and TSX, 8-space compression is mandatory, not optional. of 8 spaces into tabs.
- In TypeScript and TSX, this applies both to leading indentation and to - Tabs are only for leading indentation, never for spaces after non-space
alignment whitespace after non-space text, such as aligned inline type text.
columns. - Keep residual leading 2, 4, or 6 spaces after any tab compression.
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
compression.
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
- In TypeScript and TSX, do not alter string literals, template-literal
contents, regular expressions, or user-facing text merely to apply tab
compression.
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6 - Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1 spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs. tab + 4 spaces, 16 columns = 2 tabs.
- When TypeScript or TSX code already uses column alignment, apply the same
8-space compression rule to that alignment whitespace.
- Example:
```ts
type Props = {
row: PostImportRow
displayNumber?: number
onEdit?: () => void
onRetry?: () => void
onToggleSkip?: (checked: boolean) => void }
```
- TypeScript and TSX imports may stay on one line if they remain within the - TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically. line limit; do not expand short type-only imports mechanically.
- Keep runtime value imports and type imports in separate declarations. - Keep runtime value imports and type imports in separate declarations.
@@ -574,32 +474,8 @@ and layout reuse, follow `frontend/AGENTS.md`.
wording and placement before implementing it. wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording. - Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working within the permitted test work. When the user asks for tests, keep working and rerun them until
test-file scope and rerun them until they pass or the remaining failure is they pass or the remaining failure is clearly blocked.
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
contracts, validation, routes, authentication, permissions, UI, copy,
dependencies, limits, thresholds, defaults, migrations, schema, or
environment settings to satisfy tests.
- Do not make production code match failing tests, mock assumptions, fixtures,
snapshots, old expectations, or stale setup. This includes changing
production constants merely because a test expects a different value.
- If test work reveals a production bug, spec mismatch, or missing behaviour,
stop without modifying production code and report: the failing test or
discovered issue, the related production file, the actual behaviour, the
expected behaviour, and why a production change appears necessary.
- Modify production code for test failures only when the user explicitly asks
for that production change. Do not expand a test task into a production task
on your own authority.
- If the user explicitly asks for both production implementation and test
updates, implement production code to the confirmed specification first,
then add or update tests to verify that specification. Never roll production
behaviour back to satisfy old tests.
- If it is unclear whether the test or the production implementation is stale,
or a test cannot be corrected without changing production code, ask the user
instead of guessing.
## Backend rules ## Backend rules
@@ -722,10 +598,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
beginning of a line. beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing - The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style. parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces - The TypeScript/TSX self-review must confirm leading block indentation uses
per level, wrapped continuations use the repository's 4-space continuation 2 spaces per level, wrapped continuations use the repository's 4-space
alignment, and every complete run of 8 spaces used for indentation or continuation alignment, and complete leading runs of 8 spaces may be
alignment has been compressed to tabs. compressed to tabs.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
@@ -738,18 +614,9 @@ and layout reuse, follow `frontend/AGENTS.md`.
- Indent the block body 2 spaces deeper than the keyword and opening brace. - Indent the block body 2 spaces deeper than the keyword and opening brace.
- Put the closing `}` on its own line at the same indentation as the keyword. - Put the closing `}` on its own line at the same indentation as the keyword.
- Do not write `try {`, `catch {`, or `finally {`. - Do not write `try {`, `catch {`, or `finally {`.
- In TypeScript and TSX, convert every complete run of 8 spaces used for - In TypeScript and TSX, convert every complete leading run of 8 spaces to a
indentation or alignment to a tab character. tab character.
- In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it - A leading tab is exactly equivalent to 8 leading spaces.
appears at the beginning of a line or in alignment whitespace after
non-space text.
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
attributes use 4-space continuation indentation; after visible columns are
determined, compress every complete run of 8 spaces in the resulting
indentation or alignment to tabs.
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
columns as spaces alone; convert each complete run of 8 spaces to tabs and
keep only the residual 2, 4, or 6 spaces.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing function declarations, classify the parameter list before placing the closing
`)`. `)`.
@@ -1392,9 +1259,9 @@ to `.ts` and `.tsx`:
7. JSX `>` and `/>` stay with the final prop unless nearby code proves 7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise. otherwise.
8. JSX closing parentheses keep the compact local style. 8. JSX closing parentheses keep the compact local style.
9. Block indentation uses 2 spaces per level, wrapped continuations use the 9. Leading block indentation uses 2 spaces per level, wrapped continuations
repository's 4-space continuation alignment, and every complete run of 8 use the repository's 4-space continuation alignment, and complete leading
spaces used for indentation or alignment has been compressed to tabs. runs of 8 spaces may be compressed to tabs.
10. No line has trailing whitespace. 10. No line has trailing whitespace.
Preferred: Preferred:
+4 -19
ファイルの表示
@@ -138,10 +138,7 @@ class Post < ApplicationRecord
end end
def self.section_literal section def self.section_literal section
end_ms = "[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
end end
def self.ms_to_time ms def self.ms_to_time ms
@@ -326,9 +323,7 @@ class Post < ApplicationRecord
end end
def self.external_svg_reference?(attribute_name, attribute_value) def self.external_svg_reference?(attribute_name, attribute_value)
if ['href', 'xlink:href', 'src'].include?(attribute_name) return external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name)
return external_svg_url?(attribute_value)
end
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style' return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i) return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
@@ -349,12 +344,7 @@ class Post < ApplicationRecord
def self.svg_url_function_disallowed?(value) def self.svg_url_function_disallowed?(value)
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry| value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
reference = reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
entry.to_s.strip
.delete_prefix("'")
.delete_prefix('"')
.delete_suffix("'")
.delete_suffix('"')
reference.present? && !(reference.start_with?('#')) reference.present? && !(reference.start_with?('#'))
end end
end end
@@ -443,12 +433,7 @@ class Post < ApplicationRecord
match = match =
value.match( value.match(
/ /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
(?::(\d{2})(?:\.(\d+))?)?
(Z|[+-]\d{2}:?\d{2})?
\z
/x)
return nil if match.nil? return nil if match.nil?
year = match[1].to_i year = match[1].to_i
+5 -8
ファイルの表示
@@ -106,14 +106,11 @@ module PostRepr
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options) Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
payload = { Rails.logger.warn(
post_id: post.id, "PostRepr.thumbnail_url failed post_id=#{post.id} " \
attachment_id: post.thumbnail.attachment&.id, "attachment_id=#{post.thumbnail.attachment&.id} " \
blob_id: post.thumbnail.blob&.id, "blob_id=#{post.thumbnail.blob&.id} " \
error_class: e.class, "error_class=#{e.class} message=#{e.message}")
message: e.message }
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
nil nil
end end
end end
+1 -3
ファイルの表示
@@ -85,9 +85,7 @@ class PostCreatePlan
end_raw: match[3], end_raw: match[3],
tag_name: name) tag_name: name)
end end
if name.include?('[') || name.include?(']') raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end
[resolved_tag_name(name), category&.to_sym, sections] [resolved_tag_name(name), category&.to_sym, sections]
end end
+8 -8
ファイルの表示
@@ -225,15 +225,15 @@ class PostImportPreviewer
end end
{ data:, warnings:, validation_errors: { } } { data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }") "post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed, rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }") "post_import_metadata_fetch_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
@@ -335,14 +335,14 @@ class PostImportPreviewer
def safe_fetch_metadata url def safe_fetch_metadata url
fetch_metadata(url) fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }") "post_import_metadata_fetch_unsafe_url "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error( Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }") "post_import_metadata_fetch_unexpected_failure "\
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
+4 -3
ファイルの表示
@@ -3,9 +3,10 @@ require 'date'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}) Regexp.new(
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)? '\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
(Z|[+-]\d{2}:?\d{2})?\z/x '(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
'(Z|[+-]\d{2}:?\d{2})?\z')
def self.fetch raw_url def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url) uri, = Preview::UrlSafety.validate(raw_url)
+3 -3
ファイルの表示
@@ -1,4 +1,4 @@
require 'active_support/core_ext/integer/time' require "active_support/core_ext/integer/time"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
@@ -17,8 +17,8 @@ Rails.application.configure do
# Enable/disable Action Controller caching. By default Action Controller caching is disabled. # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching. # Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join('tmp/caching-dev.txt').exist? if Rails.root.join("tmp/caching-dev.txt").exist?
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" } config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
end end
+20 -20
ファイルの表示
@@ -101,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
describe 'GET /posts' do describe "GET /posts" do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { TagName.create!(name: 'spec_tag') } let!(:tag_name) { TagName.create!(name: "spec_tag") }
let!(:tag) { Tag.create!(tag_name:, category: :general) } let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { TagName.create!(name: 'unko') } let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) } let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do let!(:hit_post) do
Post.create!(uploaded_user: user, title: 'hello spec world', Post.create!(uploaded_user: user, title: "hello spec world",
url: 'https://example.com/spec2').tap do |p| url: 'https://example.com/spec2').tap do |p|
PostTag.create!(post: p, tag:) PostTag.create!(post: p, tag:)
end end
end end
let!(:miss_post) do let!(:miss_post) do
Post.create!(uploaded_user: user, title: 'unrelated title', Post.create!(uploaded_user: user, title: "unrelated title",
url: 'https://example.com/spec3').tap do |p| url: 'https://example.com/spec3').tap do |p|
PostTag.create!(post: p, tag: tag2) PostTag.create!(post: p, tag: tag2)
end end
end end
it 'returns posts with tag name in JSON' do it "returns posts with tag name in JSON" do
get '/posts' get "/posts"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
posts = json.fetch('posts') posts = json.fetch("posts")
# 全postの全tagが name を含むこと # 全postの全tagが name を含むこと
expect(posts).not_to be_empty expect(posts).not_to be_empty
@@ -141,8 +141,8 @@ RSpec.describe 'Posts API', type: :request do
expect(json['count']).to be_an(Integer) expect(json['count']).to be_an(Integer)
# spec_tag を含む投稿が存在すること # spec_tag を含む投稿が存在すること
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } } all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
expect(all_tag_names).to include('spec_tag') expect(all_tag_names).to include("spec_tag")
end end
it 'keeps children and sections keys in non-detail tag responses' do it 'keeps children and sections keys in non-detail tag responses' do
@@ -161,9 +161,9 @@ RSpec.describe 'Posts API', type: :request do
]) ])
end end
context 'when q is provided' do context "when q is provided" do
it 'filters posts by q (hit case)' do it "filters posts by q (hit case)" do
get '/posts', params: { tags: 'spec_tag' } get "/posts", params: { tags: "spec_tag" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
posts = json.fetch('posts') posts = json.fetch('posts')
@@ -181,8 +181,8 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
it 'filters posts by q (hit case by alias)' do it "filters posts by q (hit case by alias)" do
get '/posts', params: { tags: 'manko' } get "/posts", params: { tags: "manko" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
posts = json.fetch('posts') posts = json.fetch('posts')
@@ -200,11 +200,11 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
it 'returns empty posts when nothing matches' do it "returns empty posts when nothing matches" do
get '/posts', params: { tags: 'no_such_keyword_12345' } get "/posts", params: { tags: "no_such_keyword_12345" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('posts')).to eq([]) expect(json.fetch("posts")).to eq([])
expect(json.fetch('count')).to eq(0) expect(json.fetch('count')).to eq(0)
end end
end end
@@ -1161,7 +1161,7 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when nico tag already exists in tags' do context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'), tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
@@ -1455,7 +1455,7 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when nico tag already exists in tags' do context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'), tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
@@ -1712,7 +1712,7 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user') expect(post_record.reload.title).to eq('updated by other user')
end end
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
+27
ファイルの表示
@@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do
pending '#336 で対応予定'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')
get '/wiki/search', params: { body: 'unique body keyword' }
expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end
+37
ファイルの表示
@@ -0,0 +1,37 @@
require 'rails_helper'
RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'restores wiki page to previous version' do
pending '#337 で対応予定'
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')
v1 = page.wiki_versions.order(:version_no).last
Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end
+27 -27
ファイルの表示
@@ -1,10 +1,10 @@
require 'rails_helper' require "rails_helper"
RSpec.describe 'nico:sync' do RSpec.describe "nico:sync" do
def stub_python(json_array) def stub_python(json_array)
status = instance_double(Process::Status, success?: true) status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status]) allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status])
end end
def create_tag!(name, category:) def create_tag!(name, category:)
@@ -16,21 +16,21 @@ RSpec.describe 'nico:sync' do
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id) NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
end end
it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
# 既存 post(正規表現で拾われるURL) # 既存 post(正規表現で拾われるURL)
post = Post.create!( post = Post.create!(
title: 'old', title: "old",
url: 'https://www.nicovideo.jp/watch/sm9', url: "https://www.nicovideo.jp/watch/sm9",
uploaded_user: nil uploaded_user: nil
) )
# 既存の非nicoタグ(kept_non_nico_ids) # 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!('spec_kept', category: 'general') kept_general = create_tag!("spec_kept", category: "general")
PostTag.create!(post: post, tag: kept_general) PostTag.create!(post: post, tag: kept_general)
# 追加される linked tag を準備(nico tag に紐付く一般タグ) # 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!('spec_linked', category: 'general') linked = create_tag!("spec_linked", category: "general")
nico = create_tag!('nico:AAA', category: 'nico') nico = create_tag!("nico:AAA", category: "nico")
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提) # bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -46,22 +46,22 @@ RSpec.describe 'nico:sync' do
'deleted_at' => '2026-01-31 00:00:00' }]) 'deleted_at' => '2026-01-31 00:00:00' }])
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ # 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
run_rake_task('nico:sync') run_rake_task("nico:sync")
post.reload post.reload
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name') active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name")
expect(active_tag_names).to include('spec_kept') expect(active_tag_names).to include("spec_kept")
expect(active_tag_names).to include('nico:AAA') expect(active_tag_names).to include("nico:AAA")
expect(active_tag_names).to include('spec_linked') expect(active_tag_names).to include("spec_linked")
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z')) expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids) # 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
expect(active_tag_names).to include('bot操作') expect(active_tag_names).to include("bot操作")
end end
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
@@ -108,29 +108,29 @@ RSpec.describe 'nico:sync' do
expect(calls).to eq(2) expect(calls).to eq(2)
end end
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
post = Post.create!( post = Post.create!(
title: 'old', title: "old",
url: 'https://www.nicovideo.jp/watch/sm9', url: "https://www.nicovideo.jp/watch/sm9",
uploaded_user: nil uploaded_user: nil
) )
# 旧nicoタグ(今回の同期結果に含まれない) # 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!('nico:OLD', category: 'nico') old_nico = create_tag!("nico:OLD", category: "nico")
old_pt = PostTag.create!(post: post, tag: old_nico) old_pt = PostTag.create!(post: post, tag: old_nico)
expect(old_pt.discarded_at).to be_nil expect(old_pt.discarded_at).to be_nil
# 今回は NEW のみ欲しい # 今回は NEW のみ欲しい
new_nico = create_tag!('nico:NEW', category: 'nico') new_nico = create_tag!("nico:NEW", category: "nico")
# bot/tagme 念のため # bot/tagme 念のため
Tag.bot Tag.bot
Tag.tagme Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }]) stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
run_rake_task('nico:sync') run_rake_task("nico:sync")
# OLD は active から外れる(discarded_at が入る) # OLD は active から外れる(discarded_at が入る)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
@@ -138,9 +138,9 @@ RSpec.describe 'nico:sync' do
# NEW は active にいる # NEW は active にいる
post.reload post.reload
active_names = post.tags.joins(:tag_name).pluck('tag_names.name') active_names = post.tags.joins(:tag_name).pluck("tag_names.name")
expect(active_names).to include('nico:NEW') expect(active_names).to include("nico:NEW")
expect(active_names).not_to include('nico:OLD') expect(active_names).not_to include("nico:OLD")
end end
def snapshot_tags(post) def snapshot_tags(post)
+2 -4
ファイルの表示
@@ -92,9 +92,7 @@ describe ('PostEditForm', () => {
.toHaveValue ('180.5') .toHaveValue ('180.5')
}) })
it ( it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
'shows deduplicated original-created endpoint errors on the shared datetime field',
async () => {
const post = buildPost () const post = buildPost ()
api.isApiError.mockReturnValue (true) api.isApiError.mockReturnValue (true)
postsApi.updatePost.mockRejectedValueOnce ({ postsApi.updatePost.mockRejectedValueOnce ({
@@ -116,5 +114,5 @@ describe ('PostEditForm', () => {
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument () expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument () expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1) expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
}) })
}) })
+3 -4
ファイルの表示
@@ -75,6 +75,8 @@ const TagLink: FC<Props> = ({ tag,
const spanClass = 'tag-link-colour' const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour' const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom' const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto' const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto' const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
@@ -82,10 +84,7 @@ const TagLink: FC<Props> = ({ tag,
?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`) ?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
return ( return (
<span <span className={rootClass}>
className={cn (
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag)) && ( {(linkFlg && withWiki && isFullTag (tag)) && (
<span className={markerWrapClass}> <span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
+97 -95
ファイルの表示
@@ -1,10 +1,10 @@
'use client' "use client"
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as React from "react"
import { X } from 'lucide-react' import * as DialogPrimitive from "@radix-ui/react-dialog"
import * as React from 'react' import { X } from "lucide-react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root
@@ -15,109 +15,111 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>( >(({ className, ...props }, ref) => (
({ className, ...props }, ref) => ( <DialogPrimitive.Overlay
<DialogPrimitive.Overlay ref={ref}
ref={ref} className={cn(
className={cn ( "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
'fixed inset-0 z-50 bg-black/80', className)}
'data-[state=open]:animate-in data-[state=closed]:animate-out', {...props}
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', />))
className)}
{...props}/>))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>( >(({ className, children, ...props }, ref) => (
({ className, children, ...props }, ref) => ( <DialogPortal>
<DialogPortal> <DialogOverlay />
<DialogOverlay /> <DialogPrimitive.Content
<DialogPrimitive.Content ref={ref}
ref={ref} className={cn (
className={cn ( 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg', 'translate-x-[-50%] translate-y-[-50%]',
'translate-x-[-50%] translate-y-[-50%]', 'gap-5 rounded-2xl border border-border',
'gap-5 rounded-2xl border border-border', 'bg-background p-6 text-foreground shadow-2xl',
'bg-background p-6 text-foreground shadow-2xl', 'duration-200',
'duration-200', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', className)}
className)} {...props}
{...props}> >
{children} {children}
<DialogPrimitive.Close <DialogPrimitive.Close
className={cn ( className={cn (
'absolute left-4 top-4 rounded-full p-1', 'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors', 'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900', 'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50', 'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}> 'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
<span className="sr-only"></span> <span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal>)) </DialogPortal>))
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ( const DialogHeader = ({
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>, className,
) => ( ...props
<div }: React.HTMLAttributes<HTMLDivElement>) => (
className={cn ( <div
'flex flex-col space-y-1.5 text-center md:text-left', className={cn(
className)} "flex flex-col space-y-1.5 text-center md:text-left",
{...props}/>) className)}
DialogHeader.displayName = 'DialogHeader' {...props}
/>)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ( const DialogFooter = ({
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>, className,
) => ( ...props
<div }: React.HTMLAttributes<HTMLDivElement>) => (
className={cn ( <div
'flex flex-col-reverse md:flex-row md:justify-end md:space-x-2', className={cn(
className)} "flex flex-col-reverse md:flex-row md:justify-end md:space-x-2",
{...props}/>) className)}
DialogFooter.displayName = 'DialogFooter' {...props}
/>)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>( >(({ className, ...props }, ref) => (
({ className, ...props }, ref) => ( <DialogPrimitive.Title
<DialogPrimitive.Title ref={ref}
ref={ref} className={cn(
className={cn ( "text-lg font-semibold leading-none tracking-tight",
'text-lg font-semibold leading-none tracking-tight', className)}
className)} {...props}
{...props}/>)) />))
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>( >(({ className, ...props }, ref) => (
({ className, ...props }, ref) => ( <DialogPrimitive.Description
<DialogPrimitive.Description ref={ref}
ref={ref} className={cn("text-sm text-muted-foreground", className)}
className={cn ('text-sm text-muted-foreground', className)} {...props}
{...props}/>)) />))
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
Dialog, Dialog,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
DialogClose, DialogClose,
DialogTrigger, DialogTrigger,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} }
+4 -4
ファイルの表示
@@ -29,15 +29,15 @@ describe ('post new review URL state', () => {
.toEqual (['one', 'two']) .toEqual (['one', 'two'])
}) })
it ('allows at most a 6 143 byte request target', () => { it ('allows at most a 4095-byte request target', () => {
const baseUrl = 'https://example.com/' const baseUrl = 'https://example.com/'
const baseLength = postNewReviewPathByteLength ([baseUrl]) const baseLength = postNewReviewPathByteLength ([baseUrl])
const allowed = `${ baseUrl }${ 'a'.repeat (6_143 - baseLength) }` const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
const denied = `${ allowed }a` const denied = `${ allowed }a`
expect (postNewReviewPathByteLength ([allowed])).toBe (6_143) expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true) expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
expect (postNewReviewPathByteLength ([denied])).toBe (6_144) expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false) expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
}) })
}) })
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls=' const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144 const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
const textEncoder = new TextEncoder () const textEncoder = new TextEncoder ()
+27 -30
ファイルの表示
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { cn, dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -113,11 +113,10 @@ const clearedTagSelectionPath = (
const MaterialThumb: FC<{ material: Material }> = ({ material }) => ( const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div <div
className={cn ( className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
'flex aspect-square h-[180px] w-[180px] items-center justify-center', overflow-hidden rounded-lg border border-stone-200 bg-white text-center
'overflow-hidden rounded-lg border border-stone-200 bg-white text-center', text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900', dark:text-stone-100`}>
'dark:text-stone-100')}>
{material.thumbnail {material.thumbnail
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/> ? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: ( : (
@@ -532,32 +531,30 @@ const MaterialListPage: FC = () => {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={() => updateQuery ({ view: 'card' })} onClick={() => updateQuery ({ view: 'card' })}
className={cn ( className={`rounded-full border px-4 py-2 text-sm ${
'rounded-full border px-4 py-2 text-sm', view === 'card'
view === 'card' ? [
? [ 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400', 'dark:bg-sky-950 dark:text-sky-100'].join (' ')
'dark:bg-sky-950 dark:text-sky-100'] : [
: [ 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', 'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
'dark:bg-stone-900 dark:text-stone-100'])}>
</button> </button>
<button <button
type="button" type="button"
onClick={() => updateQuery ({ view: 'list' })} onClick={() => updateQuery ({ view: 'list' })}
className={cn ( className={`rounded-full border px-4 py-2 text-sm ${
'rounded-full border px-4 py-2 text-sm', view === 'list'
view === 'list' ? [
? [ 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400', 'dark:bg-sky-950 dark:text-sky-100'].join (' ')
'dark:bg-sky-950 dark:text-sky-100'] : [
: [ 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', 'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
'dark:bg-stone-900 dark:text-stone-100'])}>
</button> </button>
</div> </div>
+1 -1
ファイルの表示
@@ -60,7 +60,7 @@ describe ('PostImportSourcePage', () => {
const input = screen.getByRole ('textbox', { name: '' }) const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, { fireEvent.change (input, {
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } }) target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled () expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
}) })
+2 -4
ファイルの表示
@@ -262,9 +262,7 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0) expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
}) })
it ( it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
'shows child tags from the post tag tree in both vertical and horizontal layouts',
async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' }) const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({ const parentTag = buildTag ({
id: 11, id: 11,
@@ -309,7 +307,7 @@ describe ('TheatreDetailPage', () => {
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })) expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1) .toHaveLength (1)
}) })
}) })
it ('does not advance host post while video length is unknown', async () => { it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => { api.apiPut.mockImplementation ((path: string) => {
+10
ファイルの表示
@@ -0,0 +1,10 @@
import { describe, it } from 'vitest'
describe ('pending high-level browser coverage', () => {
it.todo ('adds MSW-backed API boundary tests in a follow-up issue')
it.todo ('covers TheatreDetailPage with timer polling, comment posting, and next-post updates')
it.todo ('covers NicoTagListPage linking and pagination against realistic API payloads')
it.todo ('covers TagDetailSidebar drag/drop parent-child editing with pointer-event fidelity')
it.todo ('covers TopNav desktop and mobile menu flows as browser-level integration tests')
it.todo ('covers full App bootstrap for user creation, user verification, and 503 handling')
})