Skip to content
Merged

develop #1431

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .dependency-cruiser.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ module.exports = {
{
name: 'not-to-deprecated',
comment:
'このモジュールは非推奨のnpmモジュール(またはそのバージョン)に依存しています。遅いバージョンにアップグレードするか、代わりとなるモジュールを見つけてください。非推奨のモジュールはセキュリティリスクとなる可能性があります。',
'このモジュールは非推奨のnpmモジュール(またはそのバージョン)に依存しています。新しいバージョンにアップグレードするか、代わりとなるモジュールを見つけてください。非推奨のモジュールはセキュリティリスクとなる可能性があります。',
severity: 'warn',
from: {},
to: {
Expand All @@ -135,9 +135,18 @@ module.exports = {
from: {},
to: {
couldNotResolve: true,
// dist/*.json は prebuild で生成されるビルド成果物のため lint 時には存在しない。
// 生成済みかどうかは build パイプラインの責務であり、依存関係解析の対象外とする。
pathNot: '^~/dist/.*\\.json$',
pathNot: [
// dist/*.json は prebuild で生成されるビルド成果物のため lint 時には存在しない。
// 生成済みかどうかは build パイプラインの責務であり、依存関係解析の対象外とする。
'^~/dist/.*\\.json$',
// styled-system は Panda CSS が `panda codegen`(npm prepare script)で生成する
// 成果物で、--ignore-scripts でインストールする環境(CI 等)には実体が存在しない。
// dist/*.json と同様、生成の要否は build パイプラインの責務とし解析対象外とする。
// (/.*)? のような入れ子量指定子は safe-regex の star-height チェックに
// 弾かれるため、量指定子を含まない2パターンに分けている。
'^~/styled-system$',
'^~/styled-system/',
],
},
},
{
Expand Down Expand Up @@ -174,7 +183,7 @@ module.exports = {
from: {
path: '^(src)',
pathNot: [
'[.](spec|test)[.](js|mjs|cjs|ts|ls|coffee|litcoffee|coffee[.]md)$',
'[.](spec|test)[.](js|mjs|cjs|jsx|ts|tsx|ls|coffee|litcoffee|coffee[.]md)$',
'[.]stories[.](js|mjs|cjs|ts|tsx)$',
],
},
Expand Down
68 changes: 51 additions & 17 deletions .github/workflows/pr-dependency-graph-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,32 @@ jobs:
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=AM "origin/$BASE_REF"...HEAD \
| grep -E 'src/.*\.(js|cjs|mjs|jsx|ts|tsx)$' \
# PR の変更ファイル一覧を取得する。Renamed(R) も対象に含める。
# git のリネーム検出が効くとき、リネームは新パスのみが出力される。
git diff --name-only --diff-filter=AMR "origin/$BASE_REF"...HEAD > all-changed.txt || true

# 依存解析対象の src コードファイルを抽出する(spec/test は除外)。
grep -E 'src/.*\.(js|cjs|mjs|jsx|ts|tsx)$' all-changed.txt \
| grep -v '\.\(spec\|test\)\.' > changed-files.txt || true

# ルート直下の設定ファイル変更を検出する。これらは依存ルール・依存分類・
# パス解決の全体に影響し、未変更ファイルの違反状況まで変わりうるため、
# 変更ファイルに絞らず src 全体を走査する full モードへ切り替える。
if grep -E -q '^(package\.json|\.dependency-cruiser\.js|tsconfig\.json)$' all-changed.txt; then
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi

# 変更 src コードファイルがなければスキップする(従来どおり)。
if [[ ! -s changed-files.txt ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi

# 変更 src ファイルに絞った targeted モード(従来どおり)。
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "mode=targeted" >> "$GITHUB_OUTPUT"
node <<'NODE' > focus.txt
const fs = require('fs');
const escapeRegExp = value => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
Expand All @@ -53,21 +69,34 @@ jobs:

- name: Run dependency-cruiser
if: steps.detect.outputs.skip == 'false'
env:
MODE: ${{ steps.detect.outputs.mode }}
run: |
mapfile -t files < changed-files.txt
focus="$(cat focus.txt)"
if [[ "$MODE" == 'full' ]]; then
# 設定ファイル変更時: スコープを絞る対象の変更 src ファイルが定まらないため、
# src 全体を走査する。出力は従来と同じ depcruise JSON 形式で、
# 後続の Build comment ステップがそのまま消費できる。
npx --no-install depcruise --cache -X "^node_modules" -T json src > dep-result.json

# 影響範囲(--reaches: 変更に依存するモジュール)は設定変更トリガーの
# 全走査では意味を持たないため dep-reaches.json は生成しない
# (Build comment は非存在時に影響範囲セクションをスキップする)。
else
mapfile -t files < changed-files.txt
focus="$(cat focus.txt)"

# 変更ファイルの依存関係を JSON で取得
npx --no-install depcruise --cache -X "^node_modules" --max-depth 3 \
--focus "$focus" -T json "${files[@]}" > dep-result.json
# 変更ファイルの依存関係を JSON で取得
npx --no-install depcruise --cache -X "^node_modules" --max-depth 3 \
--focus "$focus" -T json "${files[@]}" > dep-result.json

# 影響範囲(変更ファイルに依存しているモジュール)を JSON で取得
npx --no-install depcruise --cache -X "^node_modules" --max-depth 2 \
--reaches "$focus" src -T json > dep-reaches.json 2>/dev/null || true
# 影響範囲(変更ファイルに依存しているモジュール)を JSON で取得
npx --no-install depcruise --cache -X "^node_modules" --max-depth 2 \
--reaches "$focus" src -T json > dep-reaches.json 2>/dev/null || true

# JSON バリデーション(失敗時は空にする)
if ! jq empty dep-reaches.json 2>/dev/null; then
rm -f dep-reaches.json
# JSON バリデーション(失敗時は空にする)
if ! jq empty dep-reaches.json 2>/dev/null; then
rm -f dep-reaches.json
fi
fi

- name: Build comment
Expand All @@ -92,9 +121,12 @@ jobs:
echo '| 深刻度 | ルール | 対象ファイル | 依存先 | 説明 |' >> comment.md
echo '|---|---|---|---|---|' >> comment.md
jq -r '
(.summary.violations // [])[]
(.summary.ruleSetUsed.forbidden // []) as $rules
| (.summary.violations // [])[]
| select(.rule.severity == "error" or .rule.severity == "warn")
| "| \(.rule.severity) | `\(.rule.name)` | `\(.from)` | `\(.to)` | \(.comment // "-") |"
| .rule.name as $name
| ([$rules[] | select(.name == $name) | .comment] | first // "-") as $comment
| "| \(.rule.severity) | `\(.rule.name)` | `\(.from)` | `\(.to)` | \($comment) |"
' dep-result.json >> comment.md
echo '' >> comment.md
fi
Expand All @@ -106,7 +138,7 @@ jobs:
if [[ $reaches_count -gt 0 ]]; then
# 変更ファイル自身を除外して、影響を受ける側のモジュールだけ抽出
affected=$(jq -r '[.modules[].source] | sort | .[]' dep-reaches.json \
| grep -v -F -f changed-files.txt || true)
| grep -v -x -F -f changed-files.txt || true)

if [[ -n "$affected" ]]; then
affected_count=$(echo "$affected" | wc -l | tr -d ' ')
Expand All @@ -132,7 +164,9 @@ jobs:
echo '' >> comment.md
echo '```mermaid' >> comment.md
if [[ $mermaid_size -gt 50000 ]]; then
echo "$mermaid_output" | head -c 50000 >> comment.md
# head -c はバイト単位で切断され構文の途中で終わりうるため、
# 不完全な最終行を sed で落として mermaid 構文を壊さないようにする
echo "$mermaid_output" | head -c 50000 | sed '$d' >> comment.md
echo '' >> comment.md
echo '%% グラフが大きいため省略されました' >> comment.md
else
Expand Down
7 changes: 5 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Hero } from '@/components/Page/Home/Hero';
import { Heading } from '@/components/UI/Heading';
import { Container } from '@/components/UI/Layout/Container';
import { Stack } from '@/components/UI/Layout/Stack';
import { SITE_URL, TAG_VIEW_LIMIT } from '@/constants';
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL, TAG_VIEW_LIMIT } from '@/constants';
import { getOrganizationStructured, getWebSiteStructured } from '@/lib/domain/json-ld';
import { getFilteredPosts, getPopularPost, getRecentPosts, isIgnoredPostTag } from '@/lib/post/list';
import { getTagsWithCount } from '@/lib/source/tag';
Expand All @@ -23,6 +23,9 @@ const tags = tagsWithCount
.map((tag) => ({ ...tag, isNavigable: tag.count >= TAG_VIEW_LIMIT }));

export const metadata: Metadata = {
title: {
absolute: `${SITE_NAME} - ${SITE_DESCRIPTION}`,
},
alternates: {
canonical: SITE_URL,
},
Expand All @@ -36,7 +39,7 @@ export default function Page() {
<>
<StructuredData data={[webSiteStructured, organizationStructured]} />

<h1 className="sr-only">トップページ</h1>
<h1 className="sr-only">{`${SITE_NAME} - ${SITE_DESCRIPTION}`}</h1>

<Container size="default">
<Stack gap={800}>
Expand Down
3 changes: 2 additions & 1 deletion src/components/App/Search/SearchDialog/SearchHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react';

import { ICON_SIZE_XS } from '@/ui/iconSizes';
import { css, styled } from '@/ui/styled';
import { SEARCH_LABELS } from '../constants';
import { SearchClearButton } from './SearchClearButton';

interface SearchHeaderProps {
Expand Down Expand Up @@ -66,7 +67,7 @@ export function SearchHeader({ activeDescendantId, listId, onValueChange, onClea
'aria-autocomplete': 'list' as const,
'aria-controls': listId,
'aria-expanded': true,
placeholder: '記事タイトルまたはタグを検索',
placeholder: SEARCH_LABELS.searchPlaceholder,
role: 'combobox' as const,
})}
ref={refInput}
Expand Down
3 changes: 2 additions & 1 deletion src/components/App/Search/SearchPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { styled } from '@/ui/styled';
import { SEARCH_LABELS } from './constants';
import { SearchEmptyState } from './SearchPanel/SearchEmptyState';
import { SearchFooter } from './SearchPanel/SearchFooter';
import { SearchResultList } from './SearchPanel/SearchResultList';
Expand Down Expand Up @@ -30,7 +31,7 @@ export function SearchPanel({
const hasResults = results.length > 0;

return (
<SearchMain aria-label="サイト内検索">
<SearchMain aria-label={SEARCH_LABELS.siteSearch}>
<SearchStatus resultsCount={results.length} searchQuery={searchQuery} />

{hasResults ? (
Expand Down
6 changes: 5 additions & 1 deletion src/components/App/Search/SearchPanel/SearchFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Stack } from '@/components/UI/Layout/Stack';
import { SITE_URL } from '@/constants';
import { styled } from '@/ui/styled';
import { truncateQuery } from './utils/createSearchMessage';

// Google の site: 検索スコープ。SITE_URL からホスト名を導出し、ドメイン変更時の直書き残留を防ぐ
const SITE_SEARCH_SCOPE = `site:${new URL(SITE_URL).host}`;

type SearchFooterProps = {
searchQuery?: string;
};
Expand All @@ -10,7 +14,7 @@ type SearchFooterProps = {
* @summary キーボードショートカット案内と Google 検索リンクを表示するフッター。
*/
export function SearchFooter({ searchQuery }: SearchFooterProps) {
const query = searchQuery ? `site:b.0218.jp ${searchQuery}` : 'site:b.0218.jp';
const query = searchQuery ? `${SITE_SEARCH_SCOPE} ${searchQuery}` : SITE_SEARCH_SCOPE;
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
const displayQuery = searchQuery ? truncateQuery(searchQuery) : '';

Expand Down
9 changes: 8 additions & 1 deletion src/components/App/Search/SearchPanel/SearchResultList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { styled } from '@/ui/styled';
import { SEARCH_LABELS, SEARCH_RESULTS_MARKER_PROPS } from '../constants';
import type { SearchResultItem as SearchResultItemType } from '../types';
import { createSearchResultId } from '../utils/resultId';
import { SearchResultItem } from './SearchResultItem';
Expand Down Expand Up @@ -56,7 +57,13 @@ export function SearchResultList({
}, []);

return (
<Container aria-label="検索結果" data-search-results id={listId} ref={containerRef} role="listbox">
<Container
aria-label={SEARCH_LABELS.searchResults}
id={listId}
ref={containerRef}
role="listbox"
{...SEARCH_RESULTS_MARKER_PROPS}
>
<Sizer ref={sizerRef}>
{results.map(({ slug, matchedIn }, index) => (
<SearchResultItem
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from 'vitest';
import { createMarkedTitles } from './markEscapedHTML';

test('全角スペース区切りキーワードの場合、各語をハイライトする', () => {
const [segments] = createMarkedTitles([{ title: 'React と TypeScript の入門' }], 'React TypeScript');

const markedTexts = segments.filter((segment) => segment.marked).map((segment) => segment.text);

expect(markedTexts).toEqual(['React', 'TypeScript']);
});

test('半角スペース区切りキーワードの場合、各語をハイライトする', () => {
const [segments] = createMarkedTitles([{ title: 'React と TypeScript の入門' }], 'React TypeScript');

const markedTexts = segments.filter((segment) => segment.marked).map((segment) => segment.text);

expect(markedTexts).toEqual(['React', 'TypeScript']);
});

test('キーワードが空の場合、全体を非マークの 1 セグメントで返す', () => {
const [segments] = createMarkedTitles([{ title: 'React と TypeScript の入門' }], '');

expect(segments).toEqual([{ text: 'React と TypeScript の入門', marked: false, start: 0 }]);
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { QUERY_WHITESPACE_REGEX } from '../../constants';

const keywordSplitCache = new Map<string, string[]>();
const KEYWORD_CACHE_SIZE = 50;

Expand Down Expand Up @@ -67,7 +69,7 @@ export const createMarkedTitles = (suggestions: { title: string }[], keyword: st
let splitKeyword = keywordSplitCache.get(keyword);

if (!splitKeyword) {
splitKeyword = keyword.split(' ').filter((word) => word.trim() !== '');
splitKeyword = keyword.split(QUERY_WHITESPACE_REGEX).filter((word) => word.trim() !== '');

if (keywordSplitCache.size > KEYWORD_CACHE_SIZE) {
const firstKey = keywordSplitCache.keys().next().value;
Expand Down
22 changes: 21 additions & 1 deletion src/components/App/Search/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
export const SEARCH_LABELS = {
searchTitle: '記事検索',
searchDescription: '記事のタイトルから検索することができます',
searchPlaceholder: '記事タイトルやタグから検索する',
searchPlaceholder: '記事タイトルまたはタグを検索',
searchResults: '検索結果',
siteSearch: 'サイト内検索',
} as const;

/**
* 検索結果リストコンテナの DOM 契約。
* JSX 側の属性付与(SearchResultList)とセレクタ参照(useSearchDOMRefs / useSearchNavigation)が
* この定数の対で結ばれており、片方だけ変えるとキーボードナビとスクロール制御が沈黙して壊れる。
*/
export const SEARCH_RESULTS_ATTRIBUTE = 'data-search-results';
export const SEARCH_RESULTS_SELECTOR = `[${SEARCH_RESULTS_ATTRIBUTE}]`;

/** SEARCH_RESULTS_SELECTOR と対になる属性。JSX へは常にこれを spread して付与し、複数箇所での再定義によるドリフトを防ぐ */
export const SEARCH_RESULTS_MARKER_PROPS = { [SEARCH_RESULTS_ATTRIBUTE]: '' };

/**
* 検索入力欄と判定する契約セレクタ。SearchHeader の input(type="search" + role="combobox")が満たす。
* useSearchDOMRefs の入力欄取得と useSearchNavigation のキーイベント判定で共有する。
*/
export const SEARCH_INPUT_SELECTOR = 'input[type="search"], input[role="searchbox"], input[role="combobox"]';

/** 検索クエリの空白分割契約。エンジンのトークン分割とハイライターのキーワード分割で共有する(乖離するとマッチしたのにハイライトされない) */
export const QUERY_WHITESPACE_REGEX = /\s+/;
8 changes: 4 additions & 4 deletions src/components/App/Search/engine/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ describe('SearchCache', () => {
/* createKey */
/* ================================================================ */
describe('createKey', () => {
it('searchValue と dataSize を結合したキーを生成する', () => {
const key = SearchCache.createKey('react', 100);
it('searchValue を正規化したキーを生成する', () => {
const key = SearchCache.createKey('react');

expect(key).toBe('react-100');
expect(key).toBe('react');
});

it('大文字小文字と前後スペースの違いを同じキーに正規化する', () => {
expect(SearchCache.createKey(' React ', 100)).toBe(SearchCache.createKey('react', 100));
expect(SearchCache.createKey(' React ')).toBe(SearchCache.createKey('react'));
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/components/App/Search/engine/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class SearchCache {
this.cache.set(key, value);
}

static createKey(searchValue: string, dataSize: number): string {
return `${normalizeSearchToken(searchValue)}-${dataSize}`;
static createKey(searchValue: string): string {
return normalizeSearchToken(searchValue);
}
}
5 changes: 2 additions & 3 deletions src/components/App/Search/engine/indexedSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { normalizeSearchToken, type SearchDataItem, type SearchIndex } from '@/lib/search';
import { QUERY_WHITESPACE_REGEX } from '../constants';
import type { SearchResultItem } from '../types';
import { isEmptyQuery } from '../utils/validation';
import type { SearchDataPayload } from './types';
Expand All @@ -24,8 +25,6 @@ const SCORE = {
fallbackEarlyPosition: 10,
} as const;

const WHITESPACE_REGEX = /\s+/;

interface NormalizedSearchDataItem extends SearchDataItem {
titleLower: string;
tagsLower: string[];
Expand Down Expand Up @@ -89,7 +88,7 @@ function tokenizeQuery(query: string): string[] {
const normalized = normalizeSearchToken(query);
if (!normalized) return [];

const tokens = normalized.split(WHITESPACE_REGEX).filter((token) => token.length > 0);
const tokens = normalized.split(QUERY_WHITESPACE_REGEX).filter((token) => token.length > 0);
return [...new Set(tokens)];
}

Expand Down
Loading
Loading