diff --git a/.env.example b/.env.example
index 27d61180..861b052d 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.5
+APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index bec41e3b..ecfb2ee2 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.5
+APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e7ce37e..eeede335 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.6] - 2026-07-19
+
+### Added
+
+- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
+- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다.
+- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다.
+- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다.
+- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
+- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다.
+- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
+- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
+
+### Fixed
+
+- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
+
## [7.0.5] - 2026-07-16
### Added
diff --git a/INSTALL.md b/INSTALL.md
index 6c55f3cf..0f49bfba 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.5 g7
+# (필요 시) mv g7-7.0.6 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.md b/README.md
index 8a4c6494..63ae0606 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
diff --git a/app/Console/Commands/SeoGenerateSitemapCommand.php b/app/Console/Commands/SeoGenerateSitemapCommand.php
index 1198e680..9bf5f1aa 100644
--- a/app/Console/Commands/SeoGenerateSitemapCommand.php
+++ b/app/Console/Commands/SeoGenerateSitemapCommand.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
+use App\Enums\SitemapGenerationMode;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
@@ -17,7 +18,10 @@ class SeoGenerateSitemapCommand extends Command
/**
* @var string 커맨드 시그니처
*/
- protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
+ protected $signature = 'seo:generate-sitemap
+ {--sync : 큐 드라이버를 무시하고 동기 실행}
+ {--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)}
+ {--mode=auto : 재생성 모드 (full|auto|incremental)}';
/**
* @var string 커맨드 설명
@@ -31,6 +35,14 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
+ $mode = $this->resolveMode();
+ if ($mode === null) {
+ $allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases()));
+ $this->error('잘못된 모드입니다. 허용: '.$allowed);
+
+ return Command::INVALID;
+ }
+
$forceSync = (bool) $this->option('sync');
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
@@ -38,13 +50,29 @@ class SeoGenerateSitemapCommand extends Command
$isSyncDriver = $connection === 'sync';
if ($forceSync || $isSyncDriver) {
- GenerateSitemapJob::dispatchSync();
- $this->info('Sitemap이 생성되었습니다.');
+ GenerateSitemapJob::dispatchSync($mode);
+ $this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})");
} else {
- GenerateSitemapJob::dispatch();
- $this->info('Sitemap 생성이 큐에 디스패치되었습니다.');
+ GenerateSitemapJob::dispatch($mode);
+ $this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})");
}
return Command::SUCCESS;
}
+
+ /**
+ * --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다.
+ *
+ * --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다.
+ *
+ * @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null)
+ */
+ private function resolveMode(): ?SitemapGenerationMode
+ {
+ if ((bool) $this->option('rebuild')) {
+ return SitemapGenerationMode::Full;
+ }
+
+ return SitemapGenerationMode::tryFrom((string) $this->option('mode'));
+ }
}
diff --git a/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php
new file mode 100644
index 00000000..c8b7cda9
--- /dev/null
+++ b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php
@@ -0,0 +1,43 @@
+ $attributes 기록할 속성 (url, locale, layout_name, module_identifier, type, response_time_ms)
+ */
+ public function record(array $attributes): void;
+
+ /**
+ * 전체 캐시 통계를 집계합니다.
+ *
+ * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
+ * @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과
+ */
+ public function aggregate(?Carbon $since = null): array;
+
+ /**
+ * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다.
+ *
+ * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier)
+ * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
+ * @return array 그룹별 집계 결과
+ */
+ public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array;
+
+ /**
+ * 기준 시점보다 오래된 통계 레코드를 삭제합니다.
+ *
+ * @param Carbon $cutoff 기준 시점
+ * @return int 삭제된 레코드 수
+ */
+ public function deleteOlderThan(Carbon $cutoff): int;
+}
diff --git a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php
new file mode 100644
index 00000000..3e97a5b9
--- /dev/null
+++ b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php
@@ -0,0 +1,62 @@
+ $entries URL 항목
+ */
+ public function upsertForResource(string $type, string $id, array $entries): void;
+
+ /**
+ * 한 리소스의 사이트맵 URL 행을 모두 제거합니다(비공개/삭제 시).
+ *
+ * @param string $type 리소스 유형
+ * @param string $id 리소스 PK (문자열)
+ */
+ public function removeForResource(string $type, string $id): void;
+
+ /**
+ * 공개(is_visible) URL 을 id 순으로 스트리밍합니다(유계 메모리).
+ *
+ * @param string|null $contributor 기여자 식별자로 스코핑 (null = 전체)
+ * @return iterable URL 순회자
+ */
+ public function streamVisible(?string $contributor = null): iterable;
+
+ /**
+ * 공개(is_visible) URL 총 개수를 반환합니다.
+ *
+ * @return int 공개 URL 수
+ */
+ public function countVisible(): int;
+
+ /**
+ * 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성).
+ *
+ * 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여
+ * 대용량에서도 메모리를 유계로 유지합니다.
+ *
+ * @param string $contributor 기여자 식별자
+ * @param iterable $entries URL 항목 스트림
+ * @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적)
+ */
+ public function replaceAllForContributor(string $contributor, iterable $entries): int;
+}
diff --git a/app/Enums/SitemapChangeFreq.php b/app/Enums/SitemapChangeFreq.php
new file mode 100644
index 00000000..28c2046c
--- /dev/null
+++ b/app/Enums/SitemapChangeFreq.php
@@ -0,0 +1,70 @@
+ 값 Enum (sitemaps.org 폐쇄 어휘)
+ *
+ * https://www.sitemaps.org/protocol.html 의 는 크롤러에게 페이지 갱신
+ * 빈도를 알려주는 힌트로, 허용 값이 7개로 고정된 폐쇄 어휘다. 이 Enum 이 그 SSoT 이며,
+ * 잘못된 문자열이 사이트맵 XML 에 유입되는 것을 저장(SitemapIndexer)과 렌더
+ * (SitemapXmlRenderer) 두 경계에서 차단한다.
+ *
+ * 기여자(getUrlsLazy)·리스너·정적 URL 은 리터럴 문자열 대신 이 Enum 의 case 를 사용해
+ * 오타를 작성 시점에 잡는다.
+ */
+enum SitemapChangeFreq: string
+{
+ /**
+ * 접근할 때마다 변경 (예: 실시간 시세)
+ */
+ case Always = 'always';
+
+ /**
+ * 시간 단위 변경
+ */
+ case Hourly = 'hourly';
+
+ /**
+ * 일 단위 변경
+ */
+ case Daily = 'daily';
+
+ /**
+ * 주 단위 변경
+ */
+ case Weekly = 'weekly';
+
+ /**
+ * 월 단위 변경
+ */
+ case Monthly = 'monthly';
+
+ /**
+ * 연 단위 변경
+ */
+ case Yearly = 'yearly';
+
+ /**
+ * 사실상 변경 없음 (예: 보관 URL)
+ */
+ case Never = 'never';
+
+ /**
+ * 임의 문자열을 유효한 changefreq 값으로 정규화합니다.
+ *
+ * 대소문자·앞뒤 공백을 흡수하고, 폐쇄 어휘에 없는 값은 null 로 떨어뜨려
+ * 사이트맵 XML 에 비표준 값이 출력되는 것을 방지합니다.
+ *
+ * @param string|null $value 검증할 원본 값
+ * @return string|null 유효하면 정규화된 값, 아니면 null
+ */
+ public static function normalize(?string $value): ?string
+ {
+ if ($value === null || trim($value) === '') {
+ return null;
+ }
+
+ return self::tryFrom(strtolower(trim($value)))?->value;
+ }
+}
diff --git a/app/Enums/SitemapGenerationMode.php b/app/Enums/SitemapGenerationMode.php
new file mode 100644
index 00000000..32384d88
--- /dev/null
+++ b/app/Enums/SitemapGenerationMode.php
@@ -0,0 +1,48 @@
+ self::Full,
+ self::Incremental => self::Incremental,
+ self::Auto => $visibleCount === 0 ? self::Full : self::Incremental,
+ };
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php
index c449ab55..8f3e7be8 100644
--- a/app/Http/Controllers/Api/Admin/SeoCacheController.php
+++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php
@@ -2,13 +2,17 @@
namespace App\Http\Controllers\Api\Admin;
+use App\Enums\SitemapGenerationMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCacheClearRequest;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheStatsService;
use App\Seo\SitemapManager;
+use App\Seo\SitemapProgress;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Auth;
/**
* SEO 캐시 관리 컨트롤러
@@ -20,7 +24,8 @@ class SeoCacheController extends AdminBaseController
public function __construct(
private SeoCacheStatsService $statsService,
private SeoCacheManagerInterface $cacheManager,
- private SitemapManager $sitemapManager
+ private SitemapManager $sitemapManager,
+ private SitemapProgress $sitemapProgress
) {
parent::__construct();
}
@@ -54,7 +59,7 @@ class SeoCacheController extends AdminBaseController
*
* 레이아웃 또는 모듈 지정 시 해당 캐시만, 미지정 시 전체 캐시를 삭제합니다.
*
- * @param SeoCacheClearRequest $request 캐시 삭제 요청
+ * @param SeoCacheClearRequest $request 캐시 삭제 요청
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function clearCache(SeoCacheClearRequest $request): JsonResponse
@@ -97,28 +102,39 @@ class SeoCacheController extends AdminBaseController
}
/**
- * Sitemap XML 을 즉시 재생성합니다.
+ * Sitemap XML 재생성을 큐에 예약합니다.
*
- * 큐 드라이버와 무관하게 동기 실행되며, 생성 완료 후 last_updated_at 을 갱신합니다.
+ * 대용량(수백만 URL) 사이트에서 요청 스레드 동기 생성은 메모리/타임아웃 붕괴를 일으키므로
+ * 큐 잡으로 위임합니다. 응답은 예약 시점의 상태이며, 실제 완료 시각은 이후 갱신됩니다.
*
- * @return JsonResponse 재생성 결과 JSON 응답
+ * @return JsonResponse 예약 결과 JSON 응답
*/
public function regenerateSitemap(): JsonResponse
{
- $result = $this->sitemapManager->regenerate();
-
- if ($result['success']) {
- return $this->success('seo.sitemap_regenerated', $result['data'] ?? null);
+ if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
+ return $this->error('seo.sitemap_disabled', 400);
}
- $messageKey = match ($result['status']) {
- 'disabled' => 'seo.sitemap_disabled',
- default => 'seo.sitemap_regenerate_failed',
- };
+ // 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7)
+ // 실행한 관리자 ID 를 함께 실어, 완료/실패 시 그 관리자에게만 알림이 발송되게 한다.
+ GenerateSitemapJob::dispatch(SitemapGenerationMode::Full, Auth::id());
- $statusCode = $result['status'] === 'disabled' ? 400 : 500;
+ // 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록
+ $this->sitemapProgress->start(SitemapGenerationMode::Full->value);
- return $this->error($messageKey, $statusCode, $result['message'] ?? null);
+ return $this->success('seo.sitemap_regenerate_dispatched', $this->sitemapManager->getStatus());
+ }
+
+ /**
+ * Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다.
+ *
+ * SEO 탭 진입 시 초기 로드용이며, 폴링(Reverb OFF)일 때 주기적으로 재조회됩니다.
+ *
+ * @return JsonResponse 진행상황(progress) + last_updated_at + realtime_enabled 를 포함한 JSON 응답
+ */
+ public function sitemapStatus(): JsonResponse
+ {
+ return $this->success('messages.success', $this->sitemapManager->getStatus());
}
/**
diff --git a/app/Http/Controllers/Api/Public/SitemapController.php b/app/Http/Controllers/Api/Public/SitemapController.php
index 9a5d6e88..fdd6d2d6 100644
--- a/app/Http/Controllers/Api/Public/SitemapController.php
+++ b/app/Http/Controllers/Api/Public/SitemapController.php
@@ -4,42 +4,92 @@ namespace App\Http\Controllers\Api\Public;
use App\Contracts\Extension\CacheInterface;
use App\Http\Controllers\Controller;
-use App\Seo\SitemapGenerator;
-use Illuminate\Http\Response;
+use App\Jobs\GenerateSitemapJob;
+use App\Seo\SitemapFileStore;
+use App\Seo\SitemapManager;
+use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Sitemap XML 컨트롤러
*
- * sitemap.xml 요청을 처리하여 캐시된 또는 실시간 생성된
- * Sitemap XML을 반환합니다.
+ * 비공개 디스크에 커밋된 sitemap 인덱스/자식 파일을 스트리밍으로 서빙합니다.
+ * 봇 요청 스레드에서 sitemap 을 생성하지 않으며(대용량에서 OOM/타임아웃 원인),
+ * 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale)를 그대로 내보냅니다.
*/
class SitemapController extends Controller
{
/**
- * sitemap.xml을 반환합니다.
- *
- * 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장.
- *
- * @param SitemapGenerator $generator Sitemap 생성기
- * @param CacheInterface $cache 코어 캐시 드라이버
+ * 세트가 없어 503 을 반환할 때 봇에 안내할 재시도 간격 (초)
*/
- public function index(SitemapGenerator $generator, CacheInterface $cache): Response
+ private const RETRY_AFTER_SECONDS = 120;
+
+ /**
+ * sitemapindex 를 반환합니다.
+ *
+ * 커밋된 세트가 있으면 스트리밍하고, 신선도(메타 캐시)가 만료되었으면
+ * 생성 잡을 예약합니다. 세트가 전혀 없으면 503 + Retry-After 를 반환합니다.
+ *
+ * @param SitemapFileStore $store sitemap 파일 저장소 (읽기측)
+ * @param CacheInterface $cache 코어 캐시 드라이버
+ * @return StreamedResponse sitemapindex 스트림
+ */
+ public function index(SitemapFileStore $store, CacheInterface $cache): StreamedResponse
{
- $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
- if (! $enabled) {
+ $this->assertSitemapEnabled();
+
+ $isFresh = $cache->get(SitemapManager::META_CACHE_KEY) !== null;
+
+ if ($isFresh) {
+ $response = $store->indexResponse();
+ if ($response !== null) {
+ return $response;
+ }
+ }
+
+ // 신선도 만료 또는 세트 부재 — 생성은 큐에 맡긴다 (ShouldBeUnique 로 중복 디스패치 방지).
+ GenerateSitemapJob::dispatch();
+
+ $serveStale = (bool) g7_core_settings('seo.sitemap_serve_stale_on_miss', true);
+
+ if ($serveStale) {
+ $response = $store->indexResponse();
+ if ($response !== null) {
+ return $response;
+ }
+ }
+
+ abort(503, __('seo.sitemap_not_ready'), ['Retry-After' => (string) self::RETRY_AFTER_SECONDS]);
+ }
+
+ /**
+ * 자식 sitemap 을 반환합니다.
+ *
+ * gzip 여부는 manifest 가 결정하므로 `.xml` / `.xml.gz` 요청을 같은 액션에서 처리합니다.
+ *
+ * @param int $n 자식 번호 (1부터)
+ * @param SitemapFileStore $store sitemap 파일 저장소 (읽기측)
+ * @return StreamedResponse 자식 sitemap 스트림
+ */
+ public function child(int $n, SitemapFileStore $store): StreamedResponse
+ {
+ $this->assertSitemapEnabled();
+
+ $response = $store->childResponse($n);
+
+ if ($response === null) {
abort(404);
}
- $xml = $cache->get('seo.sitemap');
+ return $response;
+ }
- if (! $xml) {
- $xml = $generator->generate();
- $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400));
- $cache->put('seo.sitemap', $xml, $ttl);
+ /**
+ * Sitemap 기능이 비활성화되어 있으면 404 로 중단합니다.
+ */
+ private function assertSitemapEnabled(): void
+ {
+ if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
+ abort(404);
}
-
- return response($xml, 200, [
- 'Content-Type' => 'application/xml',
- ]);
}
}
diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php
index 7708710b..eb03e9bb 100644
--- a/app/Http/Requests/Settings/SaveSettingsRequest.php
+++ b/app/Http/Requests/Settings/SaveSettingsRequest.php
@@ -220,6 +220,12 @@ class SaveSettingsRequest extends FormRequest
'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'],
'seo.sitemap_enabled' => ['nullable', 'boolean'],
'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
+ // 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다.
+ 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'],
+ 'seo.sitemap_gzip' => ['nullable', 'boolean'],
+ 'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'],
+ 'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'],
+ 'seo.sitemap_hreflang_enabled' => ['nullable', 'boolean'],
'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])],
'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'],
'seo.generator_enabled' => ['nullable', 'boolean'],
@@ -242,6 +248,9 @@ class SaveSettingsRequest extends FormRequest
'advanced.stats_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
'advanced.seo_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
+ // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지.
+ // 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다.
+ 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
// 디버그 설정 (advanced 탭)
'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'),
@@ -605,6 +614,14 @@ class SaveSettingsRequest extends FormRequest
'seo.sitemap_cache_ttl.integer' => __('validation.settings.sitemap_cache_ttl_integer'),
'seo.sitemap_cache_ttl.min' => __('validation.settings.sitemap_cache_ttl_min'),
'seo.sitemap_cache_ttl.max' => __('validation.settings.sitemap_cache_ttl_max'),
+ 'seo.sitemap_urls_per_file.integer' => __('validation.settings.sitemap_urls_per_file_integer'),
+ 'seo.sitemap_urls_per_file.min' => __('validation.settings.sitemap_urls_per_file_min'),
+ 'seo.sitemap_urls_per_file.max' => __('validation.settings.sitemap_urls_per_file_max'),
+ 'seo.sitemap_gzip.boolean' => __('validation.settings.sitemap_gzip_boolean'),
+ 'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'),
+ 'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'),
+ 'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'),
+ 'seo.sitemap_hreflang_enabled.boolean' => __('validation.settings.sitemap_hreflang_enabled_boolean'),
'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'),
'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'),
@@ -639,12 +656,15 @@ class SaveSettingsRequest extends FormRequest
'advanced.stats_cache_ttl.integer' => __('validation.settings.stats_cache_ttl_integer'),
'advanced.stats_cache_ttl.min' => __('validation.settings.stats_cache_ttl_min'),
'advanced.stats_cache_ttl.max' => __('validation.settings.stats_cache_ttl_max'),
- 'advanced.seo_cache_enabled.required' => __('validation.settings.seo_cache_enabled_required'),
- 'advanced.seo_cache_enabled.boolean' => __('validation.settings.seo_cache_enabled_boolean'),
- 'advanced.seo_cache_ttl.required' => __('validation.settings.seo_cache_ttl_required'),
- 'advanced.seo_cache_ttl.integer' => __('validation.settings.seo_cache_ttl_integer'),
- 'advanced.seo_cache_ttl.min' => __('validation.settings.seo_cache_ttl_min'),
- 'advanced.seo_cache_ttl.max' => __('validation.settings.seo_cache_ttl_max'),
+ 'advanced.seo_cache_enabled.required' => __('validation.settings.advanced_seo_cache_enabled_required'),
+ 'advanced.seo_cache_enabled.boolean' => __('validation.settings.advanced_seo_cache_enabled_boolean'),
+ 'advanced.seo_cache_ttl.required' => __('validation.settings.advanced_seo_cache_ttl_required'),
+ 'advanced.seo_cache_ttl.integer' => __('validation.settings.advanced_seo_cache_ttl_integer'),
+ 'advanced.seo_cache_ttl.min' => __('validation.settings.advanced_seo_cache_ttl_min'),
+ 'advanced.seo_cache_ttl.max' => __('validation.settings.advanced_seo_cache_ttl_max'),
+ 'advanced.seo_sitemap_cache_ttl.integer' => __('validation.settings.seo_sitemap_cache_ttl_integer'),
+ 'advanced.seo_sitemap_cache_ttl.min' => __('validation.settings.seo_sitemap_cache_ttl_min'),
+ 'advanced.seo_sitemap_cache_ttl.max' => __('validation.settings.seo_sitemap_cache_ttl_max'),
// 디버그 설정
'advanced.debug_mode.required' => __('validation.settings.debug_mode_required'),
diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php
index df419daf..be491038 100644
--- a/app/Jobs/GenerateSitemapJob.php
+++ b/app/Jobs/GenerateSitemapJob.php
@@ -2,8 +2,12 @@
namespace App\Jobs;
+use App\Enums\SitemapGenerationMode;
+use App\Extension\HookManager;
use App\Seo\SitemapManager;
+use App\Seo\SitemapProgress;
use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -13,22 +17,82 @@ use Illuminate\Support\Facades\Log;
/**
* Sitemap XML 생성 큐 잡
*
- * 스케줄러 또는 Artisan 커맨드에서 디스패치되며,
+ * 스케줄러, Artisan 커맨드, 관리자 수동 재생성, 봇 요청 캐시 미스에서 디스패치되며,
* 실제 생성 로직은 SitemapManager 서비스에 위임합니다.
+ *
+ * 대용량(수백만 URL) 생성은 수 분~수십 분이 걸리므로 동시 실행을 막고(ShouldBeUnique)
+ * 타임아웃/재시도 한계를 넉넉히 잡되, 재시도가 무한히 누적되지 않도록 retryUntil 로 제한합니다.
*/
-class GenerateSitemapJob implements ShouldQueue
+class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ /**
+ * @var string 전용 큐 이름 — 대용량·장시간 사이트맵 생성을 default 큐에서 분리한다.
+ *
+ * 이 큐에는 반드시 워커를 1개만 배치한다(동시 중복 생성 방지). 그러면
+ * 긴 생성 작업이 default 큐의 방송/알림을 막지 않아 진행상황이 실시간으로 흐르고,
+ * 워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않는다.
+ */
+ public const QUEUE = 'sitemap';
+
/**
* @var int 최대 재시도 횟수
*/
public int $tries = 3;
/**
- * @var int 타임아웃 (초)
+ * @var int 타임아웃 (초) — 대용량 스트리밍 생성 대응
*/
- public int $timeout = 300;
+ public int $timeout = 1800;
+
+ /**
+ * @var int 재시도 대기 시간 (초)
+ */
+ public int $backoff = 120;
+
+ /**
+ * @var int 유니크 락 유지 시간 (초) — 잡이 비정상 종료해도 이 시간 후 자동 해제
+ */
+ public int $uniqueFor = 900;
+
+ /**
+ * GenerateSitemapJob 생성자
+ *
+ * @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달
+ * @param int|null $triggeredBy 관리자 수동 재생성을 실행한 사용자 ID(스케줄러/증분/봇 = null).
+ * 완료/실패 알림의 수신자(수동 실행자) 판정에 사용됩니다.
+ */
+ public function __construct(
+ public SitemapGenerationMode $mode = SitemapGenerationMode::Auto,
+ public ?int $triggeredBy = null,
+ ) {
+ $this->onQueue(self::QUEUE);
+ }
+
+ /**
+ * 유니크 락 식별자를 반환합니다.
+ *
+ * 봇 요청 캐시 미스마다 디스패치되어도 큐에는 한 건만 남도록 고정 키를 사용합니다.
+ *
+ * @return string 유니크 락 키
+ */
+ public function uniqueId(): string
+ {
+ return 'seo-sitemap';
+ }
+
+ /**
+ * 재시도 마감 시각을 반환합니다.
+ *
+ * timeout(1800초) × tries(3) 만큼 재시도가 누적되는 것을 막습니다.
+ *
+ * @return \DateTime 재시도 마감 시각
+ */
+ public function retryUntil(): \DateTime
+ {
+ return now()->addMinutes(30)->toDateTime();
+ }
/**
* Sitemap 을 생성하고 캐시에 저장합니다.
@@ -37,7 +101,7 @@ class GenerateSitemapJob implements ShouldQueue
*/
public function handle(SitemapManager $manager): void
{
- $result = $manager->regenerate();
+ $result = $manager->regenerate($this->mode, $this->triggeredBy);
if (($result['status'] ?? null) === 'disabled') {
Log::info('[SEO] Sitemap generation skipped (disabled)');
@@ -62,6 +126,18 @@ class GenerateSitemapJob implements ShouldQueue
*/
public function failed(\Throwable $exception): void
{
+ // 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지
+ app(SitemapProgress::class)->fail($exception->getMessage());
+
+ // 최종 실패 전용 훅 — 매 시도마다 발화하는 core.seo.sitemap.after_regenerate_failed 와 달리
+ // 재시도가 모두 소진된 뒤 딱 한 번만 발화한다. 재시도 중 성공하면 발화하지 않으므로
+ // 실패 알림이 중복되거나(각 시도) 결국 성공했는데도 발송되는 일이 없다.
+ HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [
+ 'success' => false,
+ 'status' => 'failed',
+ 'message' => $exception->getMessage(),
+ ], $this->triggeredBy);
+
Log::error('[SEO] Sitemap generation failed', [
'error' => $exception->getMessage(),
]);
diff --git a/app/Listeners/SeoNotificationDataListener.php b/app/Listeners/SeoNotificationDataListener.php
new file mode 100644
index 00000000..ea39ad4b
--- /dev/null
+++ b/app/Listeners/SeoNotificationDataListener.php
@@ -0,0 +1,133 @@
+ [
+ 'method' => 'extractData',
+ 'priority' => 20,
+ 'type' => 'filter',
+ ],
+ ];
+ }
+
+ /**
+ * 훅 이벤트를 처리합니다(HookListenerInterface 필수 메서드).
+ *
+ * @param mixed ...$args 훅에서 전달된 인수들
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 알림 유형에 따라 데이터와 컨텍스트를 추출합니다.
+ *
+ * @param array $default 기본 extract_data 구조
+ * @param string $type 알림 정의 유형
+ * @param array $args 훅에서 전달된 원본 인수 [$result, $triggeredBy]
+ * @return array{notifiable: null, notifiables: null, data: array, context: array}
+ */
+ public function extractData(array $default, string $type, array $args): array
+ {
+ return match ($type) {
+ 'sitemap_regenerated' => $this->extractRegenerated($args),
+ 'sitemap_regenerate_failed' => $this->extractRegenerateFailed($args),
+ default => $default,
+ };
+ }
+
+ /**
+ * 사이트맵 재생성 완료 알림 데이터를 추출합니다.
+ *
+ * @param array $args 훅 인수 [$result, $triggeredBy]
+ * @return array
+ */
+ private function extractRegenerated(array $args): array
+ {
+ $triggeredBy = $args[1] ?? null;
+ if (! is_int($triggeredBy)) {
+ return $this->skip();
+ }
+
+ $result = is_array($args[0] ?? null) ? $args[0] : [];
+ $data = $result['data'] ?? [];
+ $baseUrl = config('app.url');
+
+ return [
+ 'notifiable' => null,
+ 'notifiables' => null,
+ 'data' => [
+ 'app_name' => config('app.name'),
+ 'url_count' => (string) ($data['url_count'] ?? 0),
+ 'child_count' => (string) ($data['child_count'] ?? 1),
+ 'action_url' => "{$baseUrl}/admin/settings?tab=seo",
+ 'site_url' => $baseUrl,
+ ],
+ 'context' => [
+ 'trigger_user_id' => $triggeredBy,
+ ],
+ ];
+ }
+
+ /**
+ * 사이트맵 재생성 실패 알림 데이터를 추출합니다.
+ *
+ * @param array $args 훅 인수 [$result, $triggeredBy]
+ * @return array
+ */
+ private function extractRegenerateFailed(array $args): array
+ {
+ $triggeredBy = $args[1] ?? null;
+ if (! is_int($triggeredBy)) {
+ return $this->skip();
+ }
+
+ $result = is_array($args[0] ?? null) ? $args[0] : [];
+ $baseUrl = config('app.url');
+
+ return [
+ 'notifiable' => null,
+ 'notifiables' => null,
+ 'data' => [
+ 'app_name' => config('app.name'),
+ 'error' => (string) ($result['message'] ?? ''),
+ 'action_url' => "{$baseUrl}/admin/settings?tab=seo",
+ 'site_url' => $baseUrl,
+ ],
+ 'context' => [
+ 'trigger_user_id' => $triggeredBy,
+ ],
+ ];
+ }
+
+ /**
+ * 발송을 건너뛰도록 지시하는 결과를 반환합니다(비수동 재생성 = 유발 관리자 없음).
+ *
+ * @return array
+ */
+ private function skip(): array
+ {
+ return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => ['skip' => true]];
+ }
+}
diff --git a/app/Models/SitemapUrl.php b/app/Models/SitemapUrl.php
new file mode 100644
index 00000000..72318add
--- /dev/null
+++ b/app/Models/SitemapUrl.php
@@ -0,0 +1,61 @@
+ 대량 할당 가능 컬럼
+ */
+ protected $fillable = [
+ 'resource_type',
+ 'resource_id',
+ 'loc',
+ 'loc_hash',
+ 'lastmod',
+ 'changefreq',
+ 'priority',
+ 'contributor',
+ 'is_visible',
+ ];
+
+ /**
+ * 속성 캐스팅을 반환합니다.
+ *
+ * @return array 캐스팅 맵
+ */
+ protected function casts(): array
+ {
+ return [
+ 'lastmod' => 'datetime',
+ 'priority' => 'float',
+ 'is_visible' => 'boolean',
+ ];
+ }
+}
diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php
index 00cbac47..d827d885 100644
--- a/app/Providers/CoreServiceProvider.php
+++ b/app/Providers/CoreServiceProvider.php
@@ -32,6 +32,7 @@ use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Contracts\Repositories\ScheduleRepositoryInterface;
+use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
use App\Contracts\Repositories\SystemConfigRepositoryInterface;
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
@@ -79,6 +80,7 @@ use App\Repositories\PluginRepository;
use App\Repositories\RoleRepository;
use App\Repositories\ScheduleHistoryRepository;
use App\Repositories\ScheduleRepository;
+use App\Repositories\SeoCacheStatRepository;
use App\Repositories\SystemConfigRepository;
use App\Repositories\TemplateCustomTranslationRepository;
use App\Repositories\TemplateLayoutAttachmentRepository;
@@ -216,6 +218,9 @@ class CoreServiceProvider extends ServiceProvider
$this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class);
$this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class);
+ // SEO Repository 바인딩
+ $this->app->bind(SeoCacheStatRepositoryInterface::class, SeoCacheStatRepository::class);
+
// IdentityVerification Repository 바인딩
$this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class);
$this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class);
diff --git a/app/Repositories/SeoCacheStatRepository.php b/app/Repositories/SeoCacheStatRepository.php
new file mode 100644
index 00000000..a6bb968e
--- /dev/null
+++ b/app/Repositories/SeoCacheStatRepository.php
@@ -0,0 +1,135 @@
+
+ */
+ private const GROUPABLE_COLUMNS = ['layout_name', 'module_identifier'];
+
+ /**
+ * 캐시 통계 레코드를 기록합니다.
+ *
+ * @param array $attributes 기록할 속성
+ */
+ public function record(array $attributes): void
+ {
+ SeoCacheStat::create($attributes);
+ }
+
+ /**
+ * 전체 캐시 통계를 집계합니다.
+ *
+ * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
+ * @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과
+ */
+ public function aggregate(?Carbon $since = null): array
+ {
+ $row = $this->baseQuery($since)
+ ->select($this->aggregateSelects())
+ ->first();
+
+ return [
+ 'total' => (int) ($row->total ?? 0),
+ 'hits' => (int) ($row->hits ?? 0),
+ 'misses' => (int) ($row->misses ?? 0),
+ 'avg_response_time_ms' => $row?->avg_response_time_ms !== null
+ ? (float) $row->avg_response_time_ms
+ : null,
+ ];
+ }
+
+ /**
+ * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다.
+ *
+ * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier)
+ * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
+ * @return array 그룹별 집계 결과
+ *
+ * @throws \InvalidArgumentException 허용되지 않은 그룹 컬럼인 경우
+ */
+ public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array
+ {
+ if (! in_array($groupBy, self::GROUPABLE_COLUMNS, true)) {
+ throw new \InvalidArgumentException(
+ __('exceptions.seo.sitemap_stat_group_column_unsupported', ['column' => $groupBy])
+ );
+ }
+
+ return $this->baseQuery($since)
+ ->select(array_merge([$groupBy], $this->aggregateSelects()))
+ ->groupBy($groupBy)
+ ->get()
+ ->map(fn ($row): array => [
+ 'group' => $row->{$groupBy},
+ 'total' => (int) $row->total,
+ 'hits' => (int) $row->hits,
+ 'misses' => (int) $row->misses,
+ 'avg_response_time_ms' => $row->avg_response_time_ms !== null
+ ? (float) $row->avg_response_time_ms
+ : null,
+ ])
+ ->all();
+ }
+
+ /**
+ * 기준 시점보다 오래된 통계 레코드를 삭제합니다.
+ *
+ * @param Carbon $cutoff 기준 시점
+ * @return int 삭제된 레코드 수
+ */
+ public function deleteOlderThan(Carbon $cutoff): int
+ {
+ return SeoCacheStat::where('created_at', '<', $cutoff)->delete();
+ }
+
+ /**
+ * 기간 필터가 적용된 기본 쿼리를 반환합니다.
+ *
+ * @param Carbon|null $since 집계 시작 시점
+ * @return Builder 쿼리 빌더
+ */
+ private function baseQuery(?Carbon $since): Builder
+ {
+ $query = SeoCacheStat::query();
+
+ if ($since) {
+ $query->since($since);
+ }
+
+ return $query;
+ }
+
+ /**
+ * 집계 select 절을 반환합니다.
+ *
+ * hit/miss 는 단일 순회로 집계하기 위해 조건부 합계를 사용합니다.
+ *
+ * @return array select 표현식 배열
+ */
+ private function aggregateSelects(): array
+ {
+ return [
+ DB::raw('COUNT(*) as total'),
+ DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"),
+ DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"),
+ DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms"),
+ ];
+ }
+}
diff --git a/app/Repositories/SitemapUrlRepository.php b/app/Repositories/SitemapUrlRepository.php
new file mode 100644
index 00000000..e2e1025d
--- /dev/null
+++ b/app/Repositories/SitemapUrlRepository.php
@@ -0,0 +1,179 @@
+where('resource_type', $type)
+ ->where('resource_id', $id)
+ ->delete();
+
+ $now = now();
+ $rows = [];
+ foreach ($entries as $entry) {
+ $rows[] = $this->toRow(
+ $type,
+ $id,
+ (string) ($entry['contributor'] ?? ''),
+ $entry,
+ $now,
+ );
+ }
+
+ if ($rows !== []) {
+ SitemapUrl::insert($rows);
+ }
+ });
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function removeForResource(string $type, string $id): void
+ {
+ SitemapUrl::query()
+ ->where('resource_type', $type)
+ ->where('resource_id', $id)
+ ->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function streamVisible(?string $contributor = null): iterable
+ {
+ $query = SitemapUrl::query()->where('is_visible', true);
+
+ if ($contributor !== null) {
+ $query->where('contributor', $contributor);
+ }
+
+ // lazyById 는 id 키셋 페이징이라 결과셋 전체를 메모리/드라이버 버퍼에 적재하지 않습니다.
+ return $query->lazyById(self::STREAM_CHUNK);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function countVisible(): int
+ {
+ return SitemapUrl::query()->where('is_visible', true)->count();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function replaceAllForContributor(string $contributor, iterable $entries): int
+ {
+ // 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로
+ // 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다.
+ SitemapUrl::query()->where('contributor', $contributor)->delete();
+
+ $now = now();
+ $batch = [];
+ $inserted = 0;
+ foreach ($entries as $entry) {
+ $batch[] = $this->toRow(
+ (string) ($entry['resource_type'] ?? 'unknown'),
+ isset($entry['resource_id']) && $entry['resource_id'] !== null
+ ? (string) $entry['resource_id']
+ : null,
+ $contributor,
+ $entry,
+ $now,
+ );
+
+ if (count($batch) >= self::INSERT_CHUNK) {
+ SitemapUrl::insert($batch);
+ $inserted += count($batch);
+ $batch = [];
+ }
+ }
+
+ if ($batch !== []) {
+ SitemapUrl::insert($batch);
+ $inserted += count($batch);
+ }
+
+ return $inserted;
+ }
+
+ /**
+ * URL 항목을 sitemap_urls 삽입용 행으로 변환합니다.
+ *
+ * @param string $type 리소스 유형
+ * @param string|null $id 리소스 PK (문자열, 없으면 null)
+ * @param string $contributor 기여자 식별자
+ * @param array $entry URL 항목 (loc/lastmod/changefreq/priority)
+ * @param Carbon $now 타임스탬프 기준값
+ * @return array 삽입용 행
+ */
+ private function toRow(string $type, ?string $id, string $contributor, array $entry, Carbon $now): array
+ {
+ $loc = (string) ($entry['loc'] ?? '');
+
+ return [
+ 'resource_type' => $type,
+ 'resource_id' => $id,
+ 'loc' => $loc,
+ 'loc_hash' => hash('sha256', $loc),
+ 'lastmod' => $this->normalizeLastmod($entry['lastmod'] ?? null),
+ 'changefreq' => $entry['changefreq'] ?? null,
+ 'priority' => $entry['priority'] ?? null,
+ 'contributor' => $contributor,
+ 'is_visible' => true,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ }
+
+ /**
+ * lastmod 값을 DB 저장용 타임스탬프 문자열로 정규화합니다.
+ *
+ * insert() 는 모델 캐스팅을 우회하므로 W3C 문자열/Carbon 을 직접 파싱합니다.
+ *
+ * @param mixed $lastmod 원본 lastmod (문자열/Carbon/null)
+ * @return string|null 정규화된 'Y-m-d H:i:s' 또는 null
+ */
+ private function normalizeLastmod(mixed $lastmod): ?string
+ {
+ if ($lastmod === null || $lastmod === '') {
+ return null;
+ }
+
+ try {
+ return Carbon::parse($lastmod)->format('Y-m-d H:i:s');
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/app/Seo/AbstractSitemapContributor.php b/app/Seo/AbstractSitemapContributor.php
new file mode 100644
index 00000000..a66cd36d
--- /dev/null
+++ b/app/Seo/AbstractSitemapContributor.php
@@ -0,0 +1,57 @@
+
+ */
+ public function getUrls(): array
+ {
+ return iterator_to_array($this->getUrlsLazy(), false);
+ }
+
+ /**
+ * Sitemap URL 항목을 한 건씩 지연 순회합니다.
+ *
+ * 기본 구현은 getUrls() 배열을 순회합니다. 대용량 기여자는 이 메서드를
+ * 오버라이드해 Repository 스트림을 직접 yield 하십시오.
+ *
+ * @return iterable
+ */
+ public function getUrlsLazy(): iterable
+ {
+ yield from $this->getUrls();
+ }
+}
diff --git a/app/Seo/SeoCacheManager.php b/app/Seo/SeoCacheManager.php
index d64f803c..0df68d37 100644
--- a/app/Seo/SeoCacheManager.php
+++ b/app/Seo/SeoCacheManager.php
@@ -133,19 +133,19 @@ class SeoCacheManager implements SeoCacheManagerInterface
}
/**
- * 캐시 활성화 여부 확인
+ * 캐시 활성화 여부 확인 — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19).
*/
private function isEnabled(): bool
{
- return (bool) g7_core_settings('cache.seo_enabled', g7_core_settings('seo.cache_enabled', true));
+ return SeoCacheSettings::pageCacheEnabled();
}
/**
- * 캐시 TTL (초) — g7_core_settings('cache.seo_ttl') 우선.
+ * 캐시 TTL (초) — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19).
*/
private function getCacheTtl(): int
{
- return (int) g7_core_settings('cache.seo_ttl', g7_core_settings('seo.cache_ttl', 7200));
+ return SeoCacheSettings::pageCacheTtl();
}
/**
diff --git a/app/Seo/SeoCacheSettings.php b/app/Seo/SeoCacheSettings.php
new file mode 100644
index 00000000..065dc2e4
--- /dev/null
+++ b/app/Seo/SeoCacheSettings.php
@@ -0,0 +1,64 @@
+ $url,
- 'locale' => $locale,
- 'layout_name' => $layoutName,
- 'module_identifier' => $moduleIdentifier,
- 'type' => 'hit',
- ]);
- } catch (\Exception $e) {
- Log::warning('[SEO] 캐시 히트 기록 실패', [
- 'url' => $url,
- 'error' => $e->getMessage(),
- ]);
- }
+ $this->record([
+ 'url' => $url,
+ 'locale' => $locale,
+ 'layout_name' => $layoutName,
+ 'module_identifier' => $moduleIdentifier,
+ 'type' => 'hit',
+ ], '[SEO] 캐시 히트 기록 실패');
}
/**
* 캐시 미스를 기록합니다.
*
- * @param string $url 요청 URL
- * @param string $locale 로케일
- * @param string|null $layoutName 레이아웃명
- * @param string|null $moduleIdentifier 모듈 식별자
- * @param int|null $responseTimeMs 렌더링 소요 시간 (ms)
- * @return void
+ * @param string $url 요청 URL
+ * @param string $locale 로케일
+ * @param string|null $layoutName 레이아웃명
+ * @param string|null $moduleIdentifier 모듈 식별자
+ * @param int|null $responseTimeMs 렌더링 소요 시간 (ms)
*/
public function recordMiss(
string $url,
@@ -62,142 +62,72 @@ class SeoCacheStatsService
?string $moduleIdentifier = null,
?int $responseTimeMs = null
): void {
- try {
- SeoCacheStat::create([
- 'url' => $url,
- 'locale' => $locale,
- 'layout_name' => $layoutName,
- 'module_identifier' => $moduleIdentifier,
- 'type' => 'miss',
- 'response_time_ms' => $responseTimeMs,
- ]);
- } catch (\Exception $e) {
- Log::warning('[SEO] 캐시 미스 기록 실패', [
- 'url' => $url,
- 'error' => $e->getMessage(),
- ]);
- }
+ $this->record([
+ 'url' => $url,
+ 'locale' => $locale,
+ 'layout_name' => $layoutName,
+ 'module_identifier' => $moduleIdentifier,
+ 'type' => 'miss',
+ 'response_time_ms' => $responseTimeMs,
+ ], '[SEO] 캐시 미스 기록 실패');
}
/**
* 캐시 통계를 조회합니다.
*
- * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
- * @return array{total_entries: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null}
+ * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
+ * @return array{total_entries: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} 캐시 통계
*/
public function getStats(?Carbon $since = null): array
{
- $query = SeoCacheStat::query();
-
- if ($since) {
- $query->since($since);
- }
-
- $total = $query->count();
- $hits = (clone $query)->hits()->count();
- $misses = (clone $query)->misses()->count();
- $avgResponseTime = (clone $query)->misses()
- ->whereNotNull('response_time_ms')
- ->avg('response_time_ms');
+ $stats = $this->statRepository->aggregate($since);
return [
- 'total_entries' => $total,
- 'hits' => $hits,
- 'misses' => $misses,
- 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0,
- 'avg_response_time_ms' => $avgResponseTime !== null ? round((float) $avgResponseTime, 2) : null,
+ 'total_entries' => $stats['total'],
+ 'hits' => $stats['hits'],
+ 'misses' => $stats['misses'],
+ 'hit_rate' => $this->hitRate($stats['total'], $stats['hits']),
+ 'avg_response_time_ms' => $this->roundOrNull($stats['avg_response_time_ms']),
];
}
/**
* 레이아웃별 캐시 통계를 조회합니다.
*
- * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
- * @return array
+ * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
+ * @return array 레이아웃별 캐시 통계
*/
public function getStatsByLayout(?Carbon $since = null): array
{
- $query = SeoCacheStat::query()
- ->select(
- 'layout_name',
- DB::raw('COUNT(*) as total'),
- DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"),
- DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"),
- DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms")
- )
- ->groupBy('layout_name');
-
- if ($since) {
- $query->where('created_at', '>=', $since);
- }
-
- return $query->get()->map(function ($row) {
- $total = (int) $row->total;
- $hits = (int) $row->hits;
-
- return [
- 'layout_name' => $row->layout_name,
- 'total' => $total,
- 'hits' => $hits,
- 'misses' => (int) $row->misses,
- 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0,
- 'avg_response_time_ms' => $row->avg_response_time_ms !== null
- ? round((float) $row->avg_response_time_ms, 2)
- : null,
- ];
- })->toArray();
+ return array_map(
+ fn (array $row): array => ['layout_name' => $row['group']] + $this->formatGroupRow($row),
+ $this->statRepository->aggregateGrouped('layout_name', $since),
+ );
}
/**
* 모듈별 캐시 통계를 조회합니다.
*
- * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
- * @return array
+ * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
+ * @return array 모듈별 캐시 통계
*/
public function getStatsByModule(?Carbon $since = null): array
{
- $query = SeoCacheStat::query()
- ->select(
- 'module_identifier',
- DB::raw('COUNT(*) as total'),
- DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"),
- DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"),
- DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms")
- )
- ->groupBy('module_identifier');
-
- if ($since) {
- $query->where('created_at', '>=', $since);
- }
-
- return $query->get()->map(function ($row) {
- $total = (int) $row->total;
- $hits = (int) $row->hits;
-
- return [
- 'module_identifier' => $row->module_identifier,
- 'total' => $total,
- 'hits' => $hits,
- 'misses' => (int) $row->misses,
- 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0,
- 'avg_response_time_ms' => $row->avg_response_time_ms !== null
- ? round((float) $row->avg_response_time_ms, 2)
- : null,
- ];
- })->toArray();
+ return array_map(
+ fn (array $row): array => ['module_identifier' => $row['group']] + $this->formatGroupRow($row),
+ $this->statRepository->aggregateGrouped('module_identifier', $since),
+ );
}
/**
* 오래된 통계 레코드를 삭제합니다.
*
- * @param int $daysToKeep 보존 기간 (일, 기본값: 30)
+ * @param int $daysToKeep 보존 기간 (일, 기본값: 30)
* @return int 삭제된 레코드 수
*/
public function cleanup(int $daysToKeep = 30): int
{
- $cutoff = Carbon::now()->subDays($daysToKeep);
-
- $deleted = SeoCacheStat::where('created_at', '<', $cutoff)->delete();
+ $deleted = $this->statRepository->deleteOlderThan(Carbon::now()->subDays($daysToKeep));
Log::info('[SEO] 캐시 통계 정리 완료', [
'deleted' => $deleted,
@@ -206,4 +136,62 @@ class SeoCacheStatsService
return $deleted;
}
+
+ /**
+ * 통계 레코드를 기록하고, 실패해도 요청 흐름을 막지 않습니다.
+ *
+ * @param array $attributes 기록할 속성
+ * @param string $failureMessage 실패 시 로그 메시지
+ */
+ private function record(array $attributes, string $failureMessage): void
+ {
+ try {
+ $this->statRepository->record($attributes);
+ } catch (\Exception $e) {
+ Log::warning($failureMessage, [
+ 'url' => $attributes['url'] ?? null,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * 그룹 집계 행을 응답 형식으로 변환합니다.
+ *
+ * @param array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} $row 집계 행
+ * @return array{total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} 변환된 행
+ */
+ private function formatGroupRow(array $row): array
+ {
+ return [
+ 'total' => $row['total'],
+ 'hits' => $row['hits'],
+ 'misses' => $row['misses'],
+ 'hit_rate' => $this->hitRate($row['total'], $row['hits']),
+ 'avg_response_time_ms' => $this->roundOrNull($row['avg_response_time_ms']),
+ ];
+ }
+
+ /**
+ * 히트율(%)을 계산합니다.
+ *
+ * @param int $total 전체 건수
+ * @param int $hits 히트 건수
+ * @return float 히트율 (소수점 2자리)
+ */
+ private function hitRate(int $total, int $hits): float
+ {
+ return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0;
+ }
+
+ /**
+ * 값이 있으면 소수점 2자리로 반올림합니다.
+ *
+ * @param float|null $value 원본 값
+ * @return float|null 반올림된 값 (null 이면 null)
+ */
+ private function roundOrNull(?float $value): ?float
+ {
+ return $value !== null ? round($value, 2) : null;
+ }
}
diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php
index bad62a84..7db85ffa 100644
--- a/app/Seo/SeoServiceProvider.php
+++ b/app/Seo/SeoServiceProvider.php
@@ -2,6 +2,10 @@
namespace App\Seo;
+use App\Contracts\Extension\StorageInterface;
+use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
+use App\Extension\Storage\CoreStorageDriver;
+use App\Repositories\SitemapUrlRepository;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\Contracts\SeoRendererInterface;
use Illuminate\Support\ServiceProvider;
@@ -16,6 +20,7 @@ class SeoServiceProvider extends ServiceProvider
// 인터페이스 → 구현체 바인딩
$this->app->singleton(SeoCacheManagerInterface::class, SeoCacheManager::class);
$this->app->singleton(SeoRendererInterface::class, SeoRenderer::class);
+ $this->app->singleton(SitemapUrlRepositoryInterface::class, SitemapUrlRepository::class);
// 싱글톤 등록
$this->app->singleton(SeoCacheRegenerator::class);
@@ -28,6 +33,17 @@ class SeoServiceProvider extends ServiceProvider
$this->app->singleton(SeoMetaResolver::class);
$this->app->singleton(SitemapGenerator::class);
$this->app->singleton(SeoConfigMerger::class);
+ $this->app->singleton(SitemapFileStore::class);
+ $this->app->singleton(SitemapIndexer::class);
+ $this->app->singleton(SitemapProgress::class);
+
+ // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다.
+ // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다.
+ foreach ([SitemapManager::class, SitemapFileStore::class] as $sitemapConsumer) {
+ $this->app->when($sitemapConsumer)
+ ->needs(StorageInterface::class)
+ ->give(fn () => new CoreStorageDriver('local'));
+ }
}
/**
diff --git a/app/Seo/SitemapFileStore.php b/app/Seo/SitemapFileStore.php
new file mode 100644
index 00000000..f34d8a78
--- /dev/null
+++ b/app/Seo/SitemapFileStore.php
@@ -0,0 +1,207 @@
+ 값이며, web 라우트 `/sitemap-{n}.xml` 과 대응합니다.
+ *
+ * @param int $n 자식 번호 (1부터)
+ * @param bool $gzip gzip 압축 여부
+ * @return string 자식 sitemap 절대 URL
+ */
+ public static function childUrl(int $n, bool $gzip = false): string
+ {
+ return url('/'.self::childFilename($n, $gzip));
+ }
+
+ /**
+ * 커밋된 sitemap 세트가 존재하는지 확인합니다.
+ *
+ * manifest.json 존재 = 커밋 완료 신호입니다.
+ *
+ * @return bool 서빙 가능한 세트 존재 여부
+ */
+ public function exists(): bool
+ {
+ return $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE);
+ }
+
+ /**
+ * manifest 메타데이터를 반환합니다.
+ *
+ * @return array{generated_at?: string, url_count?: int, child_count?: int, size_bytes?: int, gzip?: bool, children?: array}|null
+ * manifest 배열 (없거나 손상 시 null)
+ */
+ public function getManifest(): ?array
+ {
+ $raw = $this->storage->get(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE);
+ if ($raw === null || $raw === '') {
+ return null;
+ }
+
+ $decoded = json_decode($raw, true);
+ if (! is_array($decoded)) {
+ Log::warning('[SEO] Sitemap manifest 손상', ['path' => self::LIVE_DIR.'/'.self::MANIFEST_FILE]);
+
+ return null;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * sitemapindex 를 스트리밍 응답으로 반환합니다.
+ *
+ * @return StreamedResponse|null 인덱스 스트림 (세트가 없으면 null)
+ */
+ public function indexResponse(): ?StreamedResponse
+ {
+ if (! $this->exists()) {
+ return null;
+ }
+
+ return $this->storage->response(
+ self::CATEGORY,
+ self::LIVE_DIR.'/'.self::INDEX_FILE,
+ self::INDEX_FILE,
+ ['Content-Type' => 'application/xml'],
+ );
+ }
+
+ /**
+ * 자식 sitemap 을 스트리밍 응답으로 반환합니다.
+ *
+ * 실제 저장된 파일명은 manifest 에서 조회하므로, 생성 이후 gzip 설정이
+ * 바뀌어도 커밋된 세트를 그대로 서빙합니다.
+ *
+ * @param int $n 자식 번호 (1부터)
+ * @return StreamedResponse|null 자식 스트림 (없으면 null)
+ */
+ public function childResponse(int $n): ?StreamedResponse
+ {
+ $filename = $this->childFile($n);
+ if ($filename === null) {
+ return null;
+ }
+
+ $headers = ['Content-Type' => 'application/xml'];
+ if (str_ends_with($filename, '.gz')) {
+ $headers['Content-Encoding'] = 'gzip';
+ }
+
+ return $this->storage->response(
+ self::CATEGORY,
+ self::LIVE_DIR.'/'.$filename,
+ $filename,
+ $headers,
+ );
+ }
+
+ /**
+ * 커밋된 sitemap 의 생성 시각을 반환합니다.
+ *
+ * @return string|null ISO8601 생성 시각 (세트가 없으면 null)
+ */
+ public function lastGeneratedAt(): ?string
+ {
+ $manifest = $this->getManifest();
+
+ return $manifest['generated_at'] ?? null;
+ }
+
+ /**
+ * 자식 번호에 해당하는 실제 파일명을 반환합니다.
+ *
+ * @param int $n 자식 번호 (1부터)
+ * @return string|null 파일명 (존재하지 않으면 null)
+ */
+ private function childFile(int $n): ?string
+ {
+ if ($n < 1) {
+ return null;
+ }
+
+ $manifest = $this->getManifest();
+ $children = $manifest['children'] ?? [];
+
+ $filename = $children[$n - 1]['file'] ?? null;
+ if (! is_string($filename) || $filename === '') {
+ return null;
+ }
+
+ if (! $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.$filename)) {
+ return null;
+ }
+
+ return $filename;
+ }
+}
diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php
index 6c300acf..bfc9cb6d 100644
--- a/app/Seo/SitemapGenerator.php
+++ b/app/Seo/SitemapGenerator.php
@@ -2,6 +2,7 @@
namespace App\Seo;
+use App\Enums\SitemapChangeFreq;
use App\Extension\TemplateManager;
use App\Seo\Contracts\SitemapContributorInterface;
use App\Services\TemplateService;
@@ -40,9 +41,161 @@ class SitemapGenerator
$this->contributors[$contributor->getIdentifier()] = $contributor;
}
+ /**
+ * 수집한 URL을 SitemapWriter로 스트리밍하여 분할 sitemap 파일을 생성합니다.
+ *
+ * 기여자를 하나씩 순회하며 URL을 writer 에 한 건씩 넘기므로
+ * 전체 URL 배열을 메모리에 병합(array_merge)하지 않습니다.
+ *
+ * @param SitemapWriter $writer 분할 기록기
+ * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array}
+ * 커밋된 sitemap 메타데이터
+ */
+ public function generateToWriter(SitemapWriter $writer): array
+ {
+ $writer->open();
+
+ foreach ($this->collectStaticRoutes() as $entry) {
+ $writer->addUrl($entry);
+ }
+
+ foreach ($this->contributors as $contributor) {
+ try {
+ foreach ($this->drain($contributor) as $entry) {
+ $writer->addUrl($this->normalizeEntry($entry));
+ }
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Sitemap contributor failed', [
+ 'contributor' => $contributor->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ $writer->close();
+
+ return $writer->commit();
+ }
+
+ /**
+ * 미리 수집된 URL 항목(사이트맵 저장소 스트림 등)을 writer 로 스트리밍하여 파일을 생성합니다.
+ *
+ * 정적 라우트를 먼저 기록하고, 이어서 전달된 항목을 한 건씩 writer 에 넘깁니다.
+ * 증분/전체 재생성 모두 이 경로로 파일을 작성하며, 항목의 출처(도메인 재쿼리 vs 테이블 스트림)는
+ * 호출자가 결정합니다.
+ *
+ * @param SitemapWriter $writer 분할 기록기
+ * @param iterable $entries URL 항목 스트림
+ * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array}
+ * 커밋된 sitemap 메타데이터
+ */
+ public function generateToWriterFromEntries(SitemapWriter $writer, iterable $entries): array
+ {
+ $writer->open();
+
+ foreach ($this->collectStaticRoutes() as $entry) {
+ $writer->addUrl($entry);
+ }
+
+ foreach ($entries as $entry) {
+ $writer->addUrl($this->normalizeEntry($entry));
+ }
+
+ $writer->close();
+
+ return $writer->commit();
+ }
+
+ /**
+ * 한 기여자의 URL 항목을 절대 URL(loc)로 정규화하여 지연 스트리밍합니다.
+ *
+ * 전체 재생성 시 사이트맵 저장소에 적재할 항목의 단일 출처이며,
+ * 기여자가 emit 한 resource_type/resource_id 등 부가 키는 그대로 통과시킵니다.
+ *
+ * @param SitemapContributorInterface $contributor Sitemap 기여자
+ * @return iterable> 정규화된 URL 항목 순회자
+ */
+ public function streamContributorEntries(SitemapContributorInterface $contributor): iterable
+ {
+ foreach ($this->drain($contributor) as $entry) {
+ yield $this->normalizeEntry($entry);
+ }
+ }
+
+ /**
+ * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다.
+ *
+ * 지연 스트리밍 capability(AbstractSitemapContributor 상속 또는 getUrlsLazy() 보유)를
+ * 감지해, 있으면 한 건씩 yield 되는 제너레이터를 사용합니다. 없으면 기존 getUrls()
+ * 배열을 그대로 순회합니다(제3자 raw 구현체 하위호환).
+ *
+ * @param SitemapContributorInterface $contributor Sitemap 기여자
+ * @return iterable> URL 항목 순회자
+ */
+ private function drain(SitemapContributorInterface $contributor): iterable
+ {
+ if ($contributor instanceof AbstractSitemapContributor || method_exists($contributor, 'getUrlsLazy')) {
+ return $contributor->getUrlsLazy();
+ }
+
+ return $contributor->getUrls();
+ }
+
+ /**
+ * 기여자 URL 항목의 'url' 키를 'loc'(절대 URL)으로 변환합니다.
+ *
+ * @param array $entry URL 항목
+ * @return array 변환된 URL 항목
+ */
+ private function normalizeEntry(array $entry): array
+ {
+ if (isset($entry['url']) && ! isset($entry['loc'])) {
+ $entry['loc'] = url($entry['url']);
+ unset($entry['url']);
+ }
+
+ return $entry;
+ }
+
+ /**
+ * URL 항목 하나를 블록 XML로 렌더링합니다.
+ *
+ * XML 이스케이프의 단일 출처는 SitemapXmlRenderer 이며 이 메서드는 위임합니다.
+ *
+ * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목
+ * @return string 블록 문자열
+ */
+ public function renderUrlBlock(array $entry): string
+ {
+ return SitemapXmlRenderer::fromConfig()->urlBlock($entry);
+ }
+
+ /**
+ * urlset 여는 태그(XML 선언 포함)를 반환합니다.
+ *
+ * @return string urlset 헤더 문자열
+ */
+ public function buildUrlsetHeader(): string
+ {
+ return SitemapXmlRenderer::fromConfig()->urlsetHeader();
+ }
+
+ /**
+ * urlset 닫는 태그를 반환합니다.
+ *
+ * @return string urlset 푸터 문자열
+ */
+ public function buildUrlsetFooter(): string
+ {
+ return SitemapXmlRenderer::fromConfig()->urlsetFooter();
+ }
+
/**
* 전체 Sitemap URL을 수집하여 XML을 생성합니다.
*
+ * 전체 URL을 메모리에 적재하므로 소규모 사이트 / 하위호환 용도로만 사용합니다.
+ * 대용량은 generateToWriter() 를 사용합니다.
+ *
* @return string sitemap XML 문자열
*/
public function generate(): string
@@ -55,16 +208,11 @@ class SitemapGenerator
// 2. 기여자들의 동적 URL 수집
foreach ($this->contributors as $contributor) {
try {
- $contributorUrls = $contributor->getUrls();
-
// 기여자 URL의 'url' 키를 'loc'(절대 URL)으로 변환
- foreach ($contributorUrls as &$entry) {
- if (isset($entry['url']) && ! isset($entry['loc'])) {
- $entry['loc'] = url($entry['url']);
- unset($entry['url']);
- }
- }
- unset($entry);
+ $contributorUrls = array_map(
+ fn (array $entry): array => $this->normalizeEntry($entry),
+ $contributor->getUrls(),
+ );
$urls = array_merge($urls, $contributorUrls);
} catch (\Throwable $e) {
@@ -143,7 +291,7 @@ class SitemapGenerator
$urls[] = [
'loc' => url($routePath),
- 'changefreq' => 'weekly',
+ 'changefreq' => SitemapChangeFreq::Weekly->value,
'priority' => 0.5,
];
}
@@ -170,79 +318,15 @@ class SitemapGenerator
*/
private function buildXml(array $urls): string
{
- $defaultLocale = config('app.locale');
- $supportedLocales = config('app.supported_locales', [$defaultLocale]);
- $isMultilingual = count($supportedLocales) > 1;
+ $renderer = SitemapXmlRenderer::fromConfig();
- $xml = ''."\n";
-
- if ($isMultilingual) {
- $xml .= 'urlsetHeader();
foreach ($urls as $entry) {
- $baseLoc = $entry['loc'];
-
- if ($isMultilingual) {
- // 각 로케일별 항목 생성
- foreach ($supportedLocales as $locale) {
- $localeLoc = $locale === $defaultLocale
- ? $baseLoc
- : $baseLoc.'?locale='.$locale;
-
- $xml .= ' '."\n";
- $xml .= ' '.htmlspecialchars($localeLoc, ENT_XML1, 'UTF-8').''."\n";
-
- if (! empty($entry['lastmod'])) {
- $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n";
- }
-
- if (! empty($entry['changefreq'])) {
- $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n";
- }
-
- if (isset($entry['priority'])) {
- $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n";
- }
-
- // 모든 로케일의 hreflang alternate 링크
- foreach ($supportedLocales as $altLocale) {
- $altHref = $altLocale === $defaultLocale
- ? $baseLoc
- : $baseLoc.'?locale='.$altLocale;
- $xml .= ' '."\n";
- }
-
- // x-default = 기본 로케일 URL
- $xml .= ' '."\n";
-
- $xml .= ' '."\n";
- }
- } else {
- // 단일 언어: 기존 형식 유지
- $xml .= ' '."\n";
- $xml .= ' '.htmlspecialchars($baseLoc, ENT_XML1, 'UTF-8').''."\n";
-
- if (! empty($entry['lastmod'])) {
- $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n";
- }
-
- if (! empty($entry['changefreq'])) {
- $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n";
- }
-
- if (isset($entry['priority'])) {
- $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n";
- }
-
- $xml .= ' '."\n";
- }
+ $xml .= $renderer->urlBlock($entry);
}
- $xml .= '';
+ $xml .= $renderer->urlsetFooter();
return $xml;
}
diff --git a/app/Seo/SitemapIndexer.php b/app/Seo/SitemapIndexer.php
new file mode 100644
index 00000000..cccec0db
--- /dev/null
+++ b/app/Seo/SitemapIndexer.php
@@ -0,0 +1,114 @@
+ $entries URL 항목
+ */
+ public function indexResource(string $type, string|int $id, string $contributor, array $entries): void
+ {
+ $entries = HookManager::applyFilters(self::COLLECT_FILTER, $entries, $type, $id, $contributor);
+
+ if (! is_array($entries) || $entries === []) {
+ // 색인할 항목이 없으면(모두 제외됨) 기존 색인을 제거해 정합을 유지합니다.
+ $this->repository->removeForResource($type, (string) $id);
+
+ return;
+ }
+
+ $normalized = [];
+ foreach ($entries as $entry) {
+ if (! is_array($entry)) {
+ continue;
+ }
+
+ $loc = $this->resolveLoc($entry);
+ if ($loc === null) {
+ continue;
+ }
+
+ $normalized[] = [
+ 'loc' => $loc,
+ 'lastmod' => $entry['lastmod'] ?? null,
+ // 폐쇄 어휘로 정규화 — 비표준 값이 sitemap_urls 에 저장되는 것을 차단.
+ 'changefreq' => SitemapChangeFreq::normalize($entry['changefreq'] ?? null),
+ 'priority' => $entry['priority'] ?? null,
+ 'contributor' => $contributor,
+ ];
+ }
+
+ if ($normalized === []) {
+ $this->repository->removeForResource($type, (string) $id);
+
+ return;
+ }
+
+ $this->repository->upsertForResource($type, (string) $id, $normalized);
+ }
+
+ /**
+ * 비공개/삭제된 리소스의 사이트맵 URL 색인을 제거합니다.
+ *
+ * @param string $type 리소스 유형
+ * @param string|int $id 리소스 PK
+ */
+ public function deindexResource(string $type, string|int $id): void
+ {
+ $this->repository->removeForResource($type, (string) $id);
+ }
+
+ /**
+ * 항목의 절대 URL(loc)을 해석합니다.
+ *
+ * 'loc'(절대 URL)이 있으면 그대로, 없으면 'url'(상대 경로)을 url() 로 절대화합니다.
+ *
+ * @param array $entry URL 항목
+ * @return string|null 절대 URL (해석 불가 시 null)
+ */
+ private function resolveLoc(array $entry): ?string
+ {
+ if (isset($entry['loc']) && is_string($entry['loc']) && $entry['loc'] !== '') {
+ return $entry['loc'];
+ }
+
+ if (isset($entry['url']) && is_string($entry['url']) && $entry['url'] !== '') {
+ return url($entry['url']);
+ }
+
+ return null;
+ }
+}
diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php
index 10a34530..3b85230b 100644
--- a/app/Seo/SitemapManager.php
+++ b/app/Seo/SitemapManager.php
@@ -3,7 +3,10 @@
namespace App\Seo;
use App\Contracts\Extension\CacheInterface;
+use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
+use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
+use App\Enums\SitemapGenerationMode;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Log;
@@ -11,56 +14,98 @@ use Illuminate\Support\Facades\Log;
* Sitemap 재생성 / 상태 조회 서비스.
*
* 큐 잡(GenerateSitemapJob)과 관리자 수동 트리거(SeoCacheController)에서 공통으로 사용합니다.
- * Sitemap XML 을 생성하여 캐시에 저장하고 마지막 업데이트 시각을
- * settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다.
+ * Sitemap 을 SitemapWriter 로 스트리밍 생성하여 비공개 디스크에 분할 파일로 커밋하고,
+ * 마지막 업데이트 시각을 settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다.
+ * 캐시에는 XML 본문이 아니라 메타데이터만 저장합니다.
*/
class SitemapManager
{
+ /**
+ * Sitemap 메타데이터 캐시 키
+ */
+ public const META_CACHE_KEY = 'seo.sitemap.meta';
+
+ /**
+ * 구 버전 Sitemap XML 본문 캐시 키 (디스크 서빙 전환으로 폐기 — 잔여분 정리용)
+ */
+ private const LEGACY_XML_CACHE_KEY = 'seo.sitemap';
+
public function __construct(
private SitemapGenerator $generator,
private CacheInterface $cache,
private ConfigRepositoryInterface $configRepository,
+ private StorageInterface $storage,
+ private SitemapUrlRepositoryInterface $repository,
+ private SitemapProgress $progress,
) {}
/**
- * Sitemap 을 즉시 생성하여 캐시에 저장하고 last_updated_at 을 기록합니다.
+ * Sitemap 을 즉시 생성하여 디스크에 커밋하고 last_updated_at 을 기록합니다.
*
+ * 모드에 따라 사이트맵 저장소(sitemap_urls) 활용 방식이 달라집니다:
+ * - full: 각 기여자를 스트리밍해 저장소를 전량 대체한 뒤 저장소 → 파일 재작성.
+ * - incremental: 저장소의 현재 상태(리스너가 반영한 델타)를 그대로 파일로 재작성.
+ * - auto: 저장소가 비어 있으면 full, 아니면 incremental.
+ *
+ * @param SitemapGenerationMode $mode 재생성 모드
+ * @param int|null $triggeredBy 이 재생성을 유발한 관리자 사용자 ID(관리자 수동 재생성만 지정, 스케줄러/증분/봇 = null).
+ * 완료/실패 훅에 함께 전달되어 알림 발송 대상(수동 실행자) 판정에 쓰입니다.
* @return array{success: bool, status: string, message?: string, data?: array}
- * status: 'updated' | 'disabled' | 'failed'
+ * status: 'updated' | 'disabled' | 'failed'
*/
- public function regenerate(): array
+ public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto, ?int $triggeredBy = null): array
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
return [
'success' => false,
'status' => 'disabled',
- 'message' => 'Sitemap 생성이 비활성화되어 있습니다.',
+ 'message' => __('seo.sitemap_disabled'),
];
}
HookManager::doAction('core.seo.sitemap.before_regenerate');
try {
- $xml = $this->generator->generate();
- $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400));
- $this->cache->put('seo.sitemap', $xml, $ttl);
+ $effectiveMode = $mode->resolve($this->repository->countVisible());
- $lastUpdatedAt = now()->toIso8601String();
+ if ($effectiveMode === SitemapGenerationMode::Full) {
+ $this->rebuildStore();
+ }
+
+ $this->progress->writing($this->repository->countVisible());
+
+ $meta = $this->generator->generateToWriterFromEntries(
+ $this->makeWriter(),
+ $this->streamStoreEntries(),
+ );
+
+ // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19)
+ $ttl = SeoCacheSettings::sitemapCacheTtl();
+
+ // 디스크 서빙 전환 — 캐시에는 메타만 저장하고 구 XML 본문 캐시는 정리합니다.
+ $this->cache->forget(self::LEGACY_XML_CACHE_KEY);
+ $this->cache->put(self::META_CACHE_KEY, $meta, $ttl);
+
+ $lastUpdatedAt = $meta['generated_at'];
$this->updateLastUpdatedAt($lastUpdatedAt);
$result = [
'success' => true,
'status' => 'updated',
- 'message' => 'Sitemap 생성이 완료되었습니다.',
+ 'message' => __('seo.sitemap_regenerated'),
'data' => [
'last_updated_at' => $lastUpdatedAt,
- 'size_bytes' => strlen($xml),
+ 'size_bytes' => $meta['size_bytes'],
+ 'url_count' => $meta['url_count'],
+ 'child_count' => $meta['child_count'],
'ttl' => $ttl,
],
];
- HookManager::doAction('core.seo.sitemap.after_regenerate', $result);
+ $this->progress->complete($meta);
+
+ HookManager::doAction('core.seo.sitemap.after_regenerate', $result, $triggeredBy);
return $result;
} catch (\Throwable $e) {
@@ -68,22 +113,28 @@ class SitemapManager
'error' => $e->getMessage(),
]);
+ $this->progress->fail($e->getMessage());
+
$result = [
'success' => false,
'status' => 'failed',
- 'message' => 'Sitemap 생성에 실패했습니다: '.$e->getMessage(),
+ 'message' => __('seo.sitemap_generate_failed', ['error' => $e->getMessage()]),
];
- HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result);
+ HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result, $triggeredBy);
return $result;
}
}
/**
- * 현재 sitemap 의 메타데이터를 반환합니다.
+ * 현재 sitemap 의 메타데이터와 진행상황을 반환합니다.
*
- * @return array{last_updated_at: ?string}
+ * realtime_enabled 는 설정 SSoT(drivers.websocket_enabled)와 실제 적용 config
+ * (broadcasting.default) 를 함께 확인해 판정합니다 — 프론트가 실시간(구독)/폴링을
+ * 분기하는 근거입니다.
+ *
+ * @return array{last_updated_at: ?string, progress: ?array, realtime_enabled: bool}
*/
public function getStatus(): array
{
@@ -91,9 +142,72 @@ class SitemapManager
return [
'last_updated_at' => $lastUpdatedAt !== '' ? $lastUpdatedAt : null,
+ 'progress' => $this->progress->get(),
+ 'realtime_enabled' => (bool) g7_core_settings('drivers.websocket_enabled', false)
+ && config('broadcasting.default') !== 'null',
];
}
+ /**
+ * 각 기여자를 스트리밍하여 사이트맵 저장소를 전량 대체합니다(전체 재생성).
+ *
+ * 기여자별로 격리하여, 한 기여자가 실패해도 나머지 기여자의 재적재는 계속됩니다.
+ */
+ private function rebuildStore(): void
+ {
+ // 스트림 삽입 건수를 누적해 running 단계의 "누적 URL 수" 를 표기합니다(D4).
+ // countVisible() 같은 사전 count 쿼리 없이 삽입 결과만 합산하므로 1.4M 에서도 부담이 없습니다.
+ $total = 0;
+ foreach ($this->generator->getContributors() as $contributor) {
+ try {
+ // 현재 기여자 진입 시점의 누적치(직전 기여자들까지)를 표시합니다.
+ $this->progress->phase($contributor->getIdentifier(), $total);
+
+ $total += $this->repository->replaceAllForContributor(
+ $contributor->getIdentifier(),
+ $this->generator->streamContributorEntries($contributor),
+ );
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Sitemap 저장소 재적재 실패', [
+ 'contributor' => $contributor->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * 사이트맵 저장소의 공개 URL 을 writer 항목 형태로 지연 스트리밍합니다.
+ *
+ * @return iterable writer 항목 순회자
+ */
+ private function streamStoreEntries(): iterable
+ {
+ foreach ($this->repository->streamVisible() as $row) {
+ yield [
+ 'loc' => (string) $row->loc,
+ 'lastmod' => $row->lastmod?->toW3cString(),
+ 'changefreq' => $row->changefreq,
+ 'priority' => $row->priority !== null ? (float) $row->priority : null,
+ ];
+ }
+ }
+
+ /**
+ * 설정값을 반영한 SitemapWriter 를 생성합니다.
+ *
+ * @return SitemapWriter 분할 기록기
+ */
+ private function makeWriter(): SitemapWriter
+ {
+ return new SitemapWriter(
+ $this->storage,
+ SitemapXmlRenderer::fromConfig(),
+ (int) g7_core_settings('seo.sitemap_urls_per_file', SitemapWriter::HARD_URL_CAP),
+ (bool) g7_core_settings('seo.sitemap_gzip', false),
+ );
+ }
+
/**
* settings 의 seo 카테고리에 sitemap_last_updated_at 을 기록합니다.
*
diff --git a/app/Seo/SitemapProgress.php b/app/Seo/SitemapProgress.php
new file mode 100644
index 00000000..e6233e1e
--- /dev/null
+++ b/app/Seo/SitemapProgress.php
@@ -0,0 +1,228 @@
+lastBroadcastUrls = 0;
+
+ $this->persist([
+ 'status' => 'queued',
+ 'mode' => $mode,
+ 'started_at' => now()->toIso8601String(),
+ 'finished_at' => null,
+ 'phase' => null,
+ 'urls' => 0,
+ 'url_count' => null,
+ 'child_count' => null,
+ 'message' => null,
+ ], broadcast: true);
+ }
+
+ /**
+ * 현재 처리 중인 기여자 단계를 기록합니다.
+ *
+ * 단계가 바뀌면 즉시 방송하고, 같은 단계에서 urls 만 증가하면 간격 스로틀을 적용합니다.
+ * 캐시는 폴링 정확도를 위해 항상 기록합니다.
+ *
+ * @param string $phase 단계 식별자(기여자 identifier)
+ * @param int|null $urls 누적 URL 수(선택)
+ */
+ public function phase(string $phase, ?int $urls = null): void
+ {
+ $state = $this->current();
+ $phaseChanged = ($state['phase'] ?? null) !== $phase;
+
+ $state['status'] = 'running';
+ $state['phase'] = $phase;
+ if ($urls !== null) {
+ $state['urls'] = $urls;
+ }
+
+ $broadcast = $phaseChanged
+ || ($urls !== null && ($urls - $this->lastBroadcastUrls) >= self::BROADCAST_URL_INTERVAL);
+
+ $this->persist($state, broadcast: $broadcast);
+
+ if ($broadcast) {
+ $this->lastBroadcastUrls = (int) ($state['urls'] ?? 0);
+ }
+ }
+
+ /**
+ * 파일 작성 단계를 기록합니다.
+ *
+ * @param int|null $urls 작성 대상 누적 URL 수(선택)
+ */
+ public function writing(?int $urls = null): void
+ {
+ $state = $this->current();
+ $state['status'] = 'writing';
+ $state['phase'] = null;
+ if ($urls !== null) {
+ $state['urls'] = $urls;
+ }
+
+ $this->persist($state, broadcast: true);
+ }
+
+ /**
+ * 재생성 완료를 기록합니다.
+ *
+ * @param array $meta 생성 메타(url_count, child_count 등)
+ */
+ public function complete(array $meta): void
+ {
+ $state = $this->current();
+ $state['status'] = 'completed';
+ $state['phase'] = null;
+ $state['finished_at'] = now()->toIso8601String();
+ $state['url_count'] = $meta['url_count'] ?? null;
+ $state['child_count'] = $meta['child_count'] ?? null;
+ $state['urls'] = $meta['url_count'] ?? ($state['urls'] ?? 0);
+ $state['message'] = null;
+
+ // 완료 시점엔 방금 커밋된 새 생성 시각을 실어 "마지막 생성"이 실시간 갱신되게 한다.
+ $this->persist($state, broadcast: true, lastUpdatedAt: $meta['generated_at'] ?? null);
+ }
+
+ /**
+ * 재생성 실패를 기록합니다(무한 running 방지).
+ *
+ * @param string $message 실패 메시지
+ */
+ public function fail(string $message): void
+ {
+ $state = $this->current();
+ $state['status'] = 'failed';
+ $state['phase'] = null;
+ $state['finished_at'] = now()->toIso8601String();
+ $state['message'] = $message;
+
+ $this->persist($state, broadcast: true);
+ }
+
+ /**
+ * 현재 진행상황을 반환합니다(폴링/초기 로드용).
+ *
+ * @return array|null 진행상황 또는 아직 실행 이력이 없으면 null
+ */
+ public function get(): ?array
+ {
+ $state = $this->cache->get(self::KEY);
+
+ return is_array($state) ? $state : null;
+ }
+
+ /**
+ * 현재 진행상황을 반환하되, 없으면 기본 골격을 돌려줍니다(부분 갱신 병합용).
+ *
+ * @return array 진행상황 골격
+ */
+ private function current(): array
+ {
+ return $this->get() ?? [
+ 'status' => 'idle',
+ 'mode' => null,
+ 'started_at' => null,
+ 'finished_at' => null,
+ 'phase' => null,
+ 'urls' => 0,
+ 'url_count' => null,
+ 'child_count' => null,
+ 'message' => null,
+ ];
+ }
+
+ /**
+ * 진행상황을 캐시에 기록하고, 필요 시 관리자 채널로 방송합니다.
+ *
+ * 방송 payload 는 상태 API 응답 봉투와 동형입니다 — 최상위 `data` 봉투 안에
+ * `progress`/`last_updated_at`/`realtime_enabled` 를 담습니다(대시보드 방송과 동일 규약).
+ * websocket 데이터소스가 `target_source` 로 대상 소스의 저장값을 통째 교체하므로, 봉투
+ * 형태여야 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지됩니다(D27).
+ *
+ * @param array $state 진행상황 상태
+ * @param bool $broadcast 방송 여부(스로틀 판정 결과)
+ * @param string|null $lastUpdatedAt 이 방송에 실을 마지막 생성 시각(완료 시 새 시각, 그 외 직전 값)
+ */
+ private function persist(array $state, bool $broadcast, ?string $lastUpdatedAt = null): void
+ {
+ $this->cache->put(self::KEY, $state, self::TTL);
+
+ if (! $broadcast) {
+ return;
+ }
+
+ // 미지정(진행 중 단계)이면 직전 완료 시각(settings)을 싣는다. target_source 가 대상 소스
+ // data 를 통째 교체하므로 매 방송마다 형제 필드를 온전히 실어야 "마지막 생성"이 유지된다.
+ if ($lastUpdatedAt === null) {
+ $stored = (string) g7_core_settings('seo.sitemap_last_updated_at', '');
+ $lastUpdatedAt = $stored !== '' ? $stored : null;
+ }
+
+ // 방송 payload 는 상태 API 응답 봉투와 동형이어야 한다(대시보드 방송과 동일 규약).
+ // 최상위 data 봉투 안에 progress/last_updated_at/realtime_enabled 를 담아야
+ // target_source 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지된다.
+ HookManager::broadcast(self::CHANNEL, self::EVENT, [
+ 'data' => [
+ 'last_updated_at' => $lastUpdatedAt,
+ 'progress' => $state,
+ 'realtime_enabled' => true,
+ ],
+ ]);
+ }
+}
diff --git a/app/Seo/SitemapWriter.php b/app/Seo/SitemapWriter.php
new file mode 100644
index 00000000..ed65be9c
--- /dev/null
+++ b/app/Seo/SitemapWriter.php
@@ -0,0 +1,338 @@
+ 블록 수
+ */
+ private int $currentUrlCount = 0;
+
+ /**
+ * 현재 자식 파일 번호 (1부터)
+ */
+ private int $childIndex = 0;
+
+ /**
+ * 현재 자식 파일이 열려 있는지 여부
+ */
+ private bool $childOpen = false;
+
+ /**
+ * 현재 자식 파일의 최신 lastmod
+ */
+ private ?string $childLastmod = null;
+
+ /**
+ * 기록된 자식 파일 목록
+ *
+ * @var array
+ */
+ private array $children = [];
+
+ /**
+ * 전체 블록 수
+ */
+ private int $totalUrls = 0;
+
+ /**
+ * 전체 자식 파일 바이트 합계
+ */
+ private int $totalBytes = 0;
+
+ /**
+ * 파일당 URL 수 임계
+ */
+ private readonly int $urlsPerFile;
+
+ /**
+ * SitemapWriter 생성자
+ *
+ * @param StorageInterface $storage 비공개 디스크 스토리지 드라이버
+ * @param SitemapXmlRenderer $renderer XML 조각 렌더러 (SitemapGenerator 와 공유)
+ * @param int $urlsPerFile 파일당 URL 수 (1 ~ HARD_URL_CAP 로 클램프)
+ * @param bool $gzip 자식 파일 gzip 압축 여부
+ */
+ public function __construct(
+ private readonly StorageInterface $storage,
+ private readonly SitemapXmlRenderer $renderer,
+ int $urlsPerFile = self::HARD_URL_CAP,
+ private readonly bool $gzip = false,
+ ) {
+ $this->urlsPerFile = max(1, min($urlsPerFile, self::HARD_URL_CAP));
+ }
+
+ /**
+ * 기록을 시작합니다 (임시 디렉토리 초기화 + 상태 리셋).
+ */
+ public function open(): void
+ {
+ $this->purgeTmpDirectory();
+
+ $this->buffer = '';
+ $this->currentUrlCount = 0;
+ $this->childIndex = 0;
+ $this->childOpen = false;
+ $this->childLastmod = null;
+ $this->children = [];
+ $this->totalUrls = 0;
+ $this->totalBytes = 0;
+ }
+
+ /**
+ * URL 항목 하나를 기록합니다.
+ *
+ * 임계(URL 수 또는 바이트) 도달 시 현재 자식 파일을 flush 합니다.
+ *
+ * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목
+ */
+ public function addUrl(array $entry): void
+ {
+ $block = $this->renderer->urlBlock($entry);
+ if ($block === '') {
+ return;
+ }
+
+ if (! $this->childOpen) {
+ $this->openNextChild();
+ }
+
+ $this->buffer .= $block;
+ $this->currentUrlCount += $this->renderer->urlBlockCount();
+ $this->totalUrls += $this->renderer->urlBlockCount();
+
+ $lastmod = $entry['lastmod'] ?? null;
+ if (is_string($lastmod) && $lastmod !== '' && ($this->childLastmod === null || $lastmod > $this->childLastmod)) {
+ $this->childLastmod = $lastmod;
+ }
+
+ // static:: 바인딩 — 하위 클래스가 임계값을 낮춰 바이트 분할 분기를 검증할 수 있게 한다
+ // (45MB 를 실제로 채우지 않고 같은 경로를 태우기 위함).
+ if ($this->currentUrlCount >= $this->urlsPerFile || strlen($this->buffer) >= static::MAX_FILE_BYTES) {
+ $this->flushChild();
+ }
+ }
+
+ /**
+ * 기록을 마감합니다 (마지막 자식 flush + sitemapindex 기록).
+ *
+ * @return array{child_count: int, url_count: int, size_bytes: int, children: array} 기록 메타데이터
+ */
+ public function close(): array
+ {
+ // 자식이 하나도 없으면(URL 0건) 빈 urlset 자식 1개를 남겨 인덱스를 유효하게 유지합니다.
+ if ($this->childOpen || $this->children === []) {
+ if (! $this->childOpen) {
+ $this->openNextChild();
+ }
+ $this->flushChild();
+ }
+
+ $indexXml = $this->renderer->sitemapIndex($this->children);
+ $this->putOrFail(SitemapFileStore::TMP_DIR.'/'.SitemapFileStore::INDEX_FILE, $indexXml);
+
+ return [
+ 'child_count' => count($this->children),
+ 'url_count' => $this->totalUrls,
+ 'size_bytes' => $this->totalBytes,
+ 'children' => $this->children,
+ ];
+ }
+
+ /**
+ * 임시 디렉토리의 세트를 live 로 커밋합니다.
+ *
+ * 순서: (1) 기존 live 파일 목록 확보 → (2) _tmp 파일을 live 로 재배치 →
+ * (3) manifest.json 기록(커밋 마커) → (4) 새 세트에 없는 stale 파일 삭제 → (5) _tmp 정리.
+ *
+ * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array}
+ * 커밋된 sitemap 메타데이터
+ *
+ * @throws \RuntimeException 파일 기록 실패 시
+ */
+ public function commit(): array
+ {
+ $staleFiles = $this->liveFilenames();
+
+ $newFiles = [SitemapFileStore::INDEX_FILE];
+ foreach ($this->children as $child) {
+ $newFiles[] = $child['file'];
+ }
+
+ foreach ($newFiles as $filename) {
+ $content = $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/'.$filename);
+ if ($content === null) {
+ throw new \RuntimeException(__('exceptions.seo.sitemap_temp_file_unreadable', ['file' => $filename]));
+ }
+
+ $this->putOrFail(SitemapFileStore::LIVE_DIR.'/'.$filename, $content);
+ unset($content);
+ }
+
+ $manifest = [
+ 'generated_at' => now()->toIso8601String(),
+ 'child_count' => count($this->children),
+ 'url_count' => $this->totalUrls,
+ 'size_bytes' => $this->totalBytes,
+ 'gzip' => $this->gzip,
+ 'urls_per_file' => $this->urlsPerFile,
+ 'children' => $this->children,
+ ];
+
+ // manifest 는 마지막에 기록 — 존재 = 커밋 완료 신호
+ $this->putOrFail(
+ SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE,
+ json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ );
+
+ $this->deleteStaleFiles($staleFiles, $newFiles);
+
+ $this->purgeTmpDirectory();
+
+ return $manifest;
+ }
+
+ /**
+ * 임시 디렉토리를 정리합니다.
+ *
+ * 정리 실패는 치명적이지 않습니다 — 임시 파일은 이름 기준으로 덮어쓰이고,
+ * 커밋은 children 목록에 있는 파일만 live 로 옮기므로 잔여 파일이 결과에 섞이지 않습니다.
+ * 따라서 예외를 전파하지 않고 경고만 남겨 sitemap 생성 자체가 중단되지 않도록 합니다.
+ */
+ private function purgeTmpDirectory(): void
+ {
+ try {
+ $this->storage->deleteDirectory(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR);
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Sitemap 임시 디렉토리 정리 실패 (생성은 계속 진행)', [
+ 'directory' => SitemapFileStore::TMP_DIR,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * 다음 자식 파일을 엽니다 (버퍼에 urlset 헤더 기록).
+ */
+ private function openNextChild(): void
+ {
+ $this->childIndex++;
+ $this->buffer = $this->renderer->urlsetHeader();
+ $this->currentUrlCount = 0;
+ $this->childLastmod = null;
+ $this->childOpen = true;
+ }
+
+ /**
+ * 현재 자식 파일 버퍼를 임시 디렉토리에 기록하고 버퍼를 비웁니다.
+ *
+ * @throws \RuntimeException 파일 기록 실패 시
+ */
+ private function flushChild(): void
+ {
+ $this->buffer .= $this->renderer->urlsetFooter();
+
+ $filename = SitemapFileStore::childFilename($this->childIndex, $this->gzip);
+ $content = $this->gzip ? gzencode($this->buffer, 6) : $this->buffer;
+
+ if ($content === false) {
+ throw new \RuntimeException(__('exceptions.seo.sitemap_gzip_failed', ['file' => $filename]));
+ }
+
+ $this->putOrFail(SitemapFileStore::TMP_DIR.'/'.$filename, $content);
+
+ $this->children[] = [
+ 'file' => $filename,
+ 'loc' => SitemapFileStore::childUrl($this->childIndex, $this->gzip),
+ 'lastmod' => $this->childLastmod,
+ 'url_count' => $this->currentUrlCount,
+ 'size_bytes' => strlen($content),
+ ];
+ $this->totalBytes += strlen($content);
+
+ $this->buffer = '';
+ $this->currentUrlCount = 0;
+ $this->childOpen = false;
+ }
+
+ /**
+ * live 디렉토리의 파일명 목록을 반환합니다.
+ *
+ * @return array 파일명 배열 (디렉토리 제외)
+ */
+ private function liveFilenames(): array
+ {
+ $paths = $this->storage->files(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR);
+
+ return array_map(static fn (string $path): string => basename($path), $paths);
+ }
+
+ /**
+ * 새 세트에 포함되지 않은 이전 자식 파일을 삭제합니다.
+ *
+ * @param array $staleFiles 커밋 전 live 파일명 목록
+ * @param array $newFiles 새 세트 파일명 목록
+ */
+ private function deleteStaleFiles(array $staleFiles, array $newFiles): void
+ {
+ $keep = array_merge($newFiles, [SitemapFileStore::MANIFEST_FILE]);
+
+ foreach ($staleFiles as $filename) {
+ if (in_array($filename, $keep, true)) {
+ continue;
+ }
+
+ $this->storage->delete(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.$filename);
+ }
+ }
+
+ /**
+ * 파일을 기록하고 실패 시 예외를 던집니다.
+ *
+ * @param string $path 카테고리 하위 상대 경로
+ * @param string $content 파일 내용
+ *
+ * @throws \RuntimeException 기록 실패 시
+ */
+ private function putOrFail(string $path, string $content): void
+ {
+ if (! $this->storage->put(SitemapFileStore::CATEGORY, $path, $content)) {
+ Log::error('[SEO] Sitemap 파일 기록 실패', ['path' => $path]);
+
+ throw new \RuntimeException(__('exceptions.seo.sitemap_write_failed', ['path' => $path]));
+ }
+ }
+}
diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php
new file mode 100644
index 00000000..dd269937
--- /dev/null
+++ b/app/Seo/SitemapXmlRenderer.php
@@ -0,0 +1,311 @@
+ 블록, sitemapindex 를 생성합니다.
+ * Sitemap XML 의 이스케이프 로직을 이 클래스 한 곳에서만 수행하며(단일 출처),
+ * SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다.
+ *
+ * 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 블록을 생성하고
+ * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. alternate 링크 집합은
+ * base URL 당 한 번만 계산해 로케일별 에 재사용하므로, alternate 생성 비용은
+ * 로케일 수의 제곱이 아니라 선형입니다.
+ */
+final class SitemapXmlRenderer
+{
+ /**
+ * hreflang alternate 를 방출하는 지원 로케일 수 상한
+ *
+ * 로케일 수가 이 값을 넘으면 URL 하나당 alternate 링크가 과도하게 팽창하므로
+ * alternate 를 생략합니다(로케일별 자체는 유지). 정상 다국어 구성(2~수개)은
+ * 이 상한에 걸리지 않습니다.
+ */
+ public const MAX_HREFLANG = 50;
+
+ /**
+ * 다국어 여부 (지원 로케일 2개 이상)
+ */
+ private readonly bool $multilingual;
+
+ /**
+ * hreflang alternate 를 방출하는지 여부
+ *
+ * 다국어이면서 hreflang 이 켜져 있고 로케일 수가 상한 이하일 때만 true.
+ */
+ private readonly bool $hreflangActive;
+
+ /**
+ * SitemapXmlRenderer 생성자
+ *
+ * @param array $supportedLocales 지원 로케일 목록
+ * @param string $defaultLocale 기본 로케일
+ * @param bool $hreflangEnabled hreflang alternate 방출 여부 (기본 true)
+ * @param int $maxHreflang hreflang 을 방출할 로케일 수 상한
+ */
+ public function __construct(
+ private readonly array $supportedLocales,
+ private readonly string $defaultLocale,
+ bool $hreflangEnabled = true,
+ int $maxHreflang = self::MAX_HREFLANG,
+ ) {
+ $this->multilingual = count($this->supportedLocales) > 1;
+ $this->hreflangActive = $this->multilingual
+ && $hreflangEnabled
+ && count($this->supportedLocales) <= $maxHreflang;
+ }
+
+ /**
+ * 애플리케이션 로케일 설정으로 렌더러를 생성합니다.
+ *
+ * hreflang 방출 여부는 seo.sitemap_hreflang_enabled 설정(기본 true)을 따릅니다.
+ * 로케일 수가 상한을 초과하면 alternate 를 생략하고 경고를 남깁니다.
+ *
+ * @return self 설정 기반 렌더러 인스턴스
+ */
+ public static function fromConfig(): self
+ {
+ $defaultLocale = (string) config('app.locale');
+ $supportedLocales = (array) config('app.supported_locales', [$defaultLocale]);
+ $hreflangEnabled = (bool) g7_core_settings('seo.sitemap_hreflang_enabled', true);
+
+ if ($hreflangEnabled && count($supportedLocales) > self::MAX_HREFLANG) {
+ Log::warning('[SEO] Sitemap hreflang alternate 링크가 로케일 수 상한을 초과하여 생략됩니다.', [
+ 'locale_count' => count($supportedLocales),
+ 'max_hreflang' => self::MAX_HREFLANG,
+ ]);
+ }
+
+ return new self($supportedLocales, $defaultLocale, $hreflangEnabled);
+ }
+
+ /**
+ * 다국어 모드 여부를 반환합니다.
+ *
+ * @return bool 지원 로케일이 2개 이상이면 true
+ */
+ public function isMultilingual(): bool
+ {
+ return $this->multilingual;
+ }
+
+ /**
+ * URL 항목 하나가 생성하는 블록 개수를 반환합니다.
+ *
+ * 분할 임계(파일당 URL 수) 계산에 사용됩니다.
+ *
+ * @return int 다국어면 로케일 수, 단일 언어면 1
+ */
+ public function urlBlockCount(): int
+ {
+ return $this->multilingual ? count($this->supportedLocales) : 1;
+ }
+
+ /**
+ * urlset 여는 태그(XML 선언 포함)를 반환합니다.
+ *
+ * @return string urlset 헤더 문자열
+ */
+ public function urlsetHeader(): string
+ {
+ $xml = ''."\n";
+
+ if ($this->hreflangActive) {
+ $xml .= '';
+ }
+
+ /**
+ * URL 항목 하나를 블록으로 렌더링합니다.
+ *
+ * 다국어면 로케일 수만큼의 블록을 이어붙여 반환합니다.
+ *
+ * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 (loc 은 절대 URL)
+ * @return string 블록 문자열 (loc 이 없으면 빈 문자열)
+ */
+ public function urlBlock(array $entry): string
+ {
+ $baseLoc = $entry['loc'] ?? '';
+ if ($baseLoc === '') {
+ return '';
+ }
+
+ if (! $this->multilingual) {
+ return $this->renderSingleUrl($baseLoc, $entry);
+ }
+
+ // hreflang alternate 집합은 base URL 당 한 번만 계산해 로케일별 에 재사용합니다.
+ // (로케일별로 다시 계산하면 alternate 생성이 로케일 수의 제곱이 됩니다.)
+ $alternateLinks = $this->hreflangActive ? $this->buildAlternateLinks($baseLoc) : '';
+
+ $xml = '';
+ foreach ($this->supportedLocales as $locale) {
+ $xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale, $alternateLinks);
+ }
+
+ return $xml;
+ }
+
+ /**
+ * 자식 sitemap 목록을 sitemapindex XML 로 렌더링합니다.
+ *
+ * @param array $children 자식 sitemap 목록
+ * @return string sitemapindex XML 문자열
+ */
+ public function sitemapIndex(array $children): string
+ {
+ $xml = ''."\n";
+ $xml .= ''."\n";
+
+ foreach ($children as $child) {
+ $xml .= ' '."\n";
+ $xml .= ' '.$this->escape($child['loc']).''."\n";
+
+ if (! empty($child['lastmod'])) {
+ $xml .= ' '.$this->escape($child['lastmod']).''."\n";
+ }
+
+ $xml .= ' '."\n";
+ }
+
+ $xml .= '';
+
+ return $xml;
+ }
+
+ /**
+ * 단일 언어 블록을 렌더링합니다.
+ *
+ * @param string $loc 절대 URL
+ * @param array $entry URL 항목
+ * @return string 블록 문자열
+ */
+ private function renderSingleUrl(string $loc, array $entry): string
+ {
+ $xml = ' '."\n";
+ $xml .= ' '.$this->escape($loc).''."\n";
+ $xml .= $this->renderMetaFields($entry);
+ $xml .= ' '."\n";
+
+ return $xml;
+ }
+
+ /**
+ * 특정 로케일의 블록을 렌더링합니다.
+ *
+ * hreflang alternate 링크 문자열은 호출부에서 base URL 당 한 번 계산해 전달합니다.
+ *
+ * @param string $baseLoc 기본 로케일 절대 URL
+ * @param array $entry URL 항목
+ * @param string $locale 대상 로케일
+ * @param string $alternateLinks 미리 계산된 hreflang alternate 링크 문자열 (비활성 시 빈 문자열)
+ * @return string 블록 문자열
+ */
+ private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale, string $alternateLinks): string
+ {
+ $xml = ' '."\n";
+ $xml .= ' '.$this->escape($this->localizedLoc($baseLoc, $locale)).''."\n";
+ $xml .= $this->renderMetaFields($entry);
+ $xml .= $alternateLinks;
+ $xml .= ' '."\n";
+
+ return $xml;
+ }
+
+ /**
+ * base URL 하나에 대한 hreflang alternate 링크 집합을 렌더링합니다.
+ *
+ * 모든 지원 로케일의 alternate + x-default 를 포함하며, 이 문자열은 base URL 의
+ * 모든 로케일 블록에 동일하게 재사용됩니다.
+ *
+ * @param string $baseLoc 기본 로케일 절대 URL
+ * @return string xhtml:link alternate 링크 문자열
+ */
+ private function buildAlternateLinks(string $baseLoc): string
+ {
+ $xml = '';
+
+ // 모든 로케일의 hreflang alternate 링크
+ foreach ($this->supportedLocales as $altLocale) {
+ $altHref = $this->localizedLoc($baseLoc, (string) $altLocale);
+ $xml .= ' '."\n";
+ }
+
+ // x-default = 기본 로케일 URL
+ $xml .= ' '."\n";
+
+ return $xml;
+ }
+
+ /**
+ * lastmod / changefreq / priority 필드를 렌더링합니다.
+ *
+ * @param array $entry URL 항목
+ * @return string 메타 필드 문자열
+ */
+ private function renderMetaFields(array $entry): string
+ {
+ $xml = '';
+
+ if (! empty($entry['lastmod'])) {
+ $xml .= ' '.$this->escape((string) $entry['lastmod']).''."\n";
+ }
+
+ // changefreq 는 sitemaps.org 폐쇄 어휘 — Enum 으로 정규화해 비표준 값은 출력하지 않는다.
+ $changefreq = SitemapChangeFreq::normalize($entry['changefreq'] ?? null);
+ if ($changefreq !== null) {
+ $xml .= ' '.$changefreq.''."\n";
+ }
+
+ if (isset($entry['priority'])) {
+ $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n";
+ }
+
+ return $xml;
+ }
+
+ /**
+ * 로케일별 URL 을 생성합니다.
+ *
+ * @param string $baseLoc 기본 로케일 절대 URL
+ * @param string $locale 대상 로케일
+ * @return string 로케일 URL (기본 로케일이면 원본 그대로)
+ */
+ private function localizedLoc(string $baseLoc, string $locale): string
+ {
+ return $locale === $this->defaultLocale
+ ? $baseLoc
+ : $baseLoc.'?locale='.$locale;
+ }
+
+ /**
+ * XML 특수문자를 이스케이프합니다.
+ *
+ * @param string $value 원본 문자열
+ * @return string 이스케이프된 문자열
+ */
+ private function escape(string $value): string
+ {
+ return htmlspecialchars($value, ENT_XML1, 'UTF-8');
+ }
+}
diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php
index 8f57e0ff..7bbc9a72 100644
--- a/app/Services/SettingsService.php
+++ b/app/Services/SettingsService.php
@@ -563,7 +563,7 @@ class SettingsService
// 각 카테고리에 속하는 원본 필드명 목록
// (frontend_key → 원본 키 역변환 후의 키 기준)
$categoryFieldMap = [
- 'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl'],
+ 'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
'debug' => ['mode', 'sql_query_log', 'log_level'],
'core_update' => ['github_url', 'github_token'],
'geoip' => ['feature_enabled', 'license_key', 'auto_update_enabled', 'last_updated_at'],
diff --git a/config/app.php b/config/app.php
index 986558af..fd969be6 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.5'),
+ 'version' => env('APP_VERSION', '7.0.6'),
/*
|--------------------------------------------------------------------------
diff --git a/config/core.php b/config/core.php
index 50534ca2..561bff61 100644
--- a/config/core.php
+++ b/config/core.php
@@ -586,6 +586,100 @@ return [
],
],
],
+
+ // 사이트맵 재생성 완료 — 관리자 수동 재생성(SEO 탭 "지금 생성")에 한해, 실행한 관리자에게 발송.
+ // 스케줄러/증분/봇 재생성은 SeoNotificationDataListener 가 context.skip 으로 제외.
+ // 기본 활성 채널은 database(앱 내 알림)만. mail 템플릿은 존재하되 비활성(운영자가 필요 시 활성화).
+ 'sitemap_regenerated' => [
+ 'hook_prefix' => 'core.seo',
+ 'name' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap Regeneration Complete'],
+ 'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 완료되면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it completes'],
+ 'channels' => ['database'],
+ 'hooks' => ['core.seo.sitemap.after_regenerate'],
+ 'variables' => [
+ ['key' => 'app_name', 'description' => '사이트명'],
+ ['key' => 'url_count', 'description' => '생성된 총 URL 수'],
+ ['key' => 'child_count', 'description' => '생성된 사이트맵 파일 수'],
+ ['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'],
+ ['key' => 'site_url', 'description' => '사이트 URL'],
+ ],
+ 'templates' => [
+ [
+ 'channel' => 'mail',
+ 'is_active' => false,
+ 'recipients' => [['type' => 'trigger_user']],
+ 'subject' => [
+ 'ko' => '[{app_name}] 사이트맵 재생성이 완료되었습니다',
+ 'en' => '[{app_name}] Sitemap Regeneration Complete',
+ ],
+ 'body' => [
+ 'ko' => '사이트맵 재생성 완료
'
+ .'요청하신 사이트맵 재생성이 완료되었습니다.
'
+ .'총 {url_count}개의 URL 이 {child_count}개의 파일로 생성되었습니다.
'
+ .''
+ .'감사합니다,
{app_name}
',
+ 'en' => 'Sitemap Regeneration Complete
'
+ .'The sitemap regeneration you requested has completed.
'
+ .'A total of {url_count} URLs were generated across {child_count} file(s).
'
+ .''
+ .'Thank you,
{app_name}
',
+ ],
+ ],
+ [
+ 'channel' => 'database',
+ 'recipients' => [['type' => 'trigger_user']],
+ 'subject' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap regeneration complete'],
+ 'body' => ['ko' => '요청하신 사이트맵 재생성이 완료되었습니다. 총 {url_count}개 URL, {child_count}개 파일이 생성되었습니다.', 'en' => 'Your sitemap regeneration is complete. {url_count} URLs across {child_count} file(s) were generated.'],
+ 'click_url' => '/admin/settings?tab=seo',
+ ],
+ ],
+ ],
+
+ // 사이트맵 재생성 실패 — 관리자 수동 재생성이 실패하면 실행한 관리자에게 발송.
+ 'sitemap_regenerate_failed' => [
+ 'hook_prefix' => 'core.seo',
+ 'name' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap Regeneration Failed'],
+ 'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 최종 실패(재시도 소진)하면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it finally fails after retries'],
+ 'channels' => ['database'],
+ // 재시도 소진 시 1회만 발화하는 최종 실패 훅에 구독 (매 시도 발화하는 after_regenerate_failed 아님)
+ 'hooks' => ['core.seo.sitemap.regenerate_failed_final'],
+ 'variables' => [
+ ['key' => 'app_name', 'description' => '사이트명'],
+ ['key' => 'error', 'description' => '실패 사유 메시지'],
+ ['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'],
+ ['key' => 'site_url', 'description' => '사이트 URL'],
+ ],
+ 'templates' => [
+ [
+ 'channel' => 'mail',
+ 'is_active' => false,
+ 'recipients' => [['type' => 'trigger_user']],
+ 'subject' => [
+ 'ko' => '[{app_name}] 사이트맵 재생성이 실패했습니다',
+ 'en' => '[{app_name}] Sitemap Regeneration Failed',
+ ],
+ 'body' => [
+ 'ko' => '사이트맵 재생성 실패
'
+ .'요청하신 사이트맵 재생성이 실패했습니다.
'
+ .'오류: {error}
'
+ .''
+ .'감사합니다,
{app_name}
',
+ 'en' => 'Sitemap Regeneration Failed
'
+ .'The sitemap regeneration you requested has failed.
'
+ .'Error: {error}
'
+ .''
+ .'Thank you,
{app_name}
',
+ ],
+ ],
+ [
+ 'channel' => 'database',
+ 'recipients' => [['type' => 'trigger_user']],
+ 'subject' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap regeneration failed'],
+ 'body' => ['ko' => '요청하신 사이트맵 재생성이 실패했습니다. 오류: {error}', 'en' => 'Your sitemap regeneration failed. Error: {error}'],
+ 'click_url' => '/admin/settings?tab=seo',
+ ],
+ ],
+ ],
],
/*
diff --git a/config/settings/defaults.json b/config/settings/defaults.json
index 67291fe7..275d8b12 100644
--- a/config/settings/defaults.json
+++ b/config/settings/defaults.json
@@ -65,10 +65,15 @@
"og_image_default_height": 630,
"twitter_default_card": "summary_large_image",
"twitter_default_site": "",
- "cache_enabled": true,
- "cache_ttl": 7200,
+ "cache_enabled": null,
+ "cache_ttl": null,
"sitemap_enabled": true,
- "sitemap_cache_ttl": 86400,
+ "sitemap_cache_ttl": null,
+ "sitemap_urls_per_file": 50000,
+ "sitemap_gzip": false,
+ "sitemap_serve_stale_on_miss": true,
+ "sitemap_max_urls_per_contributor": 0,
+ "sitemap_hreflang_enabled": true,
"sitemap_schedule": "daily",
"sitemap_schedule_time": "02:00",
"sitemap_last_updated_at": "",
@@ -213,6 +218,11 @@
"cache_ttl": { "type": "integer", "sensitive": false },
"sitemap_enabled": { "type": "boolean", "sensitive": false },
"sitemap_cache_ttl": { "type": "integer", "sensitive": false },
+ "sitemap_urls_per_file": { "type": "integer", "sensitive": false },
+ "sitemap_gzip": { "type": "boolean", "sensitive": false },
+ "sitemap_serve_stale_on_miss": { "type": "boolean", "sensitive": false },
+ "sitemap_max_urls_per_contributor": { "type": "integer", "sensitive": false },
+ "sitemap_hreflang_enabled": { "type": "boolean", "sensitive": false },
"sitemap_schedule": { "type": "string", "sensitive": false },
"sitemap_schedule_time": { "type": "string", "sensitive": false },
"sitemap_last_updated_at": { "type": "string", "sensitive": false },
diff --git a/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php b/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php
new file mode 100644
index 00000000..d1ae85e0
--- /dev/null
+++ b/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php
@@ -0,0 +1,53 @@
+bigIncrements('id')->comment('사이트맵 URL 행 ID');
+ $table->string('resource_type', 64)->index()->comment('리소스 유형 (page, board_post, product, category, board, board_index, shop_index 등)');
+ $table->string('resource_id', 64)->nullable()->comment('리소스 PK (uuid/int 문자열). 인덱스/컬렉션 URL 은 null');
+ $table->string('loc', 2048)->comment('절대 base URL (로케일 무관 — 렌더 시점에 로케일별 확장)');
+ // loc 는 2048 자라 unique 인덱스에 직접 넣으면 InnoDB 키 길이(3072바이트) 를 초과하므로
+ // sha256 해시(64자 hex)를 identity 로 사용합니다. ascii 컬럼이라 인덱스 비용이 작습니다.
+ $table->char('loc_hash', 64)->charset('ascii')->comment('loc 의 sha256 해시 (upsert/delete identity)');
+ $table->timestamp('lastmod')->nullable()->comment('최종 수정 시각 ()');
+ $table->string('changefreq', 16)->nullable()->comment('변경 빈도 ()');
+ $table->decimal('priority', 2, 1)->nullable()->comment('우선순위 0.0~1.0 ()');
+ $table->string('contributor', 64)->comment('기여자 식별자 (getIdentifier — 샤드/리빌드 스코핑)');
+ $table->boolean('is_visible')->default(true)->comment('공개 여부 (true 만 사이트맵에 노출)');
+ $table->timestamps();
+
+ // 리소스별 URL identity — upsert/delete 대상 식별
+ $table->unique(['resource_type', 'resource_id', 'loc_hash'], 'sitemap_urls_identity_unique');
+ // 리빌드/증분 스트리밍 커서 (contributor 스코핑 + 가시성 + id 순서)
+ $table->index(['contributor', 'is_visible', 'id'], 'sitemap_urls_stream_index');
+ });
+ }
+
+ /**
+ * sitemap_urls 테이블을 제거합니다.
+ */
+ public function down(): void
+ {
+ if (Schema::hasTable('sitemap_urls')) {
+ Schema::drop('sitemap_urls');
+ }
+ }
+};
diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md
index 1d2185ff..70e1eadb 100644
--- a/docs/backend/api/seo.md
+++ b/docs/backend/api/seo.md
@@ -148,13 +148,13 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-_단건 응답: `data` 객체의 필드._
+_단건 응답: `data` 객체의 필드 — `GET /api/admin/seo/sitemap/status` 와 동일한 스키마입니다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
-| last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 |
-| size_bytes | integer | `25489` | 크기 (바이트) |
-| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초) |
+| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | **예약 시점 기준** 마지막 생성 일시. 이번 재생성의 결과가 아니라 직전 생성 시각이며, 잡 완료 후 갱신됩니다 (아직 한 번도 생성되지 않았으면 null) |
+| progress | object\|null | `{"status":"queued","mode":"full",...}` | 재생성 진행상황. 예약 직후 `status=queued`, `mode=full` 로 즉시 기록됩니다. 필드 상세는 아래 상태 조회 API 참조 (진행 이력이 없으면 null) |
+| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 진행상황 채널을 구독하고, false 면 상태 API 를 주기적으로 폴링합니다 |
**응답 예시**
@@ -165,11 +165,107 @@ HTTP/1.1 200
```json
{
"success": true,
- "message": "Sitemap 재생성이 완료되었습니다.",
+ "message": "Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.",
"data": {
"last_updated_at": "2026-07-08T03:14:49+00:00",
- "size_bytes": 25489,
- "ttl": 86400
+ "progress": {
+ "status": "queued",
+ "mode": "full",
+ "started_at": "2026-07-08T05:20:00+00:00",
+ "finished_at": null,
+ "phase": null,
+ "urls": 0,
+ "url_count": null,
+ "child_count": null,
+ "message": null
+ },
+ "realtime_enabled": false
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 400 | Bad Request | SEO 설정에서 sitemap 생성이 비활성(`seo.sitemap_enabled=false`)인 경우 (`seo.sitemap_disabled`) |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
+
+
+
+**설명**
+
+sitemap.xml 재생성을 큐에 예약합니다. 관리자 수동 재생성은 현 생성 상태와 무관하게 **항상 전체(full) 재생성**입니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다.
+
+요청 스레드에서 동기 생성하지 않는 이유는 대용량(수백만 URL) 사이트에서 생성이 수 분~수십 분 걸려 관리자 요청이 메모리 초과/타임아웃으로 실패하기 때문입니다. 잡은 유니크 락(`seo-sitemap`)을 사용하므로 재생성을 연속으로 요청해도 동시에 여러 건이 실행되지 않고 한 건만 큐에 남습니다.
+
+예약 직후 진행상황이 `queued` 로 기록되며, 이후 진행 단계는 `GET /api/admin/seo/sitemap/status` 로 조회합니다.
+
+이 엔드포인트로 접수된 **관리자 수동 재생성**은 실행한 관리자의 ID 를 잡에 실어, 생성 완료 시 `sitemap_regenerated`, 실패 시 `sitemap_regenerate_failed` 알림이 **그 관리자에게만** 발송됩니다(기본 채널: 앱 내 알림). 스케줄러·리소스 변경(증분)·봇 캐시 미스로 유발된 재생성은 실행 관리자가 없어 알림을 보내지 않습니다. 알림 정의는 `config/core.php` 의 `notification_definitions` 에 있으며 관리자 알림 설정에서 채널을 조정할 수 있습니다.
+
+### GET /api/admin/seo/sitemap/status
+
+- **라우트명**: `api.admin.seo.sitemap.status`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@sitemapStatus`
+- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/admin/seo/sitemap/status HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | 마지막 생성 일시 (아직 한 번도 생성되지 않았으면 null) |
+| progress | object\|null | `{"status":"running",...}` | 재생성 진행상황. 진행 이력이 없으면 null |
+| progress.status | string | `running` | `queued`(예약) / `running`(기여자 처리 중) / `writing`(파일 작성 중) / `completed`(완료) / `failed`(실패) |
+| progress.mode | string\|null | `full` | 재생성 모드 (`full` / `auto` / `incremental`) |
+| progress.phase | string\|null | `sirsoft-board` | 현재 처리 중인 기여자 식별자 (running 단계에서만) |
+| progress.urls | integer | `12000` | 누적 처리 URL 수 |
+| progress.url_count | integer\|null | `5000` | 완료 시 총 URL 수 |
+| progress.child_count | integer\|null | `2` | 완료 시 분할 파일 수 |
+| progress.started_at | string\|null | `2026-07-08T05:20:00+00:00` | 예약 시각 |
+| progress.finished_at | string\|null | `2026-07-08T05:21:30+00:00` | 완료/실패 시각 |
+| progress.message | string\|null | `null` | 실패 시 오류 메시지 |
+| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 `core.admin.seo.sitemap` 채널을 구독하고, false 면 이 API 를 주기적으로 폴링합니다 |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "last_updated_at": "2026-07-08T03:14:49+00:00",
+ "progress": {
+ "status": "running",
+ "mode": "full",
+ "started_at": "2026-07-08T05:20:00+00:00",
+ "finished_at": null,
+ "phase": "sirsoft-board",
+ "urls": 12000,
+ "url_count": null,
+ "child_count": null,
+ "message": null
+ },
+ "realtime_enabled": false
}
}
```
@@ -179,13 +275,15 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
**설명**
-sitemap.xml 을 즉시 재생성합니다. SitemapManager 에 위임하며 큐 드라이버와 무관하게 동기(즉시) 실행되고, 완료 후 마지막 생성 시각을 갱신합니다. SEO 설정에서 sitemap 기능이 비활성인 경우 400(`seo.sitemap_disabled`), 생성 실패 시 500 을 반환합니다. 관리자가 콘텐츠 변경 후 검색엔진에 노출할 sitemap 을 스케줄 대기 없이 즉시 갱신할 때 사용합니다.
+Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다. SEO 탭 진입 시 초기 로드에 사용되며, `realtime_enabled` 가 false(웹소켓 미사용)일 때는 프론트가 진행 중(`queued`/`running`/`writing`) 동안 이 API 를 3초 간격으로 폴링합니다. 실시간 연결이 켜져 있으면 폴링 대신 `core.admin.seo.sitemap` 채널의 `sitemap.progress.updated` 이벤트로 즉시 갱신됩니다.
+
+진행상황은 캐시(TTL 3600초)에 저장되므로, 잡이 비정상 종료해도 시간이 지나면 만료됩니다. 잡 최종 실패 시에는 `progress.status=failed` 로 고정됩니다.
### GET /api/admin/seo/stats
diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md
index 984b62cb..f9ee3392 100644
--- a/docs/backend/api/settings.md
+++ b/docs/backend/api/settings.md
@@ -148,6 +148,11 @@ HTTP/1.1 200
"cache_ttl": 7200,
"sitemap_enabled": true,
"sitemap_cache_ttl": 86400,
+ "sitemap_urls_per_file": 50000,
+ "sitemap_gzip": false,
+ "sitemap_serve_stale_on_miss": true,
+ "sitemap_max_urls_per_contributor": 0,
+ "sitemap_hreflang_enabled": true,
"sitemap_schedule": "daily",
"sitemap_schedule_time": "02:00",
"sitemap_last_updated_at": "2026-07-08T03:14:49+00:00",
diff --git a/docs/backend/broadcasting.md b/docs/backend/broadcasting.md
index bc0f77de..0a6e5174 100644
--- a/docs/backend/broadcasting.md
+++ b/docs/backend/broadcasting.md
@@ -211,10 +211,19 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
| 소스 | 패턴 | 예시 |
|------|------|------|
-| 코어 | `core.*` | `core.admin.dashboard`, `core.user.notifications.{id}` |
+| 코어 | `core.*` | `core.admin.dashboard`, `core.admin.seo.sitemap`, `core.user.notifications.{id}` |
| 모듈 | `module.{identifier}.*` | `module.sirsoft-ecommerce.orders.{id}` |
| 플러그인 | `plugin.{identifier}.*` | `plugin.sirsoft-payment.status.{id}` |
+### 코어 방송 채널 목록
+
+| 채널 | 이벤트 | 인증 권한 | 페이로드 |
+|------|--------|----------|----------|
+| `core.admin.dashboard` | `dashboard.stats.updated` | `core.dashboard.read` | `{type, data}` |
+| `core.admin.seo.sitemap` | `sitemap.progress.updated` | `core.settings.read` | `{realtime_enabled, progress}` (상태 API `data` 와 동형) |
+
+> `core.admin.seo.sitemap` 은 Sitemap 생성 진행상황을 실시간 전달합니다. Reverb OFF 면 방송이 자동 skip 되고 프론트가 상태 API 폴링으로 폴백합니다. payload 가 상태 API 응답 `data` 와 동형이라 websocket 데이터소스 `target_source` 교체 후에도 UI 바인딩이 유지됩니다.
+
---
## 모듈/플러그인 채널 등록
@@ -314,6 +323,11 @@ Broadcast::channel('core.admin.dashboard', function ($user) {
return $user->hasPermission('core.dashboard.read');
});
+// 관리자 Sitemap 생성 진행상황 채널 - SEO 설정 읽기 권한
+Broadcast::channel('core.admin.seo.sitemap', function ($user) {
+ return $user->hasPermission('core.settings.read');
+});
+
// 모듈 리소스 채널 (모듈의 getChannels()로 자동 등록 — 참고용 예시)
// Broadcast::channel('module.sirsoft-ecommerce.orders.{orderId}', function ($user, $orderId) {
// return $user->hasPermission('sirsoft-ecommerce.orders.read');
diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md
index 0fcd2ec9..07d7b52a 100644
--- a/docs/backend/seo-system.md
+++ b/docs/backend/seo-system.md
@@ -12,7 +12,8 @@
5. seo-config.json: 텍스트 추출(text_props), 속성 매핑(attr_map), 허용 속성(allowed_attrs), 컴포넌트→HTML 매핑 — 모두 템플릿 선언
6. 훅 시스템: core.seo.filter_context/filter_og_data/filter_twitter_data/filter_structured_data/filter_meta/filter_view_data + 봇 감지 훅 core.seo.resolve_is_bot
7. 도메인 ownership: 모듈/플러그인이 `seoOgDefaults`/`seoTwitterDefaults`/`seoStructuredData` 메서드로 자기 도메인 OG/Twitter/JSON-LD 를 owned (이커머스 Product, 게시판 Article 등)
-7. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap
+8. Sitemap: 스트리밍 writer(유계 메모리)로 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503. 증분 저장소(sitemap_urls) + 재생성 모드(SitemapGenerationMode full/auto/incremental) + 진행상황(SitemapProgress, Reverb 실시간/OFF 폴링)
+9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap(--rebuild/--mode)
```
## 아키텍처 개요
@@ -70,7 +71,14 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
| SeoMetaResolver | 3계층 캐스케이드 메타 해석 |
| SeoMiddleware | 미들웨어 (봇 감지 → 렌더링) |
| SeoServiceProvider | DI 바인딩 |
-| SitemapGenerator | Sitemap 수집기 |
+| SitemapGenerator | Sitemap 수집기 (contributor 드레인 → writer 스트리밍) |
+| SitemapManager | Sitemap 재생성 오케스트레이션 (모드 분기 + 진행상황 + 훅) |
+| SitemapWriter | 자식파일당 in-memory 버퍼 flush + 분할 + atomic commit |
+| SitemapFileStore | 디스크 세트 읽기/서빙 (manifest·index·child 응답) |
+| SitemapXmlRenderer | XML escape/`` 블록/hreflang 단일 출처 |
+| SitemapIndexer | 리소스→sitemap_urls index/deindex (리스너 경유) |
+| SitemapProgress | 진행상황 스토어 (phase 기반 + 방송) |
+| AbstractSitemapContributor | contributor 브리지 base (getUrls↔getUrlsLazy) |
| SeoInvalidationRegistry | 무효화 규칙 레지스트리 |
| SeoDeclarationCollector | 레이아웃 SEO 선언 수집 |
| SeoCacheStatsService | 캐시 통계 집계 |
@@ -83,6 +91,8 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
| /api/admin/seo/clear-cache | POST | api.admin.seo.clear-cache | 캐시 삭제 |
| /api/admin/seo/warmup | POST | api.admin.seo.warmup | 워밍업 |
| /api/admin/seo/cached-urls | GET | api.admin.seo.cached-urls | 캐시 URL 목록 |
+| /api/admin/seo/sitemap/regenerate | POST | api.admin.seo.sitemap.regenerate | Sitemap 전체 재생성 (큐 예약, mode=full) |
+| /api/admin/seo/sitemap/status | GET | api.admin.seo.sitemap.status | Sitemap 생성 진행상황 + realtime_enabled |
## SeoMiddleware 동작 규칙
@@ -577,17 +587,35 @@ class EcommerceModule extends AbstractModule
**레이아웃 활성화 조건**: 모듈 declaration 이 호출되려면 레이아웃 `meta.seo.extensions` 에 `[{ "type": "module", "id": "sirsoft-ecommerce" }]` 선언 + `meta.seo.page_type` 명시 필요.
-### SitemapContributorInterface 구현
+### SitemapContributorInterface 구현 (지연 스트리밍 권장)
+
+대용량 도메인은 `AbstractSitemapContributor` 를 상속해 `getUrlsLazy()`(제너레이터)를 구현합니다. base 가 `getUrls()↔getUrlsLazy()` 를 양방향 브리지하므로 둘 중 하나만 구현하면 됩니다. 인터페이스 자체는 변경되지 않아 base 를 상속하지 않은 제3자 raw 구현체(`implements SitemapContributorInterface` + `getUrls()`)도 그대로 동작합니다.
```php
// modules/_bundled/[module]/src/Seo/[Module]SitemapContributor.php
-class EcommerceSitemapContributor implements SitemapContributorInterface
+class EcommerceSitemapContributor extends AbstractSitemapContributor
{
- public function getUrls(): array { /* 상품/카테고리 URL 반환 */ }
public function getIdentifier(): string { return 'sirsoft-ecommerce'; }
+
+ // 한 건씩 yield — 1.4M 배열을 메모리에 만들지 않음. 쿼리는 Repository 의
+ // stream*ForSitemap() 위임(lazyById, service-direct-data-access 규율).
+ // 리소스 단위 증분(SitemapIndexer) 매칭을 위해 resource_type/resource_id 를 함께 emit.
+ public function getUrlsLazy(): iterable
+ {
+ foreach ($this->products->streamVisibleForSitemap() as $p) {
+ yield ['loc' => url("/shop/{$p->id}"), 'lastmod' => $p->updated_at?->toW3cString(),
+ 'resource_type' => 'product', 'resource_id' => (string) $p->id];
+ }
+ }
}
```
+`SitemapGenerator::drain()` 이 `instanceof AbstractSitemapContributor || method_exists($c, 'getUrlsLazy')` 로 capability 를 감지해 지연 경로를 우선합니다.
+
+#### changefreq 는 `App\Enums\SitemapChangeFreq` (폐쇄 어휘)
+
+entry 의 `changefreq` 는 sitemaps.org 의 폐쇄 어휘(`always`/`hourly`/`daily`/`weekly`/`monthly`/`yearly`/`never`)로, `App\Enums\SitemapChangeFreq` 가 SSoT 입니다. 기여자·리스너·정적 URL 은 리터럴 문자열 대신 `SitemapChangeFreq::Weekly->value` 처럼 case 값을 사용해 오타를 작성 시점에 잡습니다. 규격에 없는 값은 저장 경계(`SitemapIndexer`)와 렌더 경계(`SitemapXmlRenderer`)에서 `SitemapChangeFreq::normalize()` 로 정규화되어 `null` 로 떨어지므로 사이트맵 XML 에 비표준 값이 출력되지 않습니다(대소문자·앞뒤 공백은 흡수). 제3자 raw 구현체가 임의 문자열을 emit 해도 이 두 경계에서 안전하게 걸러집니다.
+
### ServiceProvider 등록
```php
@@ -628,10 +656,82 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력
-php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
+php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
+php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당)
+php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정
```
+- `--mode=full`: 도메인 전량을 다시 읽어 `sitemap_urls` 를 전면 replace 후 파일 재작성(자식 균등 재분배).
+- `--mode=incremental`: 도메인 재쿼리 없이 `sitemap_urls` 스트림만으로 파일 재작성(리스너가 반영한 델타 그대로).
+- `--mode=auto`(기본): 저장소가 비어 있으면 full, 채워져 있으면 incremental.
+- `--rebuild` 은 `--mode=full` 의 별칭입니다.
+
+## Sitemap 분할 생성과 서빙
+
+Sitemap 은 비공개 디스크(StorageInterface, `cache` 카테고리)에 분할 파일로 커밋되고, 컨트롤러가 스트리밍으로 서빙합니다. `public/storage` 심볼릭 링크에 의존하지 않으며 자식 파일을 메모리에 적재하지 않습니다.
+
+**디스크 레이아웃** (SSoT: `SitemapFileStore` 상수):
+
+```text
+sitemap/manifest.json 커밋 마커 + 자식 목록 메타 (이 파일 존재 = 서빙 가능)
+sitemap/sitemap.xml sitemapindex (항상 비압축)
+sitemap/sitemap-{n}.xml 자식 sitemap (gzip 시 .xml.gz)
+sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
+```
+
+**라우트**: `/sitemap.xml`(인덱스) · `/sitemap-{n}.xml`, `/sitemap-{n}.xml.gz`(자식). 자식 경로는 `SitemapFileStore::childUrl()` 이 인덱스 `` 에 기록하는 값과 일치해야 합니다. gzip 여부는 manifest 가 결정하므로 두 경로를 같은 액션이 처리합니다.
+
+**서빙 시맨틱** (요청 스레드에서 생성하지 않음):
+
+| 상태 | 동작 |
+|------|------|
+| 메타 캐시 신선 + 세트 존재 | 디스크 세트 스트리밍 |
+| 메타 캐시 만료 | `GenerateSitemapJob` 디스패치 후 기존 세트 서빙 (`sitemap_serve_stale_on_miss=true`) |
+| 메타 캐시 만료 + stale 서빙 off | 잡 디스패치 후 503 + `Retry-After: 120` |
+| 세트 전무 (신규 설치) | 잡 디스패치 후 503 + `Retry-After: 120` |
+
+봇 요청 스레드에서 동기 생성하면 대용량(수백만 URL)에서 메모리 초과·타임아웃이 발생하므로 생성은 항상 큐가 담당합니다. 잡은 유니크 락(`seo-sitemap`)을 쓰므로 캐시 미스가 몰려도 동시에 여러 건이 실행되지 않습니다.
+
+**전용 큐(`GenerateSitemapJob::QUEUE = 'sitemap'`)**: 사이트맵 생성 잡은 `sitemap` 전용 큐로 라우팅됩니다. 배포 시 이 큐 워커를 별도로 띄우되 **워커를 1개만** 배치해야 합니다(`php artisan queue:work --queue=sitemap` — supervisor 프로그램을 기본 큐와 분리해 등록). 이유: ①긴 생성 작업이 기본(`default`) 큐의 방송·알림을 막지 않아 진행상황이 실시간으로 흐르고, ②워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않습니다(큐 `retry_after` 가 잡 실행보다 짧아도 단일 워커라 재예약 중복 실행이 발생하지 않음). `sitemap` 큐 워커가 없으면 재생성 잡이 처리되지 않으니 주의합니다.
+
+**완료/실패 알림**: 관리자 수동 재생성(위 API)은 실행한 관리자 ID 를 잡에 실어, 완료 시 `sitemap_regenerated`, **최종 실패(재시도 소진, `Job::failed()`) 시** `sitemap_regenerate_failed` 알림을 그 관리자에게 발송합니다(기본 채널: 앱 내 알림). 매 시도 실패마다 발화하는 `core.seo.sitemap.after_regenerate_failed` 훅이 아니라 최종 실패 시 1회만 발화하는 `core.seo.sitemap.regenerate_failed_final` 훅에 알림이 연결되어, 재시도 중 실패가 반복되거나 결국 성공해도 실패 알림이 중복/오발송되지 않습니다. 스케줄러·증분·봇 재생성은 실행 관리자가 없어 알림을 보내지 않습니다.
+
+**분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다.
+
+**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 `mode=full` 로 예약만 하고 즉시 진행상황(`getStatus()`)을 응답합니다. 완료 여부는 진행상황(아래) 또는 `sitemap_last_updated_at` 갱신으로 확인합니다.
+
+## 증분 저장소 (sitemap_urls)
+
+리소스 단위 공개/비공개/삭제를 사이트맵에 반영하기 위해 URL 을 `sitemap_urls` 테이블에 지속합니다(주안점 1·3). 전체 재생성 없이 바뀐 리소스 행만 upsert/remove 합니다.
+
+- **테이블**: `resource_type`/`resource_id`/`loc`/`loc_hash`(=`sha256(loc)`)/`lastmod`/`changefreq`/`priority`/`contributor`/`is_visible`. unique 는 `(resource_type, resource_id, loc_hash)` — `loc string(2048)` utf8mb4 는 InnoDB 키 길이(3072 byte)를 초과하므로 ascii 64 해시로 identity 를 유지합니다(상세: docs/database-guide.md).
+- **Repository**: `SitemapUrlRepositoryInterface`(`upsertForResource`=delete-후-insert 멱등 / `removeForResource` / `streamVisible`(lazyById) / `countVisible` / `replaceAllForContributor`). `SeoServiceProvider` 가 singleton 바인딩.
+- **인덱서**: `SitemapIndexer::indexResource($type,$model,$entries)` / `deindexResource($type,$model)` — 리스너가 직접 모델/DB 를 만지지 않고 이 서비스를 경유합니다(`service-direct-data-access` 규율). 제3자 확장은 filter 훅 `sitemap.index.collect_for_resource` 로 리소스→entries 를 가공/추가할 수 있습니다(docs/extension/hooks.md).
+- **리스너**: 모듈 SEO 리스너가 `after_create/update/delete` 에서 공개상태를 판정해 index/deindex 하고 `GenerateSitemapJob::dispatch()`(유니크 락 디바운스)를 겁니다. 리소스 단위 증분에서 자식 파일 URL 수가 임계 미만으로 줄어드는 것(5000→4999)은 감안하며, 균등 재분배(리밸런싱)는 full 재생성 때만 합니다.
+
+## 재생성 모드 (SitemapGenerationMode)
+
+`App\Enums\SitemapGenerationMode`(Backed Enum: `full`/`auto`/`incremental`)가 재생성 정책을 캡슐화합니다. `resolve(int $visibleCount)` 가 `auto` 를 저장소 상태로 full/incremental 판정합니다.
+
+| 트리거 | 모드 | 동작 |
+|--------|------|------|
+| 관리자 수동 재생성 | 항상 `full` | 도메인 전량 → 테이블 replace → 파일 전량 재작성 |
+| 스케줄러 | `auto` | 테이블 비었으면 full, 아니면 incremental |
+| 리소스 변경(리스너) | 해당 리소스만 | 행 upsert/remove 후 잡 디스패치 |
+
+`Command → GenerateSitemapJob → SitemapManager::regenerate(SitemapGenerationMode)` 전 경로가 enum 시그니처를 공유합니다(잡 직렬화는 enum 프로퍼티 그대로).
+
+## 진행상황 가시화 (Reverb 실시간 / OFF 폴링)
+
+`SitemapProgress`(캐시 키 `seo.sitemap.progress`, TTL 3600)가 phase 기반 진행상황을 기록합니다: `queued → running(기여자별 phase + 누적 URL) → writing → completed/failed`. 사전 count 쿼리 없이 스트림 누적치를 표기합니다(1.4M 에서 count 자체가 부담).
+
+- **상태 API**: `GET /api/admin/seo/sitemap/status`(`core.settings.read`)가 `{last_updated_at, progress, realtime_enabled}` 를 반환합니다. `realtime_enabled = drivers.websocket_enabled 설정 && config('broadcasting.default') !== 'null'`(설정 SSoT + 실제 적용 config 양쪽 확인).
+- **방송**: `SitemapProgress` 가 `core.admin.seo.sitemap` 채널로 `sitemap.progress.updated` 를 방송합니다(payload 는 상태 API `data` 와 동형). Reverb OFF 면 `HookManager::broadcast` 가 자동 skip 하고 캐시만 기록되어 폴링 폴백이 성립합니다. 방송은 N URL(5000) 간격으로 스로틀합니다.
+- **채널 인증**: `routes/channels.php` 에 `core.admin.seo.sitemap` → `core.settings.read`(docs/backend/broadcasting.md).
+- **잡 실패**: `GenerateSitemapJob::failed()` 가 `SitemapProgress::fail()` 을 호출해 무한 running 을 방지합니다(TTL 만료 시 idle 복귀).
+- **프론트**: SEO 탭이 상태 API 초기 로드 후 `realtime_enabled` 로 분기합니다 — true 면 websocket 데이터소스(`target_source`)로 갱신, false 면 `startInterval`/`stopInterval` 로 3초 폴링(완료/실패 전이 시 중단).
+
## 설정값 (코어 seo.*)
| 키 | 타입 | 기본값 | 설명 |
@@ -641,7 +741,12 @@ php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
| cache_enabled | boolean | true | SEO 캐시 ON/OFF |
| cache_ttl | integer | 7200 | 캐시 TTL (초) |
| sitemap_enabled | boolean | true | sitemap.xml 생성 ON/OFF |
-| sitemap_cache_ttl | integer | 86400 | Sitemap 캐시 TTL (초) |
+| sitemap_cache_ttl | integer\|null | null | Sitemap 캐시 TTL 오버라이드 (초). null=고급 탭 `cache.seo_sitemap_ttl` 을 따름 |
+| sitemap_urls_per_file | integer | 50000 | 자식 파일당 URL 수 (분할 기준). 1000~50000, 상한은 sitemaps.org 프로토콜 제한 |
+| sitemap_gzip | boolean | false | 자식 파일 gzip 압축. 인덱스 파일은 항상 비압축 |
+| sitemap_serve_stale_on_miss | boolean | true | 신선도 만료 시 기존 세트를 그대로 서빙(true) / 503 반환(false) |
+| sitemap_max_urls_per_contributor | integer | 0 | 수집기당 URL 상한 (0=무제한). 초과 시 경고 로그 + truncate |
+| sitemap_hreflang_enabled | boolean | true | 다국어 alternate(hreflang) 링크 출력 ON/OFF. 로케일 수가 `SitemapXmlRenderer::MAX_HREFLANG`(50) 초과 시 alternate 생략 |
| sitemap_schedule | string | "daily" | 생성 주기 (hourly/daily/weekly) |
| sitemap_schedule_time | string | "02:00" | 생성 시각 |
| og_default_site_name | string | "" | og:site_name 기본값. 비면 `general.site_name` fallback |
diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md
index 905f9d48..cf333c2e 100644
--- a/docs/cheatsheet.md
+++ b/docs/cheatsheet.md
@@ -249,8 +249,10 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력
-php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
-php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
+php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto)
+php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
+php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full)
+php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정
```
### API 문서 Artisan 커맨드
diff --git a/docs/database-guide.md b/docs/database-guide.md
index a81030dd..f93d1e2e 100644
--- a/docs/database-guide.md
+++ b/docs/database-guide.md
@@ -204,6 +204,26 @@ php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_l
---
+### 긴 문자열 컬럼의 unique — 해시 컬럼 패턴 (sitemap_urls 사례)
+
+utf8mb4(4 byte/char) 긴 문자열 컬럼을 unique 인덱스에 직접 넣으면 InnoDB 키 길이 제한(3072 byte)을 넘겨 마이그레이션이 실패한다. 예: `sitemap_urls` 의 identity 는 `(resource_type, resource_id, loc)` 인데 `loc string(2048)` 을 넣으면 `256+256+8192 = 8704 byte` 로 초과한다.
+
+해결: **identity 문자열의 고정 길이 해시 컬럼**을 두고 그것을 unique 에 넣는다.
+
+```php
+$table->string('loc', 2048); // 실제 값 (utf8mb4)
+$table->char('loc_hash', 64)->charset('ascii'); // sha256(loc) — ascii 64 byte
+$table->unique(['resource_type', 'resource_id', 'loc_hash']); // 64+256+256 = 576 byte
+```
+
+- 해시는 app 레벨(`hash('sha256', $loc)`)에서 계산한다 — raw SQL/DB 함수 비의존(다국어·DB 이식성 규율).
+- `loc` 는 실제 서빙/표시에 쓰고, `loc_hash` 는 오직 identity 매칭용이다.
+- upsert 는 delete-후-insert 로 멱등을 보장하고, 이 unique 가 중복 삽입(1062)을 차단한다.
+
+`sitemap_urls` 스키마: `resource_type(64,index)` · `resource_id(64,nullable)` · `loc(2048)` · `loc_hash(64,ascii)` · `lastmod` · `changefreq(16)` · `priority(2,1)` · `contributor(64,index)` · `is_visible(bool,index)` + `unique(resource_type, resource_id, loc_hash)` + `index(contributor, is_visible, id)`(리빌드 스트리밍 커서). 모든 컬럼 한국어 `->comment()`, `down()` 구현, `Schema::hasTable` 멱등 가드.
+
+---
+
### 모듈/플러그인 마이그레이션 down() 메서드
```
diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md
index 86e2a40e..a7ec6362 100644
--- a/docs/extension/hooks.md
+++ b/docs/extension/hooks.md
@@ -157,8 +157,18 @@ core.seo.filter_view_data # View 직전 ($viewData) — extraHeadTags / ex
# SEO 봇 감지 훅 (Filter) — null 반환 시 라이브러리 평가로 fallthrough
core.seo.resolve_is_bot
+
+# Sitemap 증분 인덱싱 훅 (Filter) — 리소스→sitemap 항목 가공/추가
+sitemap.index.collect_for_resource # SitemapIndexer 가 리소스 index 시 발화 — applyFilters($entries, $type, $id, $contributor)
+
+# Sitemap 재생성 훅 (Action)
+core.seo.sitemap.before_regenerate # 재생성 시작 직전
+core.seo.sitemap.after_regenerate # 재생성 성공 후 ($meta)
+core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'failed', 'success'=>false, ...])
```
+> `sitemap.index.collect_for_resource` 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — `'type' => 'filter'` 명시 필수). `core.seo.sitemap.after_regenerate_failed` 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다.
+
---
## 리스너 구현
diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md
index 537f6a47..e26e09df 100644
--- a/docs/extension/storage-driver.md
+++ b/docs/extension/storage-driver.md
@@ -1058,6 +1058,27 @@ $response = $this->storage->response(
- null 체크 후 404 응답 처리 필요
- 권한 체크는 컨트롤러/서비스에서 별도 구현
+### 파일 서빙 안티패턴 — 전체 메모리 적재 금지
+
+파일 본문을 응답으로 내려줄 때는 항상 `response()` / `download()` 스트리밍(내부 `readStream`)을 사용한다. 아래처럼 파일 전체를 문자열로 읽어 `echo` 하는 패턴은 대용량 파일에서 워커 메모리를 붕괴시킨다.
+
+```php
+// ❌ 금지 — streamDownload 로 감쌌지만 콜백 안에서 파일 전체를 메모리에 적재
+return response()->streamDownload(function () use ($image) {
+ echo Storage::disk($image->disk)->get($image->path); // get() = 전체 문자열 로드
+}, $image->original_filename, ['Content-Type' => $image->mime_type]);
+
+// ✅ 올바름 — StorageInterface::response() 로 청크 스트리밍 (파일 전체 미적재)
+$response = $this->storage->response('images', $image->path, $image->original_filename, [
+ 'Content-Type' => $image->mime_type,
+ 'Cache-Control' => 'public, max-age=31536000',
+]);
+
+return $response ?? ResponseHelper::notFound(...);
+```
+
+`StorageInterface::get()` 은 소형 메타 파일(manifest.json 등)의 전체 읽기 전용이며, 서빙 본문에는 사용하지 않는다.
+
---
## 트러블슈팅
diff --git a/docs/testing-guide.md b/docs/testing-guide.md
index 6c8bf5b0..bffed930 100644
--- a/docs/testing-guide.md
+++ b/docs/testing-guide.md
@@ -14,12 +14,14 @@
5. 테스트 실행 중 발견한 무관 에러도 같은 세션에서 함께 처리 (stale test 수정 or 로직 수정)
6. 릴리스 전 composer test-smoke 통과 필수 (Installation 스위트)
7. 백엔드: php artisan test --filter=TestName / _bundled 확장은 _bundled에서 직접 실행
+8. settings 디스크는 TestCase 가 전역 페이크 — 실제 설정 파일 오염 차단. 저장 직후 값 검증은 g7_core_settings() 말고 ConfigRepository::getCategory()
```
---
## 목차
+- [실제 환경 파일 보호 (settings 디스크 격리)](#실제-환경-파일-보호-settings-디스크-격리)
- [기능 단위 시나리오 매트릭스](#기능-단위-시나리오-매트릭스)
- [도메인별 테스트 전략 매트릭스](#도메인별-테스트-전략-매트릭스)
- [Pre-release Smoke Suite](#pre-release-smoke-suite)
@@ -211,6 +213,41 @@ beforeRefreshingDatabase()는 RefreshDatabase 트레이트가 부모 클래스
---
+## 실제 환경 파일 보호 (settings 디스크 격리)
+
+`settings` 디스크의 root 는 `storage/app/settings` — 개발/운영이 실제로 사용하는 설정 파일 그 자체다. 설정 저장 경로를 타는 테스트(설정 API 호출, `ConfigRepository::saveCategory()`)는 이 파일을 그대로 덮어쓰며, `RefreshDatabase` 는 DB 만 되돌리므로 오염이 잔류한다.
+
+`Tests\TestCase` 가 앱 부팅 직후 `Storage::fake('settings')` 로 전역 격리한다. 테스트에서 별도 조치는 필요 없다.
+
+```text
+✅ 설정 저장 테스트 = 추가 조치 불필요 (TestCase 가 이미 격리)
+❌ Storage::fake('settings') 를 테스트마다 중복 호출 — 불필요
+❌ 격리를 우회해 storage/app/settings 를 직접 읽고/쓰기 — 실제 환경 오염
+```
+
+### 저장 직후 값 검증
+
+`g7_core_settings()` / `config('g7_settings.*')` 는 **부팅 시점에 적재된 값**이라 같은 프로세스에서 저장 직후에는 갱신되지 않는다. 저장 결과를 검증하려면 저장소를 직접 읽는다.
+
+```php
+// ❌ 저장했는데 부팅 시점 값이 나온다
+$this->assertSame(20000, g7_core_settings('seo.sitemap_urls_per_file'));
+
+// ✅ 저장소 실제 값 조회
+$seo = app(ConfigRepositoryInterface::class)->getCategory('seo');
+$this->assertSame(20000, $seo['sitemap_urls_per_file']);
+```
+
+### 배경 (회귀 사례)
+
+격리 도입 전에는 `Storage::fake('settings')` 를 쓰는 테스트가 1개뿐이라, 설정 저장 테스트를 돌릴 때마다 개발 환경의 실제 설정이 테스트 값으로 대체됐다. 실측 피해: `general.json` 의 사이트명이 `"Test"`, 언어가 `ja` 로 바뀌어 **개발 사이트 언어가 일본어로 전환**되어 있었고, SEO/캐시 TTL 도 테스트 값으로 덮여 운영자가 지정한 값이 사라졌다. 테스트는 계속 green 이라 조용히 누적됐다.
+
+회귀 가드: `tests/Feature/Settings/SettingsDiskIsolationTest.php` (격리 해제 시 FAIL).
+
+`Storage::fake()` 는 **해석된 디스크 인스턴스만 교체**하고 `config('filesystems.disks.settings.root')` 는 실경로로 남는다. 격리 여부를 판정할 때는 `Storage::disk('settings')->path('')` 를 봐야 한다.
+
+---
+
## 기능 단위 시나리오 매트릭스
```text
@@ -312,6 +349,42 @@ audit rule `test-scenario-coverage` 가 매니페스트 cross product 와 effect
2. **Pairwise (all-pairs)** 전략 허용: 매니페스트에 `coverage_strategy: pairwise` 명시 시 모든 axis 쌍의 모든 조합만 커버 (n축 → O(n²) 케이스). 단순 분기형 axis(true/false) 가 많을 때 유효
3. 그래도 폭발하면 **상위 axis 분리**: 매니페스트를 도메인별로 분할 (예: `order_payment_pg_toss_member.yaml`, `order_payment_pg_toss_guest.yaml`)
+### 대규모 시나리오 축(scale)
+
+큐/스케줄러/배치 생성처럼 데이터가 누적되면 붕괴할 수 있는 기능은 대용량(scale) 축을 매니페스트에 선언하고, 규모 의존 결함(유계 메모리·분할 정확성·전량 적재 회피 등)을 회귀 가드로 고정한다. 배경: 사이트맵 생성 잡이 1.4M 게시글을 전량 in-memory 적재해 반복 OOM 붕괴한 사례(#79).
+
+**스키마** (`scale` = block-array of flow objects — 내장 YAML fallback 파서가 flow 객체만 중첩 파싱하므로 `- { ... }` 형태):
+
+```yaml
+scale:
+ - { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load] }
+```
+
+- `n`: 데이터셋 규모(건수). 10만 이상이어야 scale 축의 의미가 있다.
+- `dataset`: 대용량 데이터셋 설명(선택).
+- `assert`: 이 규모에서 검증할 단언(snake_case). 실 대용량 DB 대신 합성 iterator·계측(예: `memory_get_peak_usage`, `DB::listen`, 방송 스로틀 카운트)으로 성질을 고정해도 된다.
+
+**테스트 마킹** — `effects` 처럼 테스트 docblock `@scale` 마킹으로 각 assert 를 커버:
+
+```php
+/**
+ * @scale n=1500000 asserts=bounded_peak_memory, child_count_correct
+ */
+public function test_large_volume_splits_and_keeps_memory_bounded(): void { ... }
+```
+
+TypeScript 테스트는 `// @scale ...` 라인 주석 동일 사용.
+
+**강제 룰 2종** (자동 차단):
+
+| 룰 | 대상 | 검출 |
+|----|------|------|
+| `scenario-scale-axis-required` | `tests/scenarios/**/*.yaml` | batch/generation 매니페스트(test_files 에 `/Jobs/`·`Generator` 또는 tags `[batch]`/`[generation]`)인데 scale 축 없음 / n<100000 / assert 미커버 |
+| `job-generator-needs-scale-test` | `app/Jobs/**`·`**/*Generator.php` | 소스 변경 시 그 클래스를 언급하고 scale 축을 가진 매니페스트가 없음 |
+
+- `*Command.php` 는 흔하고 볼륨 비의존이라 `job-generator-needs-scale-test` 대상에서 제외한다. 배치 커맨드는 매니페스트 `tags: [batch]` + `scenario-scale-axis-required` 로 커버한다.
+- 면제: 매니페스트 헤더 `# audit:allow scenario-scale-axis-required reason: ...` / 소스 헤더 `// audit:allow job-generator-needs-scale-test <사유>`.
+
### 작성 절차
1. 영향 변수 카탈로그 식별
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index 75266ba7..0c15426f 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.5] - 2026-07-16
+
+### Added
+
+- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
+- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
+- SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
+- SEO 설정의 사이트맵 다국어 대체 링크(hreflang) 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.sitemap_hreflang_enabled_boolean`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
+- 관리자 사이트맵 수동 재생성 완료·실패 알림 템플릿 일본어 번역 추가 (`sitemap_regenerated`, `sitemap_regenerate_failed`) — 재생성을 실행한 관리자가 받는 앱 내 알림·메일이 일본어 로케일에서 자연스럽게 표시됩니다.
+
## [1.0.4] - 2026-07-16
### Added
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php
index 0bfaa072..1652e3d4 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php
@@ -4,12 +4,12 @@ return [
'fetch_success' => 'アクティビティログ情報を正常に取得しました。',
'fetch_failed' => 'アクティビティログ情報の取得に失敗しました。',
'delete_success' => 'アクティビティログが削除されました。',
- 'bulk_delete_success' => '選択したアクティビティログが削除されました。',
+ 'bulk_delete_success' => '選択されたアクティビティログが削除されました。',
'delete_failed' => 'アクティビティログの削除に失敗しました。',
'validation' => [
'ids_required' => '削除するアクティビティログを選択してください。',
'ids_min' => '削除するアクティビティログを1つ以上選択してください。',
- 'id_not_found' => '選択したアクティビティログが見つかりません。',
+ 'id_not_found' => '選択されたアクティビティログが見つかりません。',
],
'type' => [
'admin' => '管理者',
@@ -28,13 +28,13 @@ return [
'login_failed' => 'ログイン失敗',
'account_locked' => 'アカウントロック',
'request' => 'リクエスト',
- 'expired' => '期限切れ',
+ 'expired' => '有効期限切れ',
'verify' => '本人認証',
'verify_failed' => '本人認証失敗',
'export' => 'エクスポート',
'import' => 'インポート',
- 'index' => 'リスト表示',
- 'show' => '詳細表示',
+ 'index' => 'リスト閲覧',
+ 'show' => '詳細閲覧',
'search' => '検索',
'install' => 'インストール',
'activate' => '有効化',
@@ -62,131 +62,133 @@ return [
'withdraw' => '退会',
'update_order' => '順序変更',
'refresh_layouts' => 'レイアウト更新',
- 'version_update' => 'バージョン更新',
+ 'version_update' => 'バージョンアップデート',
'version_restore' => 'バージョン復元',
'reset' => '初期化',
],
'description' => [
- 'user_index' => 'ユーザー一覧閲覧',
- 'user_create' => 'ユーザー作成(ID::user_id)',
- 'user_show' => 'ユーザー詳細閲覧(ID::user_id)',
- 'user_update' => 'ユーザー編集(ID::user_id)',
- 'user_delete' => 'ユーザー削除(ID::user_id)',
- 'user_withdraw' => 'ユーザー退会(ID::user_id)',
+ 'user_index' => 'ユーザーリスト閲覧',
+ 'user_create' => 'ユーザー作成 (ID: :user_id)',
+ 'user_show' => 'ユーザー詳細閲覧 (ID: :user_id)',
+ 'user_update' => 'ユーザー編集 (ID: :user_id)',
+ 'user_delete' => 'ユーザー削除 (ID: :user_id)',
+ 'user_withdraw' => 'ユーザー退会 (ID: :user_id)',
'user_statistics' => 'ユーザー統計閲覧',
- 'user_recent' => '最近のユーザー閲覧',
+ 'user_recent' => '最新ユーザー閲覧',
'user_search' => 'ユーザー検索',
'user_check_email' => 'メールアドレス重複確認',
'user_update_language' => 'ユーザー言語設定変更',
- 'user_bulk_update_status' => 'ユーザー一括ステータス変更(:count件)',
- 'role_index' => 'ロール一覧閲覧',
- 'role_active' => '有効ロール一覧閲覧',
- 'role_show' => 'ロール詳細閲覧(ID::role_id)',
- 'role_create' => 'ロール作成(ID::role_id)',
- 'role_update' => 'ロール編集(ID::role_id)',
- 'role_delete' => 'ロール削除(ID::role_id)',
- 'role_toggle_status' => 'ロールステータス切り替え(ID::role_id)',
- 'role_sync_permissions' => 'ロール権限同期(ID::role_id)',
- 'permission_index' => '権限一覧閲覧',
- 'menu_index' => 'メニュー一覧閲覧',
- 'menu_hierarchy' => 'メニュー階層閲覧',
- 'menu_active' => '有効メニュー一覧閲覧',
- 'menu_show' => 'メニュー詳細閲覧(ID::menu_id)',
- 'menu_create' => 'メニュー作成(ID::menu_id)',
- 'menu_update' => 'メニュー編集(ID::menu_id)',
- 'menu_delete' => 'メニュー削除(ID::menu_id)',
- 'menu_update_order' => 'メニュー順序変更',
- 'menu_toggle_status' => 'メニューステータス切り替え(ID::menu_id)',
- 'menu_sync_roles' => 'メニュー役割同期(ID::menu_id)',
- 'menu_get_by_extension' => '拡張別メニュー閲覧(:extension_type::extension_identifier)',
- 'settings_index' => '設定一覧閲覧',
- 'settings_save' => '設定保存',
- 'settings_show' => '設定閲覧',
- 'settings_update' => '設定編集',
- 'settings_system_info' => 'システム情報閲覧',
- 'settings_clear_cache' => 'キャッシュ初期化',
+ 'user_bulk_update_status' => 'ユーザー一括ステータス変更 (:count件)',
+ 'role_index' => 'ロールリスト閲覧',
+ 'role_active' => '有効なロールリスト閲覧',
+ 'role_show' => 'ロール詳細閲覧 (ID: :role_id)',
+ 'role_create' => 'ロール作成 (ID: :role_id)',
+ 'role_update' => 'ロール編集 (ID: :role_id)',
+ 'role_delete' => 'ロール削除 (ID: :role_id)',
+ 'role_toggle_status' => 'ロールステータス変更 (ID: :role_id)',
+ 'role_sync_permissions' => 'ロール権限同期 (ID: :role_id)',
+ 'permission_index' => '権限リスト閲覧',
+ 'menu_index' => 'メニュー一覧の閲覧',
+ 'menu_hierarchy' => 'メニュー階層の閲覧',
+ 'menu_active' => '有効なメニュー一覧の閲覧',
+ 'menu_show' => 'メニュー詳細の閲覧 (ID: :menu_id)',
+ 'menu_create' => 'メニューの作成 (ID: :menu_id)',
+ 'menu_update' => 'メニューの編集 (ID: :menu_id)',
+ 'menu_delete' => 'メニューの削除 (ID: :menu_id)',
+ 'menu_update_order' => 'メニューの順序変更',
+ 'menu_toggle_status' => 'メニュー状態の切り替え (ID: :menu_id)',
+ 'menu_sync_roles' => 'メニューロールの同期 (ID: :menu_id)',
+ 'menu_get_by_extension' => '拡張別メニューの閲覧 (:extension_type: :extension_identifier)',
+ 'settings_index' => '設定一覧の閲覧',
+ 'settings_save' => '設定の保存',
+ 'settings_show' => '設定の閲覧',
+ 'settings_update' => '設定の編集',
+ 'settings_system_info' => 'システム情報の閲覧',
+ 'settings_clear_cache' => 'キャッシュの初期化',
'settings_optimize_system' => 'システム最適化',
- 'settings_backup_database' => 'データベースバックアップ',
- 'settings_get_app_key' => 'アプリキー閲覧',
- 'settings_app_key_regenerated' => 'アプリキー再生成',
- 'settings_backup' => 'バックアップ作成',
- 'settings_restore' => 'バックアップ復元',
+ 'settings_backup_database' => 'データベースのバックアップ',
+ 'settings_get_app_key' => 'アプリケーションキーの閲覧',
+ 'settings_app_key_regenerated' => 'アプリケーションキーの再生成',
+ 'settings_backup' => 'バックアップの作成',
+ 'settings_restore' => 'バックアップの復元',
'settings_test_mail' => 'メール送信テスト',
- 'settings_test_driver_connection' => 'ドライバ接続テスト',
+ 'settings_test_driver_connection' => 'ドライバー接続テスト',
'auth_login' => '管理者ログイン',
'auth_logout' => '管理者ログアウト',
'auth_register' => '会員登録',
- 'auth_forgot_password' => 'パスワード再設定申請',
- 'auth_reset_password' => 'パスワード再設定',
- 'auth_record_consents' => '利用規約同意履歴',
- 'schedule_index' => 'スケジュール一覧閲覧',
- 'schedule_show' => 'スケジュール詳細閲覧(ID::schedule_id)',
- 'schedule_create' => 'スケジュール作成(ID::schedule_id)',
- 'schedule_update' => 'スケジュール編集(ID::schedule_id)',
- 'schedule_delete' => 'スケジュール削除(ID::schedule_id)',
- 'schedule_run' => 'スケジュール手動実行(ID::schedule_id)',
- 'schedule_duplicate' => 'スケジュール複製',
- 'schedule_bulk_update_status' => 'スケジュール一括ステータス変更(:count件)',
- 'schedule_bulk_delete' => 'スケジュール一括削除(:count件)',
- 'schedule_statistics' => 'スケジュール統計閲覧',
- 'schedule_history' => 'スケジュール実行履歴閲覧(ID::schedule_id)',
- 'schedule_delete_history' => 'スケジュール履歴削除',
- 'dashboard_stats' => 'ダッシュボード統計閲覧',
- 'dashboard_resources' => 'ダッシュボードリソース閲覧',
- 'dashboard_activities' => 'ダッシュボードアクティビティ閲覧',
- 'dashboard_alerts' => 'ダッシュボード通知閲覧',
+ 'auth_forgot_password' => 'パスワード忘却リクエスト',
+ 'auth_reset_password' => 'パスワードリセット',
+ 'auth_record_consents' => '利用規約同意記録',
+ 'auth_login_failed' => 'ログイン失敗 (:email)',
+ 'auth_account_locked' => 'アカウントロック (:attempts回失敗、:minutes分ブロック)',
+ 'schedule_index' => 'スケジュール一覧の閲覧',
+ 'schedule_show' => 'スケジュール詳細の閲覧 (ID: :schedule_id)',
+ 'schedule_create' => 'スケジュールの作成 (ID: :schedule_id)',
+ 'schedule_update' => 'スケジュールの編集 (ID: :schedule_id)',
+ 'schedule_delete' => 'スケジュールの削除 (ID: :schedule_id)',
+ 'schedule_run' => 'スケジュールの手動実行 (ID: :schedule_id)',
+ 'schedule_duplicate' => 'スケジュールの複製',
+ 'schedule_bulk_update_status' => 'スケジュール一括ステータス変更 (:count件)',
+ 'schedule_bulk_delete' => 'スケジュール一括削除 (:count件)',
+ 'schedule_statistics' => 'スケジュール統計の閲覧',
+ 'schedule_history' => 'スケジュール実行履歴の閲覧 (ID: :schedule_id)',
+ 'schedule_delete_history' => 'スケジュール履歴の削除',
+ 'dashboard_stats' => 'ダッシュボード統計の閲覧',
+ 'dashboard_resources' => 'ダッシュボードリソースの閲覧',
+ 'dashboard_activities' => 'ダッシュボードアクティビティの閲覧',
+ 'dashboard_alerts' => 'ダッシュボード通知の閲覧',
'attachment_upload' => 'ファイルアップロード',
'attachment_upload_batch' => 'ファイル一括アップロード',
- 'attachment_delete' => 'ファイル削除',
+ 'attachment_delete' => 'ファイルの削除',
'attachment_bulk_delete' => 'ファイル一括削除',
- 'attachment_reorder' => 'ファイル順序変更',
- 'layout_index' => 'レイアウト一覧取得',
- 'layout_show' => 'レイアウト詳細取得 (:layout_path)',
- 'layout_update' => 'レイアウト編集 (:layout_path)',
- 'layout_versions_index' => 'レイアウトバージョン一覧取得 (:layout_path)',
- 'layout_version_show' => 'レイアウトバージョン詳細取得 (:layout_path)',
- 'layout_version_restore' => 'レイアウトバージョン復元 (:layout_path)',
- 'mail_template_update' => 'メールテンプレート編集 (:template_name)',
- 'mail_template_toggle_active' => 'メールテンプレート有効状態変更 (:template_name)',
- 'module_index' => 'モジュール一覧取得',
- 'module_installed' => 'インストール済みモジュール一覧取得',
- 'module_uninstalled' => '未インストールモジュール一覧取得',
- 'module_show' => 'モジュール詳細取得 (:module_name)',
- 'module_install' => 'モジュールインストール (:module_name)',
- 'module_activate' => 'モジュール有効化 (:module_name)',
- 'module_deactivate' => 'モジュール無効化 (:module_name)',
- 'module_uninstall' => 'モジュール削除 (:module_name)',
- 'module_uninstall_info' => 'モジュール削除情報取得 (モジュール: :module_name)',
- 'module_check_updates' => 'モジュールアップデート確認',
- 'module_update' => 'モジュールアップデート (:module_name)',
- 'module_refresh_layouts' => 'モジュールレイアウト更新 (:module_name)',
- 'module_dependent_templates' => 'モジュール依存テンプレート取得',
+ 'attachment_reorder' => 'ファイルの順序変更',
+ 'layout_index' => 'レイアウト一覧の閲覧',
+ 'layout_show' => 'レイアウト詳細の閲覧 (:layout_path)',
+ 'layout_update' => 'レイアウトの編集 (:layout_path)',
+ 'layout_versions_index' => 'レイアウトバージョン一覧の閲覧 (:layout_path)',
+ 'layout_version_show' => 'レイアウトバージョン詳細の閲覧 (:layout_path)',
+ 'layout_version_restore' => 'レイアウトバージョンの復元 (:layout_path)',
+ 'mail_template_update' => 'メールテンプレートの編集 (:template_name)',
+ 'mail_template_toggle_active' => 'メールテンプレートの有効状態変更 (:template_name)',
+ 'module_index' => 'モジュール一覧の閲覧',
+ 'module_installed' => 'インストール済みモジュール一覧の閲覧',
+ 'module_uninstalled' => '未インストールモジュール一覧の閲覧',
+ 'module_show' => 'モジュール詳細の閲覧 (:module_name)',
+ 'module_install' => 'モジュールのインストール (:module_name)',
+ 'module_activate' => 'モジュールの有効化 (:module_name)',
+ 'module_deactivate' => 'モジュールの無効化 (:module_name)',
+ 'module_uninstall' => 'モジュールの削除 (:module_name)',
+ 'module_uninstall_info' => 'モジュール削除情報の閲覧 (モジュール: :module_name)',
+ 'module_check_updates' => 'モジュールアップデートの確認',
+ 'module_update' => 'モジュールのアップデート (:module_name)',
+ 'module_refresh_layouts' => 'モジュールレイアウトの更新 (:module_name)',
+ 'module_dependent_templates' => 'モジュール依存テンプレートの閲覧',
'module_install_from_file' => 'ファイルからモジュールをインストール',
'module_install_from_github' => 'GitHubからモジュールをインストール',
- 'module_settings_save' => 'モジュール設定保存 (:module_name)',
- 'module_settings_reset' => 'モジュール設定初期化 (:module_name)',
- 'plugin_index' => 'プラグイン一覧取得',
- 'plugin_installed' => 'インストール済みプラグイン一覧取得',
- 'plugin_show' => 'プラグイン詳細取得 (:plugin_name)',
+ 'module_settings_save' => 'モジュール設定の保存 (:module_name)',
+ 'module_settings_reset' => 'モジュール設定の初期化 (:module_name)',
+ 'plugin_index' => 'プラグイン一覧の閲覧',
+ 'plugin_installed' => 'インストール済みプラグイン一覧の閲覧',
+ 'plugin_show' => 'プラグイン詳細閲覧 (:plugin_name)',
'plugin_install' => 'プラグインインストール (:plugin_name)',
'plugin_activate' => 'プラグイン有効化 (:plugin_name)',
'plugin_deactivate' => 'プラグイン無効化 (:plugin_name)',
'plugin_uninstall' => 'プラグイン削除 (:plugin_name)',
- 'plugin_uninstall_info' => 'プラグイン削除情報取得 (プラグイン: :plugin_name)',
+ 'plugin_uninstall_info' => 'プラグイン削除情報閲覧 (プラグイン: :plugin_name)',
'plugin_check_updates' => 'プラグインアップデート確認',
'plugin_update' => 'プラグインアップデート (:plugin_name)',
'plugin_refresh_layouts' => 'プラグインレイアウト更新 (:plugin_name)',
- 'plugin_dependent_templates' => 'プラグイン依存テンプレート取得',
+ 'plugin_dependent_templates' => 'プラグイン依存テンプレート閲覧',
'plugin_settings_save' => 'プラグイン設定保存 (:plugin_name)',
'plugin_settings_reset' => 'プラグイン設定初期化 (:plugin_name)',
- 'template_index' => 'テンプレート一覧取得',
- 'template_show' => 'テンプレート詳細取得 (:template_name)',
+ 'template_index' => 'テンプレート一覧の閲覧',
+ 'template_show' => 'テンプレート詳細閲覧 (:template_name)',
'template_install' => 'テンプレートインストール (:template_name)',
'template_activate' => 'テンプレート有効化 (:template_name)',
'template_deactivate' => 'テンプレート無効化 (:template_name)',
'template_uninstall' => 'テンプレート削除 (:template_name)',
'template_check_updates' => 'テンプレートアップデート確認',
- 'template_check_modified_layouts' => 'テンプレート編集済みレイアウト確認 (:template_name)',
+ 'template_check_modified_layouts' => 'テンプレート修正されたレイアウト確認 (:template_name)',
'template_update' => 'テンプレートアップデート (:template_name)',
'template_version_update' => 'テンプレートバージョンアップデート (:template_name)',
'template_install_from_file' => 'ファイルからテンプレートをインストール',
@@ -194,11 +196,9 @@ return [
'template_refresh_layouts' => 'テンプレートレイアウト更新 (:template_name)',
'core_update_check' => 'コアアップデート確認',
'core_update_update' => 'コアアップデート実行',
- 'activity_log_index' => 'アクティビティログ一覧取得',
+ 'activity_log_index' => 'アクティビティログ一覧の閲覧',
'activity_log_delete' => 'アクティビティログ削除 (ID: :log_id)',
'activity_log_bulk_delete' => 'アクティビティログ一括削除 (:count件)',
- 'auth_login_failed' => 'ログイン失敗 (:email)',
- 'auth_account_locked' => 'アカウントロック (:attempts回の失敗、:minutes分間ブロック)',
],
'fields' => [
'name' => '名前',
@@ -208,7 +208,7 @@ return [
'timezone' => 'タイムゾーン',
'country' => '国',
'status' => 'ステータス',
- 'is_super' => 'スーパー管理者',
+ 'is_super' => '最高管理者',
'homepage' => 'ホームページ',
'mobile' => '携帯電話',
'phone' => '電話番号',
@@ -218,10 +218,10 @@ return [
'bio' => '紹介',
'admin_memo' => '管理者メモ',
'identifier' => '識別子',
- 'is_active' => '有効化',
+ 'is_active' => '有効性',
'url' => 'URL',
'icon' => 'アイコン',
- 'parent_id' => '親項目',
+ 'parent_id' => '上位項目',
'order' => '順序',
'command' => 'コマンド',
'expression' => '実行周期',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php b/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php
index 35983c14..dd390b88 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php
@@ -19,60 +19,60 @@ return [
'success' => 'admin_layout.json 検証成功',
'failed' => 'admin_layout.json 検証失敗',
'exception' => 'admin_layout.json 検証中に例外が発生しました',
- 'version_error' => 'スキーマバージョン情報の取得に失敗しました',
+ 'version_error' => 'スキーマバージョン情報取得失敗',
],
],
'loader' => [
'validation_failed' => 'モジュール :module のレイアウト検証に失敗しました: :errors',
'file_not_found' => 'レイアウトファイルが見つかりません: :path',
'json_parse_error' => 'JSON パースエラー (:file): :error',
- 'file_load_error' => 'ファイル読み込み中にエラーが発生しました (:path): :error',
+ 'file_load_error' => 'ファイルロード中にエラーが発生しました (:path): :error',
'log' => [
- 'cache_hit' => 'キャッシュからレイアウトデータを読み込みました',
+ 'cache_hit' => 'キャッシュからレイアウトデータをロード',
'cache_invalidated' => 'キャッシュが無効化されました',
- 'loaded' => 'レイアウトファイル読み込み完了',
- 'cached' => 'レイアウトデータをキャッシュに保存しました',
- 'cache_manually_invalidated' => 'キャッシュを手動で無効化しました',
- 'all_cache_invalidated' => 'すべてのキャッシュを無効化しました',
- 'file_load_error' => 'ファイル読み込みエラー',
+ 'loaded' => 'レイアウトファイルのロード完了',
+ 'cached' => 'レイアウトデータをキャッシュに保存',
+ 'cache_manually_invalidated' => 'キャッシュ手動無効化',
+ 'all_cache_invalidated' => 'すべてのキャッシュを無効化',
+ 'file_load_error' => 'ファイルロードエラー',
],
],
'command' => [
- 'starting' => 'admin_layout.json ファイルの検証を開始します',
+ 'starting' => 'admin_layout.json ファイル検証を開始します',
'validating_module' => 'モジュール :module を検証中...',
'validating_all_modules' => ':count 個のモジュール検証を開始します',
'module_layout_not_found' => 'モジュール :module の admin_layout.json ファイルが見つかりません',
- 'module_valid' => 'モジュール :module の検証成功',
- 'module_invalid' => 'モジュール :module の検証失敗: :error',
+ 'module_valid' => 'モジュール :module 検証成功',
+ 'module_invalid' => 'モジュール :module 検証失敗: :error',
'unexpected_error' => 'モジュール :module で予期しないエラーが発生しました: :error',
'modules_directory_not_found' => 'modules ディレクトリが見つかりません',
'no_modules_found' => '検証するモジュールがありません',
- 'module_summary' => 'モジュール :module の概要',
+ 'module_summary' => 'モジュール :module サマリー',
'menu_name' => 'メニュー名',
'menu_route' => 'ルート',
'data_sources_count' => 'データソース数',
'layout_type' => 'レイアウトタイプ',
- 'validation_summary' => '検証結果の概要',
+ 'validation_summary' => '検証結果サマリー',
'valid_modules_count' => '有効なモジュール',
'invalid_modules_count' => '無効なモジュール',
'missing_layouts_count' => 'レイアウトファイルなし',
- 'invalid_modules_details' => '無効なモジュールの詳細:',
+ 'invalid_modules_details' => '無効なモジュール詳細:',
'missing_layouts_details' => 'レイアウトファイルがないモジュール:',
'cache_stats' => 'キャッシュ統計',
'total_cached_modules' => 'キャッシュされたモジュール数',
'cache_expiration' => 'キャッシュ有効期限',
- 'cached_modules_details' => 'キャッシュされたモジュールの詳細:',
+ 'cached_modules_details' => 'キャッシュされたモジュール詳細:',
'clearing_cache' => 'キャッシュを無効化中...',
'cache_cleared' => 'キャッシュが無効化されました',
],
'success' => [
- 'loaded' => 'admin_layout.json の読み込みに成功しました',
+ 'loaded' => 'admin_layout.json を正常に読み込みました',
'cached' => 'レイアウトデータがキャッシュに保存されました',
],
'error' => [
'module_not_found' => '要求されたモジュールが見つかりません',
'json_parse_error' => 'JSON パース中にエラーが発生しました',
- 'file_not_readable' => 'ファイルを読めません',
+ 'file_not_readable' => 'ファイルを読み込めません',
'json_parse_failed' => 'JSON パースに失敗しました',
'validation_failed' => 'レイアウト検証に失敗しました',
'server_error' => 'サーバー内部エラーが発生しました',
@@ -80,14 +80,14 @@ return [
'log' => [
'cache_hit' => 'キャッシュからレイアウトデータを取得しました',
'cache_updated' => 'キャッシュが更新されました',
- 'layout_loaded' => 'レイアウトが正常に読み込まれました',
+ 'layout_loaded' => 'レイアウトが正常にロードされました',
'validation_completed' => 'レイアウト検証が完了しました',
- 'layout_load_error' => 'レイアウト読み込み中にエラーが発生しました',
+ 'layout_load_error' => 'レイアウトロード中にエラーが発生しました',
'validation_error' => 'レイアウト検証中にエラーが発生しました',
- 'service_cache_invalidated' => 'サービスでキャッシュを無効化しました',
- 'service_all_cache_invalidated' => 'サービスですべてのキャッシュを無効化しました',
+ 'service_cache_invalidated' => 'サービスからキャッシュを無効化しました',
+ 'service_all_cache_invalidated' => 'サービスからすべてのキャッシュを無効化しました',
'cache_invalidated' => 'キャッシュが無効化されました',
'all_cache_invalidated' => 'すべてのキャッシュが無効化されました',
- 'file_read_error' => 'ファイル読み取り中にエラーが発生しました',
+ 'file_read_error' => 'ファイル読み込み中にエラーが発生しました',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php
index 0b59c0fc..1b63d4fc 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php
@@ -13,18 +13,18 @@ return [
],
'default_policy' => [
'public' => '公開 (すべての認証ユーザー)',
- 'owner_only' => 'アップローダー/管理者のみ',
+ 'owner_only' => 'アップロード者/管理者のみ',
'read_only' => '読み取り専用',
],
'validation' => [
'file_required' => 'ファイルを選択してください。',
'file_invalid' => '有効なファイルではありません。',
- 'file_max' => 'ファイルサイズは :maxMB を超えることはできません。',
+ 'file_max' => 'ファイルサイズは :maxMB を超えることができません。',
'files_required' => 'ファイルを選択してください。',
'files_array' => 'ファイル形式が正しくありません。',
'files_min' => '最低1つ以上のファイルを選択してください。',
- 'type_invalid' => '添付対象タイプが正しくありません。',
- 'id_invalid' => '添付対象IDが正しくありません。',
+ 'type_invalid' => '添付対象のタイプが正しくありません。',
+ 'id_invalid' => '添付対象のIDが正しくありません。',
'role_ids_required' => 'ロールを選択してください。',
'role_ids_invalid' => 'ロール形式が正しくありません。',
'role_id_integer' => 'ロールIDは整数である必要があります。',
@@ -41,17 +41,17 @@ return [
],
'upload_success' => 'ファイルがアップロードされました。',
'upload_batch_success' => 'ファイルが一括アップロードされました。',
- 'upload_failed' => 'ファイルアップロードに失敗しました。',
+ 'upload_failed' => 'ファイルのアップロードに失敗しました。',
'delete_success' => 'ファイルが削除されました。',
- 'delete_failed' => 'ファイル削除に失敗しました。',
- 'reorder_success' => 'ファイル順序が変更されました。',
- 'reorder_failed' => 'ファイル順序の変更に失敗しました。',
+ 'delete_failed' => 'ファイルの削除に失敗しました。',
+ 'reorder_success' => 'ファイルの順序が変更されました。',
+ 'reorder_failed' => 'ファイルの順序変更に失敗しました。',
'sync_roles_success' => 'ロールが同期されました。',
'sync_roles_failed' => 'ロール同期に失敗しました。',
'sync_permissions_success' => '権限が同期されました。',
'sync_permissions_failed' => '権限同期に失敗しました。',
'not_found' => '添付ファイルが見つかりません。',
'access_denied' => 'この添付ファイルへのアクセス権限がありません。',
- 'update_denied' => 'この添付ファイルの編集権限がありません。',
- 'delete_denied' => 'この添付ファイルの削除権限がありません。',
+ 'update_denied' => 'この添付ファイルへの編集権限がありません。',
+ 'delete_denied' => 'この添付ファイルへの削除権限がありません。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php
index 0da68689..f942a5dc 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php
@@ -1,16 +1,16 @@
'ログインに成功しました。',
+ 'login_success' => 'ログインが成功しました。',
'login_failed' => '認証に失敗しました。',
- 'register_success' => '会員登録に成功しました。',
+ 'register_success' => '会員登録が成功しました。',
'register_failed' => '会員登録に失敗しました。',
- 'logout_success' => 'ログアウトに成功しました。',
+ 'logout_success' => 'ログアウトが成功しました。',
'logout_all_devices_success' => 'すべてのデバイスからログアウトされました。',
'password_reset_success' => 'パスワードが正常にリセットされました。',
'password_reset_failed' => 'パスワードのリセットに失敗しました。',
'invalid_credentials' => '無効な認証情報です。',
- 'admin_login_success' => '管理者ログインに成功しました。',
+ 'admin_login_success' => '管理者ログインが成功しました。',
'admin_required' => '管理者権限が必要です。',
'unauthenticated' => '認証が必要です。',
'permission_denied' => '該当する権限がありません。',
@@ -23,17 +23,17 @@ return [
'scope_denied' => 'このリソースへのアクセス権限がありません。',
'scope_type_all' => 'すべて',
'scope_type_self' => '本人のみ',
- 'scope_type_role' => '所有者ロール',
+ 'scope_type_role' => '所有ロール',
'invalid_token' => '無効な認証トークンです。',
- 'token_expired' => '認証トークンが期限切れです。もう一度ログインしてください。',
+ 'token_expired' => '認証トークンが有効期限切れです。再度ログインしてください。',
'user_not_found' => 'ユーザーが見つかりません。',
'account_inactive' => '無効化されたアカウントです。管理者にお問い合わせください。',
'account_blocked' => 'ブロックされたアカウントです。管理者にお問い合わせください。',
- 'account_withdrawn' => '退会済みのアカウントです。',
- 'account_pending_verification' => '本人確認が完了していないアカウントです。メール認証を完了してください。',
- 'reset_token_invalid' => '無効なパスワードリセットトークンです。',
- 'reset_token_expired' => 'パスワードリセットトークンが期限切れです。再度リクエストしてください。',
- 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。',
- 'email_send_failed' => 'メール送信に失敗しました。しばらく後にもう一度お試しください。',
+ 'account_withdrawn' => '退会したアカウントです。',
+ 'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。',
'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。',
+ 'reset_token_invalid' => '無効なパスワードリセットトークンです。',
+ 'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。',
+ 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。',
+ 'email_send_failed' => 'メール送信に失敗しました。しばらく後に再度お試しください。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/common.php b/lang-packs/_bundled/g7-core-ja/backend/ja/common.php
index 09220a3c..b98a0f1b 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/common.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/common.php
@@ -17,17 +17,17 @@ return [
'unknown' => '不明',
'errors' => [
'github_url_empty' => 'GitHub URLが空です。',
- 'github_url_invalid' => '無効なGitHub URL形式です。',
+ 'github_url_invalid' => '無効なGitHub URLフォーマットです。',
'github_api_failed' => 'GitHub APIの呼び出しに失敗しました。',
'github_download_failed' => 'GitHubからのダウンロードに失敗しました。',
'github_archive_download_failed' => 'アーカイブのダウンロードに失敗しました。(:url)',
'zip_file_not_found' => 'ZIPファイルが見つかりません。',
'zip_open_failed' => 'ZIPファイルを開くことができません。',
],
- 'validation_error' => '入力値バリデーションエラー',
+ 'validation_error' => '入力値のバリデーションエラー',
'changelog_validation' => [
'source_in' => 'ソースはactive、bundled、githubのいずれかである必要があります。',
- 'version_format' => ':attribute形式が正しくありません。(例: 1.0.0、1.0.0-beta.1)',
+ 'version_format' => ':attributeのフォーマットが正しくありません。(例:1.0.0、1.0.0-beta.1)',
'to_version_required' => '開始バージョンが指定されている場合、終了バージョンも必須です。',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php b/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php
index f34f0452..e84bab28 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php
@@ -1,14 +1,16 @@
'ダッシュボード統計を正常に閲覧しました。',
- 'stats_failed' => 'ダッシュボード統計の閲覧に失敗しました。',
- 'resources_loaded' => 'システムリソース情報を正常に閲覧しました。',
- 'resources_failed' => 'システムリソース情報の閲覧に失敗しました。',
- 'activities_loaded' => '最近のアクティビティログを正常に閲覧しました。',
- 'activities_failed' => '最近のアクティビティログの閲覧に失敗しました。',
- 'alerts_loaded' => 'システム通知を正常に閲覧しました。',
- 'alerts_failed' => 'システム通知の閲覧に失敗しました。',
+ 'stats_loaded' => 'ダッシュボード統計の照会に成功しました。',
+ 'stats_failed' => 'ダッシュボード統計の照会に失敗しました。',
+ 'resources_loaded' => 'システムリソース情報の照会に成功しました。',
+ 'resources_failed' => 'システムリソース情報の照会に失敗しました。',
+ 'activities_loaded' => '最近のアクティビティの照会に成功しました。',
+ 'activities_failed' => '最近のアクティビティの照会に失敗しました。',
+ 'alerts_loaded' => 'システム通知の照会に成功しました。',
+ 'alerts_failed' => 'システム通知の照会に失敗しました。',
+ 'recent_notifications_loaded' => '最近の通知の照会に成功しました。',
+ 'recent_notifications_failed' => '最近の通知の照会に失敗しました。',
'stats' => [
'status_normal' => '正常',
'status_warning' => '警告',
@@ -16,6 +18,4 @@ return [
'activities' => [
'user_registered' => ':name さんが会員登録しました。',
],
- 'recent_notifications_loaded' => '最近の通知を正常に閲覧しました。',
- 'recent_notifications_failed' => '最近の通知の閲覧に失敗しました。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
index 550eb9eb..ff973b11 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
@@ -11,16 +11,16 @@ return [
],
'404' => [
'title' => 'ページが見つかりません',
- 'message' => 'リクエストされたページが存在しないか、移動されています。',
+ 'message' => '要求されたページが存在しないか、移動した可能性があります。',
],
'500' => [
'title' => 'サーバーエラーが発生しました',
- 'message' => 'リクエストを処理する際に問題が発生しました。しばらく後に再度試してください。',
+ 'message' => 'リクエストを処理する際に問題が発生しました。しばらく後に再度お試しください。',
],
'503' => [
- 'title' => 'サービス一時中断',
- 'message' => '現在、このサービスは利用できません。しばらく後に再度試してください。',
- 'unmet_dependencies' => '未解決の依存関係リスト',
+ 'title' => 'サービス一時停止',
+ 'message' => '現在サービスをご利用いただけません。しばらく後に再度お試しください。',
+ 'unmet_dependencies' => '未充足の依存性リスト',
'template' => 'テンプレート',
'modules' => 'モジュール',
'plugins' => 'プラグイン',
@@ -29,7 +29,7 @@ return [
'back_home' => 'ホームに戻る',
'bootstrap' => [
'title' => '画面を読み込めませんでした',
- 'message' => 'ネットワーク接続が不安定な可能性があります。更新後にもう一度お試しください。',
+ 'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。',
'reload' => '更新',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
index f77cd321..c48f93b0 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
@@ -3,22 +3,23 @@
return [
'cannot_delete_super_admin' => 'スーパー管理者は削除できません。',
'circular_reference' => 'レイアウト循環参照を検出しました: :trace',
- 'max_depth_exceeded' => 'レイアウトのネスト深度が最大許容深度(:max)を超えています。',
+ 'max_depth_exceeded' => 'レイアウトネストの深さが最大許容深度(:max)を超過しました。',
'template_file_copy_failed' => 'テンプレートファイルのコピーに失敗しました: :source → :destination',
- 'template_build_directory_creation_failed' => 'テンプレートビルドディレクトリの作成に失敗しました: :path',
+ 'template_build_directory_creation_failed' => 'テンプレートビルドディレクトリの生成に失敗しました: :path',
'template_dist_directory_not_found' => 'テンプレートdistディレクトリが見つかりません: :path',
'template_not_found' => 'テンプレートが見つかりません: :identifier',
'template_not_active' => 'テンプレートが有効化されていません: :identifier (ステータス: :status)',
+ 'concurrent_modification' => '別のユーザーが先に保存しました。 (リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
'layout' => [
'duplicate_data_source_id' => 'data_sources IDが重複しています: :id',
'duplicate_data_source_id_in_file' => 'レイアウトファイル内のdata_sources IDが重複しています: :ids (ファイル: :file)',
- 'duplicate_data_source_id_extends' => 'extends継承関係でdata_sources IDが重複しています: :ids (子: :child, 親: :parent)',
+ 'duplicate_data_source_id_extends' => 'extends継承関係のdata_sources IDが重複しています: :ids (子: :child、親: :parent)',
'not_found' => 'レイアウトが見つかりません: :name',
- 'parent_not_found' => '親レイアウトが見つかりません: :parent (リクエストレイアウト: :child)',
+ 'parent_not_found' => '親レイアウトが見つかりません: :parent (要求レイアウト: :child)',
'include_file_not_found' => 'includeファイルが見つかりません: :path (解決されたパス: :resolved)',
'invalid_include_json' => 'includeファイルのJSON形式が正しくありません: :path (エラー: :error)',
'circular_include' => 'include循環参照が検出されました: :trace',
- 'max_include_depth_exceeded' => 'includeの最大深度を超えています (最大: :max段階)',
+ 'max_include_depth_exceeded' => 'include最大深度を超過しました (最大: :max段階)',
'include_outside_directory' => 'includeパスが許可されたディレクトリ外です: :path (許可: :allowed_dir)',
],
'layout_version' => [
@@ -26,41 +27,46 @@ return [
'save_failed_unexpected' => 'レイアウトバージョン保存中に予期しないエラーが発生しました。',
],
'settings' => [
- 'backup_creation_failed' => '設定バックアップファイルの作成に失敗しました。',
+ 'backup_creation_failed' => '設定バックアップファイルの生成に失敗しました。',
'restore_failed' => '設定の復元に失敗しました。',
'category_not_found' => '設定カテゴリが見つかりません: :category',
'save_failed' => '設定の保存に失敗しました: :category',
- 'json_parse_failed' => 'JSON ファイルのパースに失敗しました: :path (:error)',
- 'add_category_module_only' => 'addCategory はモジュールでのみ使用できます。',
+ 'json_parse_failed' => 'JSONファイルのパースに失敗しました: :path (:error)',
+ 'add_category_module_only' => 'addCategoryはモジュールからのみ使用できます。',
],
'core_update' => [
'handoff' => 'アップグレードハンドオフ: :after_version まで完了 — :reason (再実行: :resume_command)',
],
'vendor' => [
'composer_not_available' => 'Composerを実行できない環境です。バンドルモードを使用してください。',
- 'composer_not_available_for_build' => 'vendor-bundleビルドはComposer実行が必要です。開発環境でComposerをインストール·設定した後、もう一度お試しください。',
+ 'composer_not_available_for_build' => 'vendor-bundleビルドはComposer実行が必要です。開発環境でComposerをインストール/設定した後、再度実行してください。',
'bundle_build_composer_failed' => 'vendor-bundleビルド中にcomposer installの実行に失敗しました (exit :exit): :message',
+ 'bundle_build_promote_failed' => 'vendor-bundleビルド結果を最終パスに反映できませんでした (:from → :to)',
'composer_execution_failed' => 'Composerの実行に失敗しました: :message',
'bundle_zip_missing' => 'vendor-bundle.zipファイルが見つかりません: :path',
'bundle_manifest_missing' => 'vendor-bundle.jsonファイルが見つかりません: :path',
- 'bundle_manifest_invalid' => 'vendor-bundle.jsonファイルの読み込みに失敗しました: :error',
- 'bundle_integrity_failed' => 'バンドルファイルの整合性検証に失敗しました: :details',
+ 'bundle_manifest_invalid' => 'vendor-bundle.jsonファイルを読み込めません: :error',
+ 'bundle_integrity_failed' => 'バンドルファイルの完全性検証に失敗しました: :details',
'bundle_schema_unsupported' => 'サポートされていないvendor-bundle.jsonスキーマバージョンです: :version',
'zip_archive_not_available' => 'PHP ZipArchive拡張がインストールされていません。',
- 'zip_hash_mismatch' => 'vendor-bundle.zipのハッシュが一致しません (期待値: :expected, 実際: :actual)。',
+ 'zip_hash_mismatch' => 'vendor-bundle.zipのハッシュが一致しません (期待値: :expected、実際: :actual)。',
'composer_json_sha_mismatch' => 'composer.jsonがバンドルビルド時点以降に変更されました。vendor-bundle:buildで再ビルドしてください。',
'composer_lock_sha_mismatch' => 'composer.lockがバンドルビルド時点以降に変更されました。vendor-bundle:buildで再ビルドしてください。',
'bundle_contains_unsafe_path' => 'バンドルzipに安全でないファイルパスが含まれています: :path',
'extraction_failed' => 'バンドルzipの抽出に失敗しました: :message',
- 'no_vendor_strategy_available' => 'バンドルのインストール戦略を決定できません。Composerとバンドルzipの両方が利用不可です。',
+ 'no_vendor_strategy_available' => 'Vendorインストール戦略を決定できません。Composerとバンドルzipのどちらも使用できません。',
'source_dir_not_found' => 'インストールソースディレクトリが見つかりません: :path',
- 'target_not_writable' => '対象ディレクトリに書き込み権限がありません: :path',
- 'target_not_writable_owner_hint' => '実行ユーザー(:current_user)とディレクトリ所有者(:owner)が異なります。\'chown -R :current_user_name :path\'を実行後に再試行するか、所有者(:owner_name)でSSHログイン後に実行してください。',
+ 'target_not_writable' => '対象ディレクトリへの書き込み権限がありません: :path',
+ 'target_not_writable_owner_hint' => '実行ユーザー(:current_user)とディレクトリ所有者(:owner)が異なります。\'chown -R :current_user_name :path\'を実行後に再度試すか、所有者(:owner_name)でSSHログイン後に実行してください。',
'composer_json_not_found' => 'composer.jsonファイルが見つかりません: :path',
- 'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path',
- 'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path',
+ 'no_composer_lock' => 'composer.lockファイルが必要です。最初にcomposer installを実行してください: :path',
+ 'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。最初にcomposer installを実行してください: :path',
'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。',
- 'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)',
],
- 'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
+ 'seo' => [
+ 'sitemap_write_failed' => 'Sitemapファイルの記録に失敗しました: :path',
+ 'sitemap_gzip_failed' => 'Sitemap子ファイルの圧縮に失敗しました: :file',
+ 'sitemap_temp_file_unreadable' => 'Sitemap一時ファイルを読み込めません: :file',
+ 'sitemap_stat_group_column_unsupported' => 'サポートされていないグループ基準列です: :column',
+ ],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php
index 364c249b..7281efe3 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php
@@ -7,51 +7,51 @@ return [
'template' => 'テンプレート',
],
'errors' => [
- 'core_version_mismatch' => ':extension (:type)はグヌボード7 コアバージョン :required 以上が必要です。(現在: :installed)',
+ 'core_version_mismatch' => ':extension (:type)はグヌボード7コアバージョン :required 以上が必要です。(現在: :installed)',
'version_check_failed' => 'バージョン検証に失敗しました。',
- 'operation_in_progress' => '":name"に対して実行中の処理(:status)があるため、リクエストを処理できません。',
+ 'operation_in_progress' => '":name"に対して進行中の処理(:status)があるため、リクエストを処理できません。',
'zip_missing_manifest' => 'ZIP内から :file マニフェストが見つかりません: :zip',
'zip_invalid_manifest' => 'ZIP内の :file マニフェストをJSONとして解析できません。',
- 'zip_identifier_mismatch' => 'ZIPマニフェストの識別子がターゲット拡張と一致しません。(期待値: :expected、実際: :actual)',
- 'zip_missing_version' => 'ZIP内の :file マニフェストに version フィールドがありません。',
+ 'zip_identifier_mismatch' => 'ZIPマニフェストの識別子が対象拡張と一致しません。(期待値: :expected、実際: :actual)',
+ 'zip_missing_version' => 'ZIP内の :file マニフェストにversionフィールドがありません。',
'not_found' => '拡張(:identifier)が見つかりません。',
- 'cascade_dependency_failed' => '付属インストール対象の :type (:identifier)のインストールに失敗しました: :message',
+ 'cascade_dependency_failed' => '同梱インストール対象の :type (:identifier)のインストールに失敗しました: :message',
'invalid_type' => '無効な拡張タイプです。',
- 'not_auto_deactivated' => 'この拡張はコアバージョンの互換性の問題により自動無効化された状態ではありません。',
- 'hidden_extension' => '内部用(hidden)拡張はユーザーに公開されません。',
+ 'not_auto_deactivated' => 'この拡張はコアバージョン互換性の問題により自動無効化された状態ではありません。',
+ 'hidden_extension' => '内部用(非表示)拡張はユーザーに非公開です。',
],
'warnings' => [
- 'auto_deactivated' => ':type ":identifier"はコアバージョン互換性の問題により自動的に無効化されました。',
+ 'auto_deactivated' => ':type ":identifier"がコアバージョン互換性の問題により自動無効化されました。',
],
'alerts' => [
'incompatible_deactivated' => ':type ":name"自動無効化',
- 'incompatible_message' => '必須バージョン: :required、現在インストール済み: :installed',
- 'recovered_title' => ':type「:name」再び互換性があります',
+ 'incompatible_message' => '必要バージョン: :required、現在インストール済み: :installed',
+ 'recovered_title' => ':type ":name"互換性を再取得',
'recovered_body' => 'コアアップグレード後に互換性があります(以前の要件: :previously_required)。再度有効化できます。',
- 'recovered_success' => '拡張が再び有効化されました。',
+ 'recovered_success' => '拡張が再度有効化されました。',
'dismissed' => '通知を閉じました。',
- 'auto_deactivated_listed' => '自動無効化された拡張のリストです。',
+ 'auto_deactivated_listed' => '自動無効化された拡張の一覧です。',
'recover_action' => '再度有効化',
'dismiss_action' => '通知を閉じる',
],
- 'commands' => [
- 'clear_cache_success' => '拡張バージョン検証キャッシュが削除されました。',
- ],
'badges' => [
- 'incompatible' => 'コアアップグレードが必要です',
- 'incompatible_tooltip' => 'コア:required以上が必要です(現在: :installed)',
- 'incompatible_sr' => ':nameはコア:required以上が必要ですが、現在:installedがインストールされているため更新できません。',
+ 'incompatible' => 'コアアップグレード必要',
+ 'incompatible_tooltip' => 'コア :required 以上が必要です(現在: :installed)',
+ 'incompatible_sr' => ':name はコア :required 以上が必要ですが、現在 :installed がインストールされているため、アップデートできません。',
],
'banner' => [
'title' => 'コア互換性の問題により自動無効化された拡張があります',
- 'item_required' => '必須バージョン: :required',
+ 'item_required' => '必要バージョン: :required',
'guide_link' => 'コアアップグレードガイド',
'dismiss' => '閉じる',
],
'update_modal' => [
'compat_warning_title' => 'コアバージョン互換性警告',
- 'compat_warning_message' => 'この:typeはコア:required以上が必要です。(現在: :installed)',
+ 'compat_warning_message' => 'この :type はコア :required 以上が必要です。(現在: :installed)',
'compat_guide_link' => 'コアアップグレードガイドを表示',
- 'force_label' => '警告を無視して強制更新します(推奨されません)',
+ 'force_label' => '警告を無視して強制アップデート(非推奨)',
+ ],
+ 'commands' => [
+ 'clear_cache_success' => '拡張バージョン検証キャッシュが削除されました。',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php b/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php
index 01312efc..c259eaeb 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php
@@ -1,7 +1,7 @@
'静的フック マッピング キャッシュを作成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path',
+ 'cache_generated' => '静的フック マッピング キャッシュを生成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path',
'cache_cleared' => '静的フック マッピング キャッシュを削除しました。',
'cache_absent' => '削除する静的フック マッピング キャッシュがありません。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php
index be3a6c69..962182ac 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php
@@ -6,9 +6,9 @@ return [
'label' => 'メール',
'settings' => [
'code_length' => '認証コード長',
- 'code_length_help' => '送信される数字コードのけた数(デフォルト6、最小4、最大10)。',
- 'from_address' => '送信者アドレス',
- 'from_address_help' => '空の場合、システムデフォルトの送信者が使用されます。',
+ 'code_length_help' => '送信される数字コードの桁数 (デフォルト 6、最小 4、最大 10)。',
+ 'from_address' => '差出人アドレス',
+ 'from_address_help' => '空の場合、システムのデフォルト差出人を使用します。',
],
],
],
@@ -18,48 +18,48 @@ return [
'wrong_provider' => 'この認証リクエストは別のプロバイダーで処理する必要があります。',
'invalid_state' => '既に処理された認証リクエストです。',
'expired' => '認証時間が期限切れになりました。もう一度お試しください。',
- 'max_attempts' => '試行回数を超えました。再度リクエストしてください。',
+ 'max_attempts' => '試行回数を超過しました。もう一度リクエストしてください。',
'invalid_code' => '認証コードが正しくありません。',
- 'invalid_verification_token' => '無効な本人確認トークンです。',
- 'missing_target' => '認証対象(メール·電話番号)が必要です。',
- 'target_mismatch' => '認証した対象と要求した対象が一致しません。',
+ 'invalid_verification_token' => '無効な本人認証トークンです。',
+ 'missing_target' => '認証対象 (メール·電話番号) が必要です。',
+ 'target_mismatch' => '認証された対象とリクエストされた対象が一致しません。',
'purpose_not_supported' => '選択されたプロバイダーはこの目的をサポートしていません。',
- 'provider_unavailable' => '本人確認プロバイダーを使用できません。',
- 'generic' => '本人確認に失敗しました。',
- 'missing_scope_or_target' => 'ポリシー照会にはscopeとtargetの両方が必要です。',
+ 'provider_unavailable' => '本人認証プロバイダーを使用できません。',
+ 'generic' => '本人認証に失敗しました。',
+ 'missing_scope_or_target' => 'ポリシー照会には scope と target の両方が必要です。',
'admin_policy_has_no_default' => '管理者が直接作成したポリシーには宣言デフォルト値がありません。',
'reset_field_failed' => '宣言デフォルト値の復元に失敗しました。フィールドが有効であることを確認してください。',
],
'messages' => [
- 'challenge_requested' => '本人確認コードを送信しました。',
+ 'challenge_requested' => '本人認証コードを送信しました。',
'challenge_verified' => '本人確認が完了しました。',
- 'challenge_cancelled' => '本人確認リクエストがキャンセルされました。',
+ 'challenge_cancelled' => '本人認証リクエストがキャンセルされました。',
],
'logs' => [
'activity' => [
- 'requested' => ':emailに本人確認コードを送信しました。',
+ 'requested' => ':email に本人認証コードを送信しました。',
'verified' => '本人確認が完了しました。',
- 'failed' => '本人確認に失敗しました。',
- 'expired' => '本人確認時間が期限切れになりました。',
- 'cancelled' => '本人確認リクエストをキャンセルしました。',
+ 'failed' => '本人認証に失敗しました。',
+ 'expired' => '本人認証時間が期限切れになりました。',
+ 'cancelled' => '本人認証リクエストをキャンセルしました。',
],
],
'purposes' => [
'signup' => [
'label' => '会員登録認証',
- 'description' => '新規登録者のメール/電話番号所有確認。',
+ 'description' => '新規ユーザーのメール/電話番号所有確認。',
],
'password_reset' => [
'label' => 'パスワード再設定',
- 'description' => 'パスワードを忘れたユーザーが本人確認後に再設定します。',
+ 'description' => 'パスワードを忘れたユーザーが本人確認後に再設定。',
],
'self_update' => [
- 'label' => '自己情報変更',
- 'description' => 'ログインユーザーがメール/電話などの本人情報を変更する場合。',
+ 'label' => '自分の情報を変更',
+ 'description' => 'ログインユーザーがメール/電話など本人情報を変更する場合。',
],
'sensitive_action' => [
- 'label' => '機密作業',
- 'description' => 'アカウント削除·管理者作業など再認証が必要な時点。',
+ 'label' => '機密操作',
+ 'description' => 'アカウント削除·管理者操作など再認証が必要な時点。',
],
],
'channels' => [
@@ -70,7 +70,7 @@ return [
'hook' => 'フック',
'policy' => 'ポリシー',
'middleware' => 'ミドルウェア',
- 'api' => 'API直接呼び出し',
+ 'api' => 'API 直接呼び出し',
'custom' => 'カスタム',
'system' => 'システム',
],
@@ -81,7 +81,7 @@ return [
'custom' => 'カスタム',
],
'fail_mode' => [
- 'block' => 'ブロック(HTTP 428)',
+ 'block' => 'ブロック (HTTP 428)',
'log_only' => 'ログのみ記録',
],
'applies_to' => [
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php
index 30198330..acf375ea 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php
@@ -1,27 +1,27 @@
'IDVメッセージ定義リストを取得しました。',
- 'definition_list_failed' => 'IDVメッセージ定義リスト取得に失敗しました。',
- 'definition_show_success' => 'IDVメッセージ定義詳細を取得しました。',
- 'definition_show_failed' => 'IDVメッセージ定義詳細取得に失敗しました。',
- 'definition_created' => 'IDVメッセージ定義が作成されました。',
- 'definition_create_failed' => 'IDVメッセージ定義作成に失敗しました。',
- 'definition_updated' => 'IDVメッセージ定義が編集されました。',
- 'definition_update_failed' => 'IDVメッセージ定義編集に失敗しました。',
- 'definition_toggled' => 'IDVメッセージ定義の有効状態が変更されました。',
- 'definition_toggle_failed' => 'IDVメッセージ定義有効状態変更に失敗しました。',
- 'definition_reset' => 'IDVメッセージ定義がデフォルト値に復元されました。',
- 'definition_reset_failed' => 'IDVメッセージ定義デフォルト値復元に失敗しました。',
- 'definition_deleted' => 'IDVメッセージ定義が削除されました。',
- 'definition_delete_failed' => 'IDVメッセージ定義削除に失敗しました。',
- 'definition_delete_forbidden' => 'シード済みのデフォルトメッセージ定義は削除できません。',
- 'template_updated' => 'IDVメッセージテンプレートが編集されました。',
- 'template_update_failed' => 'IDVメッセージテンプレート編集に失敗しました。',
- 'template_toggled' => 'IDVメッセージテンプレートの有効状態が変更されました。',
- 'template_toggle_failed' => 'IDVメッセージテンプレート有効状態変更に失敗しました。',
- 'template_reset' => 'IDVメッセージテンプレートがデフォルト値に復元されました。',
- 'template_reset_failed' => 'IDVメッセージテンプレートデフォルト値復元に失敗しました。',
- 'template_preview_success' => 'IDVメッセージテンプレートプレビューを生成しました。',
- 'template_preview_failed' => 'IDVメッセージテンプレートプレビュー生成に失敗しました。',
+ 'definition_list_success' => 'IDV メッセージ定義リストを取得しました。',
+ 'definition_list_failed' => 'IDV メッセージ定義リスト取得に失敗しました。',
+ 'definition_show_success' => 'IDV メッセージ定義の詳細を取得しました。',
+ 'definition_show_failed' => 'IDV メッセージ定義の詳細取得に失敗しました。',
+ 'definition_created' => 'IDV メッセージ定義が作成されました。',
+ 'definition_create_failed' => 'IDV メッセージ定義の作成に失敗しました。',
+ 'definition_updated' => 'IDV メッセージ定義が編集されました。',
+ 'definition_update_failed' => 'IDV メッセージ定義の編集に失敗しました。',
+ 'definition_toggled' => 'IDV メッセージ定義の有効状態が変更されました。',
+ 'definition_toggle_failed' => 'IDV メッセージ定義の有効状態変更に失敗しました。',
+ 'definition_reset' => 'IDV メッセージ定義がデフォルト値に復元されました。',
+ 'definition_reset_failed' => 'IDV メッセージ定義のデフォルト値復元に失敗しました。',
+ 'definition_deleted' => 'IDV メッセージ定義が削除されました。',
+ 'definition_delete_failed' => 'IDV メッセージ定義の削除に失敗しました。',
+ 'definition_delete_forbidden' => 'シードされたデフォルトメッセージ定義は削除できません。',
+ 'template_updated' => 'IDV メッセージテンプレートが編集されました。',
+ 'template_update_failed' => 'IDV メッセージテンプレートの編集に失敗しました。',
+ 'template_toggled' => 'IDV メッセージテンプレートの有効状態が変更されました。',
+ 'template_toggle_failed' => 'IDV メッセージテンプレートの有効状態変更に失敗しました。',
+ 'template_reset' => 'IDV メッセージテンプレートがデフォルト値に復元されました。',
+ 'template_reset_failed' => 'IDV メッセージテンプレートのデフォルト値復元に失敗しました。',
+ 'template_preview_success' => 'IDV メッセージテンプレートのプレビューが作成されました。',
+ 'template_preview_failed' => 'IDV メッセージテンプレートのプレビュー作成に失敗しました。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
index 2b0602a3..2b9d8318 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
@@ -2,61 +2,61 @@
return [
'fetch_success' => '言語パックリストを取得しました。',
- 'fetch_failed' => '言語パックリストを読み込めませんでした。',
+ 'fetch_failed' => '言語パックリストを読み込めませんでした: :error',
'not_found' => '言語パックが見つかりません。',
'install_success' => '言語パックをインストールしました。',
- 'install_failed' => '言語パックのインストールに失敗しました。',
+ 'install_failed' => '言語パックのインストールに失敗しました: :error',
'activate_success' => '言語パックを有効化しました。',
- 'activate_failed' => '言語パックの有効化に失敗しました。',
+ 'activate_failed' => '言語パックの有効化に失敗しました: :error',
'deactivate_success' => '言語パックを無効化しました。',
- 'deactivate_failed' => '言語パックの無効化に失敗しました。',
+ 'deactivate_failed' => '言語パックの無効化に失敗しました: :error',
'uninstall_success' => '言語パックを削除しました。',
- 'uninstall_failed' => '言語パックの削除に失敗しました。',
+ 'uninstall_failed' => '言語パックの削除に失敗しました: :error',
'manifest_invalid' => 'language-pack.json の検証に失敗しました。',
- 'check_updates_success' => 'アップデート確認を完了しました。',
- 'check_updates_failed' => 'アップデート確認に失敗しました。',
+ 'check_updates_success' => 'アップデート確認が完了しました。',
+ 'check_updates_failed' => 'アップデート確認に失敗しました: :error',
'update_success' => '言語パックをアップデートしました。',
- 'update_failed' => '言語パックのアップデートに失敗しました。',
+ 'update_failed' => '言語パックのアップデートに失敗しました: :error',
'refresh_cache_success' => '言語パックキャッシュを更新しました。',
- 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました。',
- 'preview_success' => 'manifestプレビューを完了しました。',
- 'preview_failed' => 'manifestプレビューに失敗しました。',
+ 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました: :error',
+ 'preview_success' => 'manifest プレビューが完了しました。',
+ 'preview_failed' => 'manifest プレビューに失敗しました: :error',
'errors' => [
- 'manifest_not_found' => 'ZIP内でlanguage-pack.jsonファイルが見つかりません。',
- 'manifest_invalid_json' => 'language-pack.json のJSON形式が正しくありません。',
- 'php_outside_backend' => 'backend/ディレクトリ外にPHPファイルが含まれています (:file)。',
- 'unsafe_php_pattern' => 'PHPファイルに許可されていないコードパターンが含まれています (:file)。',
+ 'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。',
+ 'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。',
+ 'php_outside_backend' => 'backend/ ディレクトリ外に PHP ファイルが含まれています (:file)。',
+ 'unsafe_php_pattern' => 'PHP ファイルに許可されていないコードパターンが含まれています (:file)。',
'core_locale_missing' => 'コア言語パック(:locale)を先に有効化する必要があります。',
- 'target_not_installed' => '対象:scope (:target)がインストールされていません。',
- 'target_inactive' => '対象:scope (:target)が無効状態です。先に有効化してください。',
- 'target_version_too_old' => '対象:scope (:target)バージョンが要件(:constraint)を満たしていません。',
+ 'target_not_installed' => '対象 :scope (:target) がインストールされていません。',
+ 'target_inactive' => '対象 :scope (:target) は無効化されています。先に有効化してください。',
+ 'target_version_too_old' => '対象 :scope (:target) のバージョンが要件(:constraint)を満たしていません。',
'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。',
'protected_pack' => '保護された言語パックは無効化·削除できません。',
- 'download_failed' => 'URL(:url)から言語パックをダウンロードできませんでした。',
+ 'download_failed' => 'URL(:url) から言語パックをダウンロードできませんでした: :error',
+ 'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhostなど)からは言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。',
'checksum_mismatch' => 'チェックサムが一致しません。',
- 'update_no_source' => 'アップデートソース情報がありません (GitHubソース言語パックのみアップデート可能)。',
- 'update_already_latest' => 'すでに最新バージョンです。',
- 'preview_invalid_manifest' => 'manifestファイルが無効なためプレビューに失敗しました。',
- 'operation_in_progress' => '言語パック(:identifier)で別の処理が進行中です。完了後に再試行してください。',
+ 'update_no_source' => 'アップデートソース情報がありません(GitHubソース言語パックのみアップデート可能)。',
+ 'update_already_latest' => '既に最新バージョンです。',
+ 'preview_invalid_manifest' => 'manifestファイルが有効でなく、プレビューに失敗しました。',
+ 'operation_in_progress' => '言語パック(:identifier)で別のタスクが処理中です。完了後に再度お試しください。',
'bundled_not_found' => 'バンドルディレクトリ(lang-packs/_bundled/:identifier)が存在しません。',
- 'slot_conflict' => '同じスロットにすでにアクティブな言語パック(:current)があります。(:target)に置き換えるには確認が必要です。',
- 'directory_not_writable' => '言語パックインストールディレクトリ(:path)への書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)',
- 'identifier_not_found' => '言語パックが見つかりません (:identifier)。',
+ 'slot_conflict' => '同じスロットに既にアクティブな言語パック(:current)があります。(:target)に置き換えるには確認が必要です。',
+ 'directory_not_writable' => '言語パックインストールディレクトリ(:path)に書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)',
+ 'identifier_not_found' => '言語パックが見つかりません(:identifier)。',
'unsupported_source' => 'サポートされていないソース(:source) — bundled、github、urlのいずれかを使用してください。',
- 'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)では言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。',
],
'validation' => [
'file_required' => '言語パックZIPファイルをアップロードしてください。',
'file_invalid' => 'アップロードされたファイルが有効ではありません。',
'file_too_large' => 'ファイルサイズは10MBを超えることはできません。',
'file_not_zip' => 'ZIP形式のファイルのみアップロードできます。',
- 'github_url_required' => 'GitHubURLを入力してください。',
+ 'github_url_required' => 'GitHubのURLを入力してください。',
'github_url_invalid' => '正しいURLを入力してください。',
- 'github_url_format' => 'GitHubリポジトリURL形式である必要があります (例: https://github.com/owner/repo)。',
+ 'github_url_format' => 'GitHubリポジトリURLの形式である必要があります(例: https://github.com/owner/repo)。',
'url_required' => 'ダウンロードURLを入力してください。',
'url_invalid' => '正しいURLを入力してください。',
'checksum_invalid' => 'SHA-256チェックサムは64文字の16進数である必要があります。',
'identifier_required' => '言語パック識別子を入力してください。',
- 'identifier_invalid' => '識別子は英数字·ピリオド·アンダースコア·ハイフンのみ許可されます。',
+ 'identifier_invalid' => '識別子は英文/数字/ドット/アンダースコア/ハイフンのみが許可されます。',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php b/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php
index f3c214de..493831ef 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php
@@ -2,5 +2,5 @@
return [
'version_warning_title' => '互換性警告',
- 'version_incompatible' => 'オーバーライド":source"がモジュールバージョンと互換性がありません (要求: :constraint、現在: :current_version)',
+ 'version_incompatible' => 'オーバーライド":source"がモジュールバージョンと互換性がありません(必須: :constraint、現在: :current_version)',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php b/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php
index 4f1252df..36b49f26 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php
@@ -2,6 +2,6 @@
return [
'title' => 'システムメンテナンス中',
- 'message' => '現在システムアップデートが進行中です。',
- 'description' => 'しばらく後に再度お試しください。ご不便をおかけして申し訳ございません。',
+ 'message' => '現在、システムアップデートが実施中です。',
+ 'description' => 'しばらく経ってからもう一度お試しください。ご不便をおかけして申し訳ございません。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php b/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php
index 2afee8d7..cdd8895f 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php
@@ -13,16 +13,16 @@ return [
'delete_success' => 'メニューが正常に削除されました。',
'delete_failed' => 'メニューの削除に失敗しました。',
'delete_error' => 'メニュー削除中にエラーが発生しました。',
- 'order_update_success' => 'メニュー順序が正常にアップデートされました。',
- 'order_update_failed' => 'メニュー順序のアップデートに失敗しました。',
+ 'order_update_success' => 'メニューの順序が正常にアップデートされました。',
+ 'order_update_failed' => 'メニューの順序アップデートに失敗しました。',
'slug_already_exists' => '既に使用中のスラッグです。',
'parent_menu_not_found' => '存在しない親メニューです。',
- 'cannot_set_self_as_parent' => '自分自身を親として設定することはできません。',
- 'cannot_delete_menu_with_children' => '子メニューがあるメニューは削除できません。',
+ 'cannot_set_self_as_parent' => '自身を親として設定することはできません。',
+ 'cannot_delete_menu_with_children' => '子メニューを持つメニューは削除できません。',
'validation' => [
'is_active_boolean' => '有効化ステータスはtrue/false値である必要があります。',
- 'sort_by_invalid' => '並べ替え基準が有効ではありません。',
- 'sort_order_invalid' => '並べ替え順序が有効ではありません。',
+ 'sort_by_invalid' => 'ソート基準が有効ではありません。',
+ 'sort_order_invalid' => 'ソート順序が有効ではありません。',
'filters_array' => 'フィルターは配列形式である必要があります。',
'filters_max' => 'フィルターは最大10個まで指定できます。',
'filter_field_required' => 'フィルターフィールドは必須です。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/module.php b/lang-packs/_bundled/g7-core-ja/backend/ja/module.php
index 6e074eaf..7866ff36 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/module.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/module.php
@@ -2,25 +2,27 @@
return [
'not_found' => 'モジュール :module が見つかりません。',
- 'dependency_not_active' => '依存関係モジュール :dependency がインストールされていないか、有効化されていません。',
+ 'dependency_not_active' => '依存モジュール :dependency がインストールされていないか、有効化されていません。',
'fetch_success' => 'モジュールを正常にインポートしました。',
- 'fetch_failed' => 'モジュールのインポートに失敗しました。',
+ 'fetch_failed' => 'モジュールのインポートに失敗しました: :error',
'install_success' => 'モジュールが正常にインストールされました。',
- 'install_failed' => 'モジュールのインストールに失敗しました。',
- 'preview_success' => 'マニフェストのプレビューが完了しました。',
- 'preview_failed' => 'マニフェストのプレビューに失敗しました。',
+ 'install_failed' => 'モジュールのインストールに失敗しました: :error',
+ 'preview_success' => 'manifestのプレビューが完了しました。',
+ 'preview_failed' => 'manifestのプレビューに失敗しました: :error',
'uninstall_success' => 'モジュールが正常に削除されました。',
- 'uninstall_failed' => 'モジュールの削除に失敗しました。',
+ 'uninstall_failed' => 'モジュールの削除に失敗しました: :error',
'activate_success' => 'モジュールが正常に有効化されました。',
- 'activate_failed' => 'モジュールの有効化に失敗しました。',
+ 'activate_failed' => 'モジュールの有効化に失敗しました: :error',
'deactivate_success' => 'モジュールが正常に無効化されました。',
- 'deactivate_failed' => 'モジュールの無効化に失敗しました。',
+ 'deactivate_failed' => 'モジュールの無効化に失敗しました: :error',
'update_success' => 'モジュールが正常にアップデートされました。',
- 'update_failed' => 'モジュールのアップデートに失敗しました。',
+ 'update_failed' => 'モジュールのアップデートに失敗しました: :error',
'refresh_layouts_success' => 'モジュールレイアウトが正常に更新されました。',
- 'refresh_layouts_failed' => 'モジュールレイアウトの更新に失敗しました。',
+ 'refresh_layouts_failed' => 'モジュールレイアウトの更新に失敗しました: :error',
'uninstall_info_success' => 'モジュール削除情報を正常に取得しました。',
- 'uninstall_info_failed' => 'モジュール削除情報の取得に失敗しました。',
+ 'uninstall_info_failed' => 'モジュール削除情報の取得に失敗しました: :error',
+ 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
+ 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
'license_not_found' => 'モジュールライセンスファイルが見つかりません。',
'status' => [
'active' => '有効',
@@ -30,12 +32,10 @@ return [
],
'errors' => [
'module_class_not_found' => 'モジュールクラスが見つかりません。',
- 'migration_failed' => 'マイグレーション実行に失敗しました。',
- 'dependency_check_failed' => '依存関係の確認に失敗しました。',
+ 'migration_failed' => 'マイグレーション実行に失敗しました: :error',
+ 'dependency_check_failed' => '依存性確認に失敗しました: :error',
'database_error' => 'データベースエラーが発生しました。',
'module_not_found' => 'モジュール :name が見つかりません。',
'module_not_active' => 'モジュール :name は有効化されていません。',
],
- 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
- 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php b/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php
index 6e9689c0..ca573afa 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php
@@ -23,58 +23,58 @@ return [
'uninstalled' => '未インストール',
],
'no_modules' => 'モジュールがありません。',
- 'summary' => '合計 :total個 モジュール (インストール済み: :installed、有効化: :active)',
+ 'summary' => '合計 :total 個のモジュール (インストール済み: :installed、有効化: :active)',
'invalid_status' => '無効なステータスです。(installed、uninstalled、active、inactive)',
],
'install' => [
- 'success' => 'モジュール ":module" インストール完了',
+ 'success' => 'モジュール ":module" のインストールが完了しました',
'vendor' => 'ベンダー: :vendor',
'version' => 'バージョン: :version',
- 'roles_created' => ':count個 ロール 作成されました',
- 'permissions_created' => ':count個 権限 作成されました',
- 'menus_created' => ':count個 メニュー 作成されました',
+ 'roles_created' => ':count 個のロールが作成されました',
+ 'permissions_created' => ':count 個の権限が作成されました',
+ 'menus_created' => ':count 個のメニューが作成されました',
'already_installed' => 'モジュール ":module" は既にインストールされています。',
'force_reinstall' => 'モジュール ":module" を強制的に再インストールします (アクティブディレクトリを上書き)...',
],
'activate' => [
- 'success' => 'モジュール ":module" 有効化完了',
- 'not_installed' => 'モジュール ":module" はインストールされていません。',
+ 'success' => 'モジュール ":module" の有効化が完了しました',
+ 'not_installed' => 'モジュール ":module" がインストールされていません。',
'already_active' => 'モジュール ":module" は既に有効化されています。',
- 'layouts_registered' => ':count個 レイアウト 登録されました',
+ 'layouts_registered' => ':count 個のレイアウトが登録されました',
],
'deactivate' => [
- 'success' => 'モジュール ":module" 無効化完了',
- 'not_installed' => 'モジュール ":module" はインストールされていません。',
+ 'success' => 'モジュール ":module" の無効化が完了しました',
+ 'not_installed' => 'モジュール ":module" がインストールされていません。',
'not_active' => 'モジュール ":module" は有効化されていません。',
'warning' => 'モジュールのルートと機能が無効化されました。',
- 'layouts_deleted' => ':count個 レイアウト 削除されました',
+ 'layouts_deleted' => ':count 個のレイアウトが削除されました',
],
'uninstall' => [
- 'success' => 'モジュール ":module" 削除完了',
- 'roles_deleted' => ':count個 ロール 削除されました',
- 'permissions_deleted' => ':count個 権限 削除されました',
- 'menus_deleted' => ':count個 メニュー 削除されました',
- 'layouts_deleted' => ':count個 レイアウト 削除されました',
- 'confirm_prompt' => 'モジュール ":module" を削除しますか?',
+ 'success' => 'モジュール ":module" の削除が完了しました',
+ 'roles_deleted' => ':count 個のロールが削除されました',
+ 'permissions_deleted' => ':count 個の権限が削除されました',
+ 'menus_deleted' => ':count 個のメニューが削除されました',
+ 'layouts_deleted' => ':count 個のレイアウトが削除されました',
+ 'confirm_prompt' => 'モジュール ":module" を削除してもよろしいですか?',
'confirm_details' => [
- 'roles' => '- :count個 のロールが削除されます。',
- 'permissions' => '- :count個 の権限が削除されます。',
- 'menus' => '- :count個 のメニューが削除されます。',
- 'layouts' => '- :count個 のレイアウトが削除されます。',
+ 'roles' => '- :count 個のロールが削除されます。',
+ 'permissions' => '- :count 個の権限が削除されます。',
+ 'menus' => '- :count 個のメニューが削除されます。',
+ 'layouts' => '- :count 個のレイアウトが削除されます。',
'data' => '- すべてのモジュールデータが削除されます。',
],
- 'confirm_question' => '本当に削除しますか?',
- 'aborted' => 'モジュール削除がキャンセルされました。',
- 'not_installed' => 'モジュール ":module" はインストールされていません。',
+ 'confirm_question' => '本当に削除してもよろしいですか?',
+ 'aborted' => 'モジュールの削除がキャンセルされました。',
+ 'not_installed' => 'モジュール ":module" がインストールされていません。',
],
'cache_clear' => [
'clearing_all' => 'すべてのモジュールキャッシュを削除します...',
- 'clearing_single' => 'モジュール ":module" キャッシュを削除します...',
- 'success_all' => 'モジュールキャッシュ削除完了 (:count個 項目)',
- 'success_single' => 'モジュール ":module" キャッシュ削除完了 (:count個 項目)',
+ 'clearing_single' => 'モジュール ":module" のキャッシュを削除します...',
+ 'success_all' => 'モジュールキャッシュの削除が完了しました (:count 個の項目)',
+ 'success_single' => 'モジュール ":module" のキャッシュ削除が完了しました (:count 個の項目)',
],
'check_updates' => [
- 'not_installed' => 'モジュール ":module" はインストールされていません。',
+ 'not_installed' => 'モジュール ":module" がインストールされていません。',
'no_installed' => 'インストール済みモジュールがありません。',
'update_available' => 'アップデート可能',
'up_to_date' => '最新',
@@ -85,122 +85,122 @@ return [
'source' => 'ソース',
'status' => 'ステータス',
],
- 'summary' => '合計 :total個 確認 (アップデート可能: :updates個)',
+ 'summary' => '合計 :total 個を確認 (アップデート可能: :updates 個)',
'single_up_to_date' => 'モジュール ":module" は最新バージョンです。(v:version)',
- 'single_update_available' => 'モジュール ":module" アップデート可能: v:current → v:latest (:source)',
+ 'single_update_available' => 'モジュール ":module" はアップデート可能です: v:current → v:latest (:source)',
],
'update' => [
- 'success' => 'モジュール ":module" アップデート完了',
+ 'success' => 'モジュール ":module" のアップデートが完了しました',
'version_change' => 'v:from → v:to',
'no_update' => 'モジュール ":module" に利用可能なアップデートはありません。',
- 'not_installed' => 'モジュール ":module" はインストールされていません。',
+ 'not_installed' => 'モジュール ":module" がインストールされていません。',
'current_version' => '現在のバージョン: :version',
'latest_version' => '最新バージョン: :version',
'update_source' => 'アップデートソース: :source',
- 'confirm_question' => 'アップデートを実行しますか?',
+ 'confirm_question' => 'アップデートを進めてもよろしいですか?',
'aborted' => 'アップデートがキャンセルされました。',
'backup_restored' => 'バックアップから以前のバージョンが復元されました。',
'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。',
'layout_strategy' => 'レイアウト戦略: :strategy',
- 'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count件が上書きされます。',
- 'modified_extension_item' => ' - :target (出典: :source)',
+ 'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count 件が上書きされます。',
+ 'modified_extension_item' => ' - :target (ソース: :source)',
],
],
'composer_install' => [
'start' => 'モジュール ":module" の Composer 依存関係をインストールします...',
- 'success' => 'モジュール ":module" の Composer 依存関係 インストール完了',
+ 'success' => 'モジュール ":module" の Composer 依存関係のインストールが完了しました',
'no_dependencies' => 'モジュール ":module" にインストールする Composer 依存関係はありません。',
- 'failed' => 'モジュール ":module" の Composer 依存関係 インストールに失敗しました。',
+ 'failed' => 'モジュール ":module" の Composer 依存関係のインストールに失敗しました。',
'summary' => '📊 結果: 成功 :success個、スキップ :skip個、失敗 :fail個',
],
- 'update_success' => 'モジュール ":module" が :version バージョンにアップデートされました。',
- 'update_failed' => 'モジュールアップデートに失敗しました: :error',
- 'update_hook_failed' => 'モジュール アップデート フック実行に失敗しました。',
+ 'update_success' => 'モジュール ":module"が :version バージョンにアップデートされました。',
+ 'update_failed' => 'モジュールのアップデートに失敗しました: :error',
+ 'update_hook_failed' => 'モジュールアップデートフック実行に失敗しました: :error',
'no_update_available' => 'アップデートする内容がありません。',
'check_updates_success' => 'アップデート確認が完了しました。',
- 'check_updates_failed' => 'アップデート確認に失敗しました。',
- 'check_modified_layouts_success' => '修正されたレイアウト確認が完了しました。',
- 'check_modified_layouts_failed' => '修正されたレイアウト確認に失敗しました: :error',
- 'not_installed' => 'モジュール":module"はインストールされていません。',
- 'pending_not_found' => 'インストール待ちリストでモジュール":module"が見つかりません。',
- 'already_exists' => 'モジュール":module"は既に存在します。',
- 'move_failed' => 'モジュール":module"の移動に失敗しました。',
- 'deactivate_warning' => 'このモジュールに依存する有効なテンプレートがあります。',
- 'installation_failed' => 'モジュール インストールに失敗しました: :error',
- 'activation_failed' => 'モジュール 有効化に失敗しました: :error',
- 'deactivation_failed' => 'モジュール 無効化に失敗しました: :error',
- 'uninstallation_failed' => 'モジュール 削除に失敗しました: :error',
- 'refresh_layouts_failed' => 'レイアウト 更新に失敗しました: :error',
+ 'check_updates_failed' => 'アップデート確認に失敗しました: :error',
+ 'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。',
+ 'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error',
+ 'not_installed' => 'モジュール ":module"はインストールされていません。',
+ 'pending_not_found' => 'インストール待機リストでモジュール ":module"が見つかりません。',
+ 'already_exists' => 'モジュール ":module"は既に存在します。',
+ 'move_failed' => 'モジュール ":module"の移動に失敗しました: :error',
+ 'deactivate_warning' => 'このモジュールに依存するアクティブなテンプレートがあります。',
+ 'installation_failed' => 'モジュールのインストールに失敗しました: :error',
+ 'activation_failed' => 'モジュールの有効化に失敗しました: :error',
+ 'deactivation_failed' => 'モジュールの無効化に失敗しました: :error',
+ 'uninstallation_failed' => 'モジュールの削除に失敗しました: :error',
+ 'refresh_layouts_failed' => 'レイアウト更新に失敗しました: :error',
'warnings' => [
'has_dependent_templates' => 'このモジュールに依存する有効化されたテンプレートがあります。無効化するには強制オプションを使用してください。',
'has_dependents' => 'このモジュールに依存する有効化された拡張があります。',
- 'deactivation_warning' => '無効化すると次のテンプレートが影響を受けます:',
+ 'deactivation_warning' => '無効化すると、次のテンプレートが影響を受けます:',
'confirm_deactivation' => 'それでも無効化しますか?',
- 'missing_dependencies' => 'このモジュールを有効化するには次の依存性が必要です。',
+ 'missing_dependencies' => 'このモジュールを有効化するには、次の依存関係が必要です。',
],
- 'activate_warning' => 'モジュール 有効化に必要な依存性が満たされていません。',
- 'deactivate_warning_extended' => 'このモジュールに依存する有効化された拡張(テンプレート、モジュール、プラグイン)があります。',
+ 'activate_warning' => 'モジュール有効化に必要な依存関係が満たされていません。',
+ 'deactivate_warning_extended' => 'このモジュールに依存する有効化された拡張 (テンプレート、モジュール、プラグイン) があります。',
'errors' => [
- 'force_update_no_source' => ':moduleを強制アップデートするソースが見つかりません。バンドルおよびGitHub URLの両方がありません。',
- 'downgrade_blocked' => 'ダウングレードがブロックされました(:from → :to)。意図的なダウングレードは--forceオプションを使用してください。',
- 'zip_open_failed' => 'ZIPファイルを開くことができません。',
- 'module_json_not_found' => 'module.jsonファイルが見つかりません。',
- 'module_json_invalid' => 'module.jsonファイル形式が正しくありません。',
- 'identifier_missing' => 'モジュール識別子が不足しています。',
+ 'force_update_no_source' => ':module を強制アップデートするソースが見つかりません。バンドルと GitHub URL の両方がありません。',
+ 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
+ 'zip_open_failed' => 'ZIP ファイルを開くことができません: :error',
+ 'module_json_not_found' => 'module.json ファイルが見つかりません。',
+ 'module_json_invalid' => 'module.json ファイル形式が正しくありません。',
+ 'identifier_missing' => 'モジュール識別子がありません。',
'already_installed' => '既にインストールされているモジュールです。',
- 'install_failed' => 'モジュール インストールに失敗しました。',
- 'github_url_invalid' => '無効なGitHub URLの形式です。',
- 'github_download_failed' => 'GitHubからのダウンロードに失敗しました。',
+ 'install_failed' => 'モジュールのインストールに失敗しました: :error',
+ 'github_url_invalid' => '無効な GitHub URL 形式です。',
+ 'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。',
- 'module_not_found' => 'モジュール:nameが見つかりません。',
- 'module_not_active' => 'モジュール:nameは有効化されていません。',
- 'not_found' => 'モジュール:moduleが見つかりません。',
+ 'module_not_found' => 'モジュール :name が見つかりません。',
+ 'module_not_active' => 'モジュール :name は有効化されていません。',
+ 'not_found' => 'モジュール :module が見つかりません。',
'file_not_found' => 'ファイルが見つかりません。',
'file_type_not_allowed' => '許可されていないファイル形式です。',
'unknown_error' => '不明なエラーが発生しました。',
- 'invalid_permission_structure' => 'モジュール":identifier"の権限構造が正しくありません: :reason',
- 'invalid_translation_path' => 'モジュール":identifier"の言語パック ファイルパスが正しくありません。":wrong_path"の代わりに":correct_path"を使用する必要があります。',
- 'seo_variable_conflict' => 'モジュール":identifier"のSEO変数名が既存の拡張と競合します: :reason',
- 'layout_validation_json_error' => 'レイアウト ファイル:fileのJSON解析に失敗しました: :error',
- 'layout_validation_missing_name' => 'レイアウト ファイル:fileにlayout_nameが不足しています。',
- 'layout_validation_partial_error' => 'レイアウト ファイル:fileのpartial処理に失敗しました: :error (partial: :partial)',
- 'layout_validation_validation_failed' => 'モジュール:identifierのレイアウト バリデーションに失敗しました。:count個のエラーが見つかりました。',
- 'operation_in_progress' => 'モジュール":name"は現在:statusの状態です。処理が完了するまでお待ちください。',
- 'github_api_failed' => 'GitHub API呼び出しに失敗しました。',
- 'invalid_github_url' => '無効なGitHub URLです。',
- 'zip_url_not_found' => 'ZIPダウンロード URLが見つかりません。',
- 'download_failed' => 'モジュール":module":versionバージョンのダウンロードに失敗しました。',
- 'zip_extract_failed' => 'ZIP展開に失敗しました。',
- 'extracted_dir_not_found' => '展開されたディレクトリが見つかりません。',
- 'reload_failed' => 'モジュール 再ロードに失敗しました。',
- 'delete_directory_failed' => 'モジュール ディレクトリの削除に失敗しました。',
- 'update_failed' => 'モジュール":module"のアップデートに失敗しました: :error',
- 'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwriteまたはkeepのみ許可)',
- 'invalid_vendor_mode' => '無効なVendor インストール モードです。(auto、composer、bundledのみ許可)',
+ 'invalid_permission_structure' => 'モジュール ":identifier"の権限構造が正しくありません: :reason',
+ 'invalid_translation_path' => 'モジュール ":identifier"の言語ファイルパスが正しくありません。":wrong_path" の代わりに ":correct_path"を使用する必要があります。',
+ 'seo_variable_conflict' => 'モジュール ":identifier"の SEO 変数名が既存の拡張と競合しています: :reason',
+ 'layout_validation_json_error' => 'レイアウトファイル :file の JSON パースに失敗しました: :error',
+ 'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name がありません。',
+ 'layout_validation_partial_error' => 'レイアウトファイル :file の partial 処理に失敗しました: :error (partial: :partial)',
+ 'layout_validation_validation_failed' => 'モジュール :identifier のレイアウト検証に失敗しました。:count 個のエラーが見つかりました: :error',
+ 'operation_in_progress' => 'モジュール ":name"は現在 :status 状態です。処理が完了するまでお待ちください。',
+ 'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error',
+ 'invalid_github_url' => '無効な GitHub URL です。',
+ 'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。',
+ 'download_failed' => 'モジュール ":module" :version バージョンのダウンロードに失敗しました: :error',
+ 'zip_extract_failed' => 'ZIP 解凍に失敗しました: :error',
+ 'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。',
+ 'reload_failed' => 'モジュールの再読み込みに失敗しました: :error',
+ 'delete_directory_failed' => 'モジュールディレクトリの削除に失敗しました: :error',
+ 'update_failed' => 'モジュール ":module"のアップデートに失敗しました: :error',
+ 'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)',
+ 'invalid_vendor_mode' => '無効な Vendor インストールモードです。(auto、composer、bundled のみ許可)',
],
'validation' => [
'name_required' => 'モジュール名は必須です。',
'name_string' => 'モジュール名は文字列である必要があります。',
- 'name_max' => 'モジュール名は255字を超えることはできません。',
- 'search_max' => '検索語は255字を超えることはできません。',
- 'filters_max' => '検索条件は最大10個までのみ指定できます。',
+ 'name_max' => 'モジュール名は 255 文字を超えることができません。',
+ 'search_max' => '検索語は 255 文字を超えることができません。',
+ 'filters_max' => '検索条件は最大 10 個までのみ指定できます。',
'filter_field_required' => '検索フィールドは必須です。',
'filter_field_invalid' => '無効な検索フィールドです。',
'filter_value_required' => '検索値は必須です。',
- 'filter_value_max' => '検索値は255字を超えることはできません。',
+ 'filter_value_max' => '検索値は 255 文字を超えることができません。',
'filter_operator_invalid' => '無効な検索演算子です。',
- 'status_invalid' => '無効なステータスです。',
- 'per_page_min' => 'ページあたりの項目数は最小1です。',
- 'per_page_max' => 'ページあたりの項目数は最大100です。',
- 'page_min' => 'ページ番号は最小1です。',
- 'file_required' => 'モジュール ファイルは必須です。',
+ 'status_invalid' => '無効な状態です。',
+ 'per_page_min' => '1 ページあたりの項目数は最小 1 個です。',
+ 'per_page_max' => '1 ページあたりの項目数は最大 100 個です。',
+ 'page_min' => 'ページ番号は最小 1 です。',
+ 'file_required' => 'モジュールファイルは必須です。',
'file_invalid' => '有効なファイルではありません。',
- 'file_must_be_zip' => 'モジュール ファイルはZIP形式である必要があります。',
- 'file_max_size' => 'モジュール ファイル サイズは:sizeMBを超えることはできません。',
+ 'file_must_be_zip' => 'モジュールファイルは ZIP 形式である必要があります。',
+ 'file_max_size' => 'モジュールファイルサイズは :sizeMB を超えることができません。',
'github_url_required' => 'GitHub URLは必須です。',
'github_url_invalid' => '有効なURL形式ではありません。',
'github_url_format' => 'GitHub リポジトリURL形式が正しくありません。',
- 'with_max' => 'withパラメータは最大10個まで指定できます。',
- 'with_invalid' => '無効なwith関係です。',
+ 'with_max' => 'with パラメータは最大10個まで指定できます。',
+ 'with_invalid' => '有効でないwith 関係です。',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php b/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php
index 45dc5513..2947af0b 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php
@@ -1,49 +1,50 @@
'通知定義一覧を取得しました。',
- 'definition_list_failed' => '通知定義一覧の取得に失敗しました。',
+ 'definition_list_success' => '通知定義リストを取得しました。',
+ 'definition_list_failed' => '通知定義リストの取得に失敗しました。',
'definition_show_success' => '通知定義を取得しました。',
'definition_show_failed' => '通知定義の取得に失敗しました。',
'definition_updated' => '通知定義が編集されました。',
'definition_update_failed' => '通知定義の編集に失敗しました。',
- 'definition_toggled' => '通知定義のアクティブ状態が変更されました。',
- 'definition_toggle_failed' => '通知定義のアクティブ状態の変更に失敗しました。',
+ 'definition_toggled' => '通知定義の有効状態が変更されました。',
+ 'definition_toggle_failed' => '通知定義の有効状態変更に失敗しました。',
'definition_not_found' => '通知定義が見つかりません。',
'definition_reset' => '通知定義のすべてのテンプレートがデフォルト値に復元されました。',
'definition_reset_failed' => '通知定義の初期化に失敗しました。',
- 'channel_skipped_no_template' => ':channel チャネルに :type タイプのアクティブなテンプレートがないため、配信をスキップしました。',
- 'channel_disabled_by_extension' => '拡張設定でそのチャネルが無効化されているため、配信をスキップしました。',
+ 'channel_skipped_no_template' => ':channel チャネルに :type タイプのアクティブテンプレートがないため、送信をスキップしました。',
+ 'channel_disabled_by_extension' => '拡張環境設定でそのチャネルが無効化されているため、送信をスキップしました。',
+ 'channel_guest_not_allowed' => 'そのチャネルは非会員送信を許可していないため、送信をスキップしました。',
'template_updated' => '通知テンプレートが編集されました。',
'template_update_failed' => '通知テンプレートの編集に失敗しました。',
- 'template_toggled' => '通知テンプレートのアクティブ状態が変更されました。',
- 'template_toggle_failed' => '通知テンプレートのアクティブ状態の変更に失敗しました。',
+ 'template_toggled' => '通知テンプレートの有効状態が変更されました。',
+ 'template_toggle_failed' => '通知テンプレートの有効状態変更に失敗しました。',
'template_reset' => '通知テンプレートがデフォルト値に復元されました。',
'template_reset_failed' => '通知テンプレートのデフォルト値復元に失敗しました。',
- 'template_inactive' => '該当チャネルの通知テンプレートは無効化状態です。',
+ 'template_inactive' => '該当チャネルの通知テンプレートは無効状態です。',
'template_not_found' => '該当チャネルの通知テンプレートが見つかりません。',
'default_data_not_found' => 'デフォルトテンプレートデータが見つかりません。',
- 'preview_success' => 'プレビューを生成しました。',
- 'preview_failed' => 'プレビューの生成に失敗しました。',
- 'channels_success' => '通知チャネル一覧を取得しました。',
- 'channels_failed' => '通知チャネル一覧の取得に失敗しました。',
+ 'preview_success' => 'プレビューを作成しました。',
+ 'preview_failed' => 'プレビューの作成に失敗しました。',
+ 'channels_success' => '通知チャネルリストを取得しました。',
+ 'channels_failed' => '通知チャネルリストの取得に失敗しました。',
'definition' => '通知定義',
'subject' => '件名',
'body' => '本文',
'readiness' => [
'mail_from_address_not_configured' => '送信者メールアドレスが設定されていません。',
- 'mail_smtp_host_empty' => 'SMTPホストが設定されていません。',
- 'mail_smtp_port_empty' => 'SMTPポートが設定されていません。',
- 'mail_mailgun_domain_empty' => 'Mailgunドメインが設定されていません。',
- 'mail_mailgun_secret_empty' => 'Mailgunシークレットが設定されていません。',
- 'mail_ses_key_empty' => 'SESキーが設定されていません。',
- 'mail_ses_secret_empty' => 'SESシークレットが設定されていません。',
+ 'mail_smtp_host_empty' => 'SMTP ホストが設定されていません。',
+ 'mail_smtp_port_empty' => 'SMTP ポートが設定されていません。',
+ 'mail_mailgun_domain_empty' => 'Mailgun ドメインが設定されていません。',
+ 'mail_mailgun_secret_empty' => 'Mailgun シークレットが設定されていません。',
+ 'mail_ses_key_empty' => 'SES キーが設定されていません。',
+ 'mail_ses_secret_empty' => 'SES シークレットが設定されていません。',
'database_table_missing' => '通知テーブルが存在しません。',
'unknown' => 'チャネル設定が完了していません。',
],
'user' => [
- 'list_success' => '通知一覧を取得しました。',
- 'list_failed' => '通知一覧の取得に失敗しました。',
+ 'list_success' => '通知リストを取得しました。',
+ 'list_failed' => '通知リストの取得に失敗しました。',
'unread_count_success' => '未読通知数を取得しました。',
'unread_count_failed' => '未読通知数の取得に失敗しました。',
'read_success' => '通知を既読にしました。',
@@ -53,19 +54,18 @@ return [
'delete_success' => '通知が削除されました。',
'delete_failed' => '通知の削除に失敗しました。',
'delete_all_success' => 'すべての通知が削除されました。',
- 'delete_all_failed' => '全件削除に失敗しました。',
+ 'delete_all_failed' => '全件通知削除に失敗しました。',
'not_found' => '通知が見つかりません。',
],
'channels' => [
- 'core_default' => 'コアの基本チャネル',
+ 'core_default' => 'コアデフォルトチャネル',
'mail' => [
'name' => 'メール',
'description' => 'メールで通知を送信',
],
'database' => [
'name' => 'サイト内通知',
- 'description' => 'サイト内の通知センターに表示',
+ 'description' => 'サイト内通知センターに表示',
],
],
- 'channel_guest_not_allowed' => '該当チャネルは非会員発送を許可していないため、発送をスキップしました。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php b/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php
index cf219dd5..9e7dc298 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php
@@ -1,8 +1,8 @@
'通知発送履歴を閲覧しました。',
- 'list_failed' => '通知発送履歴の閲覧に失敗しました。',
+ 'list_success' => '通知発送履歴を取得しました。',
+ 'list_failed' => '通知発送履歴の取得に失敗しました。',
'delete_success' => '通知発送履歴が削除されました。',
'delete_failed' => '通知発送履歴の削除に失敗しました。',
'bulk_delete_success' => '選択された通知発送履歴が削除されました。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php b/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php
index 262dd2cf..c3cb8e3b 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php
@@ -1,119 +1,121 @@
'プラグインリストの取得に成功しました。',
- 'list_failed' => 'プラグインリストの取得に失敗しました。',
- 'fetch_success' => 'プラグイン情報の取得に成功しました。',
- 'fetch_failed' => 'プラグイン情報の取得に失敗しました。',
+ 'list_success' => 'プラグインのリストを正常に取得しました。',
+ 'list_failed' => 'プラグインのリストの取得に失敗しました: :error',
+ 'fetch_success' => 'プラグインの情報を正常に取得しました。',
+ 'fetch_failed' => 'プラグインの情報の取得に失敗しました: :error',
'not_found' => 'プラグイン :plugin が見つかりません。',
- 'install_success' => 'プラグインが正常にインストールされました。',
- 'install_failed' => 'プラグインのインストールに失敗しました。',
- 'preview_success' => 'マニフェストプレビューが完了しました。',
- 'preview_failed' => 'マニフェストプレビューに失敗しました。',
- 'install_validation_failed' => 'プラグインのインストール検証に失敗しました。',
+ 'install_success' => 'プラグインは正常にインストールされました。',
+ 'install_failed' => 'プラグインのインストールに失敗しました: :error',
+ 'preview_success' => 'manifest プレビューが完了しました。',
+ 'preview_failed' => 'manifest プレビューに失敗しました: :error',
+ 'install_validation_failed' => 'プラグイン インストール バリデーションに失敗しました: :error',
'install_error' => 'プラグインのインストール中にエラーが発生しました。',
- 'uninstall_success' => 'プラグインが正常に削除されました。',
- 'uninstall_failed' => 'プラグインの削除に失敗しました。',
- 'uninstall_validation_failed' => 'プラグインの削除検証に失敗しました。',
+ 'uninstall_success' => 'プラグインは正常に削除されました。',
+ 'uninstall_failed' => 'プラグインの削除に失敗しました: :error',
+ 'uninstall_validation_failed' => 'プラグイン削除 バリデーションに失敗しました: :error',
'uninstall_error' => 'プラグインの削除中にエラーが発生しました。',
- 'activate_success' => 'プラグインが正常に有効化されました。',
- 'activate_failed' => 'プラグインの有効化に失敗しました。',
- 'activate_validation_failed' => 'プラグインの有効化検証に失敗しました。',
+ 'activate_success' => 'プラグインは正常に有効化されました。',
+ 'activate_failed' => 'プラグインの有効化に失敗しました: :error',
+ 'activate_validation_failed' => 'プラグイン有効化 バリデーションに失敗しました: :error',
'activate_error' => 'プラグインの有効化中にエラーが発生しました。',
- 'deactivate_success' => 'プラグインが正常に無効化されました。',
- 'deactivate_failed' => 'プラグインの無効化に失敗しました。',
- 'deactivate_validation_failed' => 'プラグインの無効化検証に失敗しました。',
+ 'deactivate_success' => 'プラグインは正常に無効化されました。',
+ 'deactivate_failed' => 'プラグインの無効化に失敗しました: :error',
+ 'deactivate_validation_failed' => 'プラグイン無効化 バリデーションに失敗しました: :error',
'deactivate_error' => 'プラグインの無効化中にエラーが発生しました。',
'refresh_layouts_success' => 'プラグインレイアウトが正常に更新されました。',
'refresh_layouts_failed' => 'プラグインレイアウトの更新に失敗しました: :error',
- 'uninstall_info_success' => 'プラグイン削除情報の閲覧に成功しました。',
- 'uninstall_info_failed' => 'プラグイン削除情報の閲覧に失敗しました。',
+ 'uninstall_info_success' => 'プラグイン削除情報を正常に取得しました。',
+ 'uninstall_info_failed' => 'プラグイン削除情報の取得に失敗しました: :error',
+ 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
+ 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
'license_not_found' => 'プラグインライセンスファイルが見つかりません。',
- 'refresh_layouts_validation_failed' => 'プラグインレイアウト更新検証に失敗しました。',
+ 'refresh_layouts_validation_failed' => 'プラグインレイアウト更新 バリデーションに失敗しました: :error',
'refresh_layouts_error' => 'プラグインレイアウトの更新中にエラーが発生しました。',
'status' => [
- 'active' => '有効',
- 'inactive' => '無効',
- 'updating' => 'アップデート中',
+ 'active' => '有効化',
+ 'inactive' => '無効化',
+ 'updating' => '更新中',
],
- 'update_success' => 'プラグイン ":plugin" は :version にアップデートされました。',
+ 'update_success' => 'プラグイン ":plugin" は :version バージョンにアップデートされました。',
'update_failed' => 'プラグインのアップデートに失敗しました: :error',
- 'update_hook_failed' => 'プラグイン更新フックの実行に失敗しました。',
- 'no_update_available' => '更新する内容がありません。',
- 'check_updates_success' => '更新確認が完了しました。',
- 'check_updates_failed' => '更新確認に失敗しました。',
- 'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。',
- 'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error',
+ 'update_hook_failed' => 'プラグイン アップデート フック実行に失敗しました: :error',
+ 'no_update_available' => 'アップデートする内容がありません。',
+ 'check_updates_success' => 'アップデート確認が完了しました。',
+ 'check_updates_failed' => 'アップデート確認に失敗しました: :error',
+ 'check_modified_layouts_success' => '更新されたレイアウト確認が完了しました。',
+ 'check_modified_layouts_failed' => '更新されたレイアウト確認に失敗しました: :error',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'pending_not_found' => 'インストール待機リストにプラグイン ":plugin" が見つかりません。',
'already_exists' => 'プラグイン ":plugin" は既に存在します。',
- 'move_failed' => 'プラグイン ":plugin" の移動に失敗しました。',
- 'dependency_not_active' => '依存関係 ":dependency" がインストールされていないか、無効化されています。',
+ 'move_failed' => 'プラグイン ":plugin" の移動に失敗しました: :error',
+ 'dependency_not_active' => '依存 ":dependency" はインストールされていないか、無効化されています。',
'installation_failed' => 'プラグインのインストールに失敗しました: :error',
'activation_failed' => 'プラグインの有効化に失敗しました: :error',
'deactivation_failed' => 'プラグインの無効化に失敗しました: :error',
'uninstallation_failed' => 'プラグインの削除に失敗しました: :error',
'warnings' => [
- 'has_dependent_templates' => 'このプラグインに依存する有効なテンプレートがあります。無効化するには強制オプションを使用してください。',
- 'has_dependents' => 'このプラグインに依存する有効な拡張があります。',
- 'deactivation_warning' => '無効化すると、次のテンプレートが影響を受けます:',
+ 'has_dependent_templates' => 'このプラグインに依存する有効化されたテンプレートがあります。無効化するには強制オプションを使用してください。',
+ 'has_dependents' => 'このプラグインに依存する有効化された拡張があります。',
+ 'deactivation_warning' => '無効化すると、以下のテンプレートが影響を受けます:',
'confirm_deactivation' => 'それでも無効化しますか?',
- 'missing_dependencies' => 'このプラグインを有効化するには、次の依存関係が必要です。',
+ 'missing_dependencies' => 'このプラグインを有効化するには、以下の依存が必要です。',
],
- 'activate_warning' => 'プラグインの有効化に必要な依存関係が満たされていません。',
- 'deactivate_warning' => 'このプラグインに依存する有効な拡張があります。',
- 'deactivate_warning_extended' => 'このプラグインに依存する有効な拡張(テンプレート、モジュール、プラグイン)があります。',
+ 'activate_warning' => 'プラグイン有効化に必要な依存が満たされていません。',
+ 'deactivate_warning' => 'このプラグインに依存する有効化された拡張があります。',
+ 'deactivate_warning_extended' => 'このプラグインに依存する有効化された拡張(テンプレート、モジュール、プラグイン)があります。',
'errors' => [
- 'force_update_no_source' => ':plugin を強制アップデートするソースが見つかりません。バンドルとGitHub URLがどちらもありません。',
- 'downgrade_blocked' => 'ダウングレードはブロックされています (:from → :to)。意図的なダウングレードの場合は --force オプションを使用してください。',
+ 'force_update_no_source' => ':plugin を強制アップデートするソースが見つかりません。バンドルと GitHub URL の両方がありません。',
+ 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
'invalid_permission_structure' => 'プラグイン ":identifier" の権限構造が正しくありません: :reason',
- 'invalid_translation_path' => 'プラグイン ":identifier" の言語ファイルパスが正しくありません。":wrong_path" の代わりに ":correct_path" を使用する必要があります。',
- 'seo_variable_conflict' => 'プラグイン ":identifier" のSEO変数名が既存の拡張と競合しています: :reason',
+ 'invalid_translation_path' => 'プラグイン ":identifier" の言語ファイルパスが正しくありません。":wrong_path" ではなく ":correct_path" を使用してください。',
+ 'seo_variable_conflict' => 'プラグイン ":identifier" の SEO 変数名が既存の拡張と競合します: :reason',
'plugin_not_found' => 'プラグイン ":name" が見つかりません。',
'plugin_not_active' => 'プラグイン ":name" は有効化されていません。',
'not_found' => 'プラグイン :plugin が見つかりません。',
'file_not_found' => 'ファイルが見つかりません。',
'file_type_not_allowed' => '許可されていないファイル形式です。',
'unknown_error' => '不明なエラーが発生しました。',
- 'layout_validation_json_error' => 'レイアウトファイル :file のJSON解析に失敗しました: :error',
- 'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name が不足しています。',
- 'layout_validation_partial_error' => 'レイアウトファイル :file の部分テンプレート処理に失敗しました: :error (partial: :partial)',
- 'layout_validation_validation_failed' => 'プラグイン :identifier のレイアウト検証に失敗しました。:count 個のエラーが見つかりました。',
- 'operation_in_progress' => 'プラグイン ":name" は現在 :status 状態です。作業が完了するまで待ってください。',
- 'github_api_failed' => 'GitHub API呼び出しに失敗しました。',
- 'invalid_github_url' => '無効なGitHub URLです。',
- 'zip_url_not_found' => 'ZIPダウンロードURLが見つかりません。',
- 'download_failed' => 'プラグイン ":plugin" :version バージョンのダウンロードに失敗しました。',
- 'zip_extract_failed' => 'ZIP解凍に失敗しました。',
+ 'layout_validation_json_error' => 'レイアウトファイル :file の JSON パース に失敗しました: :error',
+ 'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name がありません。',
+ 'layout_validation_partial_error' => 'レイアウトファイル :file の partial 処理に失敗しました: :error (partial: :partial)',
+ 'layout_validation_validation_failed' => 'プラグイン :identifier のレイアウト バリデーションに失敗しました。:count 個のエラーが見つかりました: :error',
+ 'operation_in_progress' => 'プラグイン ":name" は現在 :status 状態です。作業が完了するまでお待ちください。',
+ 'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error',
+ 'invalid_github_url' => '無効な GitHub URL です。',
+ 'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。',
+ 'download_failed' => 'プラグイン ":plugin" :version バージョンのダウンロードに失敗しました: :error',
+ 'zip_extract_failed' => 'ZIP 解凍に失敗しました: :error',
'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。',
- 'reload_failed' => 'プラグインの再読み込みに失敗しました。',
- 'delete_directory_failed' => 'プラグインディレクトリの削除に失敗しました。',
- 'update_failed' => 'プラグイン ":plugin" のアップデートに失敗しました: :error',
+ 'reload_failed' => 'プラグイン再読み込みに失敗しました: :error',
+ 'delete_directory_failed' => 'プラグイン ディレクトリ削除に失敗しました: :error',
+ 'update_failed' => 'プラグイン ":plugin" アップデートに失敗しました: :error',
'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)',
'invalid_vendor_mode' => '無効な Vendor インストールモードです。(auto、composer、bundled のみ許可)',
'github_url_invalid' => '無効な GitHub URL 形式です。',
- 'github_download_failed' => 'GitHub からのダウンロードに失敗しました。',
+ 'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。',
- 'zip_open_failed' => 'ZIP ファイルを開くことができません。',
+ 'zip_open_failed' => 'ZIP ファイルを開くことができません: :error',
'plugin_json_not_found' => 'plugin.json ファイルが見つかりません。',
'plugin_json_invalid' => 'plugin.json ファイル形式が正しくありません。',
- 'identifier_missing' => 'プラグイン識別子が見つかりません。',
+ 'identifier_missing' => 'プラグイン識別子が不足しています。',
'already_installed' => 'プラグイン ":plugin" は既にインストールされています。',
- 'install_failed' => 'プラグインのインストールに失敗しました。',
+ 'install_failed' => 'プラグイン インストールに失敗しました: :error',
],
'validation' => [
'plugin_name_required' => 'プラグイン名は必須です。',
'plugin_name_string' => 'プラグイン名は文字列である必要があります。',
- 'plugin_name_max' => 'プラグイン名は最大 :max 文字まで入力できます。',
+ 'plugin_name_max' => 'プラグイン名は最大 :max 文字までです。',
'name_required' => 'プラグイン名は必須です。',
'name_string' => 'プラグイン名は文字列である必要があります。',
- 'name_max' => 'プラグイン名は最大 255 文字まで入力できます。',
- 'search_max' => '検索キーワードは最大 255 文字まで入力できます。',
+ 'name_max' => 'プラグイン名は最大 255 文字までです。',
+ 'search_max' => '検索ワードは最大 255 文字までです。',
'filters_max' => '検索条件は最大 10 個まで設定できます。',
'filter_field_required' => '検索フィールドは必須です。',
'filter_field_invalid' => '無効な検索フィールドです。',
'filter_value_required' => '検索値は必須です。',
- 'filter_value_max' => '検索値は最大 255 文字まで入力できます。',
+ 'filter_value_max' => '検索値は最大 255 文字までです。',
'filter_operator_invalid' => '無効な検索演算子です。',
'status_invalid' => '無効なステータス値です。',
'per_page_min' => 'ページあたりの項目数は最小 1 個以上である必要があります。',
@@ -130,42 +132,42 @@ return [
'status' => 'ステータス',
],
'status' => [
- 'active' => '有効',
- 'inactive' => '無効',
+ 'active' => '有効化',
+ 'inactive' => '無効化',
'uninstalled' => '未インストール',
],
'no_plugins' => 'プラグインがありません。',
- 'summary' => '合計 :total 個のプラグイン (インストール済み: :installed、有効: :active)',
+ 'summary' => '合計 :total 個のプラグイン (インストール済み: :installed、有効化: :active)',
'invalid_status' => '無効なステータスです。(installed、uninstalled、active、inactive)',
],
'install' => [
- 'success' => 'プラグイン ":plugin" のインストールが完了しました',
+ 'success' => 'プラグイン ":plugin" インストール完了',
'vendor' => 'ベンダー: :vendor',
'version' => 'バージョン: :version',
'roles_created' => ':count 個のロールが作成されました',
'permissions_created' => ':count 個の権限が作成されました',
'already_installed' => 'プラグイン ":plugin" は既にインストールされています。',
- 'force_reinstall' => 'プラグイン ":plugin" を強制的に再インストールします (有効ディレクトリを上書き)...',
+ 'force_reinstall' => 'プラグイン ":plugin" を強制再インストールします (有効化ディレクトリを上書き)...',
],
'activate' => [
- 'success' => 'プラグイン ":plugin" の有効化が完了しました',
+ 'success' => 'プラグイン ":plugin" 有効化完了',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'already_active' => 'プラグイン ":plugin" は既に有効化されています。',
'layouts_registered' => ':count 個のレイアウトが登録されました',
],
'deactivate' => [
- 'success' => 'プラグイン ":plugin" の無効化が完了しました',
+ 'success' => 'プラグイン ":plugin" 無効化完了',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'not_active' => 'プラグイン ":plugin" は有効化されていません。',
'warning' => 'プラグインのルートと機能が無効化されました。',
'layouts_deleted' => ':count 個のレイアウトが削除されました',
],
'uninstall' => [
- 'success' => 'プラグイン ":plugin" の削除が完了しました',
+ 'success' => 'プラグイン ":plugin" 削除完了',
'roles_deleted' => ':count 個のロールが削除されました',
'permissions_deleted' => ':count 個の権限が削除されました',
'layouts_deleted' => ':count 個のレイアウトが削除されました',
- 'confirm_prompt' => 'プラグイン ":plugin" を削除しますか?',
+ 'confirm_prompt' => 'プラグイン ":plugin" を削除してもよろしいですか?',
'confirm_details' => [
'roles' => '- :count 個のロールが削除されます。',
'permissions' => '- :count 個の権限が削除されます。',
@@ -173,18 +175,18 @@ return [
'data' => '- すべてのプラグインデータが削除されます。',
],
'confirm_question' => '本当に削除してもよろしいですか?',
- 'aborted' => 'プラグインの削除がキャンセルされました。',
+ 'aborted' => 'プラグイン削除がキャンセルされました。',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
],
'cache_clear' => [
'clearing_all' => 'すべてのプラグインキャッシュを削除します...',
- 'clearing_single' => 'プラグイン ":plugin" のキャッシュを削除します...',
- 'success_all' => 'プラグインキャッシュの削除が完了しました (:count 個の項目)',
- 'success_single' => 'プラグイン ":plugin" のキャッシュ削除が完了しました (:count 個の項目)',
+ 'clearing_single' => 'プラグイン ":plugin" キャッシュを削除します...',
+ 'success_all' => 'プラグインキャッシュ削除完了 (:count 個の項目)',
+ 'success_single' => 'プラグイン ":plugin" キャッシュ削除完了 (:count 個の項目)',
],
'check_updates' => [
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
- 'no_installed' => 'インストール済みのプラグインがありません。',
+ 'no_installed' => 'インストールされたプラグインがありません。',
'update_available' => 'アップデート利用可能',
'up_to_date' => '最新',
'headers' => [
@@ -194,38 +196,36 @@ return [
'source' => 'ソース',
'status' => 'ステータス',
],
- 'summary' => '合計 :total個を確認しました(アップデート可能: :updates個)',
- 'single_up_to_date' => 'プラグイン":plugin"は最新バージョンです。(v:version)',
- 'single_update_available' => 'プラグイン":plugin"がアップデート可能です: v:current → v:latest(:source)',
+ 'summary' => '合計 :total個を確認しました (アップデート可能: :updates個)',
+ 'single_up_to_date' => 'プラグイン ":plugin" は最新バージョンです。(v:version)',
+ 'single_update_available' => 'プラグイン ":plugin" のアップデートが利用可能です: v:current → v:latest (:source)',
],
'update' => [
- 'success' => 'プラグイン":plugin"のアップデートが完了しました',
+ 'success' => 'プラグイン ":plugin" のアップデートが完了しました',
'version_change' => 'v:from → v:to',
- 'no_update' => 'プラグイン":plugin"に利用可能なアップデートがありません。',
- 'not_installed' => 'プラグイン":plugin"はインストールされていません。',
+ 'no_update' => 'プラグイン ":plugin" に利用可能なアップデートはありません。',
+ 'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'current_version' => '現在のバージョン: :version',
'latest_version' => '最新バージョン: :version',
'update_source' => 'アップデートソース: :source',
- 'confirm_question' => 'アップデートを実行しますか?',
+ 'confirm_question' => 'アップデートを実行しますか?',
'aborted' => 'アップデートがキャンセルされました。',
'backup_restored' => 'バックアップから前のバージョンが復元されました。',
'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。',
'layout_strategy' => 'レイアウト戦略: :strategy',
'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count件が上書きされます。',
- 'modified_extension_item' => ' - :target (出典: :source)',
+ 'modified_extension_item' => ' - :target (ソース: :source)',
],
],
'composer_install' => [
- 'start' => 'プラグイン":plugin"のComposer依存関係をインストール中です...',
- 'success' => 'プラグイン":plugin"のComposer依存関係のインストールが完了しました',
- 'no_dependencies' => 'プラグイン":plugin"にインストールするComposer依存関係がありません。',
- 'failed' => 'プラグイン":plugin"のComposer依存関係のインストールに失敗しました。',
+ 'start' => 'プラグイン ":plugin" の Composer 依存関係をインストールしています...',
+ 'success' => 'プラグイン ":plugin" の Composer 依存関係のインストールが完了しました',
+ 'no_dependencies' => 'プラグイン ":plugin" にインストールする Composer 依存関係はありません。',
+ 'failed' => 'プラグイン ":plugin" の Composer 依存関係のインストールに失敗しました。',
'summary' => '📊 結果: 成功 :success個、スキップ :skip個、失敗 :fail個',
],
'settings' => [
- 'update_failed' => 'プラグイン設定のアップデートに失敗しました。',
+ 'update_failed' => 'プラグイン設定のアップデートに失敗しました: :error',
'updated' => 'プラグイン設定が正常にアップデートされました。',
],
- 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
- 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/role.php b/lang-packs/_bundled/g7-core-ja/backend/ja/role.php
index 209c3825..ced18fc1 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/role.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/role.php
@@ -14,7 +14,7 @@ return [
'name_required' => 'ロール名は必須です。',
'identifier_required' => '識別子は必須です。',
'identifier_format' => '識別子は小文字で始まり、小文字、数字、アンダースコア(_)のみ使用できます。',
- 'identifier_unique' => '既に使用中の識別子です。',
+ 'identifier_unique' => 'すでに使用中の識別子です。',
'identifier_max' => '識別子は最大100文字まで入力できます。',
'permission_ids_array' => '権限リストは配列形式である必要があります。',
'permission_ids_exists' => '選択した権限の中に無効な権限があります。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php b/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php
index b6b28d0c..baf20f26 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php
@@ -1,36 +1,39 @@
'スケジュール一覧を取得しました。',
- 'fetch_failed' => 'スケジュール一覧の取得に失敗しました。',
+ 'fetch_success' => 'スケジュールリストを閲覧しました。',
+ 'fetch_failed' => 'スケジュールリスト閲覧に失敗しました。',
'create_success' => 'スケジュールが作成されました。',
- 'create_failed' => 'スケジュールの作成に失敗しました。',
+ 'create_failed' => 'スケジュール作成に失敗しました。',
'update_success' => 'スケジュールが編集されました。',
- 'update_failed' => 'スケジュールの編集に失敗しました。',
+ 'update_failed' => 'スケジュール編集に失敗しました。',
'delete_success' => 'スケジュールが削除されました。',
- 'delete_failed' => 'スケジュールの削除に失敗しました。',
+ 'delete_failed' => 'スケジュール削除に失敗しました。',
'run_success' => 'スケジュールが実行されました。',
- 'run_failed' => 'スケジュールの実行に失敗しました。',
+ 'run_failed' => 'スケジュール実行に失敗しました。',
'duplicate_success' => 'スケジュールが複製されました。',
- 'duplicate_failed' => 'スケジュールの複製に失敗しました。',
+ 'duplicate_failed' => 'スケジュール複製に失敗しました。',
'bulk_status_updated' => '選択されたスケジュールのステータスが変更されました。',
- 'bulk_update_status_failed' => 'スケジュール一括ステータス変更に失敗しました。',
+ 'bulk_update_status_failed' => 'スケジュールステータス一括変更に失敗しました。',
'bulk_delete_success' => '選択されたスケジュールが削除されました。',
'bulk_delete_failed' => 'スケジュール一括削除に失敗しました。',
- 'statistics_success' => 'スケジュール統計を取得しました。',
- 'statistics_failed' => 'スケジュール統計の取得に失敗しました。',
- 'history_fetch_success' => '実行履歴を取得しました。',
- 'history_fetch_failed' => '実行履歴の取得に失敗しました。',
+ 'statistics_success' => 'スケジュール統計を閲覧しました。',
+ 'statistics_failed' => 'スケジュール統計閲覧に失敗しました。',
+ 'history_fetch_success' => '実行履歴を閲覧しました。',
+ 'history_fetch_failed' => '実行履歴閲覧に失敗しました。',
'history_delete_success' => '実行履歴が削除されました。',
- 'history_delete_failed' => '実行履歴の削除に失敗しました。',
+ 'history_delete_failed' => '実行履歴削除に失敗しました。',
'history_not_found' => '実行履歴が見つかりません。',
'copy' => 'コピー',
- 'shell_command_failed' => 'シェルコマンドの実行に失敗しました。',
- 'http_request_failed' => 'HTTPリクエスト失敗(ステータス::status)',
+ 'shell_command_failed' => 'シェルコマンド実行に失敗しました。',
+ 'http_request_failed' => 'HTTP リクエスト失敗(ステータス::status)',
+ 'url_not_public' => '内部ネットワークアドレス(プライベート IP·localhost など)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
+ 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュール実行できます。',
+ 'artisan_not_allowed' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。',
'type' => [
- 'artisan' => 'Artisanコマンド',
+ 'artisan' => 'Artisan コマンド',
'shell' => 'シェルコマンド',
- 'url' => 'URL呼び出し',
+ 'url' => 'URL 呼び出し',
],
'frequency' => [
'everyMinute' => '毎分',
@@ -47,7 +50,7 @@ return [
'never' => '未実行',
],
'trigger_type' => [
- 'scheduled' => 'スケジュール実行',
+ 'scheduled' => '予約実行',
'manual' => '手動実行',
],
'duration' => [
@@ -56,51 +59,48 @@ return [
],
'validation' => [
'page_integer' => 'ページ番号は整数である必要があります。',
- 'page_min' => 'ページ番号は1以上である必要があります。',
+ 'page_min' => 'ページ番号は 1 以上である必要があります。',
'per_page_integer' => 'ページあたりの項目数は整数である必要があります。',
- 'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
- 'per_page_max' => 'ページあたりの項目数は100以下である必要があります。',
+ 'per_page_min' => 'ページあたりの項目数は 1 以上である必要があります。',
+ 'per_page_max' => 'ページあたりの項目数は 100 以下である必要があります。',
'type_invalid' => '無効なタスクタイプです。',
'frequency_invalid' => '無効な実行周期です。',
'status_invalid' => '無効なステータスです。',
'last_result_invalid' => '無効な実行結果です。',
'history_status_invalid' => '無効な履歴ステータスです。',
'trigger_type_invalid' => '無効なトリガータイプです。',
- 'created_from_invalid' => '開始日のフォーマットが正しくありません。',
- 'created_to_invalid' => '終了日のフォーマットが正しくありません。',
+ 'created_from_invalid' => '開始日の形式が正しくありません。',
+ 'created_to_invalid' => '終了日の形式が正しくありません。',
'created_to_after_from' => '終了日は開始日以降である必要があります。',
- 'started_from_invalid' => '開始日のフォーマットが正しくありません。',
- 'started_to_invalid' => '終了日のフォーマットが正しくありません。',
+ 'started_from_invalid' => '開始日の形式が正しくありません。',
+ 'started_to_invalid' => '終了日の形式が正しくありません。',
'started_to_after_from' => '終了日は開始日以降である必要があります。',
'sort_by_invalid' => '無効なソート基準です。',
'sort_order_invalid' => '無効なソート順序です。',
'name_required' => 'タスク名は必須です。',
- 'name_max' => 'タスク名は255文字以下である必要があります。',
+ 'name_max' => 'タスク名は 255 文字以下である必要があります。',
'type_required' => 'タスクタイプは必須です。',
'command_required' => 'コマンドは必須です。',
- 'command_max' => 'コマンドは2000文字以下である必要があります。',
- 'expression_required' => 'Cron式は必須です。',
- 'expression_max' => 'Cron式は100文字以下である必要があります。',
+ 'command_max' => 'コマンドは 2000 文字以下である必要があります。',
+ 'expression_required' => 'Cron 式は必須です。',
+ 'expression_max' => 'Cron 式は 100 文字以下である必要があります。',
'frequency_required' => '実行周期は必須です。',
- 'timeout_integer' => 'タイムアウト制限は整数である必要があります。',
- 'timeout_min' => 'タイムアウト制限は1秒以上である必要があります。',
- 'timeout_max' => 'タイムアウト制限は86400秒(24時間)以下である必要があります。',
- 'ids_required' => 'IDリストは必須です。',
- 'ids_array' => 'IDリストは配列である必要があります。',
- 'ids_min' => '最低1つ以上のIDが必要です。',
- 'id_integer' => 'IDは整数である必要があります。',
- 'id_exists' => '存在しないスケジュールです。',
- 'is_active_required' => '有効化は必須です。',
- 'is_active_boolean' => '有効化はtrue/falseである必要があります。',
+ 'timeout_integer' => 'タイムアウトは整数である必要があります。',
+ 'timeout_min' => 'タイムアウトは 1 秒以上である必要があります。',
+ 'timeout_max' => 'タイムアウトは 86400 秒(24 時間)以下である必要があります。',
+ 'ids_required' => 'ID リストは必須です。',
+ 'ids_array' => 'ID リストは配列である必要があります。',
+ 'ids_min' => '最小 1 個以上の ID が必要です。',
+ 'id_integer' => 'ID は整数である必要があります。',
+ 'id_exists' => 'スケジュールが存在しません。',
+ 'is_active_required' => '有効化の有無は必須です。',
+ 'is_active_boolean' => '有効化の有無は真/偽である必要があります。',
],
'permissions' => [
- 'read' => 'スケジュール調査',
+ 'read' => 'スケジュール閲覧',
'create' => 'スケジュール作成',
'update' => 'スケジュール編集',
'delete' => 'スケジュール削除',
'run' => 'スケジュール実行',
],
- 'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
- 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。',
- 'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/search.php b/lang-packs/_bundled/g7-core-ja/backend/ja/search.php
index eb2109b8..8ee409a6 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/search.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/search.php
@@ -6,7 +6,7 @@ return [
'no_results' => '検索結果がありません。',
'view_more' => 'もっと見る',
'validation' => [
- 'q_min' => '検索語は2文字以上入力してください。',
+ 'q_min' => '検索語は2文字以上で入力してください。',
'q_max' => '検索語は最大200文字まで入力可能です。',
'page_integer' => 'ページ番号は数字である必要があります。',
'page_min' => 'ページ番号は1以上である必要があります。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php b/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php
index 78687cfa..ec1d4c3c 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php
@@ -1,21 +1,24 @@
'SEOキャッシュがすべて削除されました。',
- 'cache_cleared_layout' => ':layout レイアウトのSEOキャッシュ :count件が削除されました。',
- 'warmup_started' => 'SEOキャッシュウォーミングを開始します。',
- 'warmup_started_layout' => ':layout レイアウトのSEOキャッシュウォーミングを開始します。',
- 'warmup_dispatched' => 'SEOキャッシュウォーミングが開始されました。',
- 'sitemap_regenerated' => 'サイトマップの再生成が完了しました。',
- 'sitemap_regenerate_failed' => 'サイトマップの再生成に失敗しました。',
- 'sitemap_disabled' => 'サイトマップ生成が無効化されています。',
- 'stats_period' => '最近 :days 日間のSEOキャッシュ統計',
+ 'cache_cleared_all' => 'SEO キャッシュがすべて削除されました。',
+ 'cache_cleared_layout' => ':layout レイアウトの SEO キャッシュ :count 件が削除されました。',
+ 'warmup_started' => 'SEO キャッシュ ウォーミングアップを開始します。',
+ 'warmup_started_layout' => ':layout レイアウトの SEO キャッシュ ウォーミングアップを開始します。',
+ 'warmup_dispatched' => 'SEO キャッシュ ウォーミングアップが開始されました。',
+ 'sitemap_regenerated' => 'Sitemap 再生成が完了しました。',
+ 'sitemap_regenerate_dispatched' => 'Sitemap 再生成を開始しました。完了まで時間がかかる場合があります。',
+ 'sitemap_regenerate_failed' => 'Sitemap 再生成に失敗しました。',
+ 'sitemap_disabled' => 'Sitemap 生成が無効化されています。',
+ 'sitemap_not_ready' => 'Sitemap を準備中です。しばらく後に再度お試しください。',
+ 'sitemap_generate_failed' => 'Sitemap 生成に失敗しました: :error',
+ 'stats_period' => '最近 :days 日間の SEO キャッシュ統計',
'stats_overall' => '全体統計',
'stats_metric' => '項目',
'stats_value' => '値',
'stats_total_entries' => '総キャッシュ項目',
- 'stats_hits' => 'キャッシュヒット',
- 'stats_misses' => 'キャッシュミス',
+ 'stats_hits' => 'キャッシュ ヒット',
+ 'stats_misses' => 'キャッシュ ミス',
'stats_hit_rate' => 'ヒット率',
'stats_avg_response_time' => '平均応答時間',
'stats_by_layout' => 'レイアウト別統計',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
index e2c41f60..120b375b 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
@@ -5,12 +5,12 @@ return [
'fetch_failed' => '設定のインポートに失敗しました。',
'save_success' => '設定が正常に保存されました。',
'save_failed' => '設定の保存に失敗しました。',
- 'update_success' => '設定が正常に更新されました。',
- 'update_failed' => '設定の更新に失敗しました。',
+ 'update_success' => '設定が正常にアップデートされました。',
+ 'update_failed' => '設定のアップデートに失敗しました。',
'delete_success' => '設定が正常に削除されました。',
'delete_failed' => '設定の削除に失敗しました。',
'save_error' => '設定の保存中にエラーが発生しました。',
- 'update_error' => '設定の更新中にエラーが発生しました。',
+ 'update_error' => '設定のアップデート中にエラーが発生しました。',
'cache_clear_success' => 'キャッシュが正常にクリアされました。',
'cache_clear_failed' => 'キャッシュのクリアに失敗しました。',
'cache_clear_error' => 'キャッシュのクリア中にエラーが発生しました。',
@@ -20,7 +20,11 @@ return [
'backup_success' => 'データベースバックアップが正常に開始されました。',
'backup_failed' => 'データベースバックアップに失敗しました。',
'backup_error' => 'データベースバックアップ中にエラーが発生しました。',
- 'save_individual_failed' => '設定の保存に失敗しました::error',
+ 'backup_path_required' => 'バックアップパスを入力してください。',
+ 'restore_success' => '設定が正常に復元されました。',
+ 'restore_failed' => '設定の復元に失敗しました。',
+ 'restore_error' => '設定の復元中にエラーが発生しました。',
+ 'save_individual_failed' => '設定の保存に失敗しました: :error',
'invalid_password' => 'パスワードが一致しません。',
'password_required' => 'パスワードを入力してください。',
'app_key_regenerated' => 'アプリケーションキーが正常に再生成されました。',
@@ -59,10 +63,10 @@ return [
'ses' => 'SES (Amazon)',
],
],
- 'driver_test_success' => 'すべてのドライバー接続テストが成功しました。',
- 'driver_test_partial' => '一部のドライバー接続テストが失敗しました。',
- 'driver_test_error' => 'ドライバー接続テスト中にエラーが発生しました。',
- 'unknown_driver' => '不明なドライバーです。',
+ 'driver_test_success' => 'すべてのドライバ接続テストが成功しました。',
+ 'driver_test_partial' => '一部のドライバ接続テストが失敗しました。',
+ 'driver_test_error' => 'ドライバ接続テスト中にエラーが発生しました。',
+ 'unknown_driver' => '不明なドライバです。',
's3_test_success' => 'S3バケットに正常に接続されました。',
's3_test_failed' => 'S3バケットへの接続に失敗しました。',
's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)',
@@ -82,10 +86,11 @@ return [
'memcached_connection_failed' => 'Memcachedサーバーに接続できません。',
'websocket_test_success' => 'Websocketサーバーに正常に接続されました。',
'websocket_test_failed' => 'Websocketサーバーへの接続に失敗しました。',
- 'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行されているか確認してください。',
+ 'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)またはhttp/https以外のプロトコルは使用できません。',
+ 'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行中であるか確認してください。',
'invalid_email' => '無効なメールアドレスです。',
'test_mail_subject' => ':app_name テストメール',
- 'test_mail_body' => 'これはグヌボード7から送信されたテストメールです。このメールを受け取った場合、メール設定が正しく構成されています。',
+ 'test_mail_body' => 'これはGnuboard 7から送信されたテストメールです。このメールを受け取られた場合、メール設定が正しく構成されています。',
'test_mail_sent' => 'テストメールが正常に送信されました。',
'test_mail_failed' => 'テストメールの送信に失敗しました。',
'test_mail_error' => 'テストメール送信中にエラーが発生しました。',
@@ -93,65 +98,60 @@ return [
'check_success' => 'アップデート確認が完了しました。',
'check_failed' => 'アップデート確認に失敗しました。',
'update_available' => '新しいアップデートがあります。',
- 'no_update' => '現在は最新バージョンです。',
- 'maintenance_mode_active' => 'システム保守中です。しばらく後に再度お試しください。',
+ 'no_update' => '現在、最新バージョンです。',
+ 'maintenance_mode_active' => 'システムメンテナンス中です。しばらく後に再度お試しください。',
'invalid_github_url' => 'GitHubリポジトリURLが無効です。',
'download_failed' => ':version バージョンのダウンロードに失敗しました。',
- 'zip_extract_failed' => 'ZIPファイルの解凍に失敗しました。',
+ 'zip_extract_failed' => 'ZIPファイルの展開に失敗しました。',
'invalid_package' => 'ダウンロードされたパッケージが無効です。',
- 'invalid_package_not_g7' => '指定されたディレクトリはグヌボード7プロジェクトではありません。config/app.phpファイルとversionの設定が必要です。',
- 'composer_failed' => 'composer installの実行に失敗しました。',
- 'pending_path_create_failed' => '_pendingディレクトリ(:path)の作成に失敗しました::error',
- 'pending_path_not_writable' => '_pendingディレクトリ(:path)への書き込み権限がありません。',
+ 'invalid_package_not_g7' => '指定されたディレクトリはGnuboard 7プロジェクトではありません。config/app.phpファイルとversion設定が必要です。',
+ 'composer_failed' => 'composer install の実行に失敗しました。',
+ 'pending_path_create_failed' => '_pending ディレクトリ(:path) の作成に失敗しました: :error',
+ 'pending_path_not_writable' => '_pending ディレクトリ(:path) への書き込み権限がありません。',
'downloading' => 'アップデートをダウンロード中...',
'extracting' => '解凍中...',
'validating' => 'パッケージを検証中...',
- 'running_composer' => 'composer installを実行中...',
+ 'running_composer' => 'composer install を実行中...',
'step_check' => 'アップデートを確認中...',
'step_validate_pending' => '環境を検証中...',
'step_maintenance' => 'メンテナンスモードを有効化中...',
'step_download' => 'ダウンロード中...',
'step_backup' => 'バックアップを作成中...',
'step_apply' => 'ファイルを適用中...',
- 'step_composer' => 'composer installを実行中...',
- 'step_migration' => 'マイグレーションを実行中...',
+ 'step_composer' => 'composer install 中...',
+ 'step_migration' => 'マイグレーション を実行中...',
'step_upgrade' => 'アップグレードステップを実行中...',
- 'step_composer_prod' => '運用ディレクトリのcomposer installを実行中...',
+ 'step_composer_prod' => '運用ディレクトリの composer install 中...',
'step_cleanup' => 'クリーンアップ中...',
- 'github_url_not_configured' => 'GitHubリポジトリのURLが設定されていません。',
- 'github_api_failed' => 'GitHub APIに接続できません。',
- 'github_token_required' => 'プライベートリポジトリです。GitHubアクセストークンを設定してください。',
- 'github_token_invalid' => 'GitHubアクセストークンが無効であるか、権限が不足しています。(HTTP :status — :message)',
- 'github_repo_not_found' => 'GitHubリポジトリが見つかりません。(HTTP :status — :message)',
- 'github_repo_not_found_no_token' => 'GitHubリポジトリが見つかりません。プライベートリポジトリの場合はアクセストークンを設定してください。(HTTP :status — :message)',
- 'github_api_error' => 'GitHub APIエラーが発生しました。(HTTP :status — :message)',
- 'no_releases_found' => 'GitHubリポジトリにリリースがありません。(HTTP :status — :message)',
- 'log_api_call_failed' => 'コアアップデート:GitHub APIの呼び出しに失敗しました',
- 'log_auth_failed' => 'コアアップデート:GitHub認証に失敗しました',
- 'log_not_found' => 'コアアップデート:GitHubリポジトリ/リリースが見つかりません',
- 'log_unexpected_status' => 'コアアップデート:予期しないHTTPステータスコード',
- 'log_version_check_error' => 'コアアップデート:最新バージョン確認中にエラーが発生しました',
- 'unknown_error' => '予期しないエラーが発生しました。',
+ 'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみを適用しました (新規 :added 個、変更 :changed 個)。その他のファイルは現在の状態のまま保持されました。',
+ 'apply_mode_incremental_prune_hint' => '新バージョンで削除されたファイルをあわせてクリーンアップするには、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。',
+ 'apply_mode_prune' => 'ファイル適用: 全体上書き + 削除ファイルのクリーンアップを実行しました (--prune)。',
+ 'apply_mode_fallback' => 'ファイル適用: バックアップがないため、増分適用をスキップして全体上書きを実行しました。ユーザーのカスタムファイル保持をご希望の場合は、次回以降はバックアップを保持したままアップデートしてください。',
+ 'github_url_not_configured' => 'GitHub リポジトリ URL が設定されていません。',
+ 'github_api_failed' => 'GitHub API に接続できません。',
+ 'github_token_required' => 'プライベートリポジトリです。GitHub アクセストークンを設定してください。',
+ 'github_token_invalid' => 'GitHub アクセストークンが無効であるか、権限が不足しています。(HTTP :status — :message)',
+ 'github_repo_not_found' => 'GitHub リポジトリが見つかりません。(HTTP :status — :message)',
+ 'github_repo_not_found_no_token' => 'GitHub リポジトリが見つかりません。プライベートリポジトリの場合はアクセストークンを設定してください。(HTTP :status — :message)',
+ 'github_api_error' => 'GitHub API エラーが発生しました。(HTTP :status — :message)',
+ 'no_releases_found' => 'GitHub リポジトリにリリースがありません。(HTTP :status — :message)',
+ 'log_api_call_failed' => 'コアアップデート: GitHub API 呼び出し失敗',
+ 'log_auth_failed' => 'コアアップデート: GitHub 認証失敗',
+ 'log_not_found' => 'コアアップデート: GitHub リポジトリ/リリース未検出',
+ 'log_unexpected_status' => 'コアアップデート: 予期しない HTTP ステータスコード',
+ 'log_version_check_error' => 'コアアップデート: 最新バージョン確認時にエラーが発生',
+ 'unknown_error' => '不明なエラーが発生しました。',
'system_requirements_failed' => 'システム要件が満たされていません:',
- 'no_extract_method_available' => '利用可能なアーカイブ抽出方法がありません。PHP zip拡張機能(ZipArchive)またはunzipコマンド(Linux)のいずれかが必要です。または--sourceオプションで手動アップデートできます。',
- 'manual_update_guide' => '手動アップデート:GitHubからリリースZIPをダウンロードして解凍し、php artisan core:update --source=/解凍パス コマンドを実行してください。',
- 'archive_url_not_found' => ':typeアーカイブURLが見つかりません。次の方法を試みます。',
- 'extracting_with' => ':method方式で解凍中...',
+ 'no_extract_method_available' => '利用可能なアーカイブ抽出方法がありません。PHP zip 拡張 (ZipArchive) または unzip コマンド (Linux) のいずれかが必要です。または --source オプションで手動アップデートできます。',
+ 'manual_update_guide' => '手動アップデート: GitHub からリリース ZIP をダウンロードして解凍後、php artisan core:update --source=/解凍/パス コマンドを実行してください。',
+ 'archive_url_not_found' => ':type アーカイブ URL が見つかりません。次の方法を試します。',
+ 'extracting_with' => ':method 方式で解凍中...',
'extract_empty' => '解凍されたディレクトリが空です。',
- 'extract_fallback' => ':method方式に失敗しました::error — 次の方法を試みます。',
- 'all_extract_methods_failed' => 'すべてのアーカイブ抽出方法が失敗しました。--sourceオプションで手動アップデートを試みてください。',
- 'unzip_command_failed' => 'unzipコマンド実行に失敗しました(終了コード::code)。:output',
- 'zip_file_not_found' => '指定されたZIPファイルが見つかりません::path',
- 'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
- 'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
- 'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみ適用しました (新規 :added個、変更 :changed個)。その他のファイルは現在の状態のまま保存されました。',
- 'apply_mode_incremental_prune_hint' => '新しいバージョンで削除されたファイルを一緒に整理する場合は、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。',
- 'apply_mode_prune' => 'ファイル適用: 完全な上書き + 削除されたファイルの整理を実行しました (--prune)。',
- 'apply_mode_fallback' => 'ファイル適用: バックアップがないため増分適用をスキップし、完全な上書きを実行しました。ユーザーカスタムファイルの保存を希望する場合は、次回からはバックアップを維持したまま更新してください。',
+ 'extract_fallback' => ':method 方式失敗: :error — 次の方法を試します。',
+ 'all_extract_methods_failed' => 'すべてのアーカイブ抽出方法が失敗しました。--source オプションで手動アップデートを試してください。',
+ 'unzip_command_failed' => 'unzip コマンド実行失敗 (終了コード: :code)。:output',
+ 'zip_file_not_found' => '指定された ZIP ファイルが見つかりません: :path',
+ 'source_vendor_missing' => 'ソースディレクトリに vendor がありません。composer install が実行されていない可能性があります。',
+ 'composer_failed_with_output' => 'composer install 実行に失敗しました。:output',
],
- 'backup_path_required' => 'バックアップパスを入力してください。',
- 'restore_success' => '設定が正常に復元されました。',
- 'restore_failed' => '設定の復元に失敗しました。',
- 'restore_error' => '設定の復元中にエラーが発生しました。',
- 'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)やhttp/https以外のプロトコルは使用できません。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php b/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php
index 221d699e..9219c24e 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php
@@ -4,54 +4,54 @@ return [
'title' => 'テンプレート管理',
'errors' => [
'force_update_no_source' => ':template を強制アップデートするソースが見つかりません。バンドルおよび GitHub URL の両方がありません。',
- 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
+ 'downgrade_blocked' => 'ダウングレードがブロックされています (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
'not_found' => 'テンプレートが見つかりません。(:template)',
'not_installed' => 'テンプレートがインストールされていません。(:template)',
'already_active' => '既に有効化されているテンプレートです。',
'invalid_format' => '無効なテンプレート形式です。',
- 'activation_failed' => 'テンプレート有効化に失敗しました。',
- 'file_copy_failed' => 'ファイルコピーに失敗しました。',
- 'dist_directory_not_found' => 'テンプレートビルド結果物(dist)が見つかりません。',
+ 'activation_failed' => 'テンプレートの有効化に失敗しました: :error',
+ 'file_copy_failed' => 'ファイルのコピーに失敗しました: :error',
+ 'dist_directory_not_found' => 'テンプレートビルド成果物(dist)が見つかりません。',
'invalid_directory_name' => '無効なディレクトリ名です。vendor-name 形式を使用してください。',
- 'dependency_not_met' => '必須の依存関係 :dependency (:type)が満たされていません。',
+ 'dependency_not_met' => '必須依存関係 :dependency (:type) が満たされていません。',
'version_mismatch' => ':dependency のバージョンが一致しません。(必須: :required、インストール済み: :installed)',
- 'deactivation_failed' => 'テンプレート無効化に失敗しました。',
- 'installation_failed' => 'テンプレートインストールに失敗しました。',
- 'uninstallation_failed' => 'テンプレート削除に失敗しました。',
+ 'deactivation_failed' => 'テンプレートの無効化に失敗しました: :error',
+ 'installation_failed' => 'テンプレートのインストールに失敗しました: :error',
+ 'uninstallation_failed' => 'テンプレートの削除に失敗しました: :error',
'template_json_not_found' => 'template.json ファイルが見つかりません。',
'template_json_invalid' => 'template.json ファイルが無効です。',
'missing_required_fields' => '必須フィールドが不足しています。',
'invalid_type' => '無効なテンプレートタイプです。(admin または user のみ許可)',
- 'copy_rollback_failed' => 'ファイルコピーロールバックに失敗しました。',
+ 'copy_rollback_failed' => 'ファイルコピーのロールバックに失敗しました: :error',
'invalid_layout_json' => 'レイアウト JSON ファイルが無効です。',
'missing_layout_name' => 'レイアウト JSON に layout_name フィールドが不足しています。',
- 'layout_registration_failed' => 'レイアウト登録に失敗しました。',
- 'layout_validation_failed' => ':template テンプレートのレイアウト検証に失敗しました。(:count 個のエラー)',
+ 'layout_registration_failed' => 'レイアウト登録に失敗しました: :error',
+ 'layout_validation_failed' => ':template テンプレートのレイアウト検証に失敗しました。(:count 件のエラー)',
'layout_validation_json_error' => ':file - JSON パース エラー: :error',
'layout_validation_missing_name' => ':file - layout_name フィールドが不足しています。',
'layout_validation_partial_error' => ':file - Partial 処理エラー (:partial): :error',
- 'override_layout_registration_failed' => 'オーバーライドレイアウト登録に失敗しました。',
+ 'override_layout_registration_failed' => 'オーバーライドレイアウト登録に失敗しました: :error',
'file_not_found' => 'ファイルが見つかりません。',
'file_type_not_allowed' => '許可されていないファイルタイプです。',
'components_not_found' => 'コンポーネント定義ファイルが見つかりません。',
'locale_not_supported' => ':template テンプレートは :locale 言語をサポートしていません。',
'language_file_not_found' => ':locale 言語ファイルが見つかりません。',
'invalid_json' => '無効な JSON 形式です。',
- 'invalid_cache_data' => 'キャッシュデータが破損しました。もう一度お試しください。',
+ 'invalid_cache_data' => 'キャッシュデータが破損しています。もう一度お試しください。',
'unknown_error' => '不明なエラーが発生しました。',
- 'zip_open_failed' => 'ZIP ファイルを開くことができません。',
+ 'zip_open_failed' => 'ZIP ファイルを開くことができません: :error',
'identifier_missing' => 'テンプレート識別子が不足しています。',
- 'already_installed' => '既にインストールされているテンプレートです。',
- 'install_failed' => 'テンプレートインストールに失敗しました。',
+ 'already_installed' => '既にインストール済みのテンプレートです。',
+ 'install_failed' => 'テンプレートのインストールに失敗しました: :error',
'github_url_invalid' => '無効な GitHub URL 形式です。',
- 'github_download_failed' => 'GitHub からのダウンロードに失敗しました。',
+ 'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。',
- 'refresh_layouts_failed' => 'レイアウト更新に失敗しました。',
+ 'refresh_layouts_failed' => 'レイアウト更新に失敗しました: :error',
'routes_not_found' => 'ルートファイルが見つかりません。',
- 'no_active_template' => '有効化されているテンプレートがありません',
- 'layout_load_failed' => 'レイアウトの読み込みに失敗しました',
- 'component_load_failed' => 'コンポーネントの読み込みに失敗しました',
- 'translation_load_failed' => '多言語ファイルの読み込みに失敗しました',
+ 'no_active_template' => '有効化されたテンプレートがありません',
+ 'layout_load_failed' => 'レイアウトの読み込みに失敗しました: :error',
+ 'component_load_failed' => 'コンポーネントの読み込みに失敗しました: :error',
+ 'translation_load_failed' => '多言語ファイルの読み込みに失敗しました: :error',
'template_init_error' => 'テンプレートエンジンの初期化中にエラーが発生しました',
'contact_admin_for_template' => 'システム管理者に問い合わせてテンプレートを有効化してください。',
'missing_error_config' => 'テンプレートに error_config セクションがありません。エラーページレイアウト設定が必要です。',
@@ -61,17 +61,17 @@ return [
'template_not_active' => 'テンプレートが有効化されていません: :identifier',
'dependencies_not_met' => 'テンプレートの必須依存関係が満たされていません。',
'service_unavailable' => 'サービスは一時的に利用できません。',
- 'contact_admin' => '管理者にお問い合わせください。',
- 'operation_in_progress' => 'テンプレート「:name」は現在 :status 状態です。処理が完了するまでお待ちください。',
- 'github_api_failed' => 'GitHub API 呼び出しに失敗しました。',
+ 'contact_admin' => '管理者に問い合わせてください。',
+ 'operation_in_progress' => 'テンプレート ":name" は現在 :status 状態です。処理が完了するまでお待ちください。',
+ 'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error',
'invalid_github_url' => '無効な GitHub URL です。',
'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。',
- 'download_failed' => 'テンプレート「:template」:version バージョンのダウンロードに失敗しました。',
- 'zip_extract_failed' => 'ZIP 解凍に失敗しました。',
- 'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。',
- 'reload_failed' => 'テンプレート再読み込みに失敗しました。',
- 'delete_directory_failed' => 'テンプレートディレクトリの削除に失敗しました。',
- 'update_failed' => 'テンプレート「:template」のアップデートに失敗しました: :error',
+ 'download_failed' => 'テンプレート ":template" :version バージョンのダウンロードに失敗しました: :error',
+ 'zip_extract_failed' => 'ZIP 圧縮解除に失敗しました: :error',
+ 'extracted_dir_not_found' => '圧縮解除されたディレクトリが見つかりません。',
+ 'reload_failed' => 'テンプレート再読み込みに失敗しました: :error',
+ 'delete_directory_failed' => 'テンプレートディレクトリの削除に失敗しました: :error',
+ 'update_failed' => 'テンプレート ":template" のアップデートに失敗しました: :error',
'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)',
],
'messages' => [
@@ -79,43 +79,43 @@ return [
'deactivated' => 'テンプレートが無効化されました。',
'installed' => 'テンプレートがインストールされました。',
'uninstalled' => 'テンプレートが削除されました。',
- 'previous_template_deactivated' => '前のアクティブテンプレートが無効化されました。',
+ 'previous_template_deactivated' => '前の有効なテンプレートが無効化されました。',
'files_copied' => 'テンプレートファイルがコピーされました。',
'files_removed' => 'テンプレートファイルが削除されました。',
- 'routes_retrieved' => 'テンプレートルート情報を取得しました。',
+ 'routes_retrieved' => 'テンプレートルート情報が照会されました。',
'routes_not_found' => 'テンプレートルートが見つかりません。',
'layout_served' => 'レイアウトが提供されました。',
- 'config_retrieved' => 'テンプレート設定情報を照会しました。',
+ 'config_retrieved' => 'テンプレート設定情報を閲覧しました。',
'template_activated' => 'テンプレートが有効化されました。',
'template_deactivated' => 'テンプレートが無効化されました。',
'template_uninstalled' => 'テンプレートが削除されました。',
'editor_spec_empty' => 'エディタースペックが空です。',
- 'editor_assets_retrieved' => 'エディターアセット情報を取得しました。',
- 'editor_assets_missing' => 'エディターアセットマニフェストがありません(ビルドされていません)。',
- 'language_retrieved' => '多言語データを取得しました。',
+ 'editor_spec_retrieved' => 'エディタースペックを閲覧しました。',
+ 'editor_assets_retrieved' => 'エディター資産情報を閲覧しました。',
+ 'editor_assets_missing' => 'エディター資産マニフェストがありません(ビルドされていません)。',
+ 'language_retrieved' => '多言語データを閲覧しました。',
'language_empty' => '多言語データが空です。',
- 'editor_spec_retrieved' => 'エディタースペックを取得しました。',
],
- 'not_found' => 'テンプレート :template が見つかりません。',
- 'layout_not_found' => 'レイアウトが見つかりません。',
+ 'not_found' => 'テンプレート :template を見つけることができません。',
+ 'layout_not_found' => 'レイアウトを見つけることができません。',
'info' => [
- 'scanning' => 'テンプレートディレクトリをスキャン中です。',
- 'loading' => 'テンプレートを読み込み中です。',
- 'validating_dependencies' => '依存関係を検証中です。',
- 'copying_files' => 'ファイルをコピー中です。',
- 'removing_files' => 'ファイルを削除中です。',
- 'template_found' => 'テンプレートが見つかりました。',
- 'no_templates_found' => 'テンプレートが見つかりませんでした。',
- 'public_directory_cleaned' => 'publicディレクトリが整理されました。',
+ 'scanning' => 'テンプレートディレクトリをスキャン中...',
+ 'loading' => 'テンプレートをロード中...',
+ 'validating_dependencies' => '依存性を検証中...',
+ 'copying_files' => 'ファイルをコピー中...',
+ 'removing_files' => 'ファイルを削除中...',
+ 'template_found' => 'テンプレートを発見しました。',
+ 'no_templates_found' => 'テンプレートを発見できませんでした。',
+ 'public_directory_cleaned' => 'public ディレクトリがクリーンアップされました。',
'no_layouts_directory' => 'レイアウトディレクトリが存在しません。',
- 'no_layout_files' => 'レイアウトJSONファイルが存在しません。',
+ 'no_layout_files' => 'レイアウト JSON ファイルが存在しません。',
'layout_registered' => 'レイアウトが登録されました。',
'layouts_deleted' => 'レイアウトが削除されました。',
'cache_cleared' => 'テンプレートキャッシュが削除されました。',
'cache_warmed' => 'テンプレートキャッシュが作成されました。',
'no_overrides_directory' => 'オーバーライドディレクトリが存在しません。',
'no_override_modules' => 'オーバーライド対象のモジュールディレクトリが存在しません。',
- 'no_override_layouts_for_module' => 'このモジュール用のオーバーライドレイアウトがありません。',
+ 'no_override_layouts_for_module' => '該当モジュールにオーバーライドレイアウトがありません。',
'override_layout_registered' => 'オーバーライドレイアウトが登録されました。',
'override_layouts_registered' => 'オーバーライドレイアウトが登録されました。',
'override_layouts_cache_invalidated' => 'オーバーライドレイアウトキャッシュが無効化されました。',
@@ -129,17 +129,17 @@ return [
'updating' => 'アップデート中',
],
'types' => [
- 'admin' => '管理者用',
- 'user' => 'ユーザー用',
+ 'admin' => '管理者向け',
+ 'user' => 'ユーザー向け',
],
'dependencies' => [
- 'section_title' => '依存関係',
+ 'section_title' => '依存性',
'modules' => '必須モジュール',
'plugins' => '必須プラグイン',
- 'status_met' => '充足',
- 'status_unmet' => '未充足',
- 'no_dependencies' => '依存関係なし',
- 'version_required' => '必要バージョン: :version',
+ 'status_met' => '満たしている',
+ 'status_unmet' => '満たしていない',
+ 'no_dependencies' => '依存性なし',
+ 'version_required' => '必要なバージョン: :version',
'version_installed' => 'インストール済みバージョン: :version',
'not_installed' => '未インストール',
'not_active' => '無効',
@@ -150,111 +150,111 @@ return [
'name_max' => 'テンプレート名は255文字を超えることはできません。',
'file_required' => 'ファイルは必須です。',
'file_invalid' => '有効なファイルではありません。',
- 'file_must_be_zip' => 'ZIPファイルのみアップロード可能です。',
+ 'file_must_be_zip' => 'ZIP ファイルのみアップロード可能です。',
'file_max_size' => 'ファイルサイズは :sizeMB を超えることはできません。',
- 'github_url_required' => 'GitHub URLは必須です。',
- 'github_url_invalid' => 'GitHub URLが正しくありません。',
- 'github_url_format' => 'GitHub URLの形式が正しくありません。(例: https://github.com/owner/repo)',
- 'type_in' => 'テンプレートタイプはユーザーまたは管理者のみ許可されます。',
+ 'github_url_required' => 'GitHub URL は必須です。',
+ 'github_url_invalid' => 'GitHub URL が正しくありません。',
+ 'github_url_format' => 'GitHub URL の形式が正しくありません。(例:https://github.com/owner/repo)',
+ 'type_in' => 'テンプレートタイプは user または admin のみ許可されます。',
'search_max' => '検索キーワードは255文字を超えることはできません。',
'filters_max' => '検索フィルターは最大10個まで許可されます。',
'filter_field_required' => 'フィルターフィールドは必須です。',
- 'filter_field_invalid' => '許可されていないフィルターフィールドです。',
+ 'filter_field_invalid' => '許可されないフィルターフィールドです。',
'filter_value_required' => 'フィルター値は必須です。',
'filter_value_max' => 'フィルター値は255文字を超えることはできません。',
- 'filter_operator_invalid' => '許可されていないフィルター演算子です。(like、eq、starts_with、ends_withのみ許可)',
- 'status_invalid' => '許可されていないステータス値です。(installed、not_installed、active、inactiveのみ許可)',
- 'per_page_min' => 'ページあたりの項目数は最小1以上である必要があります。',
- 'per_page_max' => 'ページあたりの項目数は最大100個まで許可されます。',
+ 'filter_operator_invalid' => '許可されないフィルター演算子です。(like、eq、starts_with、ends_with のみ許可)',
+ 'status_invalid' => '許可されない状態値です。(installed、not_installed、active、inactive のみ許可)',
+ 'per_page_min' => 'ページ当たりのアイテム数は最小1つ以上である必要があります。',
+ 'per_page_max' => 'ページ当たりのアイテム数は最大100個まで許可されます。',
'page_min' => 'ページ番号は最小1以上である必要があります。',
],
'install_success' => 'テンプレートが正常にインストールされました。',
- 'install_failed' => 'テンプレートのインストールに失敗しました。',
- 'preview_success' => 'manifestプレビューが完了しました。',
- 'preview_failed' => 'manifestプレビューに失敗しました。',
+ 'install_failed' => 'テンプレートのインストールに失敗しました: :error',
+ 'preview_success' => 'manifest プレビューが完了しました。',
+ 'preview_failed' => 'manifest プレビューに失敗しました: :error',
'uninstall_success' => 'テンプレートが正常に削除されました。',
- 'uninstall_failed' => 'テンプレートの削除に失敗しました。',
+ 'uninstall_failed' => 'テンプレートの削除に失敗しました: :error',
'activate_success' => 'テンプレートが有効化されました。',
- 'activate_failed' => 'テンプレートの有効化に失敗しました。',
- 'activate_warning' => 'テンプレートの有効化に必要な依存関係が充足されていません。',
+ 'activate_failed' => 'テンプレートの有効化に失敗しました: :error',
+ 'activate_warning' => 'テンプレートの有効化に必要な依存関係が満たされていません。',
'warnings' => [
'missing_dependencies' => 'このテンプレートを有効化するには、以下の依存関係が必要です。',
],
'deactivate_success' => 'テンプレートが無効化されました。',
- 'deactivate_failed' => 'テンプレートの無効化に失敗しました。',
+ 'deactivate_failed' => 'テンプレートの無効化に失敗しました: :error',
'fetch_success' => 'テンプレート情報を正常に取得しました。',
'license_not_found' => 'テンプレートライセンスファイルが見つかりません。',
'refresh_layouts_success' => 'テンプレートレイアウトが正常に更新されました。',
- 'refresh_layouts_failed' => 'テンプレートレイアウトの更新に失敗しました。',
- 'update_success' => 'テンプレート「:template」が :version バージョンにアップデートされました。',
+ 'refresh_layouts_failed' => 'テンプレートレイアウトの更新に失敗しました: :error',
+ 'update_success' => 'テンプレート「:template」がバージョン:versionにアップデートされました。',
'update_failed' => 'テンプレートのアップデートに失敗しました: :error',
- 'update_hook_failed' => 'テンプレートアップデートフックの実行に失敗しました。',
+ 'update_hook_failed' => 'テンプレートアップデートフックの実行に失敗しました: :error',
'no_update_available' => 'アップデートする内容がありません。',
'check_updates_success' => 'アップデート確認が完了しました。',
- 'check_updates_failed' => 'アップデート確認に失敗しました。',
- 'not_installed' => 'テンプレート「:template」がインストールされていません。',
- 'check_modified_layouts_success' => '編集済みレイアウトの確認が完了しました。',
- 'check_modified_layouts_failed' => '編集済みレイアウトの確認に失敗しました。',
- 'pending_not_found' => 'インストール待機リストからテンプレート「:template」が見つかりません。',
+ 'check_updates_failed' => 'アップデート確認に失敗しました: :error',
+ 'not_installed' => 'テンプレート「:template」はインストールされていません。',
+ 'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。',
+ 'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error',
+ 'pending_not_found' => 'インストール待機リストにテンプレート「:template」が見つかりません。',
'already_exists' => 'テンプレート「:template」は既に存在します。',
- 'move_failed' => 'テンプレート「:template」の移動に失敗しました。',
+ 'move_failed' => 'テンプレート「:template」の移動に失敗しました: :error',
'commands' => [
'install' => [
'success' => 'テンプレート「:template」のインストール完了',
'already_installed' => 'テンプレート「:template」は既にインストールされています。',
- 'force_reinstall' => 'テンプレート「:template」を強制再インストールします(アクティブディレクトリを上書き)...',
- 'layouts_created' => 'レイアウト :count 個が作成されました',
+ 'force_reinstall' => 'テンプレート「:template」を強制再インストールしています(アクティブディレクトリを上書き)...',
+ 'layouts_created' => 'レイアウト:count個を作成しました',
'type' => 'タイプ: :type',
'version' => 'バージョン: :version',
'directory_not_found' => 'テンプレートディレクトリが見つかりません: :path',
- 'config_not_found' => 'template.json ファイルが見つかりません',
+ 'config_not_found' => 'template.jsonファイルが見つかりません',
],
'activate' => [
'success' => 'テンプレート「:template」の有効化完了',
- 'not_installed' => 'テンプレート「:template」がインストールされていません。',
- 'deactivated_previous' => '既存の :type テンプレート「:previous」を無効化',
+ 'not_installed' => 'テンプレート「:template」はインストールされていません。',
+ 'deactivated_previous' => '既存の:typeテンプレート「:previous」を無効化',
],
'deactivate' => [
'success' => 'テンプレート「:template」の無効化完了',
- 'not_active' => 'テンプレート「:template」が有効化されていません。',
- 'no_active_warning' => '現在有効化されている :type テンプレートがありません。',
+ 'not_active' => 'テンプレート「:template」は有効化されていません。',
+ 'no_active_warning' => '現在有効化されている:typeテンプレートがありません。',
],
'uninstall' => [
'success' => 'テンプレート「:template」の削除完了',
- 'confirm_prompt' => 'テンプレート「:template」を削除してもよろしいですか?',
+ 'confirm_prompt' => 'テンプレート「:template」を削除しますか?',
'confirm_details' => [
- 'layouts' => '- レイアウト :count 個が削除されます',
- 'versions' => '- user タイプテンプレートの場合はバージョン履歴も削除されます',
+ 'layouts' => '- レイアウト:count個が削除されます',
+ 'versions' => '- userタイプテンプレートの場合、バージョン履歴も削除されます',
],
- 'confirm_question' => '続けてもよろしいですか?',
- 'layouts_deleted' => 'レイアウト :count 個が削除されました',
- 'versions_deleted' => 'バージョン履歴 :count 個が削除されました',
+ 'confirm_question' => '続行しますか?',
+ 'layouts_deleted' => 'レイアウト:count個を削除しました',
+ 'versions_deleted' => 'バージョン履歴:count個を削除しました',
'aborted' => '削除がキャンセルされました。',
],
'list' => [
'no_templates' => '登録されたテンプレートがありません。',
- 'invalid_type' => '無効なタイプです。admin または user のみ使用可能です。',
- 'invalid_status' => '無効なステータスです。installed、uninstalled、active、inactive のみ使用可能です。',
+ 'invalid_type' => '無効なタイプです。adminまたはuserのみ使用できます。',
+ 'invalid_status' => '無効な状態です。installed、uninstalled、active、inactiveのみ使用できます。',
'headers' => [
'identifier' => '識別子',
'name' => '名前',
'type' => 'タイプ',
'version' => 'バージョン',
- 'status' => 'ステータス',
+ 'status' => '状態',
],
'status' => [
'uninstalled' => '未インストール',
],
- 'summary' => '合計 :total 個のテンプレート(インストール済み: :installed 個、有効: :active 個)',
+ 'summary' => '合計:total個のテンプレート(インストール済み: :installed個、有効: :active個)',
],
'cache_clear' => [
'clearing_single' => 'テンプレート「:template」のキャッシュを削除中...',
'clearing_all' => 'すべてのテンプレートキャッシュを削除中...',
- 'success_single' => 'テンプレート「:template」のキャッシュ :count 個の削除完了',
- 'success_all' => '全テンプレートキャッシュ :count 個の削除完了',
+ 'success_single' => 'テンプレート「:template」のキャッシュ:count個を削除しました',
+ 'success_all' => 'テンプレート全体のキャッシュ:count個を削除しました',
],
'check_updates' => [
- 'not_installed' => 'テンプレート「:template」がインストールされていません。',
+ 'not_installed' => 'テンプレート「:template」はインストールされていません。',
'no_installed' => 'インストールされたテンプレートがありません。',
'update_available' => 'アップデート可能',
'up_to_date' => '最新',
@@ -263,27 +263,27 @@ return [
'current_version' => '現在のバージョン',
'latest_version' => '最新バージョン',
'source' => 'ソース',
- 'status' => 'ステータス',
+ 'status' => '状態',
],
- 'summary' => '合計 :total 個を確認(アップデート可能: :updates 個)',
+ 'summary' => '合計:total個を確認(アップデート可能: :updates個)',
'single_up_to_date' => 'テンプレート「:template」は最新バージョンです。(v:version)',
- 'single_update_available' => 'テンプレート「:template」がアップデート可能です: v:current → v:latest(:source)',
+ 'single_update_available' => 'テンプレート「:template」アップデート可能: v:current → v:latest(:source)',
],
'update' => [
'success' => 'テンプレート「:template」のアップデート完了',
'version_change' => 'v:from → v:to',
'no_update' => 'テンプレート「:template」に利用可能なアップデートがありません。',
- 'not_installed' => 'テンプレート「:template」がインストールされていません。',
- 'invalid_strategy' => '無効なレイアウト戦略です。overwrite または keep のみ使用可能です。',
+ 'not_installed' => 'テンプレート「:template」はインストールされていません。',
+ 'invalid_strategy' => '無効なレイアウト戦略です。overwriteまたはkeepのみ使用できます。',
'current_version' => '現在のバージョン: :version',
'latest_version' => '最新バージョン: :version',
'update_source' => 'アップデートソース: :source',
'layout_strategy' => 'レイアウト戦略: :strategy',
- 'modified_layouts_warning' => '編集済みレイアウト :count 個が上書きされます:',
- 'modified_layout_item' => ' - :name(編集日: :date)',
- 'confirm_question' => 'アップデートを進めてもよろしいですか?',
+ 'modified_layouts_warning' => '変更されたレイアウト:count個が上書きされます:',
+ 'modified_layout_item' => ' - :name(変更日: :date)',
+ 'confirm_question' => 'アップデートを実行しますか?',
'aborted' => 'アップデートがキャンセルされました。',
- 'backup_restored' => 'バックアップから前のバージョンが復元されました。',
+ 'backup_restored' => 'バックアップから以前のバージョンが復元されました。',
'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。',
],
],
@@ -303,8 +303,8 @@ return [
'file_invalid' => '正しいファイルではありません。',
'file_image' => '画像ファイルのみアップロードできます。',
'file_mimes' => 'サポートされている画像形式ではありません (jpg, jpeg, png, gif, webp, svg)。',
- 'file_max' => 'ファイルサイズは最大 :max MBまで許可されています。',
- 'layout_name_max' => 'レイアウト名は最大150文字まで許可されています。',
+ 'file_max' => 'ファイルサイズは最大 :max MB までです。',
+ 'layout_name_max' => 'レイアウト名は最大 150 文字までです。',
],
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php b/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php
index e0af4bed..be2c90db 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php
@@ -4,13 +4,13 @@ return [
'not_found' => 'テーマが見つかりません: :theme',
'name_required' => 'テーマ名は必須です。',
'name_string' => 'テーマ名は文字列である必要があります。',
- 'name_max' => 'テーマ名は255文字を超えることができません。',
+ 'name_max' => 'テーマ名は255文字を超えることはできません。',
'type_required' => 'テーマタイプは必須です。',
'type_invalid' => 'テーマタイプはcoreまたはmoduleである必要があります。',
'target_required' => 'テーマターゲットは必須です。',
'target_invalid' => 'テーマターゲットはadminまたはuserである必要があります。',
'module_name_required' => 'モジュールテーマの場合、モジュール名が必要です。',
'module_name_string' => 'モジュール名は文字列である必要があります。',
- 'module_name_max' => 'モジュール名は255文字を超えることができません。',
+ 'module_name_max' => 'モジュール名は255文字を超えることはできません。',
'cannot_uninstall_active' => '有効化されたテーマは削除できません。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php
index af10ae18..7b523fbd 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php
@@ -3,7 +3,7 @@
return [
'status' => [
'active' => '有効',
- 'inactive' => '無効化',
+ 'inactive' => '無効',
'blocked' => 'ブロック',
'withdrawn' => '退会',
],
@@ -13,35 +13,35 @@ return [
],
'not_found' => 'ユーザーが見つかりません。',
'withdrawn_user' => '退会したユーザー',
- 'fetch_success' => 'ユーザー情報の取得に成功しました。',
+ 'fetch_success' => 'ユーザー情報を正常に取得しました。',
'fetch_failed' => 'ユーザー情報の取得に失敗しました。',
'create_success' => 'ユーザーが正常に作成されました。',
- 'create_failed' => 'ユーザーの作成に失敗しました: :error',
+ 'create_failed' => 'ユーザー作成に失敗しました: :error',
'update_success' => 'ユーザー情報が正常に更新されました。',
'update_failed' => 'ユーザー情報の更新に失敗しました: :error',
'delete_success' => 'ユーザーが正常に削除されました。',
- 'delete_failed' => 'ユーザーの削除に失敗しました: :error',
- 'statistics_success' => 'ユーザー統計の取得に成功しました。',
+ 'delete_failed' => 'ユーザー削除に失敗しました: :error',
+ 'statistics_success' => 'ユーザー統計を正常に取得しました。',
'statistics_failed' => 'ユーザー統計の取得に失敗しました。',
'search_success' => 'ユーザー検索が正常に完了しました。',
'search_failed' => 'ユーザー検索に失敗しました。',
'search_name_required' => '検索する名前を入力してください。',
'search_keyword_required' => '検索キーワードを入力してください。',
'email_available' => '使用可能なメールアドレスです。',
- 'email_unavailable' => 'すでに使用されているメールアドレスです。',
+ 'email_unavailable' => '既に使用中のメールアドレスです。',
'email_required' => 'メールアドレスを入力してください。',
- 'email_check_failed' => 'メールアドレスの重複確認に失敗しました。',
- 'profile_success' => 'プロフィール情報の取得に成功しました。',
+ 'email_check_failed' => 'メールアドレス重複確認に失敗しました。',
+ 'profile_success' => 'プロフィール情報を正常に取得しました。',
'profile_failed' => 'プロフィール情報の取得に失敗しました。',
'language_update_success' => '言語設定が正常に変更されました。',
'language_update_failed' => '言語設定の変更に失敗しました。',
'invalid_language' => 'サポートされていない言語です。',
- 'activity_log_success' => 'アクティビティログの取得に成功しました。',
+ 'activity_log_success' => 'アクティビティログを正常に取得しました。',
'activity_log_failed' => 'アクティビティログの取得に失敗しました。',
'bulk_status_updated' => ':count名のユーザーステータスが変更されました。',
- 'bulk_update_status_failed' => 'ユーザーステータスの一括変更に失敗しました。',
- 'last_admin_role_cannot_remove' => 'システムに管理者ロールを持つユーザーが本人のみのため、管理者ロールを削除できません。',
- 'avatar_upload_success' => 'プロフィール画像のアップロードに成功しました。',
+ 'bulk_update_status_failed' => 'ユーザーステータス一括変更に失敗しました。',
+ 'last_admin_role_cannot_remove' => 'システムに管理者ロールを持つユーザーが本人のみであるため、管理者ロールを削除することはできません。',
+ 'avatar_upload_success' => 'プロフィール画像が正常にアップロードされました。',
'avatar_upload_failed' => 'プロフィール画像のアップロードに失敗しました。',
'avatar_delete_success' => 'プロフィール画像が正常に削除されました。',
'avatar_delete_failed' => 'プロフィール画像の削除に失敗しました。',
@@ -52,7 +52,7 @@ return [
'password_incorrect' => 'パスワードが一致しません。',
'password_verify_failed' => 'パスワード確認に失敗しました。',
'password_change_success' => 'パスワードが正常に変更されました。',
- 'password_change_failed' => 'パスワードの変更に失敗しました。',
+ 'password_change_failed' => 'パスワード変更に失敗しました。',
'withdraw_success' => '会員退会が完了しました。',
'withdraw_failed' => '会員退会に失敗しました: :error',
'withdraw_admin_forbidden' => '管理者アカウントは退会できません。',
@@ -62,12 +62,12 @@ return [
'per_page_integer' => 'ページあたりの項目数は数字である必要があります。',
'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は100以下である必要があります。',
- 'search_string' => '検索語は文字列である必要があります。',
- 'search_max' => '検索語は255文字以下である必要があります。',
+ 'search_string' => '検索キーワードは文字列である必要があります。',
+ 'search_max' => '検索キーワードは255文字以下である必要があります。',
'search_field_invalid' => '無効な検索フィールドです。',
- 'start_date_invalid' => '開始日の形式が正しくありません。',
- 'end_date_invalid' => '終了日の形式が正しくありません。',
- 'end_date_after_start' => '終了日は開始日より遅い必要があります。',
+ 'start_date_invalid' => '開始日付の形式が正しくありません。',
+ 'end_date_invalid' => '終了日付の形式が正しくありません。',
+ 'end_date_after_start' => '終了日付は開始日付より後である必要があります。',
'date_filter_invalid' => '無効な日付フィルターです。',
'sort_by_invalid' => '無効な並べ替えフィールドです。',
'sort_order_invalid' => '無効な並べ替え順序です。',
@@ -78,34 +78,34 @@ return [
'nickname_max' => 'ニックネームは50文字以下である必要があります。',
'email_required' => 'メールアドレスは必須です。',
'email_invalid' => '正しいメールアドレス形式ではありません。',
- 'email_unique' => 'すでに使用されているメールアドレスです。',
+ 'email_unique' => '既に使用中のメールアドレスです。',
'email_max' => 'メールアドレスは255文字以下である必要があります。',
'password_required' => 'パスワードは必須です。',
'password_confirmed' => 'パスワード確認が一致しません。',
- 'password_min' => 'パスワードは最低8文字以上である必要があります。',
+ 'password_min' => 'パスワードは最小8文字以上である必要があります。',
'current_password_required' => '現在のパスワードを入力してください。',
'current_password_invalid' => '現在のパスワードが正しくありません。',
'password_confirmation_required' => 'パスワード確認を入力してください。',
'language_required' => '言語を選択してください。',
'language_invalid' => 'サポートされていない言語です。',
'country_required' => '国を選択してください。',
- 'country_size' => '国コードは2文字である必要があります。',
- 'country_alpha' => '国コードは英字のみ使用可能です。',
+ 'country_size' => '国コードは2桁である必要があります。',
+ 'country_alpha' => '国コードは英文字のみ使用可能です。',
'timezone_invalid' => '有効なタイムゾーンを選択してください。',
'status_invalid' => '無効なアカウント状態です。',
- 'homepage_url' => 'URLの形式が正しくありません。',
+ 'homepage_url' => '正しいURL形式ではありません。',
'homepage_max' => 'ホームページアドレスは255文字以下である必要があります。',
'mobile_max' => '携帯電話番号は20文字以下である必要があります。',
- 'mobile_regex' => '携帯電話番号は数字、ハイフン、空白のみ入力可能です。',
+ 'mobile_regex' => '携帯電話番号は数字、ハイフン、スペースのみ入力可能です。',
'phone_max' => '電話番号は20文字以下である必要があります。',
- 'phone_regex' => '電話番号は数字、ハイフン、空白のみ入力可能です。',
+ 'phone_regex' => '電話番号は数字、ハイフン、スペースのみ入力可能です。',
'avatar_required' => 'プロフィール画像を選択してください。',
'avatar_image' => 'プロフィール画像は画像ファイルである必要があります。',
- 'avatar_mimes' => 'プロフィール画像はJPG、PNG、GIF、WEBP形式のみ対応しています。',
+ 'avatar_mimes' => 'プロフィール画像はJPG、PNG、GIF、WEBP形式のみ可能です。',
'avatar_max' => 'プロフィール画像は2MB以下である必要があります。',
'zipcode_max' => '郵便番号は10文字以下である必要があります。',
- 'address_max' => '基本アドレスは255文字以下である必要があります。',
- 'address_detail_max' => '詳細アドレスは255文字以下である必要があります。',
+ 'address_max' => '基本住所は255文字以下である必要があります。',
+ 'address_detail_max' => '詳細住所は255文字以下である必要があります。',
'signature_max' => '署名は1000文字以下である必要があります。',
'bio_max' => '自己紹介は5000文字以下である必要があります。',
'admin_memo_max' => '管理者メモは5000文字以下である必要があります。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
index df01b587..d65e8387 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
@@ -3,16 +3,16 @@
return [
'accepted' => ':attribute フィールドを承認する必要があります。',
'accepted_if' => ':other が :value の場合、:attribute フィールドを承認する必要があります。',
- 'active_url' => ':attribute フィールドは有効な URL である必要があります。',
- 'after' => ':attribute フィールドは :date より後の日付である必要があります。',
- 'after_or_equal' => ':attribute フィールドは :date 以降の日付である必要があります。',
+ 'active_url' => ':attribute フィールドは有効なURLである必要があります。',
+ 'after' => ':attribute フィールドは :date 以降の日付である必要があります。',
+ 'after_or_equal' => ':attribute フィールドは :date 以降または同じ日付である必要があります。',
'alpha' => ':attribute フィールドは文字のみを含む必要があります。',
'alpha_dash' => ':attribute フィールドは文字、数字、ダッシュ、アンダースコアのみを含む必要があります。',
'alpha_num' => ':attribute フィールドは文字と数字のみを含む必要があります。',
'array' => ':attribute フィールドは配列である必要があります。',
'ascii' => ':attribute フィールドはシングルバイト英数字文字と記号のみを含む必要があります。',
- 'before' => ':attribute フィールドは :date より前の日付である必要があります。',
- 'before_or_equal' => ':attribute フィールドは :date 以前の日付である必要があります。',
+ 'before' => ':attribute フィールドは :date 以前の日付である必要があります。',
+ 'before_or_equal' => ':attribute フィールドは :date 以前または同じ日付である必要があります。',
'between' => [
'array' => ':attribute フィールドは :min ~ :max 個の項目を含む必要があります。',
'file' => ':attribute フィールドは :min ~ :max KB の間である必要があります。',
@@ -21,34 +21,34 @@ return [
],
'boolean' => ':attribute フィールドは true または false である必要があります。',
'can' => ':attribute フィールドに許可されていない値が含まれています。',
- 'confirmed' => ':attribute フィールド確認が一致しません。',
- 'contains' => ':attribute フィールドに必須値が不足しています。',
+ 'confirmed' => ':attribute フィールドの確認が一致しません。',
+ 'contains' => ':attribute フィールドに必須の値が不足しています。',
'current_password' => 'パスワードが正しくありません。',
'date' => ':attribute フィールドは有効な日付である必要があります。',
'date_equals' => ':attribute フィールドは :date と同じ日付である必要があります。',
'date_format' => ':attribute フィールドは :format 形式と一致する必要があります。',
- 'decimal' => ':attribute フィールドは :decimal 桁数の小数点である必要があります。',
- 'declined' => ':attribute フィールドを却下する必要があります。',
- 'declined_if' => ':other が :value の場合、:attribute フィールドを却下する必要があります。',
+ 'decimal' => ':attribute フィールドは :decimal 桁の小数点である必要があります。',
+ 'declined' => ':attribute フィールドを拒否する必要があります。',
+ 'declined_if' => ':other が :value の場合、:attribute フィールドを拒否する必要があります。',
'different' => ':attribute フィールドと :other は異なる必要があります。',
- 'digits' => ':attribute フィールドは :digits 桁数である必要があります。',
- 'digits_between' => ':attribute フィールドは :min ~ :max 桁数の間である必要があります。',
+ 'digits' => ':attribute フィールドは :digits 桁である必要があります。',
+ 'digits_between' => ':attribute フィールドは :min ~ :max 桁の間である必要があります。',
'dimensions' => ':attribute フィールドの画像サイズが正しくありません。',
'distinct' => ':attribute フィールドに重複した値があります。',
- 'doesnt_end_with' => ':attribute フィールドは :values で終わってはいけません。',
- 'doesnt_start_with' => ':attribute フィールドは :values で始まってはいけません。',
+ 'doesnt_end_with' => ':attribute フィールドは次で終わってはいけません: :values。',
+ 'doesnt_start_with' => ':attribute フィールドは次で始まってはいけません: :values。',
'email' => ':attribute フィールドは有効なメールアドレスである必要があります。',
'ends_with' => ':attribute フィールドは次のいずれかで終わる必要があります: :values。',
- 'enum' => '選択された :attribute が正しくありません。',
- 'exists' => '選択された :attribute が正しくありません。',
+ 'enum' => '選択された :attribute は正しくありません。',
+ 'exists' => '選択された :attribute は正しくありません。',
'extensions' => ':attribute フィールドは次の拡張子のいずれかである必要があります: :values。',
'file' => ':attribute フィールドはファイルである必要があります。',
'filled' => ':attribute フィールドに値が存在する必要があります。',
'gt' => [
- 'array' => ':attribute フィールドは :value 個より多くの項目を含む必要があります。',
- 'file' => ':attribute フィールドは :value KB より大きくする必要があります。',
- 'numeric' => ':attribute フィールドは :value より大きくする必要があります。',
- 'string' => ':attribute フィールドは :value 文字より長くする必要があります。',
+ 'array' => ':attribute フィールドは :value 個を超える項目を含む必要があります。',
+ 'file' => ':attribute フィールドは :value KB より大きい必要があります。',
+ 'numeric' => ':attribute フィールドは :value より大きい必要があります。',
+ 'string' => ':attribute フィールドは :value 文字より長い必要があります。',
],
'gte' => [
'array' => ':attribute フィールドは :value 個以上の項目を含む必要があります。',
@@ -56,22 +56,22 @@ return [
'numeric' => ':attribute フィールドは :value 以上である必要があります。',
'string' => ':attribute フィールドは :value 文字以上である必要があります。',
],
- 'hex_color' => ':attribute フィールドは有効な16進数カラーである必要があります。',
+ 'hex_color' => ':attribute フィールドは有効な16進数色である必要があります。',
'image' => ':attribute フィールドは画像である必要があります。',
- 'in' => '選択された :attribute が正しくありません。',
+ 'in' => '選択された :attribute は正しくありません。',
'in_array' => ':attribute フィールドは :other に存在する必要があります。',
'integer' => ':attribute フィールドは整数である必要があります。',
- 'ip' => ':attribute フィールドは有効な IP アドレスである必要があります。',
- 'ipv4' => ':attribute フィールドは有効な IPv4 アドレスである必要があります。',
- 'ipv6' => ':attribute フィールドは有効な IPv6 アドレスである必要があります。',
- 'json' => ':attribute フィールドは有効な JSON 文字列である必要があります。',
+ 'ip' => ':attribute フィールドは有効なIPアドレスである必要があります。',
+ 'ipv4' => ':attribute フィールドは有効なIPv4アドレスである必要があります。',
+ 'ipv6' => ':attribute フィールドは有効なIPv6アドレスである必要があります。',
+ 'json' => ':attribute フィールドは有効なJSON文字列である必要があります。',
'list' => ':attribute フィールドはリストである必要があります。',
'lowercase' => ':attribute フィールドは小文字である必要があります。',
'lt' => [
- 'array' => ':attribute フィールドは :value 個より少ない項目を含む必要があります。',
- 'file' => ':attribute フィールドは :value KB より小さくする必要があります。',
- 'numeric' => ':attribute フィールドは :value より小さくする必要があります。',
- 'string' => ':attribute フィールドは :value 文字より短くする必要があります。',
+ 'array' => ':attribute フィールドは :value 個未満の項目を含む必要があります。',
+ 'file' => ':attribute フィールドは :value KB より小さい必要があります。',
+ 'numeric' => ':attribute フィールドは :value より小さい必要があります。',
+ 'string' => ':attribute フィールドは :value 文字より短い必要があります。',
],
'lte' => [
'array' => ':attribute フィールドは :value 個を超えてはいけません。',
@@ -79,60 +79,60 @@ return [
'numeric' => ':attribute フィールドは :value 以下である必要があります。',
'string' => ':attribute フィールドは :value 文字以下である必要があります。',
],
- 'mac_address' => ':attribute フィールドは有効な MAC アドレスである必要があります。',
+ 'mac_address' => ':attribute フィールドは有効なMACアドレスである必要があります。',
'max' => [
'array' => ':attribute フィールドは :max 個を超えてはいけません。',
'file' => ':attribute フィールドは :max KB を超えてはいけません。',
'numeric' => ':attribute フィールドは :max を超えてはいけません。',
'string' => ':attribute フィールドは :max 文字を超えてはいけません。',
],
- 'max_digits' => ':attribute フィールドは :max 桁数を超えてはいけません。',
- 'mimes' => ':attribute フィールドは次のタイプのファイルである必要があります: :values。',
- 'mimetypes' => ':attribute フィールドは次のタイプのファイルである必要があります: :values。',
+ 'max_digits' => ':attribute フィールドは :max 桁を超えてはいけません。',
+ 'mimes' => ':attribute フィールドは次の種類のファイルである必要があります: :values。',
+ 'mimetypes' => ':attribute フィールドは次の種類のファイルである必要があります: :values。',
'min' => [
'array' => ':attribute フィールドは最低 :min 個以上である必要があります。',
'file' => ':attribute フィールドは最低 :min KB 以上である必要があります。',
'numeric' => ':attribute フィールドは最低 :min 以上である必要があります。',
'string' => ':attribute フィールドは最低 :min 文字以上である必要があります。',
],
- 'min_digits' => ':attribute フィールドは最低でも :min 文字以上である必要があります。',
- 'missing' => ':attribute フィールドは存在しないようにしてください。',
- 'missing_if' => ':other が :value のとき、:attribute フィールドは存在しないようにしてください。',
- 'missing_unless' => ':other が :value ではない場合、:attribute フィールドは存在しないようにしてください。',
- 'missing_with' => ':values が存在するとき、:attribute フィールドは存在しないようにしてください。',
- 'missing_with_all' => ':values がすべて存在するとき、:attribute フィールドは存在しないようにしてください。',
+ 'min_digits' => ':attribute フィールドは最小 :min 桁以上である必要があります。',
+ 'missing' => ':attribute フィールドは存在しないことが必要です。',
+ 'missing_if' => ':other が :value の場合、:attribute フィールドは存在しないことが必要です。',
+ 'missing_unless' => ':other が :value でない場合、:attribute フィールドは存在しないことが必要です。',
+ 'missing_with' => ':values が存在する場合、:attribute フィールドは存在しないことが必要です。',
+ 'missing_with_all' => ':values がすべて存在する場合、:attribute フィールドは存在しないことが必要です。',
'multiple_of' => ':attribute フィールドは :value の倍数である必要があります。',
- 'not_in' => '選択した :attribute は無効です。',
- 'not_regex' => ':attribute フィールドの形式が無効です。',
+ 'not_in' => '選択された :attribute は有効ではありません。',
+ 'not_regex' => ':attribute フィールド形式が正しくありません。',
'numeric' => ':attribute フィールドは数値である必要があります。',
'password' => [
- 'letters' => ':attribute フィールドは最低でも 1 文字を含む必要があります。',
- 'mixed' => ':attribute フィールドは最低でも 1 つの大文字と 1 つの小文字を含む必要があります。',
- 'numbers' => ':attribute フィールドは最低でも 1 つの数字を含む必要があります。',
- 'symbols' => ':attribute フィールドは最低でも 1 つの記号を含む必要があります。',
- 'uncompromised' => '指定された :attribute はデータ流出に表示されています。別の :attribute を選択してください。',
+ 'letters' => ':attribute フィールドは最低 1 つの文字を含む必要があります。',
+ 'mixed' => ':attribute フィールドは最低 1 つの大文字と小文字を含む必要があります。',
+ 'numbers' => ':attribute フィールドは最低 1 つの数字を含む必要があります。',
+ 'symbols' => ':attribute フィールドは最低 1 つの記号を含む必要があります。',
+ 'uncompromised' => '指定の :attribute はデータ侵害に公開されています。別の :attribute を選択してください。',
],
'present' => ':attribute フィールドは存在する必要があります。',
- 'present_if' => ':other が :value のとき、:attribute フィールドは存在する必要があります。',
- 'present_unless' => ':other が :value ではない場合、:attribute フィールドは存在する必要があります。',
- 'present_with' => ':values が存在するとき、:attribute フィールドは存在する必要があります。',
- 'present_with_all' => ':values がすべて存在するとき、:attribute フィールドは存在する必要があります。',
- 'prohibited' => ':attribute フィールドは許可されていません。',
- 'prohibited_if' => ':other が :value のとき、:attribute フィールドは許可されていません。',
- 'prohibited_unless' => ':other が :values に含まれていない場合、:attribute フィールドは許可されていません。',
+ 'present_if' => ':other が :value の場合、:attribute フィールドは存在する必要があります。',
+ 'present_unless' => ':other が :value でない場合、:attribute フィールドは存在する必要があります。',
+ 'present_with' => ':values が存在する場合、:attribute フィールドは存在する必要があります。',
+ 'present_with_all' => ':values がすべて存在する場合、:attribute フィールドは存在する必要があります。',
+ 'prohibited' => ':attribute フィールドは禁止されています。',
+ 'prohibited_if' => ':other が :value の場合、:attribute フィールドは禁止されています。',
+ 'prohibited_unless' => ':other が :values にない場合、:attribute フィールドは禁止されています。',
'prohibits' => ':attribute フィールドは :other が存在することを禁止しています。',
- 'regex' => ':attribute フィールドの形式が無効です。',
+ 'regex' => ':attribute フィールド形式が正しくありません。',
'required' => ':attribute フィールドは必須です。',
- 'required_array_keys' => ':attribute フィールドは以下の項目を含む必要があります: :values。',
- 'required_if' => ':other が :value のとき、:attribute フィールドは必須です。',
- 'required_if_accepted' => ':other が承認されている場合、:attribute フィールドは必須です。',
- 'required_if_declined' => ':other が却下されている場合、:attribute フィールドは必須です。',
- 'required_unless' => ':other が :values に含まれていない場合、:attribute フィールドは必須です。',
- 'required_with' => ':values が存在するとき、:attribute フィールドは必須です。',
- 'required_with_all' => ':values がすべて存在するとき、:attribute フィールドは必須です。',
- 'required_without' => ':values が存在しないとき、:attribute フィールドは必須です。',
- 'required_without_all' => ':values がすべて存在しないとき、:attribute フィールドは必須です。',
- 'same' => ':attribute フィールドと :other が一致する必要があります。',
+ 'required_array_keys' => ':attribute フィールドは以下を含む必要があります: :values。',
+ 'required_if' => ':other が :value の場合、:attribute フィールドは必須です。',
+ 'required_if_accepted' => ':other が承認されたとき、:attribute フィールドは必須です。',
+ 'required_if_declined' => ':other が却下されたとき、:attribute フィールドは必須です。',
+ 'required_unless' => ':other が :values にない場合、:attribute フィールドは必須です。',
+ 'required_with' => ':values が存在する場合、:attribute フィールドは必須です。',
+ 'required_with_all' => ':values がすべて存在する場合、:attribute フィールドは必須です。',
+ 'required_without' => ':values が存在しない場合、:attribute フィールドは必須です。',
+ 'required_without_all' => ':values がすべて存在しない場合、:attribute フィールドは必須です。',
+ 'same' => ':attribute フィールドと :other は一致する必要があります。',
'size' => [
'array' => ':attribute フィールドは :size 個の項目を含む必要があります。',
'file' => ':attribute フィールドは :size KB である必要があります。',
@@ -148,87 +148,126 @@ return [
'url' => ':attribute フィールドは有効な URL である必要があります。',
'ulid' => ':attribute フィールドは有効な ULID である必要があります。',
'uuid' => ':attribute フィールドは有効な UUID である必要があります。',
+ 'custom_translation' => [
+ 'layout_name' => [
+ 'required' => 'レイアウト名は必須です。',
+ 'string' => 'レイアウト名は文字列である必要があります。',
+ 'max' => 'レイアウト名は :max 文字を超えることはできません。',
+ ],
+ 'locale' => [
+ 'required' => 'ロケールは必須です。',
+ 'string' => 'ロケールは文字列である必要があります。',
+ ],
+ 'value' => [
+ 'required' => '翻訳値は必須です。',
+ 'string' => '翻訳値は文字列である必要があります。',
+ ],
+ 'values' => [
+ 'required' => '翻訳値は必須です。',
+ 'array' => '翻訳値はロケール別オブジェクトである必要があります。',
+ ],
+ 'status' => [
+ 'in' => 'ステータスは active または orphaned である必要があります。',
+ ],
+ 'expected_lock_version' => [
+ 'required' => '保存リクエストに expected_lock_version がありません。',
+ 'integer' => 'expected_lock_version は整数である必要があります。',
+ 'min' => 'expected_lock_version は 0 以上である必要があります。',
+ ],
+ 'ids' => [
+ 'required' => '削除する多言語キーを 1 つ以上選択してください。',
+ 'array' => '削除対象は ID 配列である必要があります。',
+ 'min' => '削除する多言語キーを 1 つ以上選択してください。',
+ 'integer' => '多言語キー ID は整数である必要があります。',
+ 'exists' => '存在しない多言語キーが含まれています。',
+ ],
+ ],
'layout' => [
+ 'expected_lock_version' => [
+ 'required' => '保存リクエストに expected_lock_version がありません。',
+ 'integer' => 'expected_lock_version は整数である必要があります。',
+ 'min' => 'expected_lock_version は 0 以上である必要があります。',
+ ],
'invalid_json' => '無効な JSON 形式です。',
'must_be_array' => 'レイアウトデータは配列である必要があります。',
- 'required_field_missing' => '必須フィールド \':field\' が見つかりません。',
+ 'required_field_missing' => '必須フィールド \':field\' がありません。',
'version_must_be_string' => 'version フィールドは文字列である必要があります。',
'layout_name_must_be_string' => 'layout_name フィールドは文字列である必要があります。',
'components_must_be_array' => 'components フィールドは配列である必要があります。',
- 'components_or_slots_required' => '継承レイアウトには components または slots のいずれかが必要です。',
+ 'components_or_slots_required' => '継承レイアウトは components または slots のいずれかが必要です。',
'component_must_be_array' => 'components[:index] は配列である必要があります。',
- 'max_depth_exceeded' => 'コンポーネントのネストの深さが最大許容深度(:max)を超えています。',
- 'component_required_field_missing' => 'components[:index] に必須フィールド \':field\' が見つかりません。',
- 'component_field_must_be_string' => 'components[:index].component は文字列である必要があります。',
- 'component_name_must_be_string' => 'components[:index].name は文字列である必要があります。',
- 'component_type_invalid' => 'components[:index].type は basic、composite、layout のいずれかである必要があります。',
- 'props_must_be_object' => 'components[:index].props はオブジェクト(配列)である必要があります。',
- 'children_must_be_array' => 'components[:index].children は配列である必要があります。',
- 'permissions_must_be_array' => 'components[:index].permissions は配列である必要があります。',
- 'permissions_must_be_array_or_object' => 'permissions は配列または構造化オブジェクト(or/and)である必要があります。',
- 'permissions_invalid_operator' => 'permissions の構造では、有効な演算子は「or」または「and」のみが許可されます。キーは正確に 1 つである必要があります。',
- 'permissions_operator_must_be_array' => 'permissions の ":operator" 演算子の値は配列である必要があります。',
- 'permissions_operator_min_items' => 'permissions の ":operator" 演算子には最低でも :min 個の項目が必要です。',
- 'permissions_max_depth_exceeded' => 'permissions の構造の最大ネスト深さ(:max)を超えています。',
- 'permission_must_be_string' => 'components[:index].permissions[:perm_index] は文字列である必要があります。',
- 'permission_must_be_string_or_group' => 'permissions の項目[:index] は文字列(権限 ID)または構造化オブジェクト(or/and)である必要があります。',
- 'permission_invalid_format' => 'components[:index].permissions の ":permission" は正しい権限 ID 形式ではありません。',
- 'actions_must_be_array' => 'components[:index].actions は配列である必要があります。',
- 'action_must_be_array' => 'components[:index].actions[:actionIndex] は配列である必要があります。',
- 'action_type_missing' => 'components[:index].actions[:actionIndex] に必須フィールド \'type\' が見つかりません。',
- 'action_type_or_event_missing' => 'components[:index].actions[:actionIndex] には \'type\' または \'event\' のいずれかが必要です。',
- 'action_type_must_be_string' => 'components[:index].actions[:actionIndex].type は文字列である必要があります。',
- 'action_event_must_be_string' => 'components[:index].actions[:actionIndex].event は文字列である必要があります。',
+ 'max_depth_exceeded' => 'コンポーネントネスト深度が最大許容深度(:max)を超えています。',
+ 'component_required_field_missing' => 'components[:index] に必須フィールド \':field\' がありません。',
+ 'component_field_must_be_string' => 'components[:index].componentは文字列である必要があります。',
+ 'component_name_must_be_string' => 'components[:index].nameは文字列である必要があります。',
+ 'component_type_invalid' => 'components[:index].typeはbasic、composite、layoutのいずれかである必要があります。',
+ 'props_must_be_object' => 'components[:index].propsはオブジェクト(配列)である必要があります。',
+ 'children_must_be_array' => 'components[:index].childrenは配列である必要があります。',
+ 'permissions_must_be_array' => 'components[:index].permissionsは配列である必要があります。',
+ 'permissions_must_be_array_or_object' => 'permissionsは配列または構造化オブジェクト(or/and)である必要があります。',
+ 'permissions_invalid_operator' => 'permissions構造で有効な演算子は「or」または「and」のみです。キーは正確に1個である必要があります。',
+ 'permissions_operator_must_be_array' => 'permissionsの":operator"演算子の値は配列である必要があります。',
+ 'permissions_operator_min_items' => 'permissionsの":operator"演算子には最小:min個の項目が必要です。',
+ 'permissions_max_depth_exceeded' => 'permissions構造の最大ネストの深さ(:max)を超えました。',
+ 'permission_must_be_string' => 'components[:index].permissions[:perm_index]は文字列である必要があります。',
+ 'permission_must_be_string_or_group' => 'permissionsの項目[:index]は文字列(権限識別子)または構造化オブジェクト(or/and)である必要があります。',
+ 'permission_invalid_format' => 'components[:index].permissionsの":permission"は有効な権限識別子形式ではありません。',
+ 'actions_must_be_array' => 'components[:index].actionsは配列である必要があります。',
+ 'action_must_be_array' => 'components[:index].actions[:actionIndex]は配列である必要があります。',
+ 'action_type_missing' => 'components[:index].actions[:actionIndex]に必須フィールド\'type\'が見つかりません。',
+ 'action_type_or_event_missing' => 'components[:index].actions[:actionIndex]に\'type\'または\'event\'のいずれかが必要です。',
+ 'action_type_must_be_string' => 'components[:index].actions[:actionIndex].typeは文字列である必要があります。',
+ 'action_event_must_be_string' => 'components[:index].actions[:actionIndex].eventは文字列である必要があります。',
'content' => [
- 'required' => 'レイアウト content が必要です。',
- 'array' => 'レイアウト content は配列である必要があります。',
+ 'required' => 'レイアウトcontentが必要です。',
+ 'array' => 'レイアウトcontentは配列である必要があります。',
],
'version' => [
- 'required' => 'レイアウト バージョンが必要です。',
- 'string' => 'レイアウト バージョンは文字列である必要があります。',
+ 'required' => 'レイアウトバージョンが必要です。',
+ 'string' => 'レイアウトバージョンは文字列である必要があります。',
],
'layout_name' => [
- 'required' => 'レイアウト 名が必要です。',
- 'string' => 'レイアウト 名は文字列である必要があります。',
- 'max' => 'レイアウト 名は :max 文字を超えることはできません。',
+ 'required' => 'レイアウト名が必要です。',
+ 'string' => 'レイアウト名は文字列である必要があります。',
+ 'max' => 'レイアウト名は:max文字を超えることはできません。',
],
'endpoint' => [
- 'required' => 'API エンドポイントが必要です。',
- 'string' => 'API エンドポイントは文字列である必要があります。',
+ 'required' => 'APIエンドポイントが必要です。',
+ 'string' => 'APIエンドポイントは文字列である必要があります。',
],
'extends' => [
- 'string' => 'extends は文字列である必要があります。',
+ 'string' => 'extendsは文字列である必要があります。',
],
'slots' => [
- 'array' => 'slots は配列である必要があります。',
+ 'array' => 'slotsは配列である必要があります。',
],
'components' => [
'required' => 'コンポーネント配列が必要です。',
- 'array' => 'components フィールドは配列である必要があります。',
+ 'array' => 'componentsフィールドは配列である必要があります。',
],
'data_sources' => [
- 'array' => 'data_sources フィールドは配列である必要があります。',
+ 'array' => 'data_sourcesフィールドは配列である必要があります。',
],
'metadata' => [
- 'array' => 'metadata フィールドは配列である必要があります。',
+ 'array' => 'metadataフィールドは配列である必要があります。',
],
'meta' => [
- 'array' => 'meta フィールドは配列である必要があります。',
+ 'array' => 'metaフィールドは配列である必要があります。',
'title' => [
- 'string' => 'meta.title は文字列である必要があります。',
+ 'string' => 'meta.titleは文字列である必要があります。',
],
'description' => [
- 'string' => 'meta.description は文字列である必要があります。',
- ],
- 'auth_required' => [
- 'boolean' => 'meta.auth_required は論理値である必要があります。',
- ],
- 'is_base' => [
- 'boolean' => 'meta.is_base は論理値である必要があります。',
+ 'string' => 'meta.descriptionは文字列である必要があります。',
],
'keywords' => [
'string' => 'meta.keywordsは文字列である必要があります。',
],
+ 'auth_required' => [
+ 'boolean' => 'meta.auth_requiredはブール値である必要があります。',
+ ],
+ 'is_base' => [
+ 'boolean' => 'meta.is_baseはブール値である必要があります。',
+ ],
'guest_only' => [
'boolean' => 'meta.guest_onlyはブール値である必要があります。',
],
@@ -277,84 +316,19 @@ return [
],
],
'modals' => [
- 'array' => 'modals フィールドは配列である必要があります。',
+ 'array' => 'modalsフィールドは配列である必要があります。',
],
'state' => [
- 'array' => 'state フィールドは配列である必要があります。',
+ 'array' => 'stateフィールドは配列である必要があります。',
],
'init_actions' => [
- 'array' => 'init_actions フィールドは配列である必要があります。',
+ 'array' => 'init_actionsフィールドは配列である必要があります。',
],
'defines' => [
- 'array' => 'defines フィールドは配列である必要があります。',
+ 'array' => 'definesフィールドは配列である必要があります。',
],
'init_state' => [
- 'array' => 'init_state フィールドは配列である必要があります。',
- ],
- 'routes' => [
- 'array' => 'routes フィールドは配列である必要があります。',
- ],
- 'computed' => [
- 'array' => 'computed フィールドは配列である必要があります。',
- ],
- 'permissions' => [
- 'array' => 'permissions フィールドは配列である必要があります。',
- 'string' => '各権限識別子は文字列である必要があります。',
- 'regex' => '権限識別子形式が正しくありません。(例: module.entity.action)',
- ],
- 'globalHeaders' => [
- 'array' => 'globalHeaders フィールドは配列である必要があります。',
- 'pattern' => [
- 'required' => '各 globalHeaders ルールには pattern フィールドが必要です。',
- 'string' => 'globalHeaders pattern は文字列である必要があります。',
- ],
- 'headers' => [
- 'required' => '各 globalHeaders ルールには headers フィールドが必要です。',
- 'array' => 'globalHeaders headers は配列である必要があります。',
- 'string' => 'ヘッダー値は文字列である必要があります。',
- ],
- ],
- 'transition_overlay' => [
- 'enabled' => [
- 'boolean' => '遷移オーバーレイ有効化は true または false である必要があります。',
- ],
- 'style' => [
- 'string' => '遷移オーバーレイ スタイルは文字列である必要があります。',
- 'in' => '遷移オーバーレイ スタイルは opaque、blur、fade、skeleton のいずれかである必要があります。',
- ],
- 'target' => [
- 'string' => '遷移オーバーレイ ターゲットは文字列である必要があります。',
- 'max' => '遷移オーバーレイ ターゲットは :max 文字を超えることはできません。',
- ],
- 'fallback_target' => [
- 'string' => '遷移オーバーレイ フォールバック ターゲットは文字列である必要があります。',
- 'max' => '遷移オーバーレイ フォールバック ターゲットは :max 文字を超えることはできません。',
- ],
- 'skeleton' => [
- 'array' => 'スケルトン設定は配列である必要があります。',
- 'component' => [
- 'string' => 'スケルトン コンポーネント名は文字列である必要があります。',
- 'max' => 'スケルトン コンポーネント名は :max 文字を超えることはできません。',
- ],
- 'animation' => [
- 'string' => 'スケルトン アニメーションは文字列である必要があります。',
- 'in' => 'スケルトン アニメーションは pulse、wave、none のいずれかである必要があります。',
- ],
- 'iteration_count' => [
- 'integer' => 'スケルトン 繰り返し回数は整数である必要があります。',
- 'min' => 'スケルトン 繰り返し回数は最小 :min である必要があります。',
- 'max' => 'スケルトン 繰り返し回数は最大 :max を超えることはできません。',
- ],
- ],
- 'wait_for' => [
- 'background' => 'wait_for には background データソースを指定することはできません (ユーザーをブロックできません): :id',
- 'websocket' => 'wait_for には websocket データソースを指定することはできません (フェッチ完了イベントがありません): :id',
- ],
- ],
- 'expected_lock_version' => [
- 'required' => '保存リクエストに expected_lock_version が不足しています。',
- 'integer' => 'expected_lock_version は整数である必要があります。',
- 'min' => 'expected_lock_version は0以上である必要があります。',
+ 'array' => 'init_stateフィールドは配列である必要があります。',
],
'initLocal' => [
'array' => 'initLocalフィールドは配列である必要があります。',
@@ -377,27 +351,141 @@ return [
'schema' => [
'array' => 'schemaフィールドは配列である必要があります。',
],
+ 'routes' => [
+ 'array' => 'routesフィールドは配列である必要があります。',
+ ],
+ 'computed' => [
+ 'array' => 'computedフィールドは配列である必要があります。',
+ ],
+ 'permissions' => [
+ 'array' => 'permissionsフィールドは配列である必要があります。',
+ 'string' => '各権限識別子は文字列である必要があります。',
+ 'regex' => '権限識別子形式が正しくありません。(例:module.entity.action)',
+ ],
+ 'globalHeaders' => [
+ 'array' => 'globalHeadersフィールドは配列である必要があります。',
+ 'pattern' => [
+ 'required' => '各globalHeadersルールにはpatternフィールドが必要です。',
+ 'string' => 'globalHeaderspatternは文字列である必要があります。',
+ ],
+ 'headers' => [
+ 'required' => '各globalHeadersルールにはheadersフィールドが必要です。',
+ 'array' => 'globalHeaders headers は配列である必要があります。',
+ 'string' => 'ヘッダー値は文字列である必要があります。',
+ ],
+ ],
+ 'transition_overlay' => [
+ 'enabled' => [
+ 'boolean' => 'トランジション オーバーレイ有効化は true または false 値である必要があります。',
+ ],
+ 'style' => [
+ 'string' => 'トランジション オーバーレイ スタイルは文字列である必要があります。',
+ 'in' => 'トランジション オーバーレイ スタイルは opaque、blur、fade、skeleton のいずれかである必要があります。',
+ ],
+ 'target' => [
+ 'string' => 'トランジション オーバーレイ ターゲットは文字列である必要があります。',
+ 'max' => 'トランジション オーバーレイ ターゲットは :max 文字を超えることはできません。',
+ ],
+ 'fallback_target' => [
+ 'string' => 'トランジション オーバーレイ代替ターゲットは文字列である必要があります。',
+ 'max' => 'トランジション オーバーレイ代替ターゲットは :max 文字を超えることはできません。',
+ ],
+ 'skeleton' => [
+ 'array' => 'スケルトン設定は配列である必要があります。',
+ 'component' => [
+ 'string' => 'スケルトン コンポーネント名は文字列である必要があります。',
+ 'max' => 'スケルトン コンポーネント名は :max 文字を超えることはできません。',
+ ],
+ 'animation' => [
+ 'string' => 'スケルトン アニメーションは文字列である必要があります。',
+ 'in' => 'スケルトン アニメーションは pulse、wave、none のいずれかである必要があります。',
+ ],
+ 'iteration_count' => [
+ 'integer' => 'スケルトン 反復回数は整数である必要があります。',
+ 'min' => 'スケルトン 反復回数は最小 :min である必要があります。',
+ 'max' => 'スケルトン 反復回数は最大 :max を超えることはできません。',
+ ],
+ ],
+ 'wait_for' => [
+ 'background' => 'wait_for には background データソースを指定することはできません (ユーザーをブロックできません)::id',
+ 'websocket' => 'wait_for には websocket データソースを指定することはできません (フェッチ完了イベントなし)::id',
+ ],
+ ],
+ ],
+ 'layout_extension' => [
+ 'expected_lock_version' => [
+ 'required' => '保存リクエストに expected_lock_version が不足しています。',
+ 'integer' => 'expected_lock_version は整数である必要があります。',
+ 'min' => 'expected_lock_version は 0 以上である必要があります。',
+ ],
+ 'invalid_json' => '無効な JSON 形式です。',
+ 'must_be_array' => 'レイアウト拡張データは配列である必要があります。',
+ 'target_required' => '拡張定義には \'extension_point\' または \'target_layout\' のいずれかが必要です。',
+ 'target_exclusive' => '\'extension_point\' と \'target_layout\' は同時に指定することはできません。',
+ 'extension_point_invalid' => 'extension_point フィールドは空でない文字列である必要があります。',
+ 'target_layout_invalid' => 'target_layout フィールドは空でない文字列である必要があります。',
+ 'components_must_be_array' => 'components フィールドは配列である必要があります。',
+ 'injections_required' => 'overlay 拡張は injections フィールドが必要です。',
+ 'injections_must_be_array' => 'injections フィールドは配列である必要があります。',
+ 'injection_must_be_array' => 'injections[:index] は配列である必要があります。',
+ 'injection_target_id_required' => 'injections[:index] に target_id フィールドが必要です。',
+ 'injection_position_invalid' => 'injections[:index].position 値が無効です。',
+ 'injection_components_must_be_array' => 'injections[:index].components は配列である必要があります。',
+ 'injection_props_must_be_array' => 'injections[:index].props は配列である必要があります。',
+ 'section_must_be_array' => ':section フィールドは配列である必要があります。',
+ 'max_depth_exceeded' => 'コンポーネントのネスト深度が最大許容深度(:max)を超えています。',
+ 'component_must_be_array' => 'components[:index] は配列である必要があります。',
+ 'component_required_field_missing' => 'components[:index] に必須フィールド \':field\' が不足しています。',
+ 'component_name_must_be_string' => 'components[:index].name は文字列である必要があります。',
+ 'component_type_invalid' => 'components[:index].type は basic、composite、layout のいずれかである必要があります。',
+ 'props_must_be_object' => 'components[:index].props はオブジェクト(配列)である必要があります。',
+ 'children_must_be_array' => 'components[:index].children は配列である必要があります。',
+ 'content' => [
+ 'required' => '拡張コンテンツは必須です。',
+ 'array' => '拡張コンテンツは配列である必要があります。',
+ ],
+ 'priority' => [
+ 'integer' => 'priority は整数である必要があります。',
+ 'min' => 'priority は 0 以上である必要があります。',
+ 'max' => 'priority は 9999 以下である必要があります。',
+ ],
+ 'data_sources' => [
+ 'array' => 'data_sources は配列である必要があります。',
+ ],
+ 'preview_layout' => [
+ 'string' => 'プレビュー レイアウト名は文字列である必要があります。',
+ 'max' => 'プレビュー レイアウト名は 255 文字を超えることはできません。',
+ 'required' => '拡張ポイント プレビューには代表レイアウト選択が必要です。',
+ ],
],
'endpoint' => [
'must_be_string' => 'API エンドポイントは文字列である必要があります。',
'external_url_not_allowed' => '外部 URL は許可されていません。',
- 'not_whitelisted' => '許可されていない API エンドポイントです。許可されたパターン: :pattern',
- 'path_traversal_detected' => 'パストラバーサル攻撃が検出されました。',
+ 'not_whitelisted' => '許可されていない API エンドポイントです。許可されたパターン::pattern',
+ 'path_traversal_detected' => 'パストトラバーサル攻撃が検出されました。',
],
'external_url' => [
- 'detected_in_props' => 'props で外部 URL が検出されました: :url',
- 'detected_in_actions' => 'actions で外部 URL が検出されました: :url',
+ 'detected_in_props' => 'props で外部 URL が検出されました::url',
+ 'detected_in_actions' => 'actions で外部 URL が検出されました::url',
'http_not_allowed' => 'HTTP プロトコル URL は許可されていません。',
'https_not_allowed' => 'HTTPS プロトコル URL は許可されていません。',
'data_uri_not_allowed' => 'Data URI スキームは許可されていません。',
'javascript_uri_not_allowed' => 'JavaScript URI スキームは許可されていません。',
- 'dangerous_scheme_detected' => '危険な URI スキームが検出されました: :scheme',
+ 'dangerous_scheme_detected' => '危険な URI スキームが検出されました::scheme',
+ ],
+ 'outbound_url' => [
+ 'invalid' => '正しい形式の URL ではありません。http または https で始まるアドレスを入力してください。',
+ 'internal_not_allowed' => '内部ネットワークアドレス(プライベート IP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。',
+ ],
+ 'schedule_command' => [
+ 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)·セミコロン(;)などの特殊文字は使用できません。',
+ 'artisan_denied' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。',
],
'component' => [
'template_id_required' => 'コンポーネント検証には template_id が必要です。',
- 'manifest_not_found' => 'テンプレート :templateId のコンポーネント マニフェスト(components.json)が見つかりません。',
+ 'manifest_not_found' => 'テンプレート :templateId のコンポーネント マニフェスト(components.json)が見つかりません。',
'name_empty' => 'components[:index].component 名は空にすることはできません。',
- 'not_found' => 'コンポーネント \':component\' はテンプレートに登録されていません。(components[:index])',
+ 'not_found' => 'コンポーネント \':component\' はテンプレートに登録されていません。(components[:index])',
],
'request' => [
'template_id' => [
@@ -406,53 +494,53 @@ return [
'exists' => '存在しないテンプレートです。',
],
'layout_name' => [
- 'required' => 'レイアウト 名は必須です。',
- 'string' => 'レイアウト 名は文字列である必要があります。',
- 'max' => 'レイアウト 名は :max 文字を超えることはできません。',
- 'unique' => '既に存在するレイアウト 名です。',
+ 'required' => 'レイアウト名は必須です。',
+ 'string' => 'レイアウト名は文字列である必要があります。',
+ 'max' => 'レイアウト名は :max 文字を超えることはできません。',
+ 'unique' => '既に存在するレイアウト名です。',
],
'content' => [
- 'required' => 'レイアウト 内容は必須です。',
- 'array' => 'レイアウト 内容は配列(オブジェクト)である必要があります。',
+ 'required' => 'レイアウト内容は必須です。',
+ 'array' => 'レイアウトコンテンツは配列(オブジェクト)である必要があります。',
],
],
'layout_inheritance' => [
'parent_not_found' => '親レイアウト ":parent" が見つかりません。',
- 'parent_not_in_same_template' => '親レイアウトは同じテンプレート内にある必要があります。',
- 'circular_reference' => 'レイアウト 循環参照が検出されました: :trace',
- 'max_depth_exceeded' => 'レイアウト 継承の深さが最大許容深さ(:max)を超えました。',
- 'extends_must_be_string' => 'extendsフィールドは文字列である必要があります。',
- 'slots_must_be_object' => 'slotsフィールドはオブジェクトである必要があります。',
+ 'parent_not_in_same_template' => '親レイアウトは同じテンプレート内に存在する必要があります。',
+ 'circular_reference' => 'レイアウト循環参照が検出されました: :trace',
+ 'max_depth_exceeded' => 'レイアウト継承の深さが最大許容深度(:max)を超えています。',
+ 'extends_must_be_string' => 'extends フィールドは文字列である必要があります。',
+ 'slots_must_be_object' => 'slots フィールドはオブジェクトである必要があります。',
'slot_name_must_be_string' => 'スロット名は文字列である必要があります。',
'slot_value_must_be_array' => 'slots[:slotName]は配列である必要があります。',
- 'slot_not_defined_in_parent' => 'スロット":slotName"は親レイアウトに定義されていません。',
+ 'slot_not_defined_in_parent' => 'スロット ":slotName" は親レイアウトで定義されていません。',
'parent_has_no_slots' => '親レイアウトにはスロットが定義されていません。',
- 'data_source_id_duplicate' => 'data_sourcesに重複したID":id"があります。',
- 'data_sources_must_be_array' => 'data_sourcesは配列である必要があります。',
- 'data_source_must_have_id' => 'data_sources[:index]に必須フィールド"id"が不足しています。',
- 'data_source_id_must_be_string' => 'data_sources[:index].idは文字列である必要があります。',
+ 'data_source_id_duplicate' => 'data_sources に重複するID ":id" があります。',
+ 'data_sources_must_be_array' => 'data_sources は配列である必要があります。',
+ 'data_source_must_have_id' => 'data_sources[:index]に必須フィールド "id" が見つかりません。',
+ 'data_source_id_must_be_string' => 'data_sources[:index].id は文字列である必要があります。',
],
'translatable' => [
'must_be_array' => '多言語フィールドは配列である必要があります。',
- 'unsupported_language' => 'サポートされていない言語コードです:\':lang\'',
- 'must_be_string' => '\':lang\'翻訳は文字列である必要があります。',
- 'max_length' => '\':lang\'翻訳は:max文字を超えることができません。',
- 'min_length' => '\':lang\'翻訳は最小:min文字以上である必要があります。',
+ 'unsupported_language' => 'サポートされていない言語コード: \':lang\'',
+ 'must_be_string' => '\':lang\' 翻訳は文字列である必要があります。',
+ 'max_length' => '\':lang\' 翻訳は :max文字を超えることはできません。',
+ 'min_length' => '\':lang\' 翻訳は最低 :min文字以上である必要があります。',
'at_least_one_required' => '最低1つの言語での翻訳が必要です。',
- 'current_locale_required' => ':localeの値は必須です。',
+ 'current_locale_required' => ':locale 言語の値は必須です。',
],
'template' => [
'name' => [
- 'max' => 'テンプレート名は:max文字を超えることができません。',
+ 'max' => 'テンプレート名は :max文字を超えることはできません。',
],
'description' => [
- 'max' => 'テンプレート説明は:max文字を超えることができません。',
+ 'max' => 'テンプレートの説明は :max文字を超えることはできません。',
],
'metadata' => [
- 'array' => 'metadataは配列である必要があります。',
+ 'array' => 'metadata は配列である必要があります。',
],
'status' => [
- 'in' => 'statusはactiveまたはinactiveである必要があります。',
+ 'in' => 'status は active または inactive である必要があります。',
],
],
'menu' => [
@@ -462,45 +550,45 @@ return [
'slug' => [
'required' => 'スラッグを入力してください。',
'unique' => '既に使用されているスラッグです。',
- 'max' => 'スラッグは:max文字を超えることができません。',
+ 'max' => 'スラッグは :max文字を超えることはできません。',
],
'url' => [
'required' => 'パス(URL)を入力してください。',
- 'max' => 'URLは:max文字を超えることができません。',
+ 'max' => 'URL は :max文字を超えることはできません。',
],
'icon' => [
- 'max' => 'アイコンは:max文字を超えることができません。',
+ 'max' => 'アイコンは :max文字を超えることはできません。',
],
'order' => [
'integer' => '順序は整数である必要があります。',
- 'min' => '順序は:min以上である必要があります。',
+ 'min' => '順序は :min 以上である必要があります。',
],
'parent_id' => [
'exists' => '存在しない親メニューです。',
],
'is_active' => [
- 'boolean' => '有効化ステータスはtrueまたはfalseである必要があります。',
+ 'boolean' => '有効化状態は true または false である必要があります。',
],
'extension_type' => [
- 'in' => '拡張タイプはcore、module、plugin のいずれかである必要があります。',
+ 'in' => '拡張タイプは core、module、plugin のいずれかである必要があります。',
],
'extension_identifier' => [
- 'max' => '拡張識別子は最大255文字まで入力可能です。',
+ 'max' => '拡張識別子は最大255文字まで入力できます。',
'must_be_string' => '拡張識別子は文字列である必要があります。',
- 'min_parts' => '拡張識別子はvendor-name形式である必要があります(例:sirsoft-board)。',
- 'empty_part' => '拡張識別子に空白部分があります。ハイフンが連続したり両端に来ることはできません。',
- 'invalid_characters' => '拡張識別子は英小文字、数字、アンダースコア(_)のみ使用できます。',
- 'empty_word' => '拡張識別子でアンダースコアが連続したり両端に来ることはできません。',
- 'word_starts_with_digit' => '拡張識別子の各単語は数字で始まることはできません。',
+ 'min_parts' => '拡張識別子は vendor-name 形式である必要があります (例: sirsoft-board)。',
+ 'empty_part' => '拡張識別子に空の部分があります。ハイフンが連続するか、両端にくることはできません。',
+ 'invalid_characters' => '拡張識別子は英文小文字、数字、アンダースコア(_)のみ使用できます。',
+ 'empty_word' => '拡張識別子でアンダースコアが連続するか、両端にくることはできません。',
+ 'word_starts_with_digit' => '拡張識別子の各単語は数字で始めることはできません。',
],
'parent_menus' => [
'required' => '親メニュー順序配列は必須です。',
'array' => '親メニュー順序は配列である必要があります。',
'min' => '最低1つ以上の親メニューが必要です。',
'id' => [
- 'required' => '各親メニューIDは必須です。',
- 'integer' => '親メニューIDは整数である必要があります。',
- 'exists' => '存在しない親メニューIDが含まれています。',
+ 'required' => '各親メニューID は必須です。',
+ 'integer' => '親メニューID は整数である必要があります。',
+ 'exists' => '存在しない親メニューID が含まれています。',
],
'order' => [
'required' => '各親メニュー順序は必須です。',
@@ -511,9 +599,9 @@ return [
'child_menus' => [
'array' => '子メニュー順序は配列である必要があります。',
'id' => [
- 'required' => '各子メニューIDは必須です。',
- 'integer' => '子メニューIDは整数である必要があります。',
- 'exists' => '存在しない子メニューIDが含まれています。',
+ 'required' => '各子メニューID は必須です。',
+ 'integer' => '子メニューID は整数である必要があります。',
+ 'exists' => '存在しない子メニューID が含まれています。',
],
'order' => [
'required' => '各子メニュー順序は必須です。',
@@ -524,13 +612,13 @@ return [
'moved_items' => [
'array' => '移動項目は配列である必要があります。',
'id' => [
- 'required' => '移動するメニューIDは必須です。',
- 'integer' => 'メニューIDは整数である必要があります。',
- 'exists' => '存在しないメニューIDが含まれています。',
+ 'required' => '移動するメニューID は必須です。',
+ 'integer' => 'メニューID は整数である必要があります。',
+ 'exists' => '存在しないメニューID が含まれています。',
],
'new_parent_id' => [
- 'integer' => '新しい親メニューIDは整数である必要があります。',
- 'exists' => '存在しない親メニューIDです。',
+ 'integer' => '新しい親メニューID は整数である必要があります。',
+ 'exists' => '存在しない親メニューID です。',
],
],
],
@@ -539,7 +627,7 @@ return [
'required' => '権限名を入力してください。',
],
'description' => [
- 'max' => '権限説明は:max文字を超えることができません。',
+ 'max' => '権限説明は :max文字を超えることはできません。',
],
],
'role' => [
@@ -547,60 +635,60 @@ return [
'required' => 'ロール名を入力してください。',
],
'description' => [
- 'max' => 'ロール説明は:max文字を超えることができません。',
+ 'max' => 'ロール説明は :max文字を超えることはできません。',
],
],
'module' => [
'status' => [
- 'in' => 'statusはactive、inactive、installed、uninstalledのいずれかである必要があります。',
+ 'in' => 'status は active、inactive、installed、uninstalled のいずれかである必要があります。',
],
],
'plugin' => [
'status' => [
- 'in' => 'statusはactive、inactive、installed、uninstalledのいずれかである必要があります。',
+ 'in' => 'status は active、inactive、installed、uninstalled のいずれかである必要があります。',
],
],
'template_path' => [
'must_be_string' => 'テンプレートパスは文字列である必要があります。',
- 'traversal_detected' => 'パストラバーサルパターンが検出されました::pattern',
+ 'traversal_detected' => 'パストラバーサルパターンが検出されました: :pattern',
'absolute_path_not_allowed' => '絶対パスは許可されていません。',
- 'null_byte_detected' => 'NULLバイト攻撃が検出されました。',
- 'outside_base_directory' => '基準ディレクトリ外のパスは許可されていません。',
- 'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子::extension(許可::allowed)',
+ 'null_byte_detected' => 'NULL バイト攻撃が検出されました。',
+ 'outside_base_directory' => '基本ディレクトリ外のパスは許可されていません。',
+ 'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子: :extension (許可: :allowed)',
],
'module_path' => [
'must_be_string' => 'パスは文字列である必要があります。',
- 'traversal_detected' => 'パストラバーサルが検出されました::pattern',
- 'absolute_path_not_allowed' => '絶対パスは許可されていません。',
- 'null_byte_detected' => 'NULLバイトが検出されました。',
- 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されていません。',
+ 'traversal_detected' => 'パストトラバーサルが検出されました: :pattern',
+ 'absolute_path_not_allowed' => '絶対パスは許可されません。',
+ 'null_byte_detected' => 'NULL バイトが検出されました。',
+ 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。',
],
'plugin_path' => [
'must_be_string' => 'パスは文字列である必要があります。',
- 'traversal_detected' => 'パストラバーサルが検出されました: :pattern',
- 'absolute_path_not_allowed' => '絶対パスは許可されていません。',
- 'null_byte_detected' => 'NULLバイトが検出されました。',
- 'outside_base_directory' => '基本ディレクトリ外へのアクセスは許可されていません。',
+ 'traversal_detected' => 'パストトラバーサルが検出されました: :pattern',
+ 'absolute_path_not_allowed' => '絶対パスは許可されません。',
+ 'null_byte_detected' => 'NULL バイトが検出されました。',
+ 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。',
],
'auth' => [
'email' => [
- 'required' => 'メールは必須です。',
- 'email' => '正しいメール形式ではありません。',
- 'exists' => '登録されていないメールです。',
- 'unique' => '既に使用中のメールです。',
+ 'required' => 'メールアドレスは必須です。',
+ 'email' => '正しいメールアドレス形式ではありません。',
+ 'exists' => '登録されていないメールアドレスです。',
+ 'unique' => 'すでに使用中のメールアドレスです。',
],
'password' => [
'required' => 'パスワードは必須です。',
- 'min' => 'パスワードは最小:min文字以上である必要があります。',
- 'confirmed' => 'パスワード確認が一致しません。',
+ 'min' => 'パスワードは最小 :min 文字以上である必要があります。',
+ 'confirmed' => 'パスワードの確認が一致しません。',
],
'name' => [
'required' => '名前は必須です。',
- 'min' => '名前は最小:min文字以上である必要があります。',
- 'max' => '名前は:max文字を超えることはできません。',
+ 'min' => '名前は最小 :min 文字以上である必要があります。',
+ 'max' => '名前は :max 文字を超えることはできません。',
],
'nickname' => [
- 'max' => 'ニックネームは:max文字を超えることはできません。',
+ 'max' => 'ニックネームは :max 文字を超えることはできません。',
],
'agree_terms' => [
'accepted' => '利用規約に同意してください。',
@@ -627,59 +715,59 @@ return [
'value' => [
'required' => '設定値は必須です。',
'string' => '設定値は文字列である必要があります。',
- 'max' => '設定値は:max文字を超えることはできません。',
+ 'max' => '設定値は :max 文字を超えることはできません。',
],
],
- 'exclude_current_user' => '現在ログインしているユーザーは一括変更対象に含めることはできません。',
+ 'exclude_current_user' => '現在ログインしているユーザーは一括変更の対象に含めることはできません。',
'not_self_parent' => '自分自身を親として設定することはできません。',
'not_circular_parent' => '自分自身のサブメニューに移動することはできません。',
'settings' => [
'site_name_required' => 'サイト名を入力してください。',
- 'site_name_max' => 'サイト名は100文字を超えることはできません。',
- 'site_url_required' => 'サイトURLを入力してください。',
- 'site_url_invalid' => '正しいURL形式ではありません。',
- 'site_url_max' => 'サイトURLは255文字を超えることはできません。',
- 'site_description_max' => 'サイト説明は500文字を超えることはできません。',
- 'admin_email_required' => '管理者メールを入力してください。',
- 'admin_email_invalid' => '正しいメール形式ではありません。',
- 'admin_email_max' => '管理者メールは255文字を超えることはできません。',
+ 'site_name_max' => 'サイト名は 100 文字を超えることはできません。',
+ 'site_url_required' => 'サイト URL を入力してください。',
+ 'site_url_invalid' => '正しい URL 形式ではありません。',
+ 'site_url_max' => 'サイト URL は 255 文字を超えることはできません。',
+ 'site_description_max' => 'サイト説明は 500 文字を超えることはできません。',
+ 'admin_email_required' => '管理者メールアドレスを入力してください。',
+ 'admin_email_invalid' => '正しいメールアドレス形式ではありません。',
+ 'admin_email_max' => '管理者メールアドレスは 255 文字を超えることはできません。',
'timezone_required' => 'タイムゾーンを選択してください。',
'timezone_invalid' => '正しいタイムゾーンを選択してください。',
'language_required' => 'デフォルト言語を選択してください。',
'language_invalid' => '正しい言語を選択してください。',
- 'currency_max' => '通貨コードは10文字を超えることはできません。',
- 'maintenance_mode_boolean' => 'メンテナンスモードはtrueまたはfalse値である必要があります。',
+ 'currency_max' => '通貨コードは 10 文字を超えることはできません。',
+ 'maintenance_mode_boolean' => 'メンテナンスモードは true または false である必要があります。',
'mailer_required' => 'メーラーを選択してください。',
'mailer_invalid' => '正しいメーラーを選択してください。',
- 'host_required' => 'SMTPホストを入力してください。',
- 'host_max' => 'SMTPホストは255文字を超えることはできません。',
+ 'host_required' => 'SMTP ホストを入力してください。',
+ 'host_max' => 'SMTP ホストは 255 文字を超えることはできません。',
'port_required' => 'ポートを入力してください。',
'port_integer' => 'ポートは整数である必要があります。',
- 'port_min' => 'ポートは1以上である必要があります。',
- 'port_max' => 'ポートは65535を超えることはできません。',
- 'username_max' => 'ユーザー名は255文字を超えることはできません。',
- 'password_max' => 'パスワードは255文字を超えることはできません。',
+ 'port_min' => 'ポートは 1 以上である必要があります。',
+ 'port_max' => 'ポートは 65535 を超えることはできません。',
+ 'username_max' => 'ユーザー名は 255 文字を超えることはできません。',
+ 'password_max' => 'パスワードは 255 文字を超えることはできません。',
'encryption_invalid' => '正しい暗号化方式を選択してください。',
- 'from_address_required' => '送信者メールを入力してください。',
- 'from_address_invalid' => '正しいメール形式ではありません。',
- 'from_address_max' => '送信者メールは255文字を超えることはできません。',
+ 'from_address_required' => '送信者メールアドレスを入力してください。',
+ 'from_address_invalid' => '正しいメールアドレス形式ではありません。',
+ 'from_address_max' => '送信者メールアドレスは 255 文字を超えることはできません。',
'from_name_required' => '送信者名を入力してください。',
- 'from_name_max' => '送信者名は255文字を超えることはできません。',
+ 'from_name_max' => '送信者名は 255 文字を超えることはできません。',
'max_file_size_required' => '最大ファイルサイズを入力してください。',
'max_file_size_integer' => '最大ファイルサイズは整数である必要があります。',
- 'max_file_size_min' => '最大ファイルサイズは1MB以上である必要があります。',
- 'max_file_size_max' => '最大ファイルサイズは1024MBを超えることはできません。',
- 'allowed_extensions_required' => '許可されている拡張子を入力してください。',
- 'allowed_extensions_max' => '許可されている拡張子は500文字を超えることはできません。',
- 'allowed_extensions_invalid_type' => '許可されている拡張子は文字列または配列である必要があります。',
+ 'max_file_size_min' => '最大ファイルサイズは 1MB 以上である必要があります。',
+ 'max_file_size_max' => '最大ファイルサイズは 1024MB を超えることはできません。',
+ 'allowed_extensions_required' => '許可する拡張子を入力してください。',
+ 'allowed_extensions_max' => '許可する拡張子は 500 文字を超えることはできません。',
+ 'allowed_extensions_invalid_type' => '許可する拡張子は文字列または配列である必要があります。',
'image_max_width_integer' => '画像の最大幅は整数である必要があります。',
- 'image_max_width_min' => '画像の最大幅は100px以上である必要があります。',
- 'image_max_width_max' => '画像の最大幅は10000pxを超えることはできません。',
+ 'image_max_width_min' => '画像の最大幅は 100px 以上である必要があります。',
+ 'image_max_width_max' => '画像の最大幅は 10000px を超えることはできません。',
'image_max_height_integer' => '画像の最大高さは整数である必要があります。',
- 'image_max_height_min' => '画像の最大高さは100px以上である必要があります。',
- 'image_max_height_max' => '画像の最大高さは10000pxを超えることはできません。',
+ 'image_max_height_min' => '画像の最大高さは 100px 以上である必要があります。',
+ 'image_max_height_max' => '画像の最大高さは 10000px を超えることはできません。',
'image_quality_integer' => '画像品質は整数である必要があります。',
- 'image_quality_min' => '画像品質は1以上である必要があります。',
+ 'image_quality_min' => '画像品質は 1 以上である必要があります。',
'image_quality_max' => '画像品質は100を超えることはできません。',
'meta_title_suffix_max' => 'タイトルサフィックスは100文字を超えることはできません。',
'meta_description_max' => 'メタ説明は160文字を超えることはできません。',
@@ -687,79 +775,110 @@ return [
'google_analytics_id_max' => 'Google Analytics IDは50文字を超えることはできません。',
'google_site_verification_max' => 'Google サイト確認コードは100文字を超えることはできません。',
'naver_site_verification_max' => 'Naver サイト確認コードは100文字を超えることはできません。',
+ 'generator_enabled_boolean' => 'Generator タグ表示設定はtrue又はfalse値である必要があります。',
+ 'generator_content_string' => 'Generator コンテンツは文字列である必要があります。',
+ 'generator_content_max' => 'Generator コンテンツは200文字を超えることはできません。',
'bot_user_agents_array' => 'ボット User-Agent リストは配列である必要があります。',
- 'bot_user_agents_item_string' => 'ボット User-Agent 項目は文字列である必要があります。',
- 'bot_user_agents_item_max' => 'ボット User-Agent 項目は100文字を超えることはできません。',
- 'bot_detection_enabled_boolean' => 'ボット検出設定は true または false 値である必要があります。',
- 'seo_cache_enabled_boolean' => 'SEO キャッシュは true または false 値である必要があります。',
- 'seo_cache_ttl_integer' => 'SEO キャッシュ有効期限は整数である必要があります。',
- 'seo_cache_ttl_min' => 'SEO キャッシュ有効期限は最小0秒である必要があります。',
- 'seo_cache_ttl_max' => 'SEO キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。',
- 'sitemap_enabled_boolean' => 'Sitemap 生成設定は true または false 値である必要があります。',
- 'sitemap_cache_ttl_integer' => 'Sitemap キャッシュ TTL は整数である必要があります。',
- 'sitemap_cache_ttl_min' => 'Sitemap キャッシュ TTL は最小3600秒(1時間)以上である必要があります。',
- 'sitemap_cache_ttl_max' => 'Sitemap キャッシュ TTL は最大604800秒(7日)を超えることはできません。',
- 'sitemap_schedule_invalid' => '有効な Sitemap 生成周期を選択してください。',
- 'sitemap_schedule_time_invalid' => 'Sitemap 生成時刻は HH:mm 形式である必要があります。',
- 'force_https_required' => 'HTTPS 強制適用設定を選択してください。',
- 'force_https_boolean' => 'HTTPS 強制適用は true または false 値である必要があります。',
+ 'bot_user_agents_item_string' => 'ボット User-Agent アイテムは文字列である必要があります。',
+ 'bot_user_agents_item_max' => 'ボット User-Agent アイテムは100文字を超えることはできません。',
+ 'bot_detection_enabled_boolean' => 'ボット検出設定はtrue又はfalse値である必要があります。',
+ 'bot_detection_library_enabled_boolean' => 'ボット検出ライブラリ設定はtrue又はfalse値である必要があります。',
+ 'og_default_site_name_string' => 'OG サイト名デフォルト値は文字列である必要があります。',
+ 'og_default_site_name_max' => 'OG サイト名デフォルト値は200文字を超えることはできません。',
+ 'og_image_default_width_integer' => 'OG 画像デフォルト幅は整数である必要があります。',
+ 'og_image_default_width_min' => 'OG 画像デフォルト幅は0以上である必要があります。',
+ 'og_image_default_width_max' => 'OG 画像デフォルト幅は8000以下である必要があります。',
+ 'og_image_default_height_integer' => 'OG 画像デフォルト高さは整数である必要があります。',
+ 'og_image_default_height_min' => 'OG 画像デフォルト高さは0以上である必要があります。',
+ 'og_image_default_height_max' => 'OG 画像デフォルト高さは8000以下である必要があります。',
+ 'twitter_default_card_string' => 'Twitter カードデフォルト値は文字列である必要があります。',
+ 'twitter_default_card_in' => 'Twitter カードデフォルト値はsummary、summary_large_image、app、playerのいずれかである必要があります。',
+ 'twitter_default_site_string' => 'Twitter サイトハンドルは文字列である必要があります。',
+ 'twitter_default_site_max' => 'Twitter サイトハンドルは50文字を超えることはできません。',
+ 'seo_cache_enabled_boolean' => 'SEO キャッシュ設定はtrue又はfalse値である必要があります。',
+ 'seo_cache_ttl_integer' => 'SEO キャッシュ TTLは整数である必要があります。',
+ 'seo_cache_ttl_min' => 'SEO キャッシュ TTLは最小60秒以上である必要があります。',
+ 'seo_cache_ttl_max' => 'SEO キャッシュ TTLは最大86400秒(24時間)を超えることはできません。',
+ 'sitemap_enabled_boolean' => 'Sitemap 生成設定はtrue又はfalse値である必要があります。',
+ 'sitemap_cache_ttl_integer' => 'Sitemap キャッシュ TTLは整数である必要があります。',
+ 'sitemap_cache_ttl_min' => 'Sitemap キャッシュ TTLは最小3600秒(1時間)以上である必要があります。',
+ 'sitemap_cache_ttl_max' => 'Sitemap キャッシュ TTLは最大604800秒(7日)を超えることはできません。',
+ 'sitemap_urls_per_file_integer' => 'Sitemap ファイルあたりURL数は整数である必要があります。',
+ 'sitemap_urls_per_file_min' => 'Sitemap ファイルあたりURL数は最小1000件以上である必要があります。',
+ 'sitemap_urls_per_file_max' => 'Sitemap ファイルあたりURL数は最大50000件を超えることはできません。',
+ 'sitemap_gzip_boolean' => 'Sitemap 圧縮設定はtrue又はfalse値である必要があります。',
+ 'sitemap_serve_stale_on_miss_boolean' => '前Sitemap 提供設定はtrue又はfalse値である必要があります。',
+ 'sitemap_max_urls_per_contributor_integer' => 'クローラーあたり最大URL数は整数である必要があります。',
+ 'sitemap_max_urls_per_contributor_min' => 'クローラーあたり最大URL数は0以上である必要があります。',
+ 'sitemap_schedule_invalid' => '有効なSitemap生成周期を選択してください。',
+ 'sitemap_schedule_time_invalid' => 'Sitemap生成時刻はHH:mm形式である必要があります。',
+ 'force_https_required' => 'HTTPS強制適用設定を選択してください。',
+ 'force_https_boolean' => 'HTTPS強制適用はtrue又はfalse値である必要があります。',
+ 'allow_internal_outbound_urls_boolean' => '内部ネットワークアドレス呼び出し許可はtrue又はfalse値である必要があります。',
'login_attempt_enabled_required' => 'ログイン試行制限設定を選択してください。',
- 'login_attempt_enabled_boolean' => 'ログイン試行制限は true または false 値である必要があります。',
+ 'login_attempt_enabled_boolean' => 'ログイン試行制限はtrue又はfalse値である必要があります。',
'auth_token_lifetime_integer' => '認証トークン保持時間は整数である必要があります。',
'auth_token_lifetime_min' => '認証トークン保持時間は0以上である必要があります。',
'auth_token_lifetime_max' => '認証トークン保持時間は最大3600分(60時間)を超えることはできません。',
- 'auth_token_lifetime_range' => '認証トークン保持時間は0(無制限)または30~3600分の間である必要があります。',
+ 'auth_token_lifetime_range' => '認証トークン保持時間は0(無限)又は30~3600分の間である必要があります。',
'max_login_attempts_integer' => '最大ログイン試行回数は整数である必要があります。',
'max_login_attempts_min' => '最大ログイン試行回数は0以上である必要があります。',
'max_login_attempts_max' => '最大ログイン試行回数は100回を超えることはできません。',
'login_lockout_time_integer' => 'ブロック時間は整数である必要があります。',
'login_lockout_time_min' => 'ブロック時間は0以上である必要があります。',
'login_lockout_time_max' => 'ブロック時間は最大1440分(24時間)を超えることはできません。',
- 'cache_enabled_required' => '全体 キャッシュ有効化設定を選択してください。',
- 'cache_enabled_boolean' => 'キャッシュ有効化は true または false 値である必要があります。',
- 'layout_cache_enabled_required' => 'レイアウト キャッシュ設定を選択してください。',
- 'layout_cache_enabled_boolean' => 'レイアウト キャッシュは true または false 値である必要があります。',
- 'layout_cache_ttl_required' => 'レイアウト キャッシュ有効期限を入力してください。',
- 'layout_cache_ttl_integer' => 'レイアウト キャッシュ有効期限は整数である必要があります。',
- 'layout_cache_ttl_min' => 'レイアウト キャッシュ有効期限は最小0秒である必要があります。',
- 'layout_cache_ttl_max' => 'レイアウト キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。',
- 'stats_cache_enabled_required' => '統計 キャッシュ設定を選択してください。',
- 'stats_cache_enabled_boolean' => '統計 キャッシュは true または false 値である必要があります。',
- 'stats_cache_ttl_required' => '統計 キャッシュ有効期限を入力してください。',
- 'stats_cache_ttl_integer' => '統計 キャッシュ有効期限は整数である必要があります。',
- 'stats_cache_ttl_min' => '統計 キャッシュ有効期限は最小0秒である必要があります。',
- 'stats_cache_ttl_max' => '統計 キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。',
- 'seo_cache_enabled_required' => 'SEO キャッシュ設定を選択してください。',
- 'seo_cache_ttl_required' => 'SEO キャッシュ有効期限を入力してください。',
+ 'cache_enabled_required' => '全体キャッシュ有効化設定を選択してください。',
+ 'cache_enabled_boolean' => 'キャッシュ有効化はtrue又はfalse値である必要があります。',
+ 'layout_cache_enabled_required' => 'レイアウトキャッシュ設定を選択してください。',
+ 'layout_cache_enabled_boolean' => 'レイアウトキャッシュはtrue又はfalse値である必要があります。',
+ 'layout_cache_ttl_required' => 'レイアウトキャッシュ有効期限を入力してください。',
+ 'layout_cache_ttl_integer' => 'レイアウトキャッシュ有効期限は整数である必要があります。',
+ 'layout_cache_ttl_min' => 'レイアウトキャッシュ有効期限は最小0秒である必要があります。',
+ 'layout_cache_ttl_max' => 'レイアウトキャッシュ有効期限は最大14400秒(4時間)を超えることはできません。',
+ 'stats_cache_enabled_required' => '統計キャッシュ設定を選択してください。',
+ 'stats_cache_enabled_boolean' => '統計キャッシュはtrue又はfalse値である必要があります。',
+ 'stats_cache_ttl_required' => '統計キャッシュ有効期限を入力してください。',
+ 'stats_cache_ttl_integer' => '統計キャッシュ有効期限は整数である必要があります。',
+ 'stats_cache_ttl_min' => '統計キャッシュ有効期限は最小0秒である必要があります。',
+ 'stats_cache_ttl_max' => '統計キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。',
+ 'advanced_seo_cache_enabled_required' => 'SEO キャッシュ設定を選択してください。',
+ 'advanced_seo_cache_enabled_boolean' => 'SEO キャッシュはtrue又はfalse値である必要があります。',
+ 'advanced_seo_cache_ttl_required' => 'SEO キャッシュ有効期限を入力してください。',
+ 'advanced_seo_cache_ttl_integer' => 'SEO キャッシュ有効期限は整数である必要があります。',
+ 'advanced_seo_cache_ttl_min' => 'SEO キャッシュ有効期限は最小0秒である必要があります。',
+ 'advanced_seo_cache_ttl_max' => 'SEO キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。',
+ 'seo_sitemap_cache_ttl_integer' => 'Sitemap キャッシュ有効期限は整数である必要があります。',
+ 'seo_sitemap_cache_ttl_min' => 'Sitemapキャッシュの有効期限は最小3600秒(1時間)以上である必要があります。',
+ 'seo_sitemap_cache_ttl_max' => 'Sitemapキャッシュの有効期限は最大604800秒(7日)を超えることはできません。',
'debug_mode_required' => 'デバッグモード設定を選択してください。',
- 'debug_mode_boolean' => 'デバッグモードは true または false 値である必要があります。',
- 'sql_query_log_required' => 'SQL クエリログ設定を選択してください。',
- 'sql_query_log_boolean' => 'SQL クエリログは true または false 値である必要があります。',
- 'core_update_github_url_invalid' => 'GitHub リポジトリ URL 形式が正しくありません。',
- 'core_update_github_url_max' => 'GitHub リポジトリ URL は500文字を超えることはできません。',
- 'core_update_github_token_max' => 'GitHub アクセストークンは500文字を超えることはできません。',
+ 'debug_mode_boolean' => 'デバッグモードはtrueまたはfalse値である必要があります。',
+ 'sql_query_log_required' => 'SQLクエリログ設定を選択してください。',
+ 'sql_query_log_boolean' => 'SQLクエリログはtrueまたはfalse値である必要があります。',
+ 'core_update_github_url_invalid' => 'GitHubリポジトリURLの形式が正しくありません。',
+ 'core_update_github_url_max' => 'GitHubリポジトリURLは500字を超えることはできません。',
+ 'core_update_github_token_max' => 'GitHubアクセストークンは500字を超えることはできません。',
'storage_driver_required' => 'ストレージドライバーを選択してください。',
'storage_driver_invalid' => '正しいストレージドライバーを選択してください。',
- 's3_bucket_max' => 'S3 バケット名は255文字を超えることはできません。',
- 's3_region_invalid' => '正しい S3 リージョンを選択してください。',
- 's3_access_key_max' => 'S3 Access Key は255文字を超えることはできません。',
- 's3_secret_key_max' => 'S3 Secret Key は255文字を超えることはできません。',
- 's3_url_invalid' => '正しい S3 URL 形式ではありません。',
- 's3_url_max' => 'S3 URL は500文字を超えることはできません。',
+ 's3_bucket_max' => 'S3バケット名は255字を超えることはできません。',
+ 's3_region_invalid' => '正しいS3リージョンを選択してください。',
+ 's3_access_key_max' => 'S3 Access Keyは255字を超えることはできません。',
+ 's3_secret_key_max' => 'S3 Secret Keyは255字を超えることはできません。',
+ 's3_url_invalid' => '正しいS3 URL形式ではありません。',
+ 's3_url_max' => 'S3 URLは500字を超えることはできません。',
'cache_driver_required' => 'キャッシュドライバーを選択してください。',
'cache_driver_invalid' => '正しいキャッシュドライバーを選択してください。',
- 'redis_host_max' => 'Redis ホストは255文字を超えることはできません。',
- 'redis_port_integer' => 'Redis ポートは整数である必要があります。',
- 'redis_port_min' => 'Redis ポートは1以上である必要があります。',
- 'redis_port_max' => 'Redis ポートは65535を超えることはできません。',
- 'redis_password_max' => 'Redis パスワードは255文字を超えることはできません。',
- 'redis_database_integer' => 'Redis データベースは整数である必要があります。',
- 'redis_database_min' => 'Redis データベースは0以上である必要があります。',
- 'redis_database_max' => 'Redis データベースは15を超えることはできません。',
- 'memcached_host_max' => 'Memcached ホストは255文字を超えることはできません。',
- 'memcached_port_integer' => 'Memcached ポートは整数である必要があります。',
- 'memcached_port_min' => 'Memcached ポートは1以上である必要があります。',
- 'memcached_port_max' => 'Memcached ポートは65535を超えることはできません。',
+ 'redis_host_max' => 'Redisホストは255字を超えることはできません。',
+ 'redis_port_integer' => 'Redisポートは整数である必要があります。',
+ 'redis_port_min' => 'Redisポートは1以上である必要があります。',
+ 'redis_port_max' => 'Redisポートは65535を超えることはできません。',
+ 'redis_password_max' => 'Redisパスワードは255字を超えることはできません。',
+ 'redis_database_integer' => 'Redisデータベースは整数である必要があります。',
+ 'redis_database_min' => 'Redisデータベースは0以上である必要があります。',
+ 'redis_database_max' => 'Redisデータベースは15を超えることはできません。',
+ 'memcached_host_max' => 'Memcachedホストは255字を超えることはできません。',
+ 'memcached_port_integer' => 'Memcachedポートは整数である必要があります。',
+ 'memcached_port_min' => 'Memcachedポートは1以上である必要があります。',
+ 'memcached_port_max' => 'Memcachedポートは65535を超えることはできません。',
'session_driver_required' => 'セッションドライバーを選択してください。',
'session_driver_invalid' => '正しいセッションドライバーを選択してください。',
'session_lifetime_integer' => 'セッション有効時間は整数である必要があります。',
@@ -767,61 +886,32 @@ return [
'session_lifetime_max' => 'セッション有効時間は最大30日(43200分)を超えることはできません。',
'queue_driver_required' => 'キュードライバーを選択してください。',
'queue_driver_invalid' => '正しいキュードライバーを選択してください。',
- 'websocket_enabled_boolean' => 'WebSocket有効化設定はtrueまたはfalse値である必要があります。',
+ 'websocket_enabled_boolean' => 'WebSocket使用設定はtrueまたはfalse値である必要があります。',
'websocket_app_id_required' => 'WebSocket使用時はアプリIDが必須です。',
- 'websocket_app_id_max' => 'WebSocketアプリIDは255文字を超えることはできません。',
+ 'websocket_app_id_max' => 'WebSocketアプリIDは255字を超えることはできません。',
'websocket_app_key_required' => 'WebSocket使用時はアプリキーが必須です。',
- 'websocket_app_key_max' => 'WebSocketアプリキーは255文字を超えることはできません。',
+ 'websocket_app_key_max' => 'WebSocketアプリキーは255字を超えることはできません。',
'websocket_app_secret_required' => 'WebSocket使用時はアプリシークレットが必須です。',
- 'websocket_app_secret_max' => 'WebSocketアプリシークレットは255文字を超えることはできません。',
- 'websocket_host_max' => 'WebSocketホストは255文字を超えることはできません。',
+ 'websocket_app_secret_max' => 'WebSocketアプリシークレットは255字を超えることはできません。',
+ 'websocket_host_max' => 'WebSocketホストは255字を超えることはできません。',
'websocket_port_integer' => 'WebSocketポートは整数である必要があります。',
'websocket_port_min' => 'WebSocketポートは1以上である必要があります。',
'websocket_port_max' => 'WebSocketポートは65535を超えることはできません。',
'websocket_scheme_invalid' => '正しいWebSocketプロトコルを選択してください。',
'websocket_verify_ssl_boolean' => 'SSL証明書検証設定はtrueまたはfalse値である必要があります。',
- 'websocket_server_host_max' => 'WebSocketサーバーホストは255文字を超えることはできません。',
+ 'websocket_server_host_max' => 'WebSocketサーバーホストは255字を超えることはできません。',
'websocket_server_port_integer' => 'WebSocketサーバーポートは整数である必要があります。',
'websocket_server_port_min' => 'WebSocketサーバーポートは1以上である必要があります。',
'websocket_server_port_max' => 'WebSocketサーバーポートは65535を超えることはできません。',
'websocket_server_scheme_invalid' => '正しいWebSocketサーバープロトコルを選択してください。',
'search_engine_driver_invalid' => '正しい検索エンジンドライバーを選択してください。',
- 'identity_default_provider_string' => 'デフォルトプロバイダーは文字列である必要があります。',
- 'identity_default_provider_max' => 'デフォルトプロバイダー識別子は100文字を超えることはできません。',
- 'identity_purpose_providers_array' => '目的別プロバイダーマッピングは配列である必要があります。',
- 'identity_purpose_provider_string' => '目的別プロバイダー識別子は文字列である必要があります。',
- 'identity_purpose_provider_max' => '目的別プロバイダー識別子は100文字を超えることはできません。',
- 'identity_challenge_ttl_required' => 'Challenge有効期限時間を入力してください。',
- 'identity_challenge_ttl_integer' => 'Challenge有効期限時間は整数である必要があります。',
- 'identity_challenge_ttl_min' => 'Challenge有効期限時間は最小1分以上である必要があります。',
- 'identity_challenge_ttl_max' => 'Challenge有効期限時間は最大1440分(24時間)を超えることはできません。',
- 'identity_max_attempts_required' => '最大試行回数を入力してください。',
- 'identity_max_attempts_integer' => '最大試行回数は整数である必要があります。',
- 'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。',
- 'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。',
- 'bot_detection_library_enabled_boolean' => 'ボット検出ライブラリ設定はtrueまたはfalse値である必要があります。',
- 'og_default_site_name_string' => 'OGサイト名デフォルト値は文字列である必要があります。',
- 'og_default_site_name_max' => 'OGサイト名デフォルト値は200文字を超えることはできません。',
- 'og_image_default_width_integer' => 'OG画像デフォルト横サイズは整数である必要があります。',
- 'og_image_default_width_min' => 'OG画像デフォルト横サイズは0以上である必要があります。',
- 'og_image_default_width_max' => 'OG画像デフォルト横サイズは8000以下である必要があります。',
- 'og_image_default_height_integer' => 'OG画像デフォルト縦サイズは整数である必要があります。',
- 'og_image_default_height_min' => 'OG画像デフォルト縦サイズは0以上である必要があります。',
- 'og_image_default_height_max' => 'OG画像デフォルト縦サイズは8000以下である必要があります。',
- 'twitter_default_card_string' => 'Twitterカードデフォルト値は文字列である必要があります。',
- 'twitter_default_card_in' => 'Twitterカードデフォルト値はsummary、summary_large_image、app、playerのいずれかである必要があります。',
- 'twitter_default_site_string' => 'Twitterサイトハンドルは文字列である必要があります。',
- 'twitter_default_site_max' => 'Twitterサイトハンドルは50文字を超えることはできません。',
- 'generator_enabled_boolean' => 'Generator タグ表示設定は true または false 値である必要があります。',
- 'generator_content_string' => 'Generator コンテンツは文字列である必要があります。',
- 'generator_content_max' => 'Generator コンテンツは200文字を超えることはできません。',
'log_driver_required' => 'ログドライバーを選択してください。',
'log_driver_invalid' => '正しいログドライバーを選択してください。',
'log_level_required' => 'ログレベルを選択してください。',
'log_level_invalid' => '正しいログレベルを選択してください。',
- 'log_days_integer' => 'ログ保管日数は整数である必要があります。',
- 'log_days_min' => 'ログ保管日数は1以上である必要があります。',
- 'log_days_max' => 'ログ保管日数は365を超えることはできません。',
+ 'log_days_integer' => 'ログ保持日数は整数である必要があります。',
+ 'log_days_min' => 'ログ保持日数は1以上である必要があります。',
+ 'log_days_max' => 'ログ保持日数は365を超えることはできません。',
's3_bucket_required' => 'S3バケット名は必須です。',
's3_region_required' => 'S3リージョンを選択してください。',
's3_access_key_required' => 'S3 Access Keyは必須です。',
@@ -836,15 +926,28 @@ return [
'websocket_host_required' => 'WebSocketホストは必須です。',
'websocket_port_required' => 'WebSocketポートは必須です。',
'websocket_scheme_required' => 'WebSocketプロトコルを選択してください。',
- 'allow_internal_outbound_urls_boolean' => '内部ネットワークアドレス呼び出し許可は true または false 値である必要があります。',
+ 'identity_default_provider_string' => 'デフォルトプロバイダーは文字列である必要があります。',
+ 'identity_default_provider_max' => 'デフォルトプロバイダー識別子は100字を超えることはできません。',
+ 'identity_purpose_providers_array' => '目的別プロバイダーマッピングは配列である必要があります。',
+ 'identity_purpose_provider_string' => '目的別プロバイダー識別子は文字列である必要があります。',
+ 'identity_purpose_provider_max' => '目的別プロバイダー識別子は100文字を超えることはできません。',
+ 'identity_challenge_ttl_required' => 'Challenge有効期限を入力してください。',
+ 'identity_challenge_ttl_integer' => 'Challenge有効期限は整数である必要があります。',
+ 'identity_challenge_ttl_min' => 'Challenge有効期限は最小1分以上である必要があります。',
+ 'identity_challenge_ttl_max' => 'Challenge有効期限は最大1440分(24時間)を超えることはできません。',
+ 'identity_max_attempts_required' => '最大試行回数を入力してください。',
+ 'identity_max_attempts_integer' => '最大試行回数は整数である必要があります。',
+ 'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。',
+ 'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。',
+ 'sitemap_hreflang_enabled_boolean' => 'Sitemap 多言語代替リンク(hreflang) 設定は true または false 値である必要があります。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
'key_max' => 'ポリシーキーは120文字を超えることはできません。',
- 'key_unique' => '既に使用中のポリシーキーです。',
+ 'key_unique' => '既に使用されているポリシーキーです。',
'scope_required' => 'スコープを選択してください。',
'scope_invalid' => 'スコープはroute、hook、customのいずれかである必要があります。',
- 'target_required' => 'ターゲット(ルートまたはフック名)を入力してください。',
+ 'target_required' => 'ターゲット(ルート名またはフック名)を入力してください。',
'target_max' => 'ターゲットは255文字を超えることはできません。',
'purpose_required' => '認証目的(Purpose)を選択してください。',
'purpose_max' => '認証目的は64文字を超えることはできません。',
@@ -857,17 +960,17 @@ return [
'priority_integer' => '優先度は整数である必要があります。',
'priority_min' => '優先度は0以上である必要があります。',
'priority_max' => '優先度は65535を超えることはできません。',
+ 'priority_duplicate' => '同じ適用位置(:target)に優先度:priorityの有効なポリシーが既に存在します。どちらのポリシーを先に適用するか決まらないため、優先度を変更するか既存のポリシーを無効化してください。',
'conditions_array' => '条件(conditions)は配列である必要があります。',
'applies_to_required' => '適用対象を選択してください。',
'applies_to_invalid' => '適用対象はself、admin、bothのいずれかである必要があります。',
'fail_mode_required' => '失敗モードを選択してください。',
'fail_mode_invalid' => '失敗モードはblockまたはlog_onlyである必要があります。',
- 'priority_duplicate' => '同じ適用位置(:target)に優先度 :priority のアクティブなポリシーが既に存在します。どのポリシーを先に適用するか決まらないため、優先度を異なるように指定するか、既存のポリシーを無効化してください。',
],
'identity_message' => [
'provider_not_registered' => '登録されていないIDVプロバイダーです。',
'scope_value_not_admin_policy' => '運営者が追加したポリシー(source_type=admin)のキーと一致する必要があります。',
- 'definition_already_exists' => '同一(provider、scope_type、scope_value)定義が既に存在します。',
+ 'definition_already_exists' => '同一の(provider、scope_type、scope_value)定義が既に存在します。',
],
'attributes' => [
'ids' => 'ユーザーID一覧',
@@ -881,7 +984,7 @@ return [
'language' => 'デフォルト言語',
'identity_default_provider' => 'デフォルトプロバイダー',
'identity_purpose_providers' => '目的別プロバイダー',
- 'identity_challenge_ttl_minutes' => 'Challenge有効期限時間',
+ 'identity_challenge_ttl_minutes' => 'Challenge有効期限',
'identity_max_attempts' => '最大試行回数',
'identity_policy_key' => 'ポリシーキー',
'identity_policy_scope' => 'Scope',
@@ -893,20 +996,20 @@ return [
'identity_policy_applies_to' => '適用対象',
'identity_policy_fail_mode' => '失敗モード',
'mailer' => 'メーラー',
- 'host' => 'SMTP ホスト',
- 'port' => 'SMTP ポート',
- 'username' => 'SMTP ユーザー名',
- 'password' => 'SMTP パスワード',
+ 'host' => 'SMTPホスト',
+ 'port' => 'SMTPポート',
+ 'username' => 'SMTPユーザー名',
+ 'password' => 'SMTPパスワード',
'encryption' => '暗号化',
- 'from_address' => '送信元メール',
+ 'from_address' => '送信元メールアドレス',
'from_name' => '送信元名',
'max_file_size' => '最大ファイルサイズ',
- 'allowed_extensions' => '許可される拡張子',
+ 'allowed_extensions' => '許可拡張子',
'image_max_width' => '画像最大幅',
'image_max_height' => '画像最大高さ',
'image_quality' => '画像品質',
- 'meta_title_suffix' => 'メタタイトル接尾辞',
- 'meta_description' => 'メタ説明',
+ 'meta_title_suffix' => 'メタタイトルサフィックス',
+ 'meta_description' => 'メタディスクリプション',
'meta_keywords' => 'メタキーワード',
'google_analytics_id' => 'Google Analytics ID',
'google_site_verification' => 'Google サイト確認',
@@ -915,112 +1018,24 @@ return [
'to_version' => '終了バージョン',
'storage_driver' => 'ストレージドライバー',
's3_bucket' => 'S3バケット',
- 's3_region' => 'S3リージョン',
+ 's3_region' => 'S3 リージョン',
's3_access_key' => 'S3 Access Key',
's3_secret_key' => 'S3 Secret Key',
's3_url' => 'S3 URL',
- 'cache_driver' => 'キャッシュドライバー',
- 'redis_host' => 'Redisホスト',
- 'redis_port' => 'Redisポート',
- 'redis_password' => 'Redisパスワード',
- 'redis_database' => 'Redisデータベース',
- 'memcached_host' => 'Memcachedホスト',
- 'memcached_port' => 'Memcachedポート',
- 'session_driver' => 'セッションドライバー',
- 'session_lifetime' => 'セッション有効時間',
- 'queue_driver' => 'キュードライバー',
- 'websocket_enabled' => 'WebSocket使用',
- 'websocket_app_key' => 'WebSocketアプリキー',
- 'websocket_host' => 'WebSocketホスト',
- 'websocket_port' => 'WebSocketポート',
- 'websocket_scheme' => 'WebSocketプロトコル',
- ],
- 'layout_extension' => [
- 'invalid_json' => '無効なJSON形式です。',
- 'must_be_array' => 'レイアウト拡張データは配列である必要があります。',
- 'target_required' => '拡張定義には「extension_point」または「target_layout」のいずれかが必要です。',
- 'target_exclusive' => '「extension_point」と「target_layout」は同時に指定できません。',
- 'extension_point_invalid' => 'extension_pointフィールドは空でない文字列である必要があります。',
- 'target_layout_invalid' => 'target_layoutフィールドは空でない文字列である必要があります。',
- 'components_must_be_array' => 'componentsフィールドは配列である必要があります。',
- 'injections_required' => 'overlay拡張にはinjectionsフィールドが必要です。',
- 'injections_must_be_array' => 'injectionsフィールドは配列である必要があります。',
- 'injection_must_be_array' => 'injections[:index]は配列である必要があります。',
- 'injection_target_id_required' => 'injections[:index]にtarget_idフィールドが必要です。',
- 'injection_position_invalid' => 'injections[:index].positionの値が無効です。',
- 'injection_components_must_be_array' => 'injections[:index].componentsは配列である必要があります。',
- 'injection_props_must_be_array' => 'injections[:index].propsは配列である必要があります。',
- 'section_must_be_array' => ':sectionフィールドは配列である必要があります。',
- 'max_depth_exceeded' => 'コンポーネントのネスト深度が最大許可深度(:max)を超えています。',
- 'component_must_be_array' => 'components[:index]は配列である必要があります。',
- 'component_required_field_missing' => 'components[:index]に必須フィールド「:field」が不足しています。',
- 'component_name_must_be_string' => 'components[:index].nameは文字列である必要があります。',
- 'component_type_invalid' => 'components[:index].typeはbasic、composite、layoutのいずれかである必要があります。',
- 'props_must_be_object' => 'components[:index].propsはオブジェクト(配列)である必要があります。',
- 'children_must_be_array' => 'components[:index].childrenは配列である必要があります。',
- 'content' => [
- 'required' => '拡張コンテンツは必須です。',
- 'array' => '拡張コンテンツは配列である必要があります。',
- ],
- 'priority' => [
- 'integer' => 'priorityは整数である必要があります。',
- 'min' => 'priorityは0以上である必要があります。',
- 'max' => 'priorityは9999以下である必要があります。',
- ],
- 'data_sources' => [
- 'array' => 'data_sourcesは配列である必要があります。',
- ],
- 'preview_layout' => [
- 'string' => 'プレビューレイアウト名は文字列である必要があります。',
- 'max' => 'プレビューレイアウト名は255文字を超えることはできません。',
- 'required' => '拡張ポイントプレビューには代表レイアウトの選択が必要です。',
- ],
- 'expected_lock_version' => [
- 'required' => '保存リクエストに expected_lock_version が不足しています。',
- 'integer' => 'expected_lock_version は整数である必要があります。',
- 'min' => 'expected_lock_version は0以上である必要があります。',
- ],
- ],
- 'custom_translation' => [
- 'layout_name' => [
- 'required' => 'レイアウト名は必須です。',
- 'string' => 'レイアウト名は文字列である必要があります。',
- 'max' => 'レイアウト名は:max文字を超えることはできません。',
- ],
- 'locale' => [
- 'required' => 'ロケールは必須です。',
- 'string' => 'ロケールは文字列である必要があります。',
- ],
- 'value' => [
- 'required' => '翻訳値は必須です。',
- 'string' => '翻訳値は文字列である必要があります。',
- ],
- 'values' => [
- 'required' => '翻訳値は必須です。',
- 'array' => '翻訳値はロケール別のオブジェクトである必要があります。',
- ],
- 'status' => [
- 'in' => 'ステータスはactiveまたはorphanedである必要があります。',
- ],
- 'expected_lock_version' => [
- 'required' => '保存リクエストにexpected_lock_versionが不足しています。',
- 'integer' => 'expected_lock_versionは整数である必要があります。',
- 'min' => 'expected_lock_versionは0以上である必要があります。',
- ],
- 'ids' => [
- 'required' => '削除する多言語キーを1つ以上選択する必要があります。',
- 'array' => '削除対象はID配列である必要があります。',
- 'min' => '削除する多言語キーを1つ以上選択する必要があります。',
- 'integer' => '多言語キーIDは整数である必要があります。',
- 'exists' => '存在しない多言語キーが含まれています。',
- ],
- ],
- 'outbound_url' => [
- 'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。',
- 'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。',
- ],
- 'schedule_command' => [
- 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)・セミコロン(;)などの特殊文字は使用できません。',
- 'artisan_denied' => 'このArtisanコマンドはセキュリティ上、スケジュールで実行することはできません。',
+ 'cache_driver' => 'キャッシュ ドライバー',
+ 'redis_host' => 'Redis ホスト',
+ 'redis_port' => 'Redis ポート',
+ 'redis_password' => 'Redis パスワード',
+ 'redis_database' => 'Redis データベース',
+ 'memcached_host' => 'Memcached ホスト',
+ 'memcached_port' => 'Memcached ポート',
+ 'session_driver' => 'セッション ドライバー',
+ 'session_lifetime' => 'セッション 有効期限',
+ 'queue_driver' => 'キュー ドライバー',
+ 'websocket_enabled' => 'WebSocket 使用',
+ 'websocket_app_key' => 'WebSocket アプリ キー',
+ 'websocket_host' => 'WebSocket ホスト',
+ 'websocket_port' => 'WebSocket ポート',
+ 'websocket_scheme' => 'WebSocket プロトコル',
],
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php b/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php
index c3f2ebc5..97bf3402 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php
@@ -3,18 +3,18 @@
return [
'mode' => [
'auto' => '自動 (推奨)',
- 'composer' => 'Composer を実行',
- 'bundled' => 'バンドル Vendor を使用',
+ 'composer' => 'Composer 実行',
+ 'bundled' => 'バンドル Vendor 使用',
],
'installer' => [
- 'checking_bundle' => 'Vendor バンドルを検証中...',
- 'extracting_bundle' => 'Vendor バンドルを抽出中 ({current}/{total})',
- 'running_composer' => 'Composer install を実行中...',
+ 'checking_bundle' => 'Vendor バンドル検証中...',
+ 'extracting_bundle' => 'Vendor バンドル抽出中 ({current}/{total})',
+ 'running_composer' => 'Composer install 実行中...',
'mode_label' => 'Vendor インストール方式',
'mode_hint' => 'Composer が使用できない環境ではバンドルモードを選択してください。',
],
'build' => [
- 'start' => '{target} をビルド中...',
+ 'start' => '{target} ビルド中...',
'success' => '{target} ビルド完了 ({size}, {packages} packages)',
'skipped_no_deps' => '{target} スキップ (外部 composer 依存関係なし)',
'skipped_not_installed' => '{target} スキップ (拡張がインストールされていません)',
diff --git a/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json b/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json
index 4cb92766..b18d3985 100644
--- a/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json
+++ b/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json
@@ -2,8 +2,8 @@
"chrome": {
"toolbar": {
"toggle_route_tree": "ルートツリーを折りたたむ/展開",
- "undo": "元に戻す",
- "redo": "やり直す",
+ "undo": "取り消し",
+ "redo": "やり直し",
"template_route_separator": " ▸ ",
"locale_switcher_label": "コンテンツ言語",
"add_element": "要素を追加",
@@ -15,8 +15,11 @@
"zoom_reset": "元のサイズ",
"page_settings": "ページ設定",
"preview": "プレビュー",
+ "previewing": "読み込み中…",
+ "version_history": "バージョン履歴",
"edit_code": "コード編集",
"save": "保存",
+ "saving": "保存中…",
"exit": "終了",
"exit_base_edit": "共通レイアウト編集を終了",
"exit_modal_edit": "モーダル編集を終了",
@@ -28,18 +31,15 @@
"extension": "拡張編集中",
"iteration_item": "繰り返し項目編集中"
},
- "saving": "保存中…",
"images": "画像",
- "reset": "初期化",
"translations": "多言語",
"data_sources": "データ",
- "switch_template": "別のテンプレートに切り替え",
- "previewing": "開く中…",
- "version_history": "バージョン履歴"
+ "reset": "初期化",
+ "switch_template": "別のテンプレートに切り替える"
},
"route_tree": {
"panel_title": "画面/ルート",
- "search_placeholder": "名称・ルートで検索…",
+ "search_placeholder": "名前·ルートで検索…",
"search_clear": "検索をクリア",
"search_no_results": "検索結果がありません",
"group": {
@@ -52,24 +52,24 @@
},
"connected": {
"modals": "この画面のモーダル ({count})",
- "extensions": "注入される拡張 ({count})"
+ "extensions": "インジェクトされる拡張 ({count})"
},
"badge": {
"hidden": "非表示",
"redirect": "リダイレクト",
- "shared_layout": "{count}個のルートを共有",
- "modified": "変更",
+ "shared_layout": "{count}個のルート共有",
+ "modified": "変更されました",
+ "version_tooltip": "現在の保存バージョン v{version}",
"extension_point": "拡張ポイント",
"overlay": "オーバーレイ",
"priority": "p:{priority}",
- "inactive": "無効",
- "version_tooltip": "現在の保存バージョン v{version}"
+ "inactive": "無効"
},
"empty": "表示するルートがありません"
},
"fab": {
"label": "レイアウト編集",
- "tooltip": "このスクリーンをレイアウトエディターで開く"
+ "tooltip": "この画面をレイアウトエディタで開く"
},
"denied": {
"title": "レイアウト編集権限がありません",
@@ -78,8 +78,8 @@
"go_home": "ホームへ"
},
"dirty_guard": {
- "title": "保存していない変更があります",
- "message": "変更を保存せずに終了しますか?",
+ "title": "保存されていない変更があります",
+ "message": "変更を保存せずに終了しますか?",
"stay": "編集を続ける",
"discard": "変更を破棄"
},
@@ -88,55 +88,80 @@
},
"reset_guard": {
"title": "この画面を初期化します",
- "message": "この画面の変更を破棄し、前回保存した状態に戻します。"
+ "message": "この画面の変更を破棄し、最後の保存状態に戻します。"
}
},
"preview": {
- "empty": "左側から編集するルートを選択してください",
+ "empty": "左側で編集するルートを選択してください",
"empty_layout": "このレイアウトに表示するコンポーネントがありません",
"loading": "レイアウトを読み込み中...",
"error": "レイアウトの読み込みに失敗しました: {error}",
- "assets_loading": "編集対象のテンプレートアセットを読み込み中...",
- "assets_error": "編集対象のテンプレートアセットの読み込みに失敗しました: {error}",
+ "assets_loading": "編集対象テンプレートアセットを読み込み中...",
+ "assets_error": "編集対象テンプレートアセットの読み込みに失敗しました: {error}",
"sample_data_badge": "サンプルデータ",
- "extension_no_injection_title": "この画面には編集可能な要素がありません",
- "extension_no_injection_desc": "この拡張はこの画面にビジュアル編集可能な要素を追加しません。コード編集を使用してください。",
+ "extension_no_injection_title": "この画面に編集対象の要素がありません",
+ "extension_no_injection_desc": "この拡張はこの画面にビジュアル編集できる要素を追加しません。コード編集をご利用ください。",
"extension_gated_title": "この状態では編集可能な要素が表示されていません",
- "extension_gated_desc": "この拡張が配置される領域は特定の画面状態でのみ表示されます。その状態が編集設定で定義されていないため、ビジュアル編集できません。コード編集を使用してください。",
- "extension_gated_desc_with_states": "この拡張が配置される領域は特定の画面状態でのみ表示されます。上部の状態選択でその状態に変更すると編集できます。",
- "slot_area_label": "コンテンツエリア"
+ "extension_gated_desc": "この拡張が挿入される領域は特定の画面状態でのみ表示されます。その状態が編集設定に定義されていないため、ビジュアル編集できません。コード編集をご利用ください。",
+ "extension_gated_desc_with_states": "この拡張が挿入される領域は特定の画面状態でのみ表示されます。上部の状態選択からその状態に変更すれば編集できます。",
+ "slot_area_label": "コンテンツ領域"
+ },
+ "version_history": {
+ "title": "バージョン履歴",
+ "close": "閉じる",
+ "loading": "バージョン一覧を読み込み中…",
+ "empty": "保存されたバージョンがありません。",
+ "restore": "復元",
+ "restoring": "復元中…",
+ "restore_confirm": "このバージョンに復元しますか?現在編集中の変更は失われます。",
+ "restore_not_found": "対象のバージョンが見つかりません。一覧を更新してください。",
+ "latest": "最新",
+ "added": "+{count} 行",
+ "removed": "-{count} 行",
+ "chars": "({diff} 文字)",
+ "saved_by": "保存者: {name}",
+ "unknown_author": "不明",
+ "compare": "比較",
+ "comparing": "比較中…",
+ "diff_title": "v{old} → v{new} 変更内容",
+ "diff_back": "一覧",
+ "diff_identical": "2つのバージョンの内容は同じです。",
+ "diff_too_large": "変更内容が大きすぎて表示できません。"
+ },
+ "preview_action": {
+ "no_document": "プレビュー用のレイアウトが選択されていません。"
},
"access_error": {
"required_permission_label": "必要な権限:",
"detail_summary": "詳細なエラー情報",
"action": {
- "sign_in": "ログインへ",
+ "sign_in": "ログインページへ",
"go_home": "管理者ホームへ",
"go_admin_home": "管理者ホームへ",
"retry": "再試行"
},
"unauthorized": {
"title": "ログインが必要です",
- "message": "セッションの有効期限が切れているか、ログアウトしています。しばらく後にログインページに移動します。"
+ "message": "セッションの有効期限が切れているか、ログインが解除されました。しばらく後にログインページにリダイレクトされます。"
},
"forbidden": {
"title": "このレイアウトにアクセスする権限がありません",
- "message": "現在のアカウントにはこの画面を編集する権限がありません。管理者に権限の付与をリクエストするか、別のアカウントで再度ログインしてください。"
+ "message": "現在のアカウントには、この画面を編集する権限がありません。管理者に権限の付与をリクエストするか、別のアカウントで再度ログインしてください。"
},
"not_found": {
"title": "レイアウトが見つかりません",
- "message": "リクエストされたレイアウトが存在しないか、削除されています。ルートを再度選択してください。"
+ "message": "リクエストされたレイアウトが存在しないか、削除されました。ルートを再度選択してください。"
},
"server_error": {
"title": "サーバーで問題が発生しました",
"message": "レイアウトの読み込み中にサーバーエラーが発生しました。しばらく後に再度お試しください。"
},
"network": {
- "title": "ネットワーク接続をご確認ください",
- "message": "サーバーに接続できません。ネットワークの状態を確認してから再度お試しください。"
+ "title": "ネットワーク接続を確認してください",
+ "message": "サーバーに接続できません。ネットワークの状態を確認した後、再度お試しください。"
},
"unknown": {
- "title": "レイアウトの読み込みに失敗しました",
+ "title": "レイアウトを読み込めませんでした",
"message": "予期しない問題が発生しました。再度お試しいただくか、管理者にお問い合わせください。"
}
},
@@ -145,9 +170,9 @@
"desktop": "🖥 デスクトップ",
"tablet": "📱 タブレット",
"mobile": "📲 モバイル",
- "custom": "📐 カスタマイズ",
- "custom_width_label": "幅",
- "portable": "📱 モバイル+タブレット"
+ "portable": "📱 モバイル+タブレット",
+ "custom": "📐 カスタム",
+ "custom_width_label": "幅"
},
"zoom": {
"label": "ズーム",
@@ -160,19 +185,19 @@
"design": "デザイン要素",
"db": "DB要素"
},
- "empty": "この位置に追加できる要素がありません",
- "spec_missing": "このテンプレートにはビジュアル編集仕様がありません。要素の追加は無効化されています。",
"category": {
"all": "すべて",
"other": "その他",
"fallback": {
"basic": "基本要素",
- "composite": "複合要素",
+ "composite": "組み合わせ要素",
"layout": "レイアウト"
}
},
- "default_node_missing_badge": "基本スペックなし",
- "requires_default_node_missing": "このコンポーネントはテンプレートが基本スタイルを定義してから追加できます。editor-spec.json の componentPalette.entries.<名前>.defaultNode を定義してください。",
+ "empty": "この位置に追加できる要素はありません",
+ "spec_missing": "このテンプレートは、ビジュアル編集仕様がありません。要素の追加は無効化されています。",
+ "default_node_missing_badge": "デフォルトスペックなし",
+ "requires_default_node_missing": "このコンポーネントは、テンプレートがデフォルトスタイルを定義する必要があります。editor-spec.json の componentPalette.entries.<名前>.defaultNode を定義してください。",
"table": {
"label": "表",
"header_1": "タイトル",
@@ -194,12 +219,12 @@
"edit_props": "プロパティを設定",
"duplicate": "コンポーネントをコピー",
"delete": "削除",
- "separate_branch": "このデバイス専用で分離",
+ "separate_branch": "このデバイス専用に分離",
"merge_branch": "デバイス専用分離を解除",
- "separate_branch_device": "この領域を{device}専用で分離",
+ "separate_branch_device": "この領域を{device}専用に分離",
"merge_branch_device": "{device}専用分離を解除",
- "separate_branch_hint": "このデバイスのみで異なる構成(要素)を配置したい場合に使用します。色·余白などのスタイルのみ異なる場合は[属性 > スタイル]タブを使用してください。",
- "merge_branch_hint": "このデバイス専用に分離しておいた構成を削除し、デフォルト構成に戻します。",
+ "separate_branch_hint": "このデバイスだけで別の構成(要素)を配置したい場合に使用します。色・余白などのスタイルのみを異なるものにしたい場合は、[プロパティ > スタイル]タブを使用してください。",
+ "merge_branch_hint": "このデバイス専用に分離されている構成を削除して、基本構成に戻します。",
"device_desktop": "デスクトップ",
"device_tablet": "タブレット",
"device_mobile": "モバイル",
@@ -207,9 +232,9 @@
"device_custom": "{range}"
},
"overlay": {
- "edit_common_layout": "共通レイアウトを編集",
+ "edit_common_layout": "共通レイアウト編集",
"edit_extension": "拡張編集",
- "edit_iteration_item": "繰り返し項目を編集",
+ "edit_iteration_item": "繰り返し項目編集",
"edit_destination_route": "→ この画面を編集",
"cannot_drop_here": "ここには配置できません",
"locked_data_bound": "データ領域は直接編集できません",
@@ -217,65 +242,65 @@
"branch_mobile": "モバイル構成",
"branch_tablet": "タブレット構成",
"branch_desktop": "デスクトップ構成",
- "branch_custom": "{range} 構成",
+ "branch_custom": "{range}構成",
"branch_boundary_denied": "デバイス別構成間での移動はできません",
"jump_device": "{device}構成に移動",
- "jump_device_hint": "この領域には{device}に別途構成が定義されています。クリックするとそのデバイスビューに切り替えて編集します。",
- "inherit_banner": "共通レイアウトから受け取ったローディング画面設定があります。下記でこの画面に合わせて変更できます。",
- "enabled_label": "ローディング画面表示",
- "enabled_hint": "データを読み込む間に表示するローディング画面をオンまたはオフにします。",
+ "jump_device_hint": "この領域は{device}に個別の構成が定義されています。クリックするとそのデバイスビューに切り替えて編集します。",
+ "inherit_banner": "共通レイアウトから受け取ったロード画面設定があります。下記でこの画面に合わせて変更できます。",
+ "enabled_label": "ロード画面表示",
+ "enabled_hint": "データを読み込む間に表示するロード画面をオンまたはオフにします。",
"on": "オン",
"off": "オフ",
- "scope_label": "覆う範囲",
- "scope_full": "フルスクリーン",
- "scope_region": "特定の領域",
+ "scope_label": "カバー範囲",
+ "scope_full": "全画面",
+ "scope_region": "特定領域",
"target_label": "対象領域",
"fallback_label": "代替領域",
- "fallback_hint": "対象領域が見つからない場合に代わりに覆う領域です。",
- "style_label": "表示方法",
- "style_opaque": "単色カバー",
+ "fallback_hint": "対象領域が見つからない場合に代わりにカバーする領域です。",
+ "style_label": "表示方式",
+ "style_opaque": "単色マスク",
"style_blur": "ぼかし",
- "style_fade": "フェードイン",
- "style_skeleton": "スケルトン表示",
+ "style_fade": "徐々に表示",
+ "style_skeleton": "骨組み表示",
"style_spinner": "回転表示",
"anim_wave": "波紋",
"anim_pulse": "点滅",
"anim_none": "なし",
- "skeleton_component": "スケルトン形状",
+ "skeleton_component": "骨組み形状",
"skeleton_animation": "モーション効果",
"skeleton_count": "繰り返し回数",
"spinner_component": "回転形状",
- "spinner_text": "ガイダンステキスト",
- "waitfor_label": "待つデータ",
- "waitfor_hint": "選択したすべてのデータを受け取るまでローディング画面を保持します。",
- "waitfor_empty": "待つことができるデータがありません。",
- "reset_inherit": "すべてデフォルトに",
- "loading_picker": {
- "none": "選択なし",
- "select": "選択",
- "search": "形状検索...",
- "empty": "選択可能な形状がありません。"
- },
- "inherited_badge": "継承",
+ "spinner_text": "ガイドテキスト",
+ "waitfor_label": "待機するデータ",
+ "waitfor_hint": "選択したデータをすべて受け取るまでロード画面を維持します。",
+ "waitfor_empty": "待機するデータがありません。",
+ "reset_inherit": "すべてデフォルト値に戻す",
+ "inherited_badge": "継承されています",
"overridden_badge": "この画面で変更",
- "override_field": "この画面だけを変更"
+ "override_field": "この画面のみ変更",
+ "loading_picker": {
+ "none": "選択しない",
+ "select": "選択",
+ "search": "形状検索…",
+ "empty": "選択可能な形状がありません。"
+ }
},
"save": {
"success": "レイアウトを保存しました",
"in_progress": "保存中…",
"validation_failed": "バリデーションに失敗しました",
- "blocked_inactive_extension": "無効化された拡張コンポーネントが新たに追加されたため、保存できません",
+ "blocked_inactive_extension": "無効化された拡張コンポーネントが新たに追加されているため保存できません",
+ "network_error_title": "保存中にネットワークエラーが発生しました",
+ "guard_no_document": "保存するレイアウトドキュメントが読み込まれていません",
"concurrent": {
"title": "別のユーザーが先に保存しました",
- "message": "このレイアウトは別の管理者が先に保存しました。最新バージョンを読み込みますか?",
- "load_latest": "最新を読み込む",
- "view_my_changes": "私の変更内容を表示",
- "cancel": "キャンセル",
- "version_info": "最新バージョン:{current} / マイバージョン:{yours}"
+ "message": "このレイアウトは別の管理者が先に保存しました。最新版を読み込みますか?",
+ "version_info": "最新版: {current} / 自分の版: {yours}",
+ "load_latest": "最新版を読み込む",
+ "view_my_changes": "自分の変更内容を表示",
+ "cancel": "キャンセル"
},
- "network_error_title": "保存中にネットワークエラーが発生しました",
- "guard_no_document": "保存するレイアウトドキュメントがロードされていません",
- "conflict": "別の場所で先に保存されて競合が発生しました"
+ "conflict": "別の場所で先に保存されて競合が発生しました。"
},
"control": {
"use": "使用",
@@ -283,7 +308,7 @@
"color_hex_placeholder": "#000000 / なし",
"tag_add": "+ 追加",
"background_image": {
- "upload": "画像アップロード",
+ "upload": "画像をアップロード",
"clear": "削除",
"mode_fill": "フィル",
"mode_fit": "フィット",
@@ -296,7 +321,7 @@
"spacing": {
"mode_all": "一括",
"mode_sides": "個別",
- "all": "すべて",
+ "all": "全体",
"x": "左右",
"y": "上下",
"top": "上",
@@ -305,6 +330,99 @@
"left": "左"
}
},
+ "recipe": {
+ "custom_input": "直接入力…",
+ "pick_from_list": "リストから選択",
+ "page_placeholder": "移動するパス(例:/board)",
+ "datasource_placeholder": "データ名",
+ "state_key_placeholder": "ステータスキー",
+ "modal_placeholder": "モーダル選択を開く",
+ "i18n_text_placeholder": "表示するテキスト"
+ },
+ "action": {
+ "no_events": "この要素はアクションを設定できません",
+ "no_recipes": "このテンプレートは使用可能なアクションがありません",
+ "add_action": "+ アクションを追加",
+ "remove": "アクションを削除",
+ "advanced_action": "直接作成されたアクション",
+ "advanced_preserved": "コードエディターで作成されたアクションです(保存されます)",
+ "event": {
+ "onClick": "クリックしたら",
+ "onHover": "マウスをホバーしたら",
+ "onChange": "値が変わったら",
+ "onSubmit": "送信したら",
+ "onClose": "閉じたら",
+ "onFilesChange": "ファイルが変わったら",
+ "onPageChange": "ページが変わったら",
+ "onReact": "反応したら",
+ "onTabChange": "タブが変わったら",
+ "onUploadSuccess": "アップロードが成功したら",
+ "onUploadError": "アップロードが失敗したら",
+ "onButtonClick": "ボタンを押したら",
+ "onDismiss": "閉じたら",
+ "onItemAdd": "項目を追加したら",
+ "onItemClick": "項目を押したら",
+ "onItemRemove": "項目を削除したら",
+ "onLanguageChange": "言語を変更したら",
+ "onLayoutEditClick": "レイアウト編集を押したら",
+ "onLogoutClick": "ログアウトを押したら",
+ "onOrderChange": "順序を変更したら",
+ "onProfileClick": "プロフィールを押したら",
+ "onReset": "初期化したら",
+ "onRowClick": "行を押したら",
+ "onSelect": "選択したら",
+ "onSelectionChange": "選択が変わったら",
+ "onToggle": "展開または折りたたんだら",
+ "onComponentEvent": "特定のイベントが発生したら"
+ },
+ "advanced": "詳細",
+ "add_search": "アクションを検索…",
+ "add_no_match": "一致するアクションがありません。",
+ "view_code": "コードを表示",
+ "source_core": "コア",
+ "source_module": "モジュール",
+ "source_plugin": "プラグイン",
+ "source_template": "テンプレート",
+ "source_base": "共通",
+ "source_route": "この画面",
+ "summary_missing_required": "必須項目が空です。",
+ "group": {
+ "state": "状態管理",
+ "nav": "画面遷移",
+ "notify": "通知・モーダル",
+ "data": "データ",
+ "locale": "言語",
+ "etc": "その他",
+ "flow": "フロー制御",
+ "error": "エラーハンドリング",
+ "extension": "拡張アクション"
+ }
+ },
+ "visibility": {
+ "no_recipes": "このテンプレートは使用可能な表示条件がありません",
+ "show_when": "この要素を表示する条件",
+ "and": "かつ",
+ "or": "または",
+ "remove": "条件を削除",
+ "preview": "プレビュー",
+ "clear": "条件をクリア",
+ "advanced_preserved": "コードエディターで作成された条件です(保存されます)"
+ },
+ "flex": {
+ "container_title": "フレックスボックス",
+ "item_title": "フレックスボックス内",
+ "direction": "方向",
+ "justify": "子要素の配置(メイン軸)",
+ "align": "子要素の配置(交差軸)",
+ "wrap": "折り返しを許可",
+ "gap": "子要素間隔",
+ "item_grow": "拡張",
+ "item_align": "個別配置",
+ "item_order": "順序",
+ "make_flex": "フレックスボックスにする",
+ "unmake_flex": "フレックスボックス解除",
+ "not_flex": "この要素はフレックスボックスではありません"
+ },
"property_modal": {
"title": "属性編集: {name}",
"settings_title": "{name} 設定",
@@ -313,235 +431,83 @@
"delete_element": "この要素を削除",
"no_editable_props": "この要素に編集可能な属性がありません",
"injected_props": {
- "title": "拡張が注入した属性",
- "help": "基本属性はすべての画面に適用され、注入属性はこの拡張が有効なときのみ適用されます。",
- "origin": "{source} が注入",
+ "title": "拡張注入属性",
+ "help": "基本属性はすべての画面に適用され、拡張注入属性はこの拡張が有効な場合にのみ適用されます。",
+ "origin": "{source} により注入",
"save_target": "保存時: この拡張行に記録されます。",
"save_button": "{source} に保存",
"saving": "保存中…",
- "saved": "保存しました。",
- "invalid_json": "JSON 形式が正しくありません。"
+ "saved": "保存されました。",
+ "invalid_json": "JSON形式が正しくありません。"
},
- "tab_deferred": "このタブは次のバージョンでサポートされます",
- "advanced_deferred": "この高度な属性は次のバージョンでサポートされます",
+ "tab_deferred": "このタブは次のステップでサポートされています",
+ "advanced_deferred": "この高度な属性は次のステップでサポートされています",
"unsupported_widget": "サポートされていないコントロール",
- "value_conflict": "重複する値",
+ "value_conflict": "値の重複",
"settings_group": "グループ {index}",
"advanced_badge": "この要素はコードエディターで作成された高度な設定を含みます",
- "advanced_preserved_title": "コード編集で作成された高度な設定 {count}個(保持)",
+ "branch_badge": "この要素は {device} 専用構成に属します — 他のデバイスでは異なる構成が表示されます。",
+ "advanced_preserved_title": "コード編集で作成された高度な設定 {count} 個 (保持)",
"advanced_kind_developer": "開発者属性",
"advanced_kind_expression": "複雑な式",
"tab": {
"settings": "設定",
+ "props": "属性",
"style": "スタイル",
"action": "動作",
"visibility": "表示条件",
"translation": "翻訳",
- "advanced": "詳細",
- "props": "属性"
+ "advanced": "高度"
},
"advanced": {
"permissions": "表示権限",
- "blur_until_loaded": "データ読み込み時にオーバーレイを表示",
- "blur_hint": "データ読み込み中にオーバーレイを表示",
+ "blur_until_loaded": "データ読込時に薄くする",
+ "blur_hint": "データ読込中に薄くする",
"comment": "メモ"
},
"scope": {
"scheme_light": "ライト",
"scheme_dark": "ダーク",
- "device_base": "共通",
+ "device_base": "デフォルト",
"device_desktop": "PC",
"device_tablet": "タブレット",
"device_mobile": "モバイル",
+ "device_portable": "モバイル+タブレット",
"add_custom": "カスタムサイズ",
"custom_min": "最小",
"custom_max": "最大",
- "custom_priority_note": "カスタム範囲がPC/タブレット/モバイルより優先的に適用されます。",
- "custom_invalid": "無効な範囲です (最小 ≤ 最大、数字)。",
- "reset_to_base": "デフォルト値にリセット",
- "device_portable": "モバイル+タブレット"
+ "custom_priority_note": "カスタム範囲は PC/タブレット/モバイルより優先適用されます。",
+ "custom_invalid": "無効な範囲です (最小 ≤ 最大、数値)。",
+ "reset_to_base": "デフォルト値にリセット"
},
"dark_code_only": "この色はダークモードではコードモードでのみ編集できます",
- "dark_preset_only": "ダークモードではパネルのプリセット色を選ぶと適用されます(直接入力した自由値はライト専用)",
- "branch_badge": "この要素は{device}専用構成に属します — 他のデバイスでは異なる構成が表示されます。"
+ "dark_preset_only": "ダークモードではパネルのプリセット色を選択すると適用されます (直接入力した自由値はライト専用)"
},
- "attachment_manager": {
- "title": "このレイアウトの画像を管理",
- "recent": "最近アップロードされた",
- "manage_link": "画像管理",
- "upload": "画像アップロード",
- "use_as_bg": "背景",
- "delete": "削除",
- "delete_confirm": "この画像を削除しますか? 取り消すことはできません。",
- "empty": "このレイアウトにアップロードされた画像がありません。",
- "loading": "読み込み中…",
- "close": "閉じる"
- },
- "recipe": {
- "custom_input": "直接入力…",
- "pick_from_list": "リストから選択",
- "page_placeholder": "移動するパス (例: /board)",
- "datasource_placeholder": "データ名",
- "state_key_placeholder": "ステータスキー",
- "i18n_text_placeholder": "表示するテキスト",
- "modal_placeholder": "列モーダルを選択"
- },
- "action": {
- "no_events": "このエレメントはアクションを設定できません",
- "no_recipes": "このテンプレートは使用可能なアクションがありません",
- "add_action": "+ アクションを追加",
- "remove": "アクションを削除",
- "advanced_action": "直接作成されたアクション",
- "advanced_preserved": "コードエディタで作成されたアクションです (保持されます)",
- "event": {
- "onClick": "クリックしたとき",
- "onHover": "マウスをホバーしたとき",
- "onChange": "値が変更されたとき",
- "onSubmit": "送信したとき",
- "onComponentEvent": "特定のイベントが発生したとき",
- "onClose": "閉じたら",
- "onFilesChange": "ファイルが変わったら",
- "onPageChange": "ページが変わったら",
- "onReact": "反応したら",
- "onTabChange": "タブが変わったら",
- "onUploadSuccess": "アップロードに成功したら",
- "onUploadError": "アップロードに失敗したら",
- "onButtonClick": "ボタンをクリックすると",
- "onDismiss": "閉じると",
- "onItemAdd": "項目を追加すると",
- "onItemClick": "項目をクリックすると",
- "onItemRemove": "項目を削除すると",
- "onLanguageChange": "言語を変更すると",
- "onLayoutEditClick": "レイアウト編集をクリックすると",
- "onLogoutClick": "ログアウトをクリックすると",
- "onOrderChange": "順序を変更すると",
- "onProfileClick": "プロフィールをクリックすると",
- "onReset": "初期化すると",
- "onRowClick": "行をクリックすると",
- "onSelect": "選択すると",
- "onSelectionChange": "選択が変更されると",
- "onToggle": "展開または折りたたむと"
+ "core_props": {
+ "id": {
+ "label": "要素 ID",
+ "placeholder": "例: main-content",
+ "add_data": "データ",
+ "binding_hint": "反復リストのインデックスまたは行キーをデータとして接続すると、各項目ごとに一意の ID が作成されます (例: item_{{$idx}})"
},
- "advanced": "詳細設定",
- "add_search": "アクション検索...",
- "add_no_match": "一致するアクションがありません。",
- "view_code": "コード表示",
- "source_core": "コア",
- "source_module": "モジュール",
- "source_plugin": "プラグイン",
- "source_template": "テンプレート",
- "source_base": "共通",
- "source_route": "この画面",
- "summary_missing_required": "必ず入力する値が空です。",
- "group": {
- "state": "ステート管理",
- "nav": "画面遷移",
- "notify": "通知·モーダル",
- "data": "データ",
- "locale": "言語",
- "etc": "その他",
- "flow": "フロー制御",
- "error": "エラー処理",
- "extension": "拡張アクション"
- }
- },
- "visibility": {
- "no_recipes": "このテンプレートは使用可能な表示条件がありません",
- "show_when": "このエレメントを表示する条件",
- "and": "かつ",
- "or": "または",
- "remove": "条件を削除",
- "preview": "プレビュー",
- "clear": "条件をクリア",
- "advanced_preserved": "コードエディタで作成された条件です (保持されます)"
- },
- "flex": {
- "container_title": "フレックスボックス",
- "item_title": "フレックスボックス内で",
- "direction": "方向",
- "justify": "子の配置(メイン軸)",
- "align": "子の配置(交差軸)",
- "wrap": "折り返しを許可",
- "gap": "子要素間隔",
- "item_grow": "拡張",
- "item_align": "個別配置",
- "item_order": "順序",
- "make_flex": "フレックスボックスにする",
- "not_flex": "このエレメントはフレックスボックスではありません",
- "unmake_flex": "配列ボックスを解除"
- },
- "toolbar": {
- "color_scheme_group": "色テーマ",
- "color_scheme_light": "☀ ライト",
- "color_scheme_dark": "🌙 ダーク",
- "state": "状態:"
- },
- "locale": {
- "ko": "한국어",
- "en": "English",
- "ja": "日本語",
- "zh": "中文",
- "vi": "Tiếng Việt"
- },
- "inline_edit": {
- "aria_label": "テキストインライン編集",
- "toolbar_label": "テキスト書式",
- "new_key_hint": "確定時に多言語キーが生成されます",
- "existing_key_hint": "多言語 — 現在の言語値のみ修正されます",
- "open_translations": "すべての言語を編集",
- "empty_warning": "コンテンツを入力してください",
- "format": {
- "fontWeight": "太字",
- "fontBold": "太字",
- "fontStyle": "イタリック",
- "fontItalic": "イタリック",
- "textDecoration": "下線",
- "textUnderline": "下線",
- "textAlign": "配置",
- "fontSize": "フォントサイズ",
- "textColor": "フォントカラー"
+ "dataKey": {
+ "placeholder": "例: form",
+ "hint": "入力フィールドをまとめる名前です。_global. または _isolated. プレフィックスで グローバル/分離領域に接続できます。",
+ "bound_degraded": "この値はコードエディターで設定されます。",
+ "label": "フォームデータ接続ポイント"
},
- "prompt": {
- "fontSize": "フォントサイズを入力してください (例: 16px, 1.2rem)",
- "textColor": "フォントカラーを入力してください (例: #2563eb)"
+ "isolatedState": {
+ "label": "分離領域",
+ "initial_label": "分離開始値",
+ "off_hint": "この領域のみの独立した状態を持ちたいときにオンにします。"
+ },
+ "isolatedScopeId": {
+ "label": "領域名",
+ "placeholder": "例: cart-scope",
+ "search": "領域名検索…",
+ "duplicate": "同じ名前の領域が他にもあります。両領域が状態を共有します。"
}
},
- "translation": {
- "key_label": "多言語キー",
- "needs_translation": "翻訳が必要です",
- "not_a_key": "この要素は多言語キーではありません。キャンバスでテキストをダブルクリックしてインライン編集するとキーが生成されます。",
- "loading": "読み込み中…",
- "load_failed": "多言語値を読み込めませんでした",
- "save": "保存",
- "saving": "保存中…",
- "save_failed": "保存に失敗しました",
- "conflict": "別のユーザーが先に修正しました。更新後、もう一度お試しください。",
- "token_mismatch": "翻訳値のデータ連結({{ }})を変更または削除することはできません。データ連結は[プロパティ]タブで編集してください。",
- "placeholder_mismatch": "翻訳値のデータプレースホルダー({p0} など)を削除または変更することはできません。文は自由に翻訳してください。ただしプレースホルダーはそのままにしてください。",
- "placeholder_locked": "データチップは文字のように削除できません。位置はドラッグで移動し、解除は[プロパティ]タブで行ってください。",
- "saved": "保存済み"
- },
- "translation_manager": {
- "title": "このレイアウトの多言語キー管理",
- "filter_label": "ステータスフィルター",
- "filter_all": "すべて",
- "filter_active": "使用中",
- "filter_orphaned": "未使用",
- "badge_orphaned": "未使用/接続切断",
- "badge_live_in_use": "現在のキャンバス使用中",
- "badge_live_unused": "現在のキャンバス未使用",
- "select_aria": "{key}を選択",
- "delete_selected": "選択を削除 ({count})",
- "purge_orphaned": "未使用をすべて削除 ({count})",
- "delete_confirm": "選択した多言語キー{count}個を削除してもよろしいですか?元に戻すことはできません。",
- "conflict": "別の場所で先に編集されています。更新後、もう一度お試しください。",
- "save": "保存",
- "saving": "保存中…",
- "delete": "削除",
- "empty": "このレイアウトに作成された多言語キーはありません。",
- "loading": "読み込み中…",
- "close": "閉じる"
- },
"icon_picker": {
"search_placeholder": "アイコン検索",
"none": "なし",
@@ -558,68 +524,63 @@
"edit_translation": "翻訳編集",
"option_value": "値",
"option_label": "ラベル",
- "bound_degraded": "データに連結された一覧です(コード編集)",
+ "bound_degraded": "データに連結されたリストです(コード編集)",
"items_title": "項目",
"item_text": "項目テキスト",
- "new_item": "新規項目",
- "add_child": "{component} を追加",
- "no_child_component": "追加する子コンポーネントが指定されていません",
"item_label": "ラベル",
- "item_placeholder": "ガイダンステキスト"
+ "item_placeholder": "ガイドメッセージ",
+ "new_item": "新しい項目",
+ "add_child": "{component}を追加",
+ "no_child_component": "追加する子コンポーネントが指定されていません"
+ },
+ "array_editor": {
+ "items_title": "項目リスト",
+ "item": "項目",
+ "add": "{item}を追加",
+ "remove": "削除",
+ "move_up": "上に移動",
+ "move_down": "下に移動",
+ "empty": "項目がありません",
+ "icon_placeholder": "アイコン名(例:user、cog)",
+ "bound_degraded": "データに連結されたリストです(コード編集)",
+ "no_array_prop": "編集する配列プロパティが指定されていません",
+ "number_list_placeholder": "カンマ区切りの数値(例:10、20、30)"
+ },
+ "cell_tree_editor": {
+ "columns_title": "列リスト",
+ "id_placeholder": "列識別子",
+ "expand_cells": "セル内容を編集",
+ "collapse_cells": "セル内容を閉じる",
+ "add_column": "{item}を追加"
},
"table_editor": {
- "add_row": "行追加",
- "add_col": "列追加",
- "remove_row": "行削除",
- "remove_col": "列削除",
- "merge_cells": "セル結合",
- "unmerge_cells": "結合解除",
+ "title": "表を編集",
+ "add_row": "行を追加",
+ "add_col": "列を追加",
+ "remove_row": "行を削除",
+ "remove_col": "列を削除",
+ "merge_cells": "セルを統合",
+ "unmerge_cells": "統合を解除",
"cell_border": "セル枠線",
- "move_row_up": "行上へ移動",
- "move_row_down": "行下へ移動",
- "header_row": "ヘッダー行",
- "title": "テーブル編集",
- "cell_class_placeholder": "例: border border-gray-300",
+ "cell_class_placeholder": "例:border border-gray-300",
+ "move_row_up": "行を上に移動",
+ "move_row_down": "行を下に移動",
"move_col_left": "列を左に移動",
"move_col_right": "列を右に移動",
+ "move_blocked_section": "表ヘッダー/本文の境界を超えて移動することはできません",
+ "header_row": "ヘッダー行",
"cell_text": "セルテキスト",
- "structural_cell": "構造セル (キャンバスで編集)",
- "grid_invalid": "テーブル構造が一致していません (セル結合/穴を確認)",
- "select_hint": "セルをクリック=選択、Shift+クリック=領域を選択してから結合",
- "move_blocked_section": "表のヘッダー/本文の境界を超えて移動することはできません",
+ "cell_i18n_label": "選択セル多言語",
+ "structural_cell": "構造セル(キャンバスで編集)",
+ "grid_invalid": "表の構造が一致しません(セル統合/穴を確認してください)",
+ "select_hint": "セルクリック=選択、Shift+クリック=範囲選択後に統合",
"cell_fill": "セル背景色",
- "cell_fill_none": "なし(透明)",
+ "cell_fill_none": "なし(透明)",
"cell_fill_custom": "直接色を選択",
- "cell_padding": "セル内部の余白",
+ "cell_padding": "セル内部余白",
"cell_padding_custom": "直接",
- "cell_color": "セルカラー",
- "dark_free_color_hint": "ダークモードはプリセット色のみ指定できます (自由色はライトのみ対応)",
- "cell_i18n_label": "選択セル多言語"
- },
- "core_props": {
- "id": {
- "label": "要素 ID",
- "placeholder": "例:main-content",
- "add_data": "データ",
- "binding_hint": "繰り返しリストのインデックスや行キーをデータとして連結すると、項目ごとに一意の ID が生成されます(例:item_{{$idx}})"
- },
- "dataKey": {
- "placeholder": "例:form",
- "hint": "入力フィールドをグループ化する名前です。_global.または_isolated.接頭辞でグローバル/分離領域に接続できます。",
- "bound_degraded": "この値はコードエディターで設定されます。",
- "label": "フォームデータ接続ポイント"
- },
- "isolatedState": {
- "label": "分離領域",
- "initial_label": "分離初期値",
- "off_hint": "この領域のみの独立した状態を持ちたい場合はオンにします。"
- },
- "isolatedScopeId": {
- "label": "領域名",
- "placeholder": "例:cart-scope",
- "search": "領域名検索...",
- "duplicate": "同じ名前の領域が他の場所にも存在します。2つの領域が状態を共有します。"
- }
+ "cell_color": "セル色",
+ "dark_free_color_hint": "ダークモードはプリセット色のみ指定できます(自由色はライト専用)"
},
"table_inplace": {
"select_cell": "セルを選択",
@@ -633,158 +594,127 @@
"move_col_right": "列を右に移動",
"merge": "統合",
"unmerge": "解除",
- "select_all": "すべてを選択",
+ "select_all": "すべて選択",
"border_apply_count": "{count}個のセルに適用",
- "merge_right": "右のセルと統合",
- "merge_down": "下のセルと統合",
+ "merge_right": "右側のセルと統合",
+ "merge_down": "下側のセルと統合",
"border_apply_all": "表全体に適用",
- "select_cell_hint": "キャンバス内のセルをクリックすると、統合・枠線ツールが表示されます。",
+ "select_cell_hint": "キャンバスでセルをクリックすると、統合・枠線ツールが表示されます。",
"merge_range": "領域を統合",
"range_selected": "{count}個のセルが選択されています",
"custom_color": "直接選択"
},
- "shortcuts": {
- "title": "ショートカットキー",
- "close": "閉じる",
- "or": "または",
- "footnote": "入力欄・モーダルにフォーカスがある場合、ショートカットキーは動作しません。",
- "group": {
- "clipboard": "クリップボード",
- "element": "要素",
- "history": "元に戻す",
- "view": "表示/ツール",
- "document": "ドキュメント"
- },
- "action": {
- "copy": "コピー",
- "cut": "切り取り",
- "paste": "貼り付け(別レイアウトも可能)",
- "open_props": "属性パネルを開く",
- "delete": "削除",
- "select_parent": "親要素を選択",
- "deselect": "選択を解除",
- "add_element": "要素を追加",
- "undo": "元に戻す",
- "redo": "やり直す",
- "edit_code": "コードを編集",
- "preview": "プレビュー",
- "translations": "多言語画面",
- "help": "ショートカットキーマップ",
- "reset": "初期化",
- "save": "保存",
- "exit": "終了"
- }
+ "attachment_manager": {
+ "title": "このレイアウト画像を管理",
+ "recent": "最近アップロード",
+ "manage_link": "画像管理",
+ "upload": "画像をアップロード",
+ "use_as_bg": "背景",
+ "delete": "削除",
+ "delete_confirm": "この画像を削除しますか?元に戻すことはできません。",
+ "empty": "このレイアウトにアップロードされた画像がありません。",
+ "loading": "読み込み中…",
+ "close": "閉じる"
},
- "array_editor": {
- "items_title": "項目リスト",
- "item": "項目",
- "add": "{item}を追加",
- "remove": "削除",
- "move_up": "上に移動",
- "move_down": "下に移動",
- "empty": "項目がありません",
- "icon_placeholder": "アイコン名(例:user、cog)",
- "bound_degraded": "データにバインドされたリストです(コード編集)",
- "no_array_prop": "編集する配列プロパティが指定されていません",
- "number_list_placeholder": "カンマで区切られた数字 (例: 10, 20, 30)"
- },
- "binding": {
- "section_title": "データバインディング",
- "search": "データ検索",
- "search_placeholder": "名前·パスで検索…",
- "no_results": "一致するデータがありません",
- "not_connected": "未接続",
- "clear": "解除",
- "complex": "複合バインディング(コード編集)",
- "required": "必須",
- "item_fields": "項目フィールド",
- "shape_scalar": "単一値",
- "shape_array": "配列",
- "shape_object": "オブジェクト",
- "iteration_title": "リピートデータ接続",
- "iteration_label": "リピートデータソース",
- "iteration_hint": "このエリアが繰り返すための配列データです。1つの項目が内側の構造でレンダリングされ、データ数だけ繰り返されます。",
- "iteration_item_var": "アイテム変数",
- "iteration_index_var": "シーケンス番号変数"
+ "translation_manager": {
+ "title": "このレイアウト多言語キー管理",
+ "filter_label": "ステータスフィルター",
+ "filter_all": "すべて",
+ "filter_active": "使用中",
+ "filter_orphaned": "未使用",
+ "badge_orphaned": "未使用/接続切断",
+ "badge_live_in_use": "現在のキャンバス使用中",
+ "badge_live_unused": "現在のキャンバス未使用",
+ "select_aria": "{key}を選択",
+ "delete_selected": "選択削除({count})",
+ "purge_orphaned": "未使用全削除({count})",
+ "delete_confirm": "選択した多言語キー{count}個を削除しますか?元に戻すことはできません。",
+ "conflict": "他の場所で先に編集されました。更新してからもう一度お試しください。",
+ "save": "保存",
+ "saving": "保存中…",
+ "delete": "削除",
+ "empty": "このレイアウトに作成された多言語キーがありません。",
+ "loading": "読み込み中…",
+ "close": "閉じる"
},
"data_sources": {
"title": "データソース",
- "description": "この画面が読み込むデータソースを追加·編集·削除します。ソースを定義するとコンポーネントのデータ接続で選択できます。",
- "add": "データソースを追加",
+ "description": "この画面が読み込むデータソースを追加・編集・削除します。ソースを定義するとコンポーネントのデータ接続で選択できます。",
+ "add": "データソース追加",
"edit": "編集",
"remove": "削除",
"close": "閉じる",
"empty": "この画面に定義されたデータソースがありません。",
- "no_label": "表示名称(label_key)未指定",
- "inherited_title": "継承されたソース(読み取り専用)",
+ "no_label": "表示名称(label_key)未指定",
+ "inherited_title": "継承されたソース(読み取り専用)",
"source": {
"module": "モジュール",
"plugin": "プラグイン"
},
"form": {
- "add_title": "データソースを追加",
- "edit_title": "データソースを編集",
+ "add_title": "データソース追加",
+ "edit_title": "データソース編集",
"save": "保存",
"cancel": "キャンセル"
},
"field": {
"id": "ID",
"label_key": "表示名称",
- "label_key_placeholder": "例:商品一覧",
- "label_key_hint": "検索型データピッカーに表示される親切な名称です。平文を入力すると多言語キーが自動生成され、🌐ボタンで言語ごとの値を編集できます。",
- "type": "タイプ",
+ "label_key_placeholder": "例:商品リスト",
+ "label_key_hint": "検索型データピッカーに表示されるわかりやすい名称です。平文を入力すると多言語キーが自動生成され、🌐ボタンで言語別の値を編集できます。",
+ "type": "種類",
"endpoint": "エンドポイント",
"method": "HTTPメソッド",
"auth_mode": "認証モード",
- "loading_strategy": "ロード戦略",
+ "loading_strategy": "読み込み戦略",
"auto_fetch": "画面読み込み時に自動呼び出し",
- "params": "リクエストパラメータ(JSON)",
+ "params": "要求パラメーター",
"params_hint": "値に{{query.page}}のようなバインディング式を使用できます。",
- "fallback": "デフォルト値(fallback、JSON)",
- "content_type": "コンテンツタイプ",
- "refetch_on_mount": "再進入時に再読み込み",
- "channel": "チャネル",
- "event": "イベント",
- "channel_type": "チャネル種別",
- "target_source": "対象ソース",
- "on_receive": "メッセージ受信時の動作",
- "params_empty": "API に一緒に送信する値を追加してください。例:page、per_page、検索語など。",
- "params_preview": "送信される値のプレビュー",
+ "params_empty": "APIと一緒に送信する値を追加してください。例:page、per_page、検索語など。",
+ "params_preview": "送信される値プレビュー",
"params_mode_block": "ブロックで",
"params_mode_code": "コードで",
- "param_key_placeholder": "パラメータ名",
- "param_add": "パラメータを追加",
- "fallback_add": "デフォルト値を追加",
- "fallback_add_field": "項目を追加",
- "fallback_clear": "デフォルト値を削除",
- "fallback_empty": "データを読み込む前・失敗時の代わりに表示する値です。例:data に空のリスト([])。",
+ "param_key_placeholder": "パラメーター名",
+ "param_add": "パラメーター追加",
+ "fallback": "デフォルト値",
+ "fallback_add": "デフォルト値追加",
+ "fallback_add_field": "項目追加",
+ "fallback_clear": "デフォルト値削除",
+ "fallback_empty": "データを読み込む前、失敗時の代わりに表示する値です。例:dataに空のリスト([])。",
"fallback_key_placeholder": "フィールド名(例:data)",
- "fallback_preview": "デフォルト値のプレビュー"
+ "fallback_preview": "デフォルト値プレビュー",
+ "content_type": "コンテンツタイプ",
+ "refetch_on_mount": "再進入時に再度読み込み",
+ "channel": "チャネル",
+ "event": "イベント",
+ "channel_type": "チャネル種類",
+ "target_source": "対象ソース",
+ "on_receive": "メッセージ受信時の動作"
},
"ds_type": {
"api": "API呼び出し",
"static": "静的データ",
- "route_params": "パスパラメータ",
- "query_params": "クエリパラメータ",
- "websocket": "WebSocket"
+ "route_params": "パスパラメーター",
+ "query_params": "クエリーパラメーター",
+ "websocket": "ウェブソケット"
},
"auth_mode": {
- "none": "なし(公開)",
- "optional": "選択(トークンがあれば送信)",
- "required": "必須(トークンがなければ失敗)"
+ "none": "なし(公開)",
+ "optional": "選択(トークンがあれば送信)",
+ "required": "必須(トークンがなければ失敗)"
},
"loading_strategy": {
"progressive": "段階的",
"blocking": "ブロック",
- "background": "バックグラウンド"
+ "background": "背景"
},
"error": {
"id_required": "IDを入力してください。",
- "id_invalid": "IDは英文字·数字·アンダースコアのみ使用でき、数字で始まることはできません。",
- "id_duplicate": "ID'{id}'は既に使用中です。",
- "params_json": "リクエストパラメータが正しいJSON形式ではありません。",
- "params_object": "リクエストパラメータはオブジェクト(JSON)形式である必要があります。",
- "fallback_json": "デフォルト値(fallback)が正しいJSON形式ではありません。"
+ "id_invalid": "IDは英文字/数字/アンダースコアのみ使用でき、数字で始めることはできません。",
+ "id_duplicate": "ID '{id}' はすでに使用中です。",
+ "params_json": "リクエストパラメーターは正しいJSON形式ではありません。",
+ "params_object": "リクエストパラメーターはオブジェクト(JSON)形式である必要があります。",
+ "fallback_json": "フォールバック値が正しいJSON形式ではありません。"
},
"global_headers": "グローバルヘッダー",
"global_headers_empty": "設定されたグローバルヘッダーがありません。",
@@ -796,355 +726,102 @@
"websocket": "リアルタイム(WebSocket)設定",
"on_success": "成功時の動作",
"on_error": "失敗時の動作",
- "error_handling": "エラーコード別処理",
+ "error_handling": "エラーコード別の処理",
"load_condition": "読み込み条件"
},
"add_error_code": "+ エラーコードを追加"
},
- "prop_i18n": {
- "placeholder": "文句を入力してください",
- "edit_languages": "言語ごとに編集",
- "bound_code_only": "バインディング済み(コード編集)",
- "create_first": "現在の言語値を先に入力すると多言語キーが生成され、言語ごとに編集できます。"
+ "toolbar": {
+ "color_scheme_group": "カラーテーマ",
+ "color_scheme_light": "☀ ライト",
+ "color_scheme_dark": "🌙 ダーク",
+ "state": "ステータス:"
},
- "cell_tree_editor": {
- "columns_title": "カラムリスト",
- "id_placeholder": "カラム識別子",
- "expand_cells": "セルの内容を編集",
- "collapse_cells": "セルの内容を閉じる",
- "add_column": "{item} を追加"
+ "locale": {
+ "ko": "日本語",
+ "en": "English",
+ "ja": "日本語",
+ "zh": "中文",
+ "vi": "Tiếng Việt"
},
- "version_history": {
- "title": "バージョン履歴",
- "close": "閉じる",
- "loading": "バージョンリストを読み込み中…",
- "empty": "保存されたバージョンがありません。",
- "restore": "復元",
- "restoring": "復元中…",
- "restore_confirm": "このバージョンに復元しますか?現在編集中の変更内容は失われます。",
- "restore_not_found": "該当するバージョンが見つかりません。リストを更新してください。",
- "latest": "最新",
- "added": "+{count} 行",
- "removed": "-{count} 行",
- "chars": "({diff} 文字)",
- "saved_by": "保存者: {name}",
- "unknown_author": "不明",
- "compare": "比較",
- "comparing": "比較中…",
- "diff_title": "v{old} → v{new} の変更内容",
- "diff_back": "リスト",
- "diff_identical": "2つのバージョンの内容は同じです。",
- "diff_too_large": "変更内容が大きすぎて表示できません。"
- },
- "preview_action": {
- "no_document": "プレビューするレイアウトが選択されていません。"
- },
- "inline_binding": {
- "section_title": "テキストデータ連結",
- "none": "このテキストに連結されたデータはありません",
- "search": "データ検索",
- "search_placeholder": "名称・パスで検索…",
- "no_results": "一致するデータがありません",
- "clear": "解除",
- "complex": "複合バインディング(コード編集)",
- "append_hint": "テキストの最後にデータを挿入",
- "insert_data": "データ",
- "param_hint": "このフレーズは[翻訳]タブで文を編集し(データチップの位置はドラッグで移動)、ここでは連結されたデータソースを変更または新規追加・解除できます。"
- },
- "host_picker": {
- "title": "代表ホストレイアウト選択",
- "description": "この拡張は複数の画面に挿入されます。どの画面上で編集するかを選択してください。保存した変更は、この拡張が挿入されるすべての画面に反映されます。",
- "description_with_label": "\"{label}\" 拡張は複数の画面に挿入されます。どの画面上で編集するかを選択してください。"
- },
- "computed": {
- "empty": "設定された自動計算値がありません。",
- "add": "値追加",
- "group_common": "よく使用",
- "group_more": "その他",
- "source_base": "共通",
- "source_overridden": "この画面で覆う",
- "overridden_hint": "共通レイアウトの値をこの画面で上書きしています。",
- "override_notice": "共通レイアウトの値をこの画面でのみ変更します。",
- "revert": "共通値に戻す",
- "custom_open": "手動作成",
- "custom_add": "追加",
- "custom_step1": "どのデータを",
- "custom_step2": "何をしましょうか",
- "custom_step3": "結果名",
- "custom_sum_field": "合計するフィールド (例: price)",
- "custom_value_field": "値として使用するフィールド",
- "custom_label_field": "表示するフィールド",
- "custom_prop": "取得する属性 (選択)",
- "custom_candidates": "候補値 (カンマ区切り)",
- "custom_fallback": "ない場合のデフォルト値",
- "cond_add": "条件を追加",
- "cond_field": "フィールド",
- "cond_value": "値",
- "key_duplicate": "既に使用されている名前です。",
- "kind_string": "文字",
- "kind_number": "数字",
- "kind_boolean": "はい・いいえ",
- "op_count": "個数を数える",
- "op_sum": "合計を求める",
- "op_filter": "条件でフィルター",
- "op_to_options": "選択項目に置き換える",
- "op_nth": "N番目を取得",
- "op_first_of": "最初にある値を使用",
- "op_literal": "固定値",
- "preview_label": "結果",
- "preview_error": "この値は計算できません (保存されます)。",
- "effect_preview": "この条件の場合の画面",
- "list_more": "他に {count} 件",
- "value_none": "なし",
- "type_number": "数字",
- "type_string": "文字",
- "type_boolean": "はい・いいえ",
- "type_list": "リスト",
- "type_object": "グループ",
- "type_null": "なし"
- },
- "init_actions": {
- "empty": "追加された画面アクションがありません。",
- "empty_self": "この画面に追加されたアクションがありません。",
- "group_base": "共通レイアウトアクション",
- "group_self": "この画面アクション",
- "edit": "編集",
- "remove": "削除",
- "locked_parent": "共通項目",
- "merge_order": "共通レイアウトアクションが最初に実行され、その後この画面アクションが続きます。",
- "cond_badge": "条件",
- "if_label": "実行条件",
- "if_placeholder": "例: {{user?.uuid}} (条件が真の場合のみ実行)",
- "advanced_param_hint": "この値はコード編集器で設定します。",
- "drag_reorder": "ドラッグして順序を変更",
- "base_readonly_hint": "共通レイアウト動作は共通(base)レイアウトのページ設定で修正します。"
- },
- "initstate": {
- "legacy_state": "古い方式で保存された初期値があります。編集すると新しい方式に整理されます。",
- "section_local": "この画面の初期値",
- "section_global": "グローバル初期値",
- "section_isolated": "分離領域の初期値",
- "section_isolated_desc": "分離領域はコンポーネント別の初期値です。",
- "isolated_dynamic_hint": "分離領域は画面を使用している間に作成されることもあります。",
- "isolated_orphan": "この名前の分離領域を使用するコンポーネントがありません。",
- "no_values": "設定された値がありません。",
- "value_name": "値の名前",
- "add_value": "値を追加",
- "revert": "元に戻す",
- "subkey_name": "下位の名前",
- "add_subkey": "下位の値を追加",
- "add_item": "項目を追加",
- "kind_string": "文字",
- "kind_number": "数字",
- "kind_boolean": "はい・いいえ",
- "kind_null": "なし",
- "kind_list": "リスト",
- "kind_object": "グループ",
- "value_kind": "値の種類",
- "name_required": "値の名前を入力してください。",
- "name_duplicate": "同じ名前の値がすでに存在します。",
- "name_invalid": "値の名前は英文字で始まり、英文字・数字・_ のみが使用できます。"
- },
- "error": {
- "intro": "ステータスコード別に表示する画面または実行するアクションを設定します。",
- "degrade": "このテンプレートはフレンドリーな編集をサポートしていません。コード編集器で設定してください。",
- "handler_none": "設定なし",
- "handler_showErrorPage": "エラー画面を表示",
- "handler_navigate": "別の画面に移動",
- "handler_openModal": "モーダルを開く",
- "handler_toast": "通知を表示",
- "handler_setState": "ステータスを変更",
- "handler_sequence": "複数のアクションを順序で",
- "handler_parallel": "複数のアクションを同時に",
- "target_content": "コンテンツ領域のみ",
- "target_full": "フルスクリーン",
- "setstate_key": "ステータス名",
- "message_placeholder": "表示するメッセージ(🔍 でエラー情報を挿入可能)"
- },
- "target_picker": {
- "canvas_pick_hint": "画面上で接続する要素をクリックしてください",
- "cancel": "キャンセル",
- "select_area": "領域を選択",
- "id_placeholder": "要素IDを入力するか、領域を選択してください",
- "none": "選択なし",
- "minimized_hint": "キャンバスで接続する要素をクリックしてください",
- "needs_id": "接続するにはIDが必要です"
- },
- "page_settings": {
- "title": "{name} 設定",
- "close": "閉じる",
- "advanced_summary": "コードエディターで作成された高度な設定 {count}個が含まれています",
- "tab": {
- "meta": "基本情報",
- "seo": "検索エンジン",
- "init": "画面動作",
- "overlay": "読み込み画面",
- "computed": "自動計算",
- "state": "初期状態",
- "error": "エラー処理",
- "data": "データ"
+ "inline_edit": {
+ "aria_label": "テキストインライン編集",
+ "toolbar_label": "テキスト形式",
+ "new_key_hint": "確定時に多言語キーが生成されます",
+ "existing_key_hint": "多言語 — 現在の言語値のみ編集されます",
+ "open_translations": "すべての言語を編集",
+ "empty_warning": "コンテンツを入力してください",
+ "format": {
+ "fontWeight": "太字",
+ "fontBold": "太字",
+ "fontStyle": "斜体",
+ "fontItalic": "斜体",
+ "textDecoration": "下線",
+ "textUnderline": "下線",
+ "textAlign": "配置",
+ "fontSize": "フォントサイズ",
+ "textColor": "テキスト色"
},
- "banner": {
- "inline_hint": "⏎ 保存 · Esc キャンセル",
- "dblclick_hint": "ダブルクリックして編集",
- "advanced": "詳細設定",
- "open_settings": "ページ設定を開く"
- },
- "meta": {
- "title": "ページ名",
- "description": "ページの説明",
- "icon": "メニューアイコン",
- "icon_placeholder": "例:fa-solid fa-house",
- "editor_label": "エディターツリー名(選択)",
- "editor_label_hint": "エディター左側の画面リストに表示される名前です。空欄の場合はページ名を使用します。",
- "permissions": "ページアクセス権限",
- "permissions_hint": "選択した権限を持つユーザーのみがこの画面を表示できます。空欄の場合は制限なしです。",
- "dynamic_hint": "動的タイトル"
- },
- "computed": {
- "heading": "データで自動計算される値を設定します。タイトル·表示条件などで使用できます。"
- },
- "init_actions": {
- "heading": "画面が最初に開かれるときに順番に実行するアクションを設定します。"
- },
- "initstate": {
- "heading": "画面が開かれるときにあらかじめ入力する開始値を設定します。"
- },
- "error": {
- "heading": "データの読み込み中にエラーが発生した場合の対応を設定します。"
- },
- "seo": {
- "title": "検索タイトル",
- "description": "検索の説明",
- "enabled": "検索エンジンへの公開",
- "disabled_note": "この画面は検索エンジンに公開されません。有効にすると以下の設定を使用できます。",
- "page_type": "ページ種別",
- "page_type_none": "選択なし",
- "page_type_free_placeholder": "ページ種別を直接入力",
- "page_type_free_note": "連携可能な種別がないため直接入力します。",
- "page_type_free": "直接入力",
- "extensions": "拡張 SEO 連携",
- "add_extension": "拡張を追加",
- "no_extension_candidate": "連携できる拡張がありません。",
- "defaults_precondition": "デフォルト値を取得するには、以下の項目を先に入力してください。",
- "missing_extensions": "連携する拡張を選択してください。",
- "missing_page_type": "ページ種別を選択してください。",
- "toggle_setting": "公開スイッチ設定の連携",
- "toggle_setting_search": "設定を検索…",
- "data_sources": "SEO 連携データ",
- "data_sources_empty": "この画面に定義されたデータがありません。",
- "data_sources_hint": "検索エンジン変数・構造化データが参照するデータを選択してください。",
- "priority": "優先度(sitemap)",
- "changefreq": "更新頻度",
- "use_default": "デフォルト値を使用",
- "value_placeholder": "値を入力(データ接続可能)",
- "og_section": "Open Graph(ソーシャル共有)",
- "og_type": "タイプ(og:type)",
- "og_title": "タイトル",
- "og_description": "説明",
- "og_image": "画像",
- "og_image_alt": "画像の代替テキスト",
- "og_site_name": "サイト名",
- "og_locale": "言語",
- "og_extra": "その他の属性",
- "og_locked": "フィルターで固定",
- "og_source_core": "コア設定",
- "og_source_filter": "フィルターを適用",
- "og_source_module": "拡張提供",
- "tw_section": "Twitter カード",
- "tw_fallback_note": "空欄の場合は Open Graph の値をそのまま使用します。",
- "tw_card": "カード種別",
- "tw_site": "サイトアカウント",
- "tw_creator": "作成者アカウント",
- "tw_title": "タイトル",
- "tw_description": "説明",
- "tw_image": "画像",
- "tw_image_alt": "画像の代替テキスト",
- "tw_extra": "その他の属性",
- "extra_property": "属性(property)",
- "extra_name": "名前 (name)",
- "extra_content": "値",
- "add_extra": "属性を追加",
- "remove": "削除",
- "structured_data": "構造化データ",
- "sd_mode": "構造化データを直接指定",
- "sd_manual_toggle": "直接指定",
- "sd_filter_locked": "フィルターで固定されています",
- "sd_filter_locked_note": "フィルターが構造化データを代わりに入力しているため、ここでは編集できません。",
- "sd_auto_title": "自動生成 (モジュール提供)",
- "sd_auto_empty": "自動生成された構造化データがありません。",
- "sd_replace_pre_warn": "直接指定を有効にすると、自動生成の内容はすべて削除され、以下の内容に置き換わります。",
- "sd_replace_warn": "この内容は自動生成された構造化データをすべて置き換えます。",
- "sd_type": "@type (構造タイプ)",
- "sd_prop_key": "属性パス (例: offers.price)",
- "sd_prop_value": "値",
- "sd_add_prop": "属性を追加",
- "sd_seed_from_module": "モジュール自動値を読み込む",
- "sd_empty_note": "値が空の属性は保存時に除外されます。",
- "sd_has_empty": "空の値があります。",
- "vars": "動的変数",
- "vars_precondition": "連携拡張とページタイプをまず選択すると、使用できる変数が表示されます。",
- "vars_auto_group": "自動入力 (読み取り専用)",
- "vars_auto": "自動",
- "vars_data_group": "値を入力",
- "vars_custom_group": "直接追加",
- "vars_required_empty": "必須変数です。値を入力してください。",
- "vars_add": "変数を追加",
- "vars_add_name": "変数名",
- "vars_value_hint": "変数はタイトル·説明·構造化データで {中括弧名} として使用できます。",
- "vars_name_reserved": "{name} は自動入力変数と名前が重複しています。",
- "vars_name_duplicate": "{name} は既に追加済みの変数です。",
- "vars_name_not_provided": "{name} はどの拡張も提供していない変数です。",
- "bot_preview_title": "検索ボット プレビュー",
- "bot_preview_refresh": "更新",
- "bot_preview_disabled": "この画面は検索エンジンに表示されません。",
- "bot_preview_mode_code": "コード",
- "bot_preview_mode_render": "レンダー",
- "bot_preview_empty": "表示するコンテンツがありません。",
- "bot_preview_sample_note": "サンプルデータと保存前の設定に基づいて表示されます。",
- "revert_default": "やり直す",
- "og_image_adv": "詳細画像オプション(高さ·形式·セキュアURL)",
- "og_image_height": "画像の高さ(px)",
- "og_image_type": "画像形式(例:image/png)",
- "og_image_secure_url": "画像セキュアURL(https)",
- "og_intro": "空白はコア·拡張が定めたデフォルト値を使用します(ソース表示)。入力するとこの画面の値で上書きされます",
- "og_source_inherited": "共通レイアウト",
- "auto_chip_source": "拡張提供",
- "auto_chip_replace": "別のデータに変更"
+ "prompt": {
+ "fontSize": "フォントサイズを入力してください(例:16px, 1.2rem)",
+ "textColor": "テキスト色を入力してください(例:#2563eb)"
}
},
+ "translation": {
+ "key_label": "多言語キー",
+ "needs_translation": "翻訳が必要です",
+ "not_a_key": "この要素は多言語キーではありません。キャンバスでテキストをダブルクリックしてインライン編集するとキーが生成されます。",
+ "loading": "読み込み中…",
+ "load_failed": "多言語値を読み込めませんでした",
+ "save": "保存",
+ "saving": "保存中…",
+ "saved": "保存されました",
+ "save_failed": "保存に失敗しました",
+ "conflict": "別のユーザーが先に編集しました。再読み込み後に再度お試しください。",
+ "token_mismatch": "翻訳値のデータ連携({{ }})は変更または削除できません。データ連携は[属性]タブで編集してください。",
+ "placeholder_mismatch": "翻訳値のデータプレースホルダー({p0}など)は削除または変更できません。文は自由に翻訳してくださいが、プレースホルダーはそのままにしてください。",
+ "placeholder_locked": "データチップはテキストのように削除できません。位置はドラッグで移動し、接続解除は[属性]タブで実行してください。"
+ },
+ "prop_i18n": {
+ "placeholder": "フレーズを入力してください",
+ "edit_languages": "言語別編集",
+ "bound_code_only": "バインドされました(コード編集)",
+ "create_first": "現在の言語値を最初に入力すると多言語キーが生成され、言語別に編集できるようになります。"
+ },
"value_tree": {
"show_source": "元の式を表示",
"raw_code_only": "コードで編集",
- "segmented_hint": "複数の断片がつながった値です。断片を追加·削除するか、ハンドルで順序を変更してください",
+ "segmented_hint": "複数の断片がつながった値です。断片を追加・削除したり、ハンドルで順序を変更してください。",
"drag_reorder": "ドラッグして順序を変更",
- "change_data": "データを置き換え",
+ "change_data": "データを置き換える",
"edit": "編集",
+ "done": "完了",
"collapse": "折りたたむ",
- "to_expression": "式で置き換え",
- "to_plain": "通常の名前で",
- "to_plain_lead": "式を削除し、以下の値だけを残します:",
- "to_plain_warn": "その他の条件·分岐·データ接続はすべて削除され、取り消すことができません",
+ "to_expression": "式に置き換える",
+ "to_plain": "通常の名前に",
+ "to_plain_lead": "式を削除し、下の値のみを保持します:",
+ "to_plain_warn": "他の条件・分岐・データ連携はすべて削除され、元に戻すことはできません。",
"to_plain_ok": "この値に戻す",
"to_plain_cancel": "キャンセル",
+ "raw_expression_hint": "すでに式として記述されているため、フレンドリー編集で展開できません。式自体は上から直接編集できます。より複雑な編集は上部の[コード編集]を使用してください。",
"segment": {
"text": "固定テキスト",
- "expression": "条件に応じて異なる部分",
- "fallback": "値がない場合は代わりに",
+ "expression": "条件によって異なる部分",
+ "fallback": "値がない場合の代わり",
"data": "データ",
"remove": "この断片を削除",
- "data_pick_hint": "表示するデータを検索して選択してください",
- "data_no_candidates": "この画面で接続できるデータがありません"
+ "data_pick_hint": "表示するデータを検索して選択してください。",
+ "data_no_candidates": "この画面で接続できるデータがありません。"
},
"cond": {
"label": "条件",
"code_only": "コードで編集",
- "field_placeholder": "基準値(例:編集モード)",
+ "field_placeholder": "基準値(例: 編集モード)",
"value_placeholder": "比較する値",
- "when_true": "この条件の場合",
+ "when_true": "この条件のとき",
"when_false": "その他の場合",
- "op_truthy": "値がある場合",
- "op_falsy": "値がない場合",
+ "op_truthy": "値があるとき",
+ "op_falsy": "値がないとき",
"op_eq": "と等しい",
"op_neq": "と異なる",
"op_gt": "より大きい",
@@ -1154,31 +831,370 @@
},
"fallback": {
"primary": "デフォルト値",
- "when_empty": "値がない場合は代わりに",
- "when_falsy": "空の場合は代わりに"
+ "when_empty": "値がないときに代わりに",
+ "when_falsy": "空のときに代わりに"
},
"concat": {
- "hint": "複数の値を順番に連結します",
- "part": "{n}番目の断片"
+ "hint": "複数の値を順に繋ぎます。",
+ "part": "{n}番目のピース"
+ }
+ },
+ "shortcuts": {
+ "title": "ショートカット",
+ "close": "閉じる",
+ "or": "または",
+ "footnote": "入力欄・モーダルにフォーカスがあるときはショートカットが動作しません。",
+ "group": {
+ "clipboard": "クリップボード",
+ "element": "要素",
+ "history": "元に戻す",
+ "view": "表示/ツール",
+ "document": "ドキュメント"
},
- "done": "完了",
- "raw_expression_hint": "すでに式として書き込まれた値であるため、フレンドリーな編集では展開して表示することができません。式自体は上記で直接編集でき、より複雑な編集は上部の [コード編集] を使用してください。"
+ "action": {
+ "copy": "コピー",
+ "cut": "切り取り",
+ "paste": "貼り付け(別のレイアウトが可能)",
+ "open_props": "属性パネルを開く",
+ "delete": "削除",
+ "select_parent": "親要素を選択",
+ "deselect": "選択を解除",
+ "add_element": "要素を追加",
+ "undo": "元に戻す",
+ "redo": "やり直す",
+ "edit_code": "コードを編集",
+ "preview": "プレビュー",
+ "translations": "多言語画面",
+ "help": "ショートカットマップ",
+ "reset": "初期化",
+ "save": "保存",
+ "exit": "終了"
+ }
+ },
+ "binding": {
+ "section_title": "データ連携",
+ "search": "データ検索",
+ "search_placeholder": "名称·パスで検索…",
+ "no_results": "一致するデータがありません",
+ "not_connected": "接続していません",
+ "clear": "解除",
+ "complex": "複合バインディング(コード編集)",
+ "required": "必須",
+ "item_fields": "項目フィールド",
+ "shape_scalar": "単一値",
+ "shape_array": "配列",
+ "shape_object": "オブジェクト",
+ "iteration_title": "反復データ連携",
+ "iteration_label": "反復データソース",
+ "iteration_hint": "この領域が反復する配列データです。1つの項目が内側の構造としてレンダーされ、データ数だけ反復されます。",
+ "iteration_item_var": "項目変数",
+ "iteration_index_var": "序番変数"
+ },
+ "inline_binding": {
+ "section_title": "テキストデータ連携",
+ "none": "このテキストに接続されたデータがありません",
+ "search": "データ検索",
+ "search_placeholder": "名称·パスで検索…",
+ "no_results": "一致するデータがありません",
+ "clear": "解除",
+ "complex": "複合バインディング(コード編集)",
+ "append_hint": "テキストの最後にデータを挿入",
+ "insert_data": "データ",
+ "param_hint": "この文句は[翻訳]タブで文を編集し(データチップの位置はドラッグで移動)、ここでは接続されたデータソースを変更するか新規に追加/解除することができます。"
+ },
+ "host_picker": {
+ "title": "代表ホストレイアウトを選択",
+ "description": "この拡張は複数の画面に注入されます。どの画面上で編集するかを選択してください。保存した変更はこの拡張が注入されるすべての画面に反映されます。",
+ "description_with_label": "\"{label}\"拡張は複数の画面に注入されます。どの画面上で編集するかを選択してください。"
+ },
+ "computed": {
+ "empty": "設定された自動計算値がありません。",
+ "add": "値を追加",
+ "group_common": "よく使う",
+ "group_more": "その他",
+ "source_base": "共通",
+ "source_overridden": "この画面で上書き",
+ "overridden_hint": "共通レイアウトの値をこの画面で上書きしています。",
+ "override_notice": "共通レイアウトの値をこの画面だけで変更します。",
+ "revert": "共通値に戻す",
+ "custom_open": "直接作成",
+ "custom_add": "追加",
+ "custom_step1": "どのデータを",
+ "custom_step2": "何をしますか",
+ "custom_step3": "結果名",
+ "custom_sum_field": "合計するフィールド (例: price)",
+ "custom_value_field": "値として使用するフィールド",
+ "custom_label_field": "表示するフィールド",
+ "custom_prop": "取得する属性 (オプション)",
+ "custom_candidates": "候補値 (カンマで区切る)",
+ "custom_fallback": "ない場合のデフォルト値",
+ "cond_add": "条件を追加",
+ "cond_field": "フィールド",
+ "cond_value": "値",
+ "key_duplicate": "既に使用されている名前です。",
+ "kind_string": "文字",
+ "kind_number": "数字",
+ "kind_boolean": "はい·いいえ",
+ "op_count": "個数をカウント",
+ "op_sum": "合計を計算",
+ "op_filter": "条件でフィルタリング",
+ "op_to_options": "選択項目に置き換え",
+ "op_nth": "N番目を取得",
+ "op_first_of": "最初にある値を使用",
+ "op_literal": "固定値",
+ "preview_label": "結果",
+ "preview_error": "この値は計算できません (保存されます)。",
+ "effect_preview": "この条件のときの画面",
+ "list_more": "ほか {count}個",
+ "value_none": "なし",
+ "type_number": "数字",
+ "type_string": "文字",
+ "type_boolean": "はい·いいえ",
+ "type_list": "リスト",
+ "type_object": "グループ",
+ "type_null": "なし"
+ },
+ "init_actions": {
+ "empty": "追加された画面アクションがありません。",
+ "empty_self": "この画面に追加されたアクションがありません。",
+ "group_base": "共通レイアウトアクション",
+ "group_self": "この画面のアクション",
+ "edit": "編集",
+ "remove": "削除",
+ "drag_reorder": "ドラッグして順序を変更",
+ "locked_parent": "共通項目",
+ "base_readonly_hint": "共通レイアウトアクションは共通 (base) レイアウトのページ設定で編集します。",
+ "merge_order": "共通レイアウトアクションが最初に実行され、その後この画面アクションが続きます。",
+ "cond_badge": "条件",
+ "if_label": "実行条件",
+ "if_placeholder": "例: {{user?.uuid}} (条件が真の場合のみ実行)",
+ "advanced_param_hint": "この値はコードエディターで設定します。"
+ },
+ "action_param": {
+ "key_placeholder": "名前",
+ "value_placeholder": "値 (データ接続·式が可能)",
+ "add_pair": "値を追加",
+ "number_placeholder": "数字またはデータ接続"
+ },
+ "initstate": {
+ "legacy_state": "古い方法で保存された初期値があります。編集すると新しい方法で整理されます。",
+ "section_local": "この画面の初期値",
+ "section_global": "グローバル初期値",
+ "section_isolated": "隔離エリアの初期値",
+ "section_isolated_desc": "コンポーネント別隔離エリアの初期値です。",
+ "isolated_dynamic_hint": "隔離エリアは画面を使用する際に作成される場合があります。",
+ "isolated_orphan": "この名前の隔離エリアを使用するコンポーネントがありません。",
+ "no_values": "設定されている値がありません。",
+ "value_name": "値の名前",
+ "value_kind": "値の種類",
+ "add_value": "値を追加",
+ "revert": "元に戻す",
+ "subkey_name": "サブ名",
+ "add_subkey": "サブ値を追加",
+ "add_item": "項目を追加",
+ "kind_string": "文字",
+ "kind_number": "数字",
+ "kind_boolean": "はい·いいえ",
+ "kind_null": "なし",
+ "kind_list": "リスト",
+ "kind_object": "グループ",
+ "name_required": "値の名前を入力してください。",
+ "name_duplicate": "同じ名前の値が既に存在します。",
+ "name_invalid": "値の名前は英文字で始まり、英文字・数字・_ のみ使用できます。"
+ },
+ "action_chip": {
+ "response_data": "応答データ",
+ "response_message": "応答メッセージ",
+ "error_status": "エラーステータスコード",
+ "error_message": "エラーメッセージ",
+ "error_errors": "検証エラー一覧",
+ "error_data": "エラー応答データ",
+ "error_status_text": "エラーステータステキスト",
+ "payload_data": "受信データ"
+ },
+ "error": {
+ "intro": "ステータスコード別に表示する画面または実行するアクションを設定します。",
+ "degrade": "このテンプレートはビジュアル編集に対応していません。コードエディターで設定してください。",
+ "message_placeholder": "表示するメッセージ(🔍 でエラー情報を挿入可能)",
+ "handler_none": "設定しない",
+ "handler_showErrorPage": "エラー画面を表示",
+ "handler_navigate": "別の画面に移動",
+ "handler_openModal": "モーダルを開く",
+ "handler_toast": "通知を表示",
+ "handler_setState": "ステータスを変更",
+ "handler_sequence": "複数のアクションを順序立てて",
+ "handler_parallel": "複数のアクションを同時に",
+ "target_content": "コンテンツエリアのみ",
+ "target_full": "全画面",
+ "setstate_key": "ステータス名"
+ },
+ "target_picker": {
+ "canvas_pick_hint": "画面で接続する要素をクリックしてください",
+ "cancel": "キャンセル",
+ "select_area": "エリアを選択",
+ "id_placeholder": "要素IDを入力するか、エリアを選択してください",
+ "none": "選択しない",
+ "minimized_hint": "キャンバスで接続する要素をクリックしてください",
+ "needs_id": "接続にはIDが必要です"
+ },
+ "page_settings": {
+ "title": "{name}設定",
+ "close": "閉じる",
+ "advanced_summary": "コードエディターで作成された高度な設定{count}個が含まれています",
+ "tab": {
+ "meta": "基本情報",
+ "seo": "検索エンジン",
+ "init": "画面アクション",
+ "overlay": "ローディング画面",
+ "computed": "自動計算",
+ "state": "初期状態",
+ "error": "エラー処理",
+ "data": "データ"
+ },
+ "banner": {
+ "inline_hint": "⏎ 保存 · Esc キャンセル",
+ "dblclick_hint": "ダブルクリックして編集",
+ "advanced": "高度な設定",
+ "open_settings": "ページ設定を開く"
+ },
+ "meta": {
+ "title": "ページ名",
+ "description": "ページの説明",
+ "icon": "メニューアイコン",
+ "icon_placeholder": "例: fa-solid fa-house",
+ "editor_label": "エディターツリー名(オプション)",
+ "editor_label_hint": "エディター左側の画面一覧に表示される名前です。空白の場合はページ名が使用されます。",
+ "permissions": "ページアクセス権限",
+ "permissions_hint": "選択した権限を持つユーザーのみこの画面を表示できます。空白の場合は制限がありません。",
+ "dynamic_hint": "動的タイトル"
+ },
+ "computed": {
+ "heading": "データで自動計算される値を設定します。タイトル・表示条件などで使用できます。"
+ },
+ "init_actions": {
+ "heading": "画面が最初に開かれるときに順序立てて実行するアクションを設定します。"
+ },
+ "initstate": {
+ "heading": "画面が開かれるときに事前に埋め込む初期値を設定します。"
+ },
+ "error": {
+ "heading": "データの読み込み中にエラーが発生した場合の対処方法を設定します。"
+ },
+ "seo": {
+ "title": "検索タイトル",
+ "description": "検索説明",
+ "enabled": "検索エンジンへの露出",
+ "disabled_note": "この画面は検索エンジンに露出されません。有効にすると以下の設定が使用できます。",
+ "page_type": "ページタイプ",
+ "page_type_none": "選択しない",
+ "page_type_free_placeholder": "ページタイプを直接入力",
+ "page_type_free_note": "連携可能なタイプがないため、直接入力します。",
+ "page_type_free": "直接入力",
+ "extensions": "拡張SEO連携",
+ "add_extension": "拡張を追加",
+ "no_extension_candidate": "連携できる拡張がありません。",
+ "defaults_precondition": "デフォルト値を取得するには、以下の項目を先に入力してください。",
+ "missing_extensions": "連携する拡張を選択してください。",
+ "missing_page_type": "ページタイプを選択してください。",
+ "toggle_setting": "露出スイッチ設定連携",
+ "toggle_setting_search": "設定を検索…",
+ "data_sources": "SEO連携データ",
+ "data_sources_hint": "検索エンジン変数・構造化データが参照するデータを選択してください。",
+ "data_sources_empty": "この画面に定義されたデータがありません。",
+ "priority": "優先度(sitemap)",
+ "changefreq": "更新周期",
+ "use_default": "デフォルト値を使用",
+ "revert_default": "元に戻す",
+ "value_placeholder": "値を入力 (データ接続可能)",
+ "og_section": "Open Graph (ソーシャル共有)",
+ "og_type": "タイプ (og:type)",
+ "og_title": "タイトル",
+ "og_description": "説明",
+ "og_image": "画像",
+ "og_image_alt": "画像の代替テキスト",
+ "og_image_adv": "高度な画像オプション (高さ·形式·セキュアURL)",
+ "og_image_height": "画像の高さ (px)",
+ "og_image_type": "画像形式 (例: image/png)",
+ "og_image_secure_url": "画像セキュアURL (https)",
+ "og_site_name": "サイト名",
+ "og_locale": "言語",
+ "og_extra": "その他の属性",
+ "og_locked": "フィルターで固定",
+ "og_intro": "空白の場合、コア·拡張機能が定めたデフォルト値を使用します (出典を表示)。入力した場合、この画面の値で上書きされます。",
+ "og_source_core": "コア設定",
+ "og_source_filter": "フィルター適用",
+ "og_source_inherited": "共通レイアウト",
+ "og_source_module": "拡張機能提供",
+ "auto_chip_source": "拡張機能提供",
+ "auto_chip_replace": "別のデータに変更",
+ "tw_section": "Twitter カード",
+ "tw_fallback_note": "空白の場合、Open Graph の値がそのまま使用されます。",
+ "tw_card": "カードタイプ",
+ "tw_site": "サイトアカウント",
+ "tw_creator": "作成者アカウント",
+ "tw_title": "タイトル",
+ "tw_description": "説明",
+ "tw_image": "画像",
+ "tw_image_alt": "画像の代替テキスト",
+ "tw_extra": "その他の属性",
+ "extra_property": "属性 (property)",
+ "extra_name": "名前 (name)",
+ "extra_content": "値",
+ "add_extra": "属性を追加",
+ "remove": "削除",
+ "structured_data": "構造化データ",
+ "sd_mode": "構造化データを直接指定",
+ "sd_manual_toggle": "直接指定",
+ "sd_filter_locked": "フィルターで固定",
+ "sd_filter_locked_note": "フィルターが構造化データを代わりに入力しているため、ここでは編集できません。",
+ "sd_auto_title": "自動生成 (モジュール提供)",
+ "sd_auto_empty": "自動生成された構造化データがありません。",
+ "sd_replace_pre_warn": "直接指定を有効にすると、自動生成されたコンテンツはすべて削除され、以下のコンテンツに置き換わります。",
+ "sd_replace_warn": "このコンテンツが自動生成構造化データをすべて置き換えます。",
+ "sd_type": "@type (構造タイプ)",
+ "sd_prop_key": "属性パス (例: offers.price)",
+ "sd_prop_value": "値",
+ "sd_add_prop": "属性を追加",
+ "sd_seed_from_module": "モジュール自動値を読み込む",
+ "sd_empty_note": "値が空の属性は保存時に除外されます。",
+ "sd_has_empty": "空の値があります。",
+ "vars": "動的変数",
+ "vars_precondition": "連携拡張機能とページタイプを先に選択すると、使用可能な変数が表示されます。",
+ "vars_auto_group": "自動入力 (読み取り専用)",
+ "vars_auto": "自動",
+ "vars_data_group": "値を入力",
+ "vars_custom_group": "直接追加",
+ "vars_required_empty": "必須変数です。値を入力してください。",
+ "vars_add": "変数を追加",
+ "vars_add_name": "変数名",
+ "vars_value_hint": "変数はタイトル·説明·構造化データで {中括弧で囲まれた名前} として使用できます。",
+ "vars_name_reserved": "{name} は自動入力変数と名前が重複しています。",
+ "vars_name_duplicate": "{name} は既に追加済みの変数です。",
+ "vars_name_not_provided": "{name} はどの拡張機能も提供していない変数です。",
+ "bot_preview_title": "検索ボット プレビュー",
+ "bot_preview_refresh": "更新",
+ "bot_preview_disabled": "この画面は検索エンジンに表示されません。",
+ "bot_preview_mode_code": "コード",
+ "bot_preview_mode_render": "レンダリング",
+ "bot_preview_empty": "表示するコンテンツがありません。",
+ "bot_preview_sample_note": "サンプルデータと保存前の設定に基づいて表示されます。"
+ }
},
"action_recipe": {
"set_state": {
- "label": "画面状態を埋める/置き換え",
- "param_target": "対象(ローカル/グローバル)",
- "param_state": "状態値",
- "param_merge": "既存の値とマージ"
+ "label": "画面ステータスの入力/変更",
+ "param_target": "対象 (ローカル/グローバル)",
+ "param_state": "ステータス値",
+ "param_merge": "既存値とマージ"
},
"load_local": {
"label": "保存された設定を読み込む",
"param_key": "ストレージキー",
- "param_state_key": "保持する状態キー",
+ "param_state_key": "保存する状態キー",
"param_default": "デフォルト値"
},
"save_local": {
- "label": "設定を保存",
+ "label": "設定を保存する",
"param_key": "ストレージキー",
"param_value": "保存する値"
},
@@ -1186,15 +1202,15 @@
"label": "別のページに移動",
"param_path": "移動するページ",
"param_query": "クエリ値",
- "param_merge_query": "既存のクエリを維持",
+ "param_merge_query": "既存のクエリを保持",
"param_scroll": "スクロール位置",
- "param_replace": "履歴を置換",
- "param_overlay_target": "移動中のローディング表示領域"
+ "param_replace": "履歴を置き換える",
+ "param_overlay_target": "移動中のロード表示エリア"
},
"open_window": {
"label": "新しいウィンドウで開く",
"param_target": "開くページ",
- "param_window_target": "ウィンドウ方式(新規/現在)"
+ "param_window_target": "ウィンドウ方式(新しいウィンドウ/現在のウィンドウ)"
},
"navigate_back": {
"label": "戻る"
@@ -1215,11 +1231,11 @@
"param_icon": "アイコン"
},
"open_modal": {
- "label": "通知ダイアログを開く",
- "param_target": "開く通知ダイアログ"
+ "label": "通知ウィンドウを開く",
+ "param_target": "開く通知ウィンドウ"
},
"close_modal": {
- "label": "通知ダイアログを閉じる"
+ "label": "通知ウィンドウを閉じる"
},
"show_alert": {
"label": "ブラウザ通知を表示",
@@ -1231,7 +1247,7 @@
"param_state_target": "対象(ローカル/グローバル)"
},
"refetch": {
- "label": "リストを更新",
+ "label": "リスト更新",
"param_data_source": "データソース",
"param_sync": "同期実行"
},
@@ -1242,15 +1258,15 @@
"param_new_data": "追加するデータ"
},
"update_data": {
- "label": "リストデータを置換",
+ "label": "リストデータを置き換える",
"param_data_source": "データソース",
"param_data": "新しいデータ",
- "param_merge": "既存と統合"
+ "param_merge": "既存とマージ"
},
"scroll": {
"label": "特定の位置にスクロール",
- "param_selector": "対象セレクター",
- "param_behavior": "スクロール方式(スムーズ/即時)",
+ "param_selector": "ターゲットセレクター",
+ "param_behavior": "スクロール方式(スムーズ/即座)",
"param_block": "配置位置"
},
"login": {
@@ -1268,7 +1284,7 @@
"emit_event": {
"label": "コンポーネントイベントを送信",
"param_event": "イベント名",
- "param_data": "送信データ"
+ "param_data": "転送するデータ"
},
"api_call": {
"label": "サーバーに送信して結果を処理",
@@ -1276,38 +1292,38 @@
"param_method": "方式(GET/POSTなど)",
"param_body": "送信するデータ",
"param_query": "クエリ値",
- "param_on_success": "成功時のアクション",
- "param_on_error": "失敗時のアクション",
"param_identity_target_email": "本人認証対象メールアドレス",
- "param_identity_target_phone": "本人認証対象電話番号"
- },
- "sequence": {
- "label": "順次複数のアクション",
- "param_actions": "アクションリスト"
- },
- "parallel": {
- "label": "同時に複数のアクション",
- "param_actions": "アクションリスト"
- },
- "switch": {
- "label": "値に基づいて分岐",
- "param_value": "判定値",
- "param_cases": "ケース別アクション"
- },
- "suppress": {
- "label": "何もしない(エラー抑制)"
+ "param_identity_target_phone": "本人認証対象電話番号",
+ "param_on_success": "成功時の動作",
+ "param_on_error": "失敗時の動作"
},
"conditions": {
- "label": "条件に基づいて分岐",
+ "label": "条件に応じて分岐",
"param_branches": "分岐リスト",
"param_if": "実行条件",
- "param_then": "実行するアクション",
+ "param_then": "実行する動作",
"branch_when": "条件",
"branch_else": "その他",
"add_branch": "分岐を追加",
"remove_branch": "分岐を削除",
"move_up": "上へ",
"move_down": "下へ"
+ },
+ "sequence": {
+ "label": "順序に複数の動作",
+ "param_actions": "動作リスト"
+ },
+ "parallel": {
+ "label": "複数のアクションを同時に実行",
+ "param_actions": "アクションリスト"
+ },
+ "switch": {
+ "label": "値に基づいて分岐",
+ "param_value": "判定する値",
+ "param_cases": "ケース別アクション"
+ },
+ "suppress": {
+ "label": "何もしない(エラー抑制)"
}
},
"action_list": {
@@ -1317,44 +1333,28 @@
"view_code": "コードを表示"
},
"error_rows": {
- "default": "デフォルト(その他すべてのエラー)",
+ "default": "既定値(その他すべてのエラー)",
"clear": "設定をクリア",
"no_action": "設定なし",
- "default_warning": "上記で設定されていないすべてのエラーに適用され、テンプレートのデフォルト動作をオーバーライドします",
+ "default_warning": "上記で設定されていないすべてのエラーに適用され、テンプレートのデフォルト動作をオーバーライドします。",
"source_self": "このページで設定",
"source_inherited": "共通レイアウトから継承",
- "source_template": "テンプレートデフォルト",
- "source_none": "テンプレートデフォルト",
+ "source_template": "テンプレート既定値",
+ "source_none": "テンプレート既定値",
"revert": "元に戻す",
- "override_here": "このページで上書き",
- "override_inherited": "この値を変更すると、このページにのみ適用され、親の設定を上書きします。",
- "override_template": "この動作はテンプレート全体に設定されています。ここで変更すると、このページにのみ適用されテンプレート設定を上書きします。"
+ "override_here": "このページでオーバーライド",
+ "override_inherited": "この値を変更すると、このページにのみ適用され、親設定をオーバーライドします。",
+ "override_template": "このアクションはテンプレート全体に設定されています。ここで変更すると、このページにのみ適用されてテンプレート設定をオーバーライドします。"
},
"modal": {
"minimized_default": "ページ設定",
"restore": "復元"
},
- "action_param": {
- "key_placeholder": "名前",
- "value_placeholder": "値(データ連携・式が利用可能)",
- "add_pair": "値を追加",
- "number_placeholder": "数値またはデータ連携"
- },
- "action_chip": {
- "response_data": "レスポンスデータ",
- "response_message": "レスポンスメッセージ",
- "error_status": "エラーステータスコード",
- "error_message": "エラーメッセージ",
- "error_errors": "検証エラーリスト",
- "error_data": "エラーレスポンスデータ",
- "error_status_text": "エラーステータステキスト",
- "payload_data": "受信データ"
- },
"json_block": {
"mode_code": "コードで",
"mode_block": "ブロックで",
- "invalid": "正しい JSON 形式ではありません。",
- "must_be_object": "オブジェクト(JSON)形式である必要があります。例: { \"名前\": 値 }",
- "invalid_keys": "値の名前は英文字で始まり、英文字・数字・_ のみが使用できます: {keys}"
+ "invalid": "有効なJSON形式ではありません。",
+ "must_be_object": "オブジェクト(JSON)形式である必要があります。例:{ \"名前\": 値 }",
+ "invalid_keys": "値の名前は英文字で始まり、英文字・数字・_ のみ使用できます:{keys}"
}
}
diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json
index ec1fafe7..4d25cdab 100644
--- a/lang-packs/_bundled/g7-core-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-core-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"scope": "core",
"target_identifier": null,
diff --git a/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json b/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json
index 2b2734b5..37846d39 100644
--- a/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json
+++ b/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json
@@ -1,13 +1,13 @@
{
"mail.provider_default": {
"definition": {
- "name": "メール本人確認 (デフォルト)",
- "description": "特定の目的がマッチしない場合に使用されるデフォルトのメールテンプレート"
+ "name": "メール本人確認(基本)",
+ "description": "特定の目的がマッチしない場合に使用されるデフォルトメールテンプレート"
},
"templates": {
"mail": {
"subject": "[{app_name}] 本人確認認証コード",
- "body": "こんにちは。
下記の認証コードを入力して本人確認を完了してください。
{code}
このコードは{expire_minutes}分後に有効期限切れになります。
本人が申請していない場合は、このメールを無視してください。
ご利用ありがとうございます。
{app_name}
"
+ "body": "こんにちは。
下記の認証コードを入力して本人確認を完了してください。
{code}
このコードは{expire_minutes}分後に失効します。
本人が要求していない場合は、このメールを無視してください。
ありがとうございます。
{app_name}
"
}
}
},
@@ -19,19 +19,19 @@
"templates": {
"mail": {
"subject": "[{app_name}] 会員登録認証コード",
- "body": "{app_name} 会員登録認証
下記の認証コードを会員登録画面に入力してください。
{code}
このコードは{expire_minutes}分後に有効期限切れになります。
本人が登録を試行していない場合は、このメールを無視してください。
ご利用ありがとうございます。
{app_name}
"
+ "body": "{app_name} 会員登録認証
下記の認証コードを会員登録画面に入力してください。
{code}
このコードは{expire_minutes}分後に失効します。
本人が登録を試みていない場合は、このメールを無視してください。
ありがとうございます。
{app_name}
"
}
}
},
"mail.purpose.password_reset": {
"definition": {
"name": "パスワード再設定リンク",
- "description": "パスワード再設定リクエスト時に送信される署名付きリンクメール (link フロー)"
+ "description": "パスワード再設定要求時に送信される署名付きリンクメール(link フロー)"
},
"templates": {
"mail": {
- "subject": "[{app_name}] パスワード再設定のご案内",
- "body": "パスワード再設定
パスワード再設定のリクエストを受け取りました。下記のボタンをクリックしてパスワードを再設定してください。
このリンクは{expire_minutes}分後に有効期限切れになります。
本人が申請していない場合は、このメールを無視してください。
ご利用ありがとうございます。
{app_name}
"
+ "subject": "[{app_name}] パスワード再設定のお知らせ",
+ "body": "パスワード再設定
パスワード再設定のリクエストを受け取りました。下記のボタンをクリックしてパスワードを再設定してください。
このリンクは{expire_minutes}分後に失効します。
本人が要求していない場合は、このメールを無視してください。
ありがとうございます。
{app_name}
"
}
}
},
@@ -43,19 +43,19 @@
"templates": {
"mail": {
"subject": "[{app_name}] アカウント情報変更認証コード",
- "body": "アカウント情報変更認証
{purpose_label}の処理のために下記の認証コードを入力してください。
{code}
このコードは{expire_minutes}分後に有効期限切れになります。
本人が変更を試行していない場合は、直ちにパスワードを変更してください。
ご利用ありがとうございます。
{app_name}
"
+ "body": "アカウント情報変更認証
{purpose_label}の処理のため、下記の認証コードを入力してください。
{code}
このコードは{expire_minutes}分後に失効します。
本人が変更を試みていない場合は、直ちにパスワードを変更してください。
ありがとうございます。
{app_name}
"
}
}
},
"mail.purpose.sensitive_action": {
"definition": {
"name": "重要な作業認証",
- "description": "決済、重要な設定変更など重要な作業時の本人確認コードメール"
+ "description": "決済や主要な設定変更など重要な作業時の本人確認コードメール"
},
"templates": {
"mail": {
"subject": "[{app_name}] 重要な作業認証コード",
- "body": "重要な作業認証
{purpose_label}の進行のために下記の認証コードを入力してください。
{code}
このコードは{expire_minutes}分後に有効期限切れになります。
本人が試行していない場合は、直ちにカスタマーサポートチームにお問い合わせください。
ご利用ありがとうございます。
{app_name}
"
+ "body": "重要な作業認証
{purpose_label}の進行のため、下記の認証コードを入力してください。
{code}
このコードは{expire_minutes}分後に失効します。
本人が試みていない場合は、直ちにカスタマーサポートにお問い合わせください。
ありがとうございます。
{app_name}
"
}
}
}
diff --git a/lang-packs/_bundled/g7-core-ja/seed/notifications.json b/lang-packs/_bundled/g7-core-ja/seed/notifications.json
index 8c88d528..f9ed683c 100644
--- a/lang-packs/_bundled/g7-core-ja/seed/notifications.json
+++ b/lang-packs/_bundled/g7-core-ja/seed/notifications.json
@@ -1,33 +1,33 @@
{
"welcome": {
"definition": {
- "name": "会員登録 ウェルカム",
+ "name": "会員登録ウェルカム",
"description": "会員登録完了時に送信されるウェルカム通知"
},
"templates": {
"mail": {
- "subject": "[{app_name}] 会員登録をお待ちしておりました",
- "body": "{name}様、ようこそ!
{app_name}にご登録いただきありがとうございます。
これで全てのサービスをご利用いただけます。以下のボタンをクリックしてログインしてください。
ありがとうございます。
{app_name}
"
+ "subject": "[{app_name}] 会員登録をお待ちしていました",
+ "body": "{name}様、ようこそ!
{app_name}へのご登録ありがとうございます。
これですべてのサービスをご利用いただけます。下のボタンをクリックしてログインしてください。
ありがとうございます。
{app_name}
"
},
"database": {
- "subject": "会員登録をお待ちしておりました",
- "body": "{name}様、{app_name}にご登録いただきありがとうございます。"
+ "subject": "会員登録をお待ちしていました",
+ "body": "{name}様、{app_name}へのご登録ありがとうございます。"
}
}
},
"reset_password": {
"definition": {
- "name": "パスワード再設定",
- "description": "パスワード再設定要求時に送信される通知"
+ "name": "パスワードリセット",
+ "description": "パスワードリセット要求時に送信される通知"
},
"templates": {
"mail": {
- "subject": "[{app_name}] パスワード再設定のご案内",
- "body": "{name}様、こんにちは。
パスワード再設定のご要望を承りました。以下のボタンをクリックしてパスワードを再設定してください。
このリンクは{expire_minutes}分後に期限切れになります。
パスワード再設定をご要望でなかった場合は、このメールを無視してください。
ありがとうございます。
{app_name}
"
+ "subject": "[{app_name}] パスワードリセットのご案内",
+ "body": "{name}様、こんにちは。
パスワードリセットのご要求をお受けしました。下のボタンをクリックしてパスワードをリセットしてください。
このリンクは{expire_minutes}分後に有効期限切れになります。
パスワードリセットのご要求をされていない場合は、このメールを無視してください。
ありがとうございます。
{app_name}
"
},
"database": {
- "subject": "パスワード再設定のご案内",
- "body": "{name}様、パスワード再設定が要求されました。ご本人様による操作でない場合は、この通知を無視してください。"
+ "subject": "パスワードリセットのご案内",
+ "body": "{name}様、パスワードリセットが要求されました。ご自身で要求されていない場合は、この通知を無視してください。"
}
}
},
@@ -39,11 +39,43 @@
"templates": {
"mail": {
"subject": "[{app_name}] パスワードが変更されました",
- "body": "{name}様、こんにちは。
アカウントのパスワードが正常に変更されました。
ご本人様による操作でない場合は、直ちにカスタマーサポートまでお問い合わせください。
ありがとうございます。
{app_name}
"
+ "body": "{name}様、こんにちは。
アカウントのパスワードが正常に変更されました。
ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。
ありがとうございます。
{app_name}
"
},
"database": {
"subject": "パスワードが変更されました",
- "body": "{name}様、パスワードが変更されました。ご本人様による操作でない場合は、直ちにカスタマーサポートまでお問い合わせください。"
+ "body": "{name}様、パスワードが変更されました。ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。"
+ }
+ }
+ },
+ "sitemap_regenerated": {
+ "definition": {
+ "name": "サイトマップ再生成完了",
+ "description": "管理者が手動で実行したサイトマップ再生成が完了した際に、実行した管理者へ送信される通知"
+ },
+ "templates": {
+ "mail": {
+ "subject": "[{app_name}] サイトマップの再生成が完了しました",
+ "body": "サイトマップ再生成完了
ご依頼いただいたサイトマップの再生成が完了しました。
合計{url_count}件のURLが{child_count}個のファイルに生成されました。
ありがとうございます。
{app_name}
"
+ },
+ "database": {
+ "subject": "サイトマップ再生成完了",
+ "body": "ご依頼いただいたサイトマップの再生成が完了しました。合計{url_count}件のURL、{child_count}個のファイルが生成されました。"
+ }
+ }
+ },
+ "sitemap_regenerate_failed": {
+ "definition": {
+ "name": "サイトマップ再生成失敗",
+ "description": "管理者が手動で実行したサイトマップ再生成が失敗した際に、実行した管理者へ送信される通知"
+ },
+ "templates": {
+ "mail": {
+ "subject": "[{app_name}] サイトマップの再生成に失敗しました",
+ "body": "サイトマップ再生成失敗
ご依頼いただいたサイトマップの再生成に失敗しました。
エラー: {error}
ありがとうございます。
{app_name}
"
+ },
+ "database": {
+ "subject": "サイトマップ再生成失敗",
+ "body": "ご依頼いただいたサイトマップの再生成に失敗しました。エラー: {error}"
}
}
}
diff --git a/lang-packs/_bundled/g7-core-ja/seed/permissions.json b/lang-packs/_bundled/g7-core-ja/seed/permissions.json
index c3c2d0af..37c84694 100644
--- a/lang-packs/_bundled/g7-core-ja/seed/permissions.json
+++ b/lang-packs/_bundled/g7-core-ja/seed/permissions.json
@@ -8,8 +8,8 @@
"description": "ユーザー管理権限"
},
"core.users.read": {
- "name": "ユーザー조회",
- "description": "ユーザー一覧および詳細情報を閲覧できます。"
+ "name": "ユーザー閲覧",
+ "description": "ユーザーリストと詳細情報を閲覧できます。"
},
"core.users.create": {
"name": "ユーザー作成",
@@ -28,8 +28,8 @@
"description": "メニュー管理権限"
},
"core.menus.read": {
- "name": "メニュー조회",
- "description": "メニュー一覧を閲覧できます。"
+ "name": "メニュー閲覧",
+ "description": "メニューリストを閲覧できます。"
},
"core.menus.create": {
"name": "メニュー作成",
@@ -48,8 +48,8 @@
"description": "モジュール管理権限"
},
"core.modules.read": {
- "name": "モジュール조회",
- "description": "モジュール一覧を閲覧できます。"
+ "name": "モジュール閲覧",
+ "description": "モジュールリストを閲覧できます。"
},
"core.modules.install": {
"name": "モジュールインストール",
@@ -68,8 +68,8 @@
"description": "プラグイン管理権限"
},
"core.plugins.read": {
- "name": "プラグイン조회",
- "description": "プラグイン一覧を閲覧できます。"
+ "name": "プラグイン閲覧",
+ "description": "プラグインリストを閲覧できます。"
},
"core.plugins.install": {
"name": "プラグインインストール",
@@ -92,8 +92,8 @@
"description": "テンプレート管理権限"
},
"core.templates.read": {
- "name": "テンプレート조회",
- "description": "テンプレート一覧を閲覧できます。"
+ "name": "テンプレート閲覧",
+ "description": "テンプレートリストを閲覧できます。"
},
"core.templates.install": {
"name": "テンプレートインストール",
@@ -116,8 +116,8 @@
"description": "ロールおよび権限管理権限"
},
"core.permissions.read": {
- "name": "権限조회",
- "description": "ロールおよび権限一覧を閲覧できます。"
+ "name": "権限閲覧",
+ "description": "ロールおよび権限リストを閲覧できます。"
},
"core.permissions.create": {
"name": "ロール作成",
@@ -125,7 +125,7 @@
},
"core.permissions.update": {
"name": "ロール編集",
- "description": "ロール情報および権限を編集できます。"
+ "description": "ロール情報と権限を編集できます。"
},
"core.permissions.delete": {
"name": "ロール削除",
@@ -136,7 +136,7 @@
"description": "通知発送履歴管理権限"
},
"core.notification-logs.read": {
- "name": "発送履歴조회",
+ "name": "発送履歴閲覧",
"description": "通知発送履歴を閲覧できます。"
},
"core.notification-logs.delete": {
@@ -145,11 +145,11 @@
},
"core.notifications": {
"name": "通知(管理者)",
- "description": "管理者用通知管理権限(管理者画面で使用)"
+ "description": "管理者用通知管理権限(管理画面で使用)"
},
"core.notifications.read": {
- "name": "通知조회",
- "description": "通知一覧および未読数を閲覧できます。"
+ "name": "通知閲覧",
+ "description": "通知リストと未読数を閲覧できます。"
},
"core.notifications.update": {
"name": "通知既読処理",
@@ -161,11 +161,11 @@
},
"core.user-notifications": {
"name": "通知 (ユーザー)",
- "description": "ユーザー用通知権限 (ユーザー画面で本人通知管理)"
+ "description": "ユーザー向け通知権限 (ユーザー画面で本人の通知を管理)"
},
"core.user-notifications.read": {
"name": "通知閲覧",
- "description": "本人の通知リスト及び未読数を閲覧できます。"
+ "description": "本人の通知一覧および未読数を閲覧できます。"
},
"core.user-notifications.update": {
"name": "通知読了処理",
@@ -176,56 +176,56 @@
"description": "本人の通知を削除できます。"
},
"core.identity": {
- "name": "本人確認 (ユーザー)",
- "description": "ログインユーザーの本人確認チャレンジリクエスト/検証/キャンセル権限"
+ "name": "本人認証 (ユーザー)",
+ "description": "ログインユーザーの本人認証チャレンジ要求/検証/キャンセル権限"
},
"core.identity.request": {
- "name": "IDVリクエスト",
- "description": "本人確認チャレンジをリクエストできます。"
+ "name": "IDV要求",
+ "description": "本人認証チャレンジを要求できます。"
},
"core.identity.verify": {
"name": "IDV検証",
- "description": "本人確認チャレンジを検証できます。"
+ "description": "本人の本人認証チャレンジを検証できます。"
},
"core.identity.cancel": {
"name": "IDVキャンセル",
- "description": "本人の本人確認チャレンジをキャンセルできます。"
+ "description": "本人の本人認証チャレンジをキャンセルできます。"
},
"core.admin.identity": {
- "name": "本人確認管理 (管理者)",
+ "name": "本人認証管理 (管理者)",
"description": "管理者用IDVプロバイダ/ポリシー/ログ管理権限"
},
"core.admin.identity.providers.read": {
"name": "プロバイダ設定閲覧",
- "description": "本人確認プロバイダ設定を閲覧できます。"
+ "description": "本人認証プロバイダ設定を閲覧できます。"
},
"core.admin.identity.providers.update": {
"name": "プロバイダ設定編集",
- "description": "本人確認プロバイダ設定を編集できます。"
+ "description": "本人認証プロバイダ設定を編集できます。"
},
"core.admin.identity.policies.read": {
"name": "ポリシー閲覧",
- "description": "本人確認ポリシー(ルート/フック別)を閲覧できます。"
+ "description": "本人認証ポリシー(ルート/フック別)を閲覧できます。"
},
"core.admin.identity.policies.update": {
"name": "ポリシー編集",
- "description": "本人確認ポリシー(ルート/フック別)をCRUD できます。"
+ "description": "本人認証ポリシー(ルート/フック別)をCRUD できます。"
},
"core.admin.identity.logs.read": {
"name": "ログ閲覧",
- "description": "本人確認履歴を閲覧できます。"
+ "description": "本人認証履歴を閲覧できます。"
},
"core.admin.identity.logs.purge": {
- "name": "ログ破棄",
- "description": "本人確認履歴を破棄(保管期間外)できます。"
+ "name": "ログ削除",
+ "description": "本人認証履歴を削除(保管周期外)できます。"
},
"core.admin.identity.messages.read": {
"name": "メッセージテンプレート閲覧",
- "description": "本人確認メッセージ定義/テンプレートを閲覧できます。"
+ "description": "本人認証メッセージ定義/テンプレートを閲覧できます。"
},
"core.admin.identity.messages.update": {
"name": "メッセージテンプレート編集",
- "description": "本人確認メッセージ定義/テンプレートを編集できます。"
+ "description": "本人認証メッセージ定義/テンプレートを編集できます。"
},
"core.settings": {
"name": "環境設定",
@@ -245,19 +245,19 @@
},
"core.dashboard.read": {
"name": "ダッシュボード閲覧",
- "description": "ダッシュボード統計及び情報を閲覧できます。"
+ "description": "ダッシュボード統計および情報を閲覧できます。"
},
"core.dashboard.system-status": {
- "name": "システム状態",
- "description": "システム状態情報を閲覧できます。"
+ "name": "システムステータス",
+ "description": "システムステータス情報を閲覧できます。"
},
"core.dashboard.resources": {
"name": "システムリソース",
"description": "CPU、メモリ、ディスク使用量を閲覧できます。"
},
"core.dashboard.activities": {
- "name": "最近の活動",
- "description": "最近の活動履歴を閲覧できます。"
+ "name": "最近のアクティビティ",
+ "description": "最近のアクティビティ履歴を閲覧できます。"
},
"core.dashboard.alerts": {
"name": "システム通知",
@@ -285,7 +285,7 @@
},
"core.attachments.update": {
"name": "添付ファイル編集",
- "description": "添付ファイル情報及び順序を編集できます。"
+ "description": "添付ファイル情報および順序を編集できます。"
},
"core.attachments.delete": {
"name": "添付ファイル削除",
@@ -297,7 +297,7 @@
},
"core.schedules.read": {
"name": "スケジュール閲覧",
- "description": "スケジュールリスト及び詳細情報を閲覧できます。"
+ "description": "スケジュール一覧および詳細情報を閲覧できます。"
},
"core.schedules.create": {
"name": "スケジュール作成",
@@ -317,11 +317,11 @@
},
"core.language_packs": {
"name": "言語パック管理",
- "description": "言語パックインストール/削除/有効化権限"
+ "description": "言語パックインストール/アンインストール/有効化権限"
},
"core.language_packs.read": {
"name": "言語パック閲覧",
- "description": "インストール済み言語パックリスト及び詳細情報を閲覧できます。"
+ "description": "インストールされた言語パックの一覧と詳細情報を閲覧できます。"
},
"core.language_packs.install": {
"name": "言語パックインストール",
@@ -332,7 +332,7 @@
"description": "言語パックを有効化/無効化/削除できます。"
},
"core.language_packs.update": {
- "name": "言語パック アップデート",
+ "name": "言語パックアップデート",
"description": "インストールされた言語パックのアップデートを確認して実行できます。"
}
}
diff --git a/lang-packs/_bundled/g7-core-ja/seed/roles.json b/lang-packs/_bundled/g7-core-ja/seed/roles.json
index a74a914c..33d6fcd8 100644
--- a/lang-packs/_bundled/g7-core-ja/seed/roles.json
+++ b/lang-packs/_bundled/g7-core-ja/seed/roles.json
@@ -9,10 +9,10 @@
},
"user": {
"name": "一般ユーザー",
- "description": "基本的なユーザーロールです。"
+ "description": "基本ユーザーロールです。"
},
"guest": {
"name": "非会員",
- "description": "認証されていないユーザーロールです。管理者が権限を付与することができます。"
+ "description": "認証されていないユーザーロールです。管理者が権限を付与できます。"
}
}
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
index 1b41679b..25e8d62d 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.5] - 2026-07-19
+
+### Removed
+
+- 더 이상 사용하지 않는 장바구니 옵션 조합 검증 문구의 일본어 번역을 정리했습니다.
+
## [1.0.4] - 2026-07-16
### Added
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
index 602d995b..e116971f 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
@@ -845,7 +845,6 @@ return [
'item_not_found' => 'カート商品が見つかりません。',
'items_required' => 'カートに追加する商品を選択してください。',
'items_min' => '最低1つ以上の商品を選択してください。',
- 'option_values_not_found' => '該当するオプション組み合わせが見つかりません。',
],
'wishlist' => [
'product_id_required' => '商品を選択してください。',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
index 180054e3..b5d6bd1c 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index 45bf9ed3..cd91fbd1 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -4,6 +4,19 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.4] - 2026-07-17
+
+### Added
+
+- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
+- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
+- 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
+- 환경설정 > SEO 화면의 Sitemap 생성 진행 상황 표시(대기·생성 중·파일 작성 중·완료·실패 단계, 현재 처리 항목, 누적 URL 수, 실시간/주기 확인 안내) 일본어 번역 추가 — 진행 상황 화면이 일본어 로케일에서도 올바르게 표시됩니다.
+
+### Changed
+
+- 환경설정 > SEO 화면의 캐시 항목(SEO 캐시 활성화·캐시 유지 시간, Sitemap 캐시 유지 시간) 안내 문구 일본어 번역을 갱신했습니다 — 비워두면 고급 설정의 값을 따르고 값을 넣으면 그 값이 우선한다는 새 동작을 일본어 로케일에서도 올바르게 안내합니다.
+
## [1.0.3] - 2026-07-14
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index 293e14e6..d38b1491 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -1410,9 +1410,9 @@
"seo_cache_settings": "SEO キャッシュ 設定",
"seo_cache_settings_desc": "SEO ページキャッシュ関連の設定です。",
"cache_enabled": "SEO キャッシュ 有効化",
- "cache_enabled_desc": "SEO ページをキャッシュして、応答速度を向上させます。",
+ "cache_enabled_desc": "SEO ページをキャッシュして、応答速度を向上させます。指定しない場合は詳細設定の値に従います。",
"cache_ttl": "キャッシュ 保持時間 (秒)",
- "cache_ttl_hint": "最小 60 秒、最大 86400 秒 (24 時間)。デフォルト: 7200 秒 (2 時間)",
+ "cache_ttl_hint": "空欄にすると詳細設定の SEO キャッシュ時間に従います。値を入力するとこの画面の値が優先されます — 最小 60 秒、最大 86400 秒 (24 時間)",
"sitemap_settings": "Sitemap 設定",
"sitemap_settings_desc": "sitemap.xml 自動生成関連の設定です。",
"sitemap_enabled": "Sitemap 生成 有効化",
@@ -1424,12 +1424,29 @@
"sitemap_schedule_time": "生成 時刻",
"sitemap_schedule_time_hint": "HH:mm 形式で入力してください。(例: 02:00)",
"sitemap_cache_ttl": "Sitemap キャッシュ 保持時間 (秒)",
- "sitemap_cache_ttl_hint": "最小 3600 秒 (1 時間)、最大 604800 秒 (7 日)。デフォルト: 86400 秒 (24 時間)",
+ "sitemap_cache_ttl_hint": "空欄にすると詳細設定の Sitemap キャッシュ時間に従います。値を入力するとこの画面の値が優先されます — 最小 3600 秒 (1 時間)、最大 604800 秒 (7 日)",
"sitemap_last_updated_at": "最後の生成",
"sitemap_manual_update": "今すぐ生成",
"sitemap_updating": "生成中...",
"sitemap_update_success": "Sitemap 生成が完了しました。",
- "sitemap_update_failed": "Sitemap 生成に失敗しました。"
+ "sitemap_update_failed": "Sitemap 生成に失敗しました。",
+ "sitemap_urls_per_file": "Sitemap ファイルあたりの URL 数",
+ "sitemap_urls_per_file_hint": "URL がこの数を超えると sitemap ファイルが複数に分割され、インデックスファイルが一緒に作成されます — 最小 1000 個、最大 50000 個(検索エンジン仕様上限)",
+ "sitemap_gzip": "Sitemap 圧縮(gzip)",
+ "sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。",
+ "sitemap_hreflang_enabled": "多言語代替リンク (hreflang)",
+ "sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。",
+ "sitemap_regenerating": "作成中…",
+ "sitemap_progress": "作成進行状況",
+ "sitemap_status_queued": "待機中",
+ "sitemap_status_running": "作成中",
+ "sitemap_status_writing": "ファイル作成中",
+ "sitemap_status_completed": "完了",
+ "sitemap_status_failed": "失敗",
+ "sitemap_progress_phase": "現在処理中: {{phase}}",
+ "sitemap_progress_urls": "累積URL: {{count}}個",
+ "sitemap_realtime_connected": "リアルタイムで接続されています。",
+ "sitemap_polling": "進行状況を定期的に確認しています。"
},
"security": {
"title": "セキュリティ 設定",
@@ -1736,7 +1753,9 @@
"geoip_manual_update": "今すぐ更新",
"geoip_updating": "更新中...",
"geoip_update_success": "GeoIP DBの更新が完了しました。",
- "geoip_update_failed": "GeoIP DBの更新に失敗しました。"
+ "geoip_update_failed": "GeoIP DBの更新に失敗しました。",
+ "seo_sitemap_cache": "Sitemap キャッシュ",
+ "seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。"
},
"notification_definitions": {
"title": "通知設定",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json
index 68e0fe21..12fbc873 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json
@@ -1575,7 +1575,9 @@
"tokenValidation": "トークン検証",
"user": "ユーザー",
"users": "ユーザーリスト",
- "dashboard_recent_notifications": "ダッシュボード最近の通知"
+ "dashboard_recent_notifications": "ダッシュボード最近の通知",
+ "sitemap_status": "サイトマップ生成ステータス",
+ "sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)"
},
"array_group": {
"chart_labels": "項目ラベル",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
index 9c1a1431..d759ed88 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
- "version": "1.0.3",
+ "version": "1.0.4",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php
index 7e2c0016..a876b138 100644
--- a/lang/en/exceptions.php
+++ b/lang/en/exceptions.php
@@ -79,4 +79,12 @@ return [
'vendor_dir_not_found' => 'vendor/ directory does not exist. Run composer install first: :path',
'composer_binary_not_found' => 'Composer binary not found. Set the COMPOSER_BINARY environment variable or add composer to your PATH.',
],
+
+ // SEO / Sitemap 관련 예외
+ 'seo' => [
+ 'sitemap_write_failed' => 'Failed to write sitemap file: :path',
+ 'sitemap_gzip_failed' => 'Failed to compress sitemap child file: :file',
+ 'sitemap_temp_file_unreadable' => 'Cannot read sitemap temporary file: :file',
+ 'sitemap_stat_group_column_unsupported' => 'Unsupported group-by column: :column',
+ ],
];
diff --git a/lang/en/seo.php b/lang/en/seo.php
index 16a4f3f4..b6c57a69 100644
--- a/lang/en/seo.php
+++ b/lang/en/seo.php
@@ -12,9 +12,14 @@ return [
// Sitemap manual regeneration
'sitemap_regenerated' => 'Sitemap regeneration completed.',
+ 'sitemap_regenerate_dispatched' => 'Sitemap regeneration has started. It may take a while to finish.',
'sitemap_regenerate_failed' => 'Sitemap regeneration failed.',
'sitemap_disabled' => 'Sitemap generation is disabled.',
+ // Sitemap generation/serving
+ 'sitemap_not_ready' => 'The sitemap is being prepared. Please try again shortly.',
+ 'sitemap_generate_failed' => 'Sitemap generation failed: :error',
+
// Cache stats
'stats_period' => 'SEO cache statistics for the last :days days',
'stats_overall' => 'Overall Statistics',
diff --git a/lang/en/validation.php b/lang/en/validation.php
index 71f2d15b..43bd1c3d 100644
--- a/lang/en/validation.php
+++ b/lang/en/validation.php
@@ -865,12 +865,16 @@ return [
'stats_cache_ttl_integer' => 'Stats cache TTL must be an integer.',
'stats_cache_ttl_min' => 'Stats cache TTL must be at least 0 seconds.',
'stats_cache_ttl_max' => 'Stats cache TTL may not be greater than 14400 seconds (4 hours).',
- 'seo_cache_enabled_required' => 'Please select the SEO cache setting.',
- 'seo_cache_enabled_boolean' => 'SEO cache must be true or false.',
- 'seo_cache_ttl_required' => 'Please enter the SEO cache TTL.',
- 'seo_cache_ttl_integer' => 'SEO cache TTL must be an integer.',
- 'seo_cache_ttl_min' => 'SEO cache TTL must be at least 0 seconds.',
- 'seo_cache_ttl_max' => 'SEO cache TTL may not be greater than 14400 seconds (4 hours).',
+ // Advanced tab only — the SEO tab override field (seo_cache_*) allows a different range.
+ 'advanced_seo_cache_enabled_required' => 'Please select the SEO cache setting.',
+ 'advanced_seo_cache_enabled_boolean' => 'SEO cache must be true or false.',
+ 'advanced_seo_cache_ttl_required' => 'Please enter the SEO cache TTL.',
+ 'advanced_seo_cache_ttl_integer' => 'SEO cache TTL must be an integer.',
+ 'advanced_seo_cache_ttl_min' => 'SEO cache TTL must be at least 0 seconds.',
+ 'advanced_seo_cache_ttl_max' => 'SEO cache TTL may not be greater than 14400 seconds (4 hours).',
+ 'seo_sitemap_cache_ttl_integer' => 'Sitemap cache TTL must be an integer.',
+ 'seo_sitemap_cache_ttl_min' => 'Sitemap cache TTL must be at least 3600 seconds (1 hour).',
+ 'seo_sitemap_cache_ttl_max' => 'Sitemap cache TTL may not be greater than 604800 seconds (7 days).',
// Debug settings
'debug_mode_required' => 'Please select the debug mode setting.',
@@ -954,6 +958,14 @@ return [
'sitemap_cache_ttl_integer' => 'Sitemap cache TTL must be an integer.',
'sitemap_cache_ttl_min' => 'Sitemap cache TTL must be at least 3600 seconds (1 hour).',
'sitemap_cache_ttl_max' => 'Sitemap cache TTL may not be greater than 604800 seconds (7 days).',
+ 'sitemap_urls_per_file_integer' => 'URLs per sitemap file must be an integer.',
+ 'sitemap_urls_per_file_min' => 'URLs per sitemap file must be at least 1000.',
+ 'sitemap_urls_per_file_max' => 'URLs per sitemap file may not be greater than 50000.',
+ 'sitemap_gzip_boolean' => 'Sitemap compression setting must be true or false.',
+ 'sitemap_serve_stale_on_miss_boolean' => 'Serving the previous sitemap setting must be true or false.',
+ 'sitemap_max_urls_per_contributor_integer' => 'Maximum URLs per contributor must be an integer.',
+ 'sitemap_max_urls_per_contributor_min' => 'Maximum URLs per contributor must be at least 0.',
+ 'sitemap_hreflang_enabled_boolean' => 'The sitemap hreflang alternate links setting must be true or false.',
'sitemap_schedule_invalid' => 'Please select a valid sitemap generation schedule.',
'sitemap_schedule_time_invalid' => 'Sitemap generation time must be in HH:mm format.',
diff --git a/lang/ko/exceptions.php b/lang/ko/exceptions.php
index 61bef32e..bb5bee32 100644
--- a/lang/ko/exceptions.php
+++ b/lang/ko/exceptions.php
@@ -79,4 +79,12 @@ return [
'vendor_dir_not_found' => 'vendor/ 디렉토리가 존재하지 않습니다. 먼저 composer install을 실행하세요: :path',
'composer_binary_not_found' => 'Composer 바이너리를 찾을 수 없습니다. COMPOSER_BINARY 환경변수를 설정하거나 composer를 PATH에 추가하세요.',
],
+
+ // SEO / Sitemap 관련 예외
+ 'seo' => [
+ 'sitemap_write_failed' => 'Sitemap 파일 기록에 실패했습니다: :path',
+ 'sitemap_gzip_failed' => 'Sitemap 자식 파일 압축에 실패했습니다: :file',
+ 'sitemap_temp_file_unreadable' => 'Sitemap 임시 파일을 읽을 수 없습니다: :file',
+ 'sitemap_stat_group_column_unsupported' => '지원하지 않는 그룹 기준 컬럼입니다: :column',
+ ],
];
diff --git a/lang/ko/seo.php b/lang/ko/seo.php
index 77148fa6..f58b046a 100644
--- a/lang/ko/seo.php
+++ b/lang/ko/seo.php
@@ -12,9 +12,14 @@ return [
// Sitemap 수동 재생성
'sitemap_regenerated' => 'Sitemap 재생성이 완료되었습니다.',
+ 'sitemap_regenerate_dispatched' => 'Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.',
'sitemap_regenerate_failed' => 'Sitemap 재생성에 실패했습니다.',
'sitemap_disabled' => 'Sitemap 생성이 비활성화되어 있습니다.',
+ // Sitemap 생성/서빙
+ 'sitemap_not_ready' => 'Sitemap 을 준비하는 중입니다. 잠시 후 다시 시도해주세요.',
+ 'sitemap_generate_failed' => 'Sitemap 생성에 실패했습니다: :error',
+
// 캐시 통계
'stats_period' => '최근 :days일간 SEO 캐시 통계',
'stats_overall' => '전체 통계',
diff --git a/lang/ko/validation.php b/lang/ko/validation.php
index 02c496e5..e7469a5f 100644
--- a/lang/ko/validation.php
+++ b/lang/ko/validation.php
@@ -903,6 +903,14 @@ return [
'sitemap_cache_ttl_integer' => 'Sitemap 캐시 TTL은 정수여야 합니다.',
'sitemap_cache_ttl_min' => 'Sitemap 캐시 TTL은 최소 3600초(1시간) 이상이어야 합니다.',
'sitemap_cache_ttl_max' => 'Sitemap 캐시 TTL은 최대 604800초(7일)를 초과할 수 없습니다.',
+ 'sitemap_urls_per_file_integer' => 'Sitemap 파일당 URL 수는 정수여야 합니다.',
+ 'sitemap_urls_per_file_min' => 'Sitemap 파일당 URL 수는 최소 1000개 이상이어야 합니다.',
+ 'sitemap_urls_per_file_max' => 'Sitemap 파일당 URL 수는 최대 50000개를 초과할 수 없습니다.',
+ 'sitemap_gzip_boolean' => 'Sitemap 압축 설정은 true 또는 false 값이어야 합니다.',
+ 'sitemap_serve_stale_on_miss_boolean' => '이전 Sitemap 제공 설정은 true 또는 false 값이어야 합니다.',
+ 'sitemap_max_urls_per_contributor_integer' => '수집기당 최대 URL 수는 정수여야 합니다.',
+ 'sitemap_max_urls_per_contributor_min' => '수집기당 최대 URL 수는 0 이상이어야 합니다.',
+ 'sitemap_hreflang_enabled_boolean' => 'Sitemap 다국어 대체 링크(hreflang) 설정은 true 또는 false 값이어야 합니다.',
'sitemap_schedule_invalid' => '유효한 Sitemap 생성 주기를 선택해주세요.',
'sitemap_schedule_time_invalid' => 'Sitemap 생성 시각은 HH:mm 형식이어야 합니다.',
@@ -938,12 +946,16 @@ return [
'stats_cache_ttl_integer' => '통계 캐시 만료 시간은 정수여야 합니다.',
'stats_cache_ttl_min' => '통계 캐시 만료 시간은 최소 0초여야 합니다.',
'stats_cache_ttl_max' => '통계 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.',
- 'seo_cache_enabled_required' => 'SEO 캐시 설정을 선택해주세요.',
- 'seo_cache_enabled_boolean' => 'SEO 캐시는 true 또는 false 값이어야 합니다.',
- 'seo_cache_ttl_required' => 'SEO 캐시 만료 시간을 입력해주세요.',
- 'seo_cache_ttl_integer' => 'SEO 캐시 만료 시간은 정수여야 합니다.',
- 'seo_cache_ttl_min' => 'SEO 캐시 만료 시간은 최소 0초여야 합니다.',
- 'seo_cache_ttl_max' => 'SEO 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.',
+ // 고급 탭 전용 — SEO 탭의 오버라이드 칸(seo_cache_*)과 허용 범위가 다르므로 키를 분리한다.
+ 'advanced_seo_cache_enabled_required' => 'SEO 캐시 설정을 선택해주세요.',
+ 'advanced_seo_cache_enabled_boolean' => 'SEO 캐시는 true 또는 false 값이어야 합니다.',
+ 'advanced_seo_cache_ttl_required' => 'SEO 캐시 만료 시간을 입력해주세요.',
+ 'advanced_seo_cache_ttl_integer' => 'SEO 캐시 만료 시간은 정수여야 합니다.',
+ 'advanced_seo_cache_ttl_min' => 'SEO 캐시 만료 시간은 최소 0초여야 합니다.',
+ 'advanced_seo_cache_ttl_max' => 'SEO 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.',
+ 'seo_sitemap_cache_ttl_integer' => 'Sitemap 캐시 만료 시간은 정수여야 합니다.',
+ 'seo_sitemap_cache_ttl_min' => 'Sitemap 캐시 만료 시간은 최소 3600초(1시간) 이상이어야 합니다.',
+ 'seo_sitemap_cache_ttl_max' => 'Sitemap 캐시 만료 시간은 최대 604800초(7일)를 초과할 수 없습니다.',
// 디버그 설정
'debug_mode_required' => '디버그 모드 설정을 선택해주세요.',
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 9369166f..4270bdb7 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-07-16
+
+### Added
+
+- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
+- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
+
+### Changed
+
+- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
+- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
+
## [1.0.2] - 2026-07-14
### Fixed
diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json
index a59b9a81..02e719ba 100644
--- a/modules/_bundled/sirsoft-board/composer.json
+++ b/modules/_bundled/sirsoft-board/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json
index 1476f801..14ab4275 100644
--- a/modules/_bundled/sirsoft-board/module.json
+++ b/modules/_bundled/sirsoft-board/module.json
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
- "g7_version": ">=7.0.0",
+ "g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json
index 1f256d45..8c3338ef 100644
--- a/modules/_bundled/sirsoft-board/package-lock.json
+++ b/modules/_bundled/sirsoft-board/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
- "version": "1.0.2",
+ "version": "1.0.3",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json
index 9106b48a..92974da1 100644
--- a/modules/_bundled/sirsoft-board/package.json
+++ b/modules/_bundled/sirsoft-board/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php
index 3b9fc5c7..c964da6f 100644
--- a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php
+++ b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php
@@ -4,9 +4,13 @@ namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\HookListenerInterface;
+use App\Enums\SitemapChangeFreq;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheRegenerator;
+use App\Seo\SitemapIndexer;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Board\Enums\PostStatus;
/**
* 게시글 변경 시 SEO 캐시 무효화 리스너
@@ -65,6 +69,8 @@ class SeoBoardCacheListener implements HookListenerInterface
// 게시글 상세 페이지 캐시 즉시 재생성
$this->regenerateDetailCache($args);
+
+ $this->syncPostSitemapIndex($args, false);
}
/**
@@ -78,6 +84,8 @@ class SeoBoardCacheListener implements HookListenerInterface
// 게시글 상세 페이지 캐시 즉시 재생성
$this->regenerateDetailCache($args);
+
+ $this->syncPostSitemapIndex($args, false);
}
/**
@@ -90,6 +98,8 @@ class SeoBoardCacheListener implements HookListenerInterface
public function onPostDelete(...$args): void
{
$this->invalidateRelatedCaches($args);
+
+ $this->syncPostSitemapIndex($args, true);
}
/**
@@ -141,6 +151,96 @@ class SeoBoardCacheListener implements HookListenerInterface
'board_slug' => $slug,
]);
}
+
+ $this->syncBoardSitemapIndex($board);
+ }
+
+ /**
+ * 게시글의 사이트맵 색인을 증분 갱신합니다.
+ *
+ * 게시 상태(Published)이고 비밀글이 아니며 'SEO 제공 페이지(게시글 상세)' 토글이 켜져 있으면
+ * 색인(upsert), 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다.
+ * 색인 규칙은 BoardSitemapContributor 의 게시글 URL 규칙과 일치해야 합니다.
+ *
+ * @param array $args 훅 인자 배열 (첫 번째: Post 모델, 두 번째: 게시판 slug)
+ * @param bool $deleted 삭제 이벤트 여부
+ */
+ private function syncPostSitemapIndex(array $args, bool $deleted): void
+ {
+ $post = $args[0] ?? null;
+ $slug = $args[1] ?? null;
+
+ if (! $post || ! isset($post->id) || ! $slug) {
+ return;
+ }
+
+ try {
+ $indexer = app(SitemapIndexer::class);
+
+ $visible = ! $deleted
+ && ($post->status ?? null) === PostStatus::Published
+ && ! (bool) ($post->is_secret ?? false)
+ && (bool) g7_module_settings('sirsoft-board', 'seo.seo_post_detail', true);
+
+ if ($visible) {
+ $indexer->indexResource('board_post', $post->id, 'sirsoft-board', [[
+ 'url' => "/board/{$slug}/{$post->id}",
+ 'lastmod' => $post->updated_at?->toW3cString(),
+ 'changefreq' => SitemapChangeFreq::Monthly->value,
+ 'priority' => 0.5,
+ ]]);
+ } else {
+ $indexer->deindexResource('board_post', $post->id);
+ }
+
+ GenerateSitemapJob::dispatch();
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Board post sitemap index sync failed', [
+ 'error' => $e->getMessage(),
+ 'post_id' => is_object($post) ? ($post->id ?? null) : null,
+ ]);
+ }
+ }
+
+ /**
+ * 게시판의 사이트맵 색인을 증분 갱신합니다.
+ *
+ * 활성(is_active) 이며 'SEO 제공 페이지(게시판)' 토글이 켜져 있으면 색인(upsert),
+ * 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다.
+ * 색인 규칙은 BoardSitemapContributor 의 게시판 URL 규칙과 일치해야 합니다.
+ *
+ * @param mixed $board Board 모델
+ */
+ private function syncBoardSitemapIndex(mixed $board): void
+ {
+ if (! is_object($board) || ! isset($board->id) || ! isset($board->slug)) {
+ return;
+ }
+
+ try {
+ $indexer = app(SitemapIndexer::class);
+
+ $visible = (bool) ($board->is_active ?? false)
+ && (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true);
+
+ if ($visible) {
+ $indexer->indexResource('board', $board->id, 'sirsoft-board', [[
+ 'url' => "/board/{$board->slug}",
+ 'lastmod' => $board->updated_at?->toW3cString(),
+ 'changefreq' => SitemapChangeFreq::Daily->value,
+ 'priority' => 0.6,
+ ]]);
+ } else {
+ $indexer->deindexResource('board', $board->id);
+ }
+
+ GenerateSitemapJob::dispatch();
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Board sitemap index sync failed', [
+ 'error' => $e->getMessage(),
+ 'board_id' => is_object($board) ? ($board->id ?? null) : null,
+ ]);
+ }
}
/**
diff --git a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php
index 68a1f2cb..cc2e521a 100644
--- a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php
+++ b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php
@@ -105,8 +105,9 @@ class BoardServiceProvider extends BaseModuleServiceProvider
// Sitemap 기여자 등록
$this->app->booted(function () {
if ($this->app->bound(SitemapGenerator::class)) {
+ // Repository 주입을 위해 컨테이너로 해석합니다.
$this->app->make(SitemapGenerator::class)->registerContributor(
- new BoardSitemapContributor
+ $this->app->make(BoardSitemapContributor::class)
);
}
});
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php
index 2649b453..aa578fc3 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php
@@ -2,10 +2,12 @@
namespace Modules\Sirsoft\Board\Repositories;
+use App\Enums\UserStatus;
use App\Helpers\PermissionHelper;
-use App\Helpers\TimezoneHelper;
use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -32,7 +34,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function create(array $data): Board
{
- $board = new Board();
+ $board = new Board;
$board->fill($data);
// created_by/updated_by는 guarded이므로 직접 할당
@@ -63,7 +65,7 @@ class BoardRepository implements BoardRepositoryInterface
* @param int $id 게시판 ID
* @return Board 게시판 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function findOrFail(int $id): Board
{
@@ -88,7 +90,7 @@ class BoardRepository implements BoardRepositoryInterface
* @param array $data 수정할 데이터
* @return Board 수정된 게시판 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function update(int $id, array $data): Board
{
@@ -113,7 +115,7 @@ class BoardRepository implements BoardRepositoryInterface
* @param int $id 게시판 ID
* @return bool 삭제 성공 여부
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function delete(int $id): bool
{
@@ -179,7 +181,7 @@ class BoardRepository implements BoardRepositoryInterface
/**
* 쿼리 빌더를 반환합니다. (체이닝 가능)
*
- * @return \Illuminate\Database\Eloquent\Builder
+ * @return Builder
*/
public function query()
{
@@ -243,7 +245,7 @@ class BoardRepository implements BoardRepositoryInterface
'board_name' => $post->board?->getLocalizedName(),
'title' => $post->title,
'author_name' => $post->author_name,
- 'created_at' => $this->formatCreatedAt($post->created_at),
+ 'created_at' => $this->formatCreatedAt($post->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
'view_count' => $post->view_count,
'comment_count' => $post->comments_count ?? 0,
@@ -295,7 +297,7 @@ class BoardRepository implements BoardRepositoryInterface
'id' => $post->id,
'title' => $post->title,
'author_name' => $post->author_name,
- 'created_at' => $this->formatCreatedAt($post->created_at),
+ 'created_at' => $this->formatCreatedAt($post->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
'view_count' => $post->view_count,
'comment_count' => $post->comments_count ?? 0,
@@ -481,8 +483,8 @@ class BoardRepository implements BoardRepositoryInterface
}
// 사용자 상태 처리 (탈퇴한 사용자는 익명화)
- $userStatus = $post->user_id ? \App\Enums\UserStatus::tryFrom($post->user_status) : null;
- $isWithdrawn = $userStatus === \App\Enums\UserStatus::Withdrawn;
+ $userStatus = $post->user_id ? UserStatus::tryFrom($post->user_status) : null;
+ $isWithdrawn = $userStatus === UserStatus::Withdrawn;
$authorName = $post->user_id
? ($isWithdrawn ? __('user.withdrawn_user') : $post->user_name)
: $post->guest_author_name;
@@ -504,7 +506,7 @@ class BoardRepository implements BoardRepositoryInterface
],
'view_count' => $viewCount,
'comment_count' => $commentCount,
- 'created_at' => $this->formatCreatedAt(Carbon::parse($post->created_at)),
+ 'created_at' => $this->formatCreatedAt(Carbon::parse($post->created_at)),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
];
})->toArray();
@@ -513,20 +515,34 @@ class BoardRepository implements BoardRepositoryInterface
/**
* 활성화된 게시판 목록을 조회합니다.
*
- * @param string|null $slug 특정 게시판 슬러그 (null이면 전체)
+ * @param string|null $slug 특정 게시판 슬러그 (null이면 전체)
* @return Collection 활성 게시판 컬렉션
*/
public function getActiveBoards(?string $slug = null): Collection
{
$query = Board::where('is_active', true);
- if (!empty($slug)) {
+ if (! empty($slug)) {
$query->where('slug', $slug);
}
return $query->get();
}
+ /**
+ * Sitemap 생성용으로 활성 게시판을 순회자로 조회합니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 활성 게시판 순회자 (id, slug, updated_at 만 조회)
+ */
+ public function streamActiveForSitemap(int $chunkSize = 500): iterable
+ {
+ return Board::where('is_active', true)
+ ->select(['id', 'slug', 'updated_at'])
+ ->orderBy('id')
+ ->lazyById($chunkSize);
+ }
+
/**
* 필터용 전체 활성 게시판 목록을 반환합니다.
*
@@ -542,9 +558,9 @@ class BoardRepository implements BoardRepositoryInterface
/**
* 게시판을 일괄 업데이트합니다.
*
- * @param array $data 업데이트할 데이터
- * @param bool $applyAll 전체 적용 여부
- * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용)
+ * @param array $data 업데이트할 데이터
+ * @param bool $applyAll 전체 적용 여부
+ * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용)
* @return int 업데이트된 게시판 수
*/
public function bulkUpdate(array $data, bool $applyAll = true, array $boardIds = []): int
@@ -590,9 +606,9 @@ class BoardRepository implements BoardRepositoryInterface
*
* @param string $orderBy 정렬 기준 컬럼
* @param string $orderDirection 정렬 방향 (asc/desc)
- * @return \Illuminate\Database\Eloquent\Collection 활성 게시판 컬렉션
+ * @return Collection 활성 게시판 컬렉션
*/
- public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): \Illuminate\Database\Eloquent\Collection
+ public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection
{
return Board::where('is_active', true)
->orderBy($orderBy, $orderDirection)
@@ -618,9 +634,9 @@ class BoardRepository implements BoardRepositoryInterface
*
* id, name, slug 컬럼만 조회하여 메뉴 렌더링에 필요한 최소 데이터만 반환합니다.
*
- * @return \Illuminate\Database\Eloquent\Collection 활성 게시판 컬렉션 (id, name, slug만 포함)
+ * @return Collection 활성 게시판 컬렉션 (id, name, slug만 포함)
*/
- public function getActiveBoardsForMenu(): \Illuminate\Database\Eloquent\Collection
+ public function getActiveBoardsForMenu(): Collection
{
return Board::where('is_active', true)
->select(['id', 'name', 'slug'])
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php
index e82f5457..b31f471a 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php
@@ -2,7 +2,9 @@
namespace Modules\Sirsoft\Board\Repositories\Contracts;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Board\Models\Board;
@@ -14,7 +16,7 @@ interface BoardRepositoryInterface
/**
* 게시판을 생성합니다.
*
- * @param array $data 게시판 생성 데이터
+ * @param array $data 게시판 생성 데이터
* @return Board 생성된 게시판 모델
*/
public function create(array $data): Board;
@@ -22,7 +24,7 @@ interface BoardRepositoryInterface
/**
* ID로 게시판을 조회합니다.
*
- * @param int $id 게시판 ID
+ * @param int $id 게시판 ID
* @return Board|null 게시판 모델 또는 null
*/
public function find(int $id): ?Board;
@@ -30,17 +32,17 @@ interface BoardRepositoryInterface
/**
* ID로 게시판을 조회하며, 없으면 예외를 발생시킵니다.
*
- * @param int $id 게시판 ID
+ * @param int $id 게시판 ID
* @return Board 게시판 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function findOrFail(int $id): Board;
/**
* 슬러그로 게시판을 조회합니다.
*
- * @param string $slug 게시판 슬러그
+ * @param string $slug 게시판 슬러그
* @return Board|null 게시판 모델 또는 null
*/
public function findBySlug(string $slug): ?Board;
@@ -48,21 +50,21 @@ interface BoardRepositoryInterface
/**
* 게시판을 수정합니다.
*
- * @param int $id 게시판 ID
- * @param array $data 수정할 데이터
+ * @param int $id 게시판 ID
+ * @param array $data 수정할 데이터
* @return Board 수정된 게시판 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function update(int $id, array $data): Board;
/**
* 게시판을 삭제합니다.
*
- * @param int $id 게시판 ID
+ * @param int $id 게시판 ID
* @return bool 삭제 성공 여부
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function delete(int $id): bool;
@@ -76,7 +78,7 @@ interface BoardRepositoryInterface
/**
* 게시판 목록을 페이지네이션하여 조회합니다.
*
- * @param int $perPage 페이지당 개수
+ * @param int $perPage 페이지당 개수
* @return LengthAwarePaginator 페이지네이션된 게시판 목록
*/
public function paginate(int $perPage = 15): LengthAwarePaginator;
@@ -84,7 +86,7 @@ interface BoardRepositoryInterface
/**
* 키워드로 게시판을 검색합니다.
*
- * @param string $keyword 검색 키워드
+ * @param string $keyword 검색 키워드
* @return Collection 검색 결과 컬렉션
*/
public function search(string $keyword): Collection;
@@ -92,7 +94,7 @@ interface BoardRepositoryInterface
/**
* 타입으로 게시판을 필터링합니다.
*
- * @param string $type 게시판 타입
+ * @param string $type 게시판 타입
* @return Collection 필터링된 게시판 컬렉션
*/
public function filterByType(string $type): Collection;
@@ -100,7 +102,7 @@ interface BoardRepositoryInterface
/**
* 쿼리 빌더를 반환합니다.
*
- * @return \Illuminate\Database\Eloquent\Builder
+ * @return Builder
*/
public function query();
@@ -164,11 +166,22 @@ interface BoardRepositoryInterface
/**
* 활성화된 게시판 목록을 조회합니다.
*
- * @param string|null $slug 특정 게시판 슬러그 (null이면 전체)
+ * @param string|null $slug 특정 게시판 슬러그 (null이면 전체)
* @return Collection 활성 게시판 컬렉션
*/
public function getActiveBoards(?string $slug = null): Collection;
+ /**
+ * Sitemap 생성용으로 활성 게시판을 순회자로 조회합니다.
+ *
+ * 전체 결과를 메모리에 적재하지 않도록 lazyById 로 청크 단위 조회하며,
+ * sitemap 에 필요한 컬럼만 select 합니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 활성 게시판 순회자 (id, slug, updated_at 만 조회)
+ */
+ public function streamActiveForSitemap(int $chunkSize = 500): iterable;
+
/**
* 필터용 전체 활성 게시판 목록을 반환합니다.
*
@@ -179,9 +192,9 @@ interface BoardRepositoryInterface
/**
* 게시판을 일괄 업데이트합니다.
*
- * @param array $data 업데이트할 데이터
- * @param bool $applyAll 전체 적용 여부
- * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용)
+ * @param array $data 업데이트할 데이터
+ * @param bool $applyAll 전체 적용 여부
+ * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용)
* @return int 업데이트된 게시판 수
*/
public function bulkUpdate(array $data, bool $applyAll = true, array $boardIds = []): int;
@@ -189,7 +202,7 @@ interface BoardRepositoryInterface
/**
* 게시판 ID 목록별 게시글 개수를 조회합니다.
*
- * @param array $boardIds 게시판 ID 목록
+ * @param array $boardIds 게시판 ID 목록
* @return array board_id => count 매핑
*/
public function getPostsCountByBoardIds(array $boardIds): array;
@@ -197,8 +210,8 @@ interface BoardRepositoryInterface
/**
* 활성화된 게시판 목록을 정렬 옵션과 함께 조회합니다.
*
- * @param string $orderBy 정렬 기준 컬럼
- * @param string $orderDirection 정렬 방향 (asc/desc)
+ * @param string $orderBy 정렬 기준 컬럼
+ * @param string $orderDirection 정렬 방향 (asc/desc)
* @return Collection 활성 게시판 컬렉션
*/
public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection;
@@ -222,7 +235,7 @@ interface BoardRepositoryInterface
/**
* 특정 유형의 게시판 개수를 반환합니다.
*
- * @param string $type 게시판 유형 slug
+ * @param string $type 게시판 유형 slug
* @return int 해당 유형의 게시판 개수
*/
public function countByType(string $type): int;
@@ -244,4 +257,4 @@ interface BoardRepositoryInterface
* @return int 갱신된 카운트 값
*/
public function recalculateCommentsCount(int $boardId): int;
-}
\ No newline at end of file
+}
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php
index 88a7c8a2..751c172d 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php
@@ -22,6 +22,7 @@ interface PostRepositoryInterface
* @param int $perPage 페이지당 항목 수
* @param bool $withTrashed 삭제된 게시글 포함 여부
* @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지)
+ * @return Paginator 게시글 페이지네이터
*/
public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator;
@@ -96,6 +97,7 @@ interface PostRepositoryInterface
* @param string $status 변경할 상태 (blinded/deleted)
* @param array $actionLog 작업 이력 데이터
* @param string|null $triggerType 트리거 유형 (admin, report 등)
+ * @return Post 상태가 변경된 게시글 모델
*
* @throws ModelNotFoundException
*/
@@ -263,6 +265,18 @@ interface PostRepositoryInterface
*/
public function findByBoardId(int $boardId, int $id): ?Post;
+ /**
+ * Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다.
+ *
+ * 공개 게시글 = 게시 상태 + 비밀글 아님. 전체 적재를 피하기 위해
+ * id 기준으로 청크 단위 지연 조회합니다.
+ *
+ * @param int $boardId 게시판 ID
+ * @param int $chunkSize 청크 크기
+ * @return iterable 공개 게시글 순회자 (id, updated_at 만 조회)
+ */
+ public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable;
+
/**
* 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
index 40e8fb86..6eb3a160 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
@@ -44,6 +44,7 @@ class PostRepository implements PostRepositoryInterface
* @param array $filters 필터 조건
* @param int $perPage 페이지당 항목 수 (일반 게시글 기준)
* @param bool $withTrashed 삭제된 게시글 포함 여부
+ * @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지)
* @return Paginator 페이지네이션된 게시글 목록 (simplePaginate — COUNT 쿼리 제거)
*/
public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator
@@ -176,6 +177,7 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
+ * @return Post|null 게시글 모델 (없으면 null)
*/
public function find(string $slug, int $id): ?Post
{
@@ -189,6 +191,7 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
+ * @return Post 게시글 모델
*
* @throws ModelNotFoundException
*/
@@ -205,6 +208,7 @@ class PostRepository implements PostRepositoryInterface
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param array $data 수정할 데이터
+ * @return Post 수정된 게시글 모델
*
* @throws ModelNotFoundException
*/
@@ -221,6 +225,7 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
+ * @return bool 삭제 성공 여부
*
* @throws ModelNotFoundException
*/
@@ -236,6 +241,7 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
+ * @return bool 영구 삭제 성공 여부
*
* @throws ModelNotFoundException
*/
@@ -253,6 +259,8 @@ class PostRepository implements PostRepositoryInterface
* @param int $id 게시글 ID
* @param string $status 변경할 상태 (published/blinded/deleted)
* @param array $actionLog 작업 이력 데이터
+ * @param string|null $triggerType 트리거 유형 (admin, report 등)
+ * @return Post 상태가 변경된 게시글 모델
*
* @throws ModelNotFoundException
*/
@@ -406,6 +414,7 @@ class PostRepository implements PostRepositoryInterface
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
+ * @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @return Post|null 게시글 모델 (카운트 포함)
*/
public function findWithCounts(string $slug, int $id, ?int $boardId = null): ?Post
@@ -554,6 +563,7 @@ class PostRepository implements PostRepositoryInterface
* @param int $id 현재 게시글 ID
* @param array $filters 정렬 파라미터 (order_by, order_direction)
* @param bool $withTrashed 삭제된 게시글 포함 여부 (기본: false)
+ * @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @return array{prev: Post|null, next: Post|null} 이전/다음 게시글
*/
public function getAdjacentPosts(string $slug, int $id, array $filters = [], bool $withTrashed = false, ?int $boardId = null): array
@@ -1391,6 +1401,27 @@ class PostRepository implements PostRepositoryInterface
->find($id);
}
+ /**
+ * Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다.
+ *
+ * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
+ * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
+ *
+ * @param int $boardId 게시판 ID
+ * @param int $chunkSize 청크 크기
+ * @return iterable 공개 게시글 순회자 (id, updated_at 만 조회)
+ */
+ public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable
+ {
+ return Post::query()
+ ->where('board_id', $boardId)
+ ->where('status', PostStatus::Published)
+ ->where('is_secret', false)
+ ->select(['id', 'updated_at'])
+ ->orderBy('id')
+ ->lazyById($chunkSize);
+ }
+
/**
* 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php
index 07d7ac83..952cbe11 100644
--- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php
+++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php
@@ -2,18 +2,32 @@
namespace Modules\Sirsoft\Board\Seo;
-use App\Seo\Contracts\SitemapContributorInterface;
-use Modules\Sirsoft\Board\Enums\PostStatus;
-use Modules\Sirsoft\Board\Models\Board;
-use Modules\Sirsoft\Board\Models\Post;
+use App\Enums\SitemapChangeFreq;
+use App\Seo\AbstractSitemapContributor;
+use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
+use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
/**
* Board 모듈 Sitemap 기여자
*
* 게시판 및 게시글 URL을 sitemap에 제공합니다.
+ * 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고
+ * 한 건씩 지연 yield 하여 대용량 게시글(수백만 건)에서도 메모리를 유계로 유지합니다.
*/
-class BoardSitemapContributor implements SitemapContributorInterface
+class BoardSitemapContributor extends AbstractSitemapContributor
{
+ /**
+ * BoardSitemapContributor 생성자
+ *
+ * @param BoardRepositoryInterface $boardRepository 게시판 Repository
+ * @param PostRepositoryInterface $postRepository 게시글 Repository
+ */
+ public function __construct(
+ private readonly BoardRepositoryInterface $boardRepository,
+ private readonly PostRepositoryInterface $postRepository,
+ ) {}
+
/**
* 확장 식별자를 반환합니다.
*
@@ -25,16 +39,41 @@ class BoardSitemapContributor implements SitemapContributorInterface
}
/**
- * Sitemap URL 항목 배열을 반환합니다.
+ * Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
- * 게시판 목록, 게시판별 페이지, 각 게시판의 게시글 URL을 생성합니다.
+ * 게시판 목록, 게시판별 페이지, 각 게시판의 게시글 URL을 순서대로 yield 합니다.
+ * 기여자당 안전 상한(seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고
+ * 경고를 남깁니다. 상한은 이 기여자가 내보내는 모든 URL 유형에 함께 적용됩니다.
*
- * @return array
+ * @return iterable
*/
- public function getUrls(): array
+ public function getUrlsLazy(): iterable
{
- $urls = [];
+ $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0);
+ $emitted = 0;
+ foreach ($this->collectUrls() as $entry) {
+ if ($maxUrls > 0 && $emitted >= $maxUrls) {
+ Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
+ 'contributor' => $this->getIdentifier(),
+ 'max_urls' => $maxUrls,
+ ]);
+
+ return;
+ }
+
+ yield $entry;
+ $emitted++;
+ }
+ }
+
+ /**
+ * 상한 적용 이전의 URL 후보를 순서대로 지연 생성합니다.
+ *
+ * @return iterable
+ */
+ private function collectUrls(): iterable
+ {
// 'SEO 제공 페이지' 토글 — OFF 시 해당 URL 유형 제외 (기본 노출)
$seoBoards = (bool) g7_module_settings('sirsoft-board', 'seo.seo_boards', true);
$seoBoard = (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true);
@@ -42,27 +81,30 @@ class BoardSitemapContributor implements SitemapContributorInterface
// 게시판 목록 페이지
if ($seoBoards) {
- $urls[] = [
+ yield [
'url' => '/boards',
- 'changefreq' => 'weekly',
+ 'changefreq' => SitemapChangeFreq::Weekly->value,
'priority' => 0.5,
+ 'resource_type' => 'board_index',
+ 'resource_id' => null,
];
}
// 개별 게시판/게시글 URL이 모두 OFF면 게시판 조회 자체를 생략
if (! $seoBoard && ! $seoPostDetail) {
- return $urls;
+ return;
}
// 게시판별 페이지
- $boards = Board::where('is_active', true)->get(['id', 'slug', 'updated_at']);
- foreach ($boards as $board) {
+ foreach ($this->boardRepository->streamActiveForSitemap() as $board) {
if ($seoBoard) {
- $urls[] = [
+ yield [
'url' => "/board/{$board->slug}",
'lastmod' => $board->updated_at?->toW3cString(),
- 'changefreq' => 'daily',
+ 'changefreq' => SitemapChangeFreq::Daily->value,
'priority' => 0.6,
+ 'resource_type' => 'board',
+ 'resource_id' => (string) $board->id,
];
}
@@ -71,20 +113,16 @@ class BoardSitemapContributor implements SitemapContributorInterface
}
// 각 게시판의 공개된 게시글 (비밀글 제외, 게시 상태만)
- $posts = Post::where('board_id', $board->id)
- ->where('status', PostStatus::Published)
- ->where('is_secret', false)
- ->get(['id', 'updated_at']);
- foreach ($posts as $post) {
- $urls[] = [
+ foreach ($this->postRepository->streamPublishedForSitemap($board->id) as $post) {
+ yield [
'url' => "/board/{$board->slug}/{$post->id}",
'lastmod' => $post->updated_at?->toW3cString(),
- 'changefreq' => 'monthly',
+ 'changefreq' => SitemapChangeFreq::Monthly->value,
'priority' => 0.5,
+ 'resource_type' => 'board_post',
+ 'resource_id' => (string) $post->id,
];
}
}
-
- return $urls;
}
}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php
new file mode 100644
index 00000000..588eb528
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php
@@ -0,0 +1,144 @@
+app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class));
+ $this->app->instance(SeoCacheRegenerator::class, Mockery::spy(SeoCacheRegenerator::class));
+ Bus::fake([GenerateSitemapJob::class]);
+
+ $this->listener = new SeoBoardCacheListener;
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ /**
+ * 게시(Published)·비밀글 아님 게시글 생성 시 색인(append)된다.
+ */
+ public function test_published_public_post_is_indexed(): void
+ {
+ $post = (object) ['id' => 100, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()];
+
+ $this->listener->onPostCreate($post, 'notice');
+
+ $row = SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '100')->first();
+ $this->assertNotNull($row, '공개 게시글은 색인돼야 합니다.');
+ $this->assertStringEndsWith('/board/notice/100', $row->loc);
+ Bus::assertDispatched(GenerateSitemapJob::class);
+ }
+
+ /**
+ * 비밀글은 색인되지 않는다(전시 상태여도).
+ */
+ public function test_secret_post_is_not_indexed(): void
+ {
+ $post = (object) ['id' => 101, 'status' => PostStatus::Published, 'is_secret' => true, 'updated_at' => now()];
+
+ $this->listener->onPostCreate($post, 'notice');
+
+ $this->assertSame(0, SitemapUrl::where('resource_id', '101')->count(), '비밀글은 색인되면 안 됩니다.');
+ }
+
+ /**
+ * 블라인드 처리(수정)되면 색인이 제거된다.
+ */
+ public function test_blinded_post_update_removes_index(): void
+ {
+ $this->listener->onPostCreate(
+ (object) ['id' => 102, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
+ 'notice'
+ );
+ $this->assertSame(1, SitemapUrl::where('resource_id', '102')->count());
+
+ $this->listener->onPostUpdate(
+ (object) ['id' => 102, 'status' => PostStatus::Blinded, 'is_secret' => false, 'updated_at' => now()],
+ 'notice'
+ );
+
+ $this->assertSame(0, SitemapUrl::where('resource_id', '102')->count(), '블라인드 게시글은 색인에서 제거돼야 합니다.');
+ }
+
+ /**
+ * 게시글 삭제 시 색인이 제거된다.
+ */
+ public function test_post_delete_removes_index(): void
+ {
+ $this->listener->onPostCreate(
+ (object) ['id' => 103, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
+ 'notice'
+ );
+ $this->assertSame(1, SitemapUrl::where('resource_id', '103')->count());
+
+ $this->listener->onPostDelete((object) ['id' => 103], 'notice');
+
+ $this->assertSame(0, SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '103')->count());
+ }
+
+ /**
+ * 활성/비활성 게시판 수정이 색인을 토글한다.
+ */
+ public function test_board_visibility_toggles_index(): void
+ {
+ $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => true, 'updated_at' => now()]);
+ $row = SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->first();
+ $this->assertNotNull($row);
+ $this->assertStringEndsWith('/board/free', $row->loc);
+
+ $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => false, 'updated_at' => now()]);
+ $this->assertSame(0, SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->count());
+ }
+
+ /**
+ * 'SEO 제공 페이지(게시글 상세)' 토글이 OFF 면 공개 게시글이어도 색인이 제거된다.
+ *
+ * 리스너의 공개상태 판정은 게시 상태·비밀글 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치).
+ */
+ public function test_post_detail_toggle_off_removes_index_even_when_public(): void
+ {
+ // 토글 ON 상태로 먼저 색인
+ $this->listener->onPostCreate(
+ (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
+ 'notice'
+ );
+ $this->assertSame(1, SitemapUrl::where('resource_id', '110')->count());
+
+ // 토글 OFF → 공개 게시글이어도 색인 제거
+ Config::set('g7_settings.modules.sirsoft-board.seo.seo_post_detail', false);
+ $this->listener->onPostUpdate(
+ (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
+ 'notice'
+ );
+
+ $this->assertSame(0, SitemapUrl::where('resource_id', '110')->count(), 'seo_post_detail OFF 면 공개 게시글도 색인에서 제거돼야 합니다.');
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php
index f2412274..5ae3138b 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php
@@ -29,17 +29,27 @@ class PostSeoOgImageRenderingTest extends ModuleTestCase
// 의존성 모듈 (sirsoft-ecommerce / sirsoft-page) 을 active 상태로 등록 — 템플릿 의존 만족용
// (전체 install 은 비용 크므로 Module DB 행만 직접 셋업)
+ // 버전은 번들 매니페스트 실버전으로 등록한다. sirsoft-basic 템플릿은 의존성 제약
+ // (예: sirsoft-ecommerce >=1.0.2) 을 두므로, 하드코딩 버전은 확장 버전 bump 시 stale 되어
+ // installTemplate() 이 "버전이 맞지 않습니다" 로 실패 → 활성 user 템플릿 부재 →
+ // SEO 라우트 미해석 → og:image 미출력으로 이어진다. updateOrCreate 로 기존 행의
+ // stale 버전까지 매니페스트 값으로 교정한다(트랜잭션 롤백으로 격리).
foreach ([
['sirsoft-ecommerce', 'sirsoft', '이커머스', 'Ecommerce'],
['sirsoft-page', 'sirsoft', '페이지', 'Page'],
] as [$id, $vendor, $ko, $en]) {
- \App\Models\Module::firstOrCreate(
+ $manifestVersion = json_decode(
+ (string) @file_get_contents(base_path("modules/_bundled/{$id}/module.json")),
+ true
+ )['version'] ?? '1.0.0';
+
+ \App\Models\Module::updateOrCreate(
['identifier' => $id],
[
'vendor' => $vendor,
'name' => ['ko' => $ko, 'en' => $en],
'status' => \App\Enums\ExtensionStatus::Active->value,
- 'version' => '1.0.0',
+ 'version' => $manifestVersion,
'config' => [],
]
);
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php
index 5f3cd456..1da4b733 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php
@@ -2,8 +2,12 @@
namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
+use App\Contracts\Extension\CacheInterface;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheRegenerator;
+use App\Seo\SitemapIndexer;
+use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Mockery;
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
@@ -38,6 +42,10 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
$this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class);
$this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock);
+ // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
+ $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
+ Bus::fake([GenerateSitemapJob::class]);
+
$this->listener = new SeoBoardCacheListener;
}
@@ -77,6 +85,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
* 이 훅 미구독이 "게시판 정보 수정 후 SEO 메타 캐시 미무효화" 버그의 원인이었습니다.
*
* @scenario name_changed=no, description_changed=no
+ *
* @effects board_after_update_hook_subscribed
*/
public function test_subscribes_board_after_update_hook(): void
@@ -98,6 +107,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
* sitemap 캐시를 무효화해야 합니다. (게시글 상세는 board name 미노출이라 제외)
*
* @scenario name_changed=yes, description_changed=no
+ *
* @effects board_update_invalidates_board_list_url, board_update_invalidates_boards_index_url, board_update_invalidates_home_and_search_layouts, board_update_invalidates_sitemap_cache
*/
public function test_on_board_update_invalidates_board_seo_caches(): void
@@ -390,9 +400,9 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
});
// sitemap 캐시(seo.sitemap) forget 검증 — CacheInterface
- $cacheInterfaceMock = Mockery::mock(\App\Contracts\Extension\CacheInterface::class);
+ $cacheInterfaceMock = Mockery::mock(CacheInterface::class);
$cacheInterfaceMock->shouldReceive('forget')->once()->with('seo.sitemap');
- $this->app->instance(\App\Contracts\Extension\CacheInterface::class, $cacheInterfaceMock);
+ $this->app->instance(CacheInterface::class, $cacheInterfaceMock);
Log::shouldReceive('debug')->atLeast()->once();
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php
new file mode 100644
index 00000000..5e31b53f
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php
@@ -0,0 +1,140 @@
+ $slug,
+ 'name' => ['ko' => '레포지토리 사이트맵 테스트', 'en' => 'Repository Sitemap Test'],
+ 'is_active' => true,
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->repository = $this->app->make(BoardRepositoryInterface::class);
+ }
+
+ /**
+ * 테스트 정리 - 이 클래스가 생성한 게시판을 제거합니다.
+ *
+ * BoardTestCase 는 트랜잭션 롤백 대신 수동 삭제 전략을 쓰며 게시판 자체는
+ * 지우지 않는다. 추가 게시판을 만든 쪽이 정리하지 않으면 활성 게시판이
+ * 다른 테스트 클래스로 누출되어 게시판 목록 단언을 깨뜨린다.
+ */
+ protected function tearDown(): void
+ {
+ Board::where('slug', 'like', 'repo-sitemap-%')->forceDelete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * streamActiveForSitemap: 전체 적재가 아닌 순회자를 반환한다
+ */
+ public function test_stream_active_for_sitemap_returns_lazy_collection(): void
+ {
+ $this->assertInstanceOf(
+ LazyCollection::class,
+ $this->repository->streamActiveForSitemap(),
+ 'lazyById 순회자여야 전체 적재를 피할 수 있습니다.'
+ );
+ }
+
+ /**
+ * streamActiveForSitemap: sitemap 에 필요한 컬럼만 적재한다 (회귀)
+ *
+ * 범용 getActiveBoards() 는 전컬럼을 적재하므로 sitemap 경로가 그것을 재사용하면
+ * 메모리가 후퇴한다. select 가 유지되는지 속성 키로 검증한다.
+ */
+ public function test_stream_active_for_sitemap_selects_only_sitemap_columns(): void
+ {
+ $board = $this->repository->streamActiveForSitemap()->first();
+
+ $this->assertNotNull($board);
+ $this->assertSame(
+ ['id', 'slug', 'updated_at'],
+ array_keys($board->getAttributes()),
+ 'sitemap 조회는 id/slug/updated_at 만 적재해야 합니다.'
+ );
+ }
+
+ /**
+ * streamActiveForSitemap: 비활성 게시판은 반환하지 않는다
+ */
+ public function test_stream_active_for_sitemap_excludes_inactive_boards(): void
+ {
+ $this->updateBoardSettings(['is_active' => false]);
+
+ $slugs = collect($this->repository->streamActiveForSitemap())
+ ->pluck('slug')
+ ->all();
+
+ $this->assertNotContains($this->board->slug, $slugs);
+ }
+
+ /**
+ * streamActiveForSitemap: 활성 게시판이 포함된다
+ */
+ public function test_stream_active_for_sitemap_includes_active_boards(): void
+ {
+ $slugs = collect($this->repository->streamActiveForSitemap())
+ ->pluck('slug')
+ ->all();
+
+ $this->assertContains($this->board->slug, $slugs);
+ }
+
+ /**
+ * streamActiveForSitemap: 청크 크기보다 많은 게시판도 전부 순회한다
+ */
+ public function test_stream_active_for_sitemap_traverses_beyond_chunk_size(): void
+ {
+ foreach (range(1, 3) as $i) {
+ Board::create([
+ 'slug' => "repo-sitemap-extra-{$i}",
+ 'name' => ['ko' => "추가 게시판 {$i}", 'en' => "Extra Board {$i}"],
+ 'is_active' => true,
+ ]);
+ }
+
+ // 청크 크기 1 → 여러 번 쿼리해도 누락 없이 전부 순회해야 한다
+ $slugs = collect($this->repository->streamActiveForSitemap(1))
+ ->pluck('slug')
+ ->all();
+
+ foreach (range(1, 3) as $i) {
+ $this->assertContains("repo-sitemap-extra-{$i}", $slugs);
+ }
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php
index c974b66f..98a3dc84 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Seo;
require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Seo\AbstractSitemapContributor;
+use App\Seo\Contracts\SitemapContributorInterface;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Seo\BoardSitemapContributor;
@@ -47,7 +49,16 @@ class BoardSitemapContributorTest extends BoardTestCase
protected function setUp(): void
{
parent::setUp();
- $this->contributor = new BoardSitemapContributor;
+ // Repository 주입이 필요하므로 컨테이너로 해석합니다.
+ $this->contributor = $this->app->make(BoardSitemapContributor::class);
+ }
+
+ /**
+ * SitemapContributorInterface 를 구현한다
+ */
+ public function test_implements_sitemap_contributor_interface(): void
+ {
+ $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor);
}
/**
@@ -58,6 +69,51 @@ class BoardSitemapContributorTest extends BoardTestCase
$this->assertSame('sirsoft-board', $this->contributor->getIdentifier());
}
+ /**
+ * getUrls: 활성 게시판이 없으면 정적 목록 URL만 반환한다
+ */
+ public function test_get_urls_returns_only_static_url_when_no_active_boards(): void
+ {
+ $this->updateBoardSettings(['is_active' => false]);
+
+ $urls = $this->contributor->getUrls();
+
+ $this->assertSame(['/boards'], array_column($urls, 'url'));
+ }
+
+ /**
+ * getUrls: 기여자당 URL 안전 상한을 초과하면 게시글 URL이 잘린다
+ */
+ public function test_get_urls_truncates_at_max_urls_per_contributor(): void
+ {
+ $this->createTestPost(['status' => 'published', 'is_secret' => false]);
+ $this->createTestPost(['status' => 'published', 'is_secret' => false]);
+
+ // /boards + /board/{slug} 로 이미 2건이므로 게시글은 1건까지만 담긴다.
+ // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다.
+ Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 3);
+
+ $this->assertCount(3, $this->contributor->getUrls());
+ }
+
+ /**
+ * getUrls: 상한이 게시판 루프에도 적용된다 (회귀)
+ *
+ * 상한 검사가 게시글 루프에만 있으면, 게시판 URL 만으로 상한을 초과해도 잘리지 않는다.
+ */
+ public function test_get_urls_truncates_within_board_loop(): void
+ {
+ $this->createTestPost(['status' => 'published', 'is_secret' => false]);
+
+ // /boards 1건만 담기면 상한 도달 → 게시판/게시글 URL 은 하나도 담기지 않아야 한다.
+ Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 1);
+
+ $urls = $this->contributor->getUrls();
+
+ $this->assertCount(1, $urls, '게시판 루프에서도 상한이 지켜져야 합니다.');
+ $this->assertSame('/boards', $urls[0]['url']);
+ }
+
/**
* getUrls: /boards 항목이 반드시 포함된다
*/
@@ -231,4 +287,23 @@ class BoardSitemapContributorTest extends BoardTestCase
$this->assertContains("/board/{$this->board->slug}", $urlPaths);
$this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
+
+ /**
+ * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍)
+ *
+ * 소비 경로는 getUrls() 가 아니라 getUrlsLazy() 이므로, 이것이 Traversable 로
+ * 한 건씩 yield 되어야 대용량 게시글에서 메모리가 유계로 유지된다.
+ */
+ public function test_get_urls_lazy_streams_entries(): void
+ {
+ $postId = $this->createTestPost(['status' => 'published', 'is_secret' => false]);
+
+ $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor);
+
+ $lazy = $this->contributor->getUrlsLazy();
+ $this->assertInstanceOf(\Traversable::class, $lazy);
+
+ $urlPaths = array_column(iterator_to_array($lazy, false), 'url');
+ $this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index 6becea4f..a8c94972 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -4,6 +4,22 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.5] - 2026-07-16
+
+### Added
+
+- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
+- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
+
+### Changed
+
+- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
+- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
+
+### Fixed
+
+- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
+
## [1.0.4] - 2026-07-16
### Fixed
diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json
index 3eda744e..542c3464 100644
--- a/modules/_bundled/sirsoft-ecommerce/composer.json
+++ b/modules/_bundled/sirsoft-ecommerce/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md
index 32468e36..71296329 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md
@@ -55,4 +55,6 @@ Accept: application/json
**설명** 공개 API로, 해시(`hash`)로 식별되는 카테고리 이미지 원본 파일을 스트리밍 서빙합니다. 인증이 필요 없으며(`PublicBaseController`), `CategoryImageController@download`가 `CategoryImageService::download()`를 호출해 리포지토리에서 해시로 이미지를 찾고 `StorageInterface::response()`로 `StreamedResponse`를 반환합니다. 응답에는 저장된 `mime_type`과 `Cache-Control: public, max-age=31536000`(1년) 헤더가 부여되어 브라우저/CDN 캐싱에 최적화됩니다. 해시에 해당하는 레코드가 없거나 스토리지에 실제 파일이 없으면 404를, 그 외 처리 오류 시 400 에러 응답을 반환합니다.
+> **서빙 방식**: 이미지 본문은 `StorageInterface::response()`(내부 `readStream`) 로 청크 스트리밍되며 파일 전체를 메모리에 적재하지 않습니다. 관리자용 카테고리 이미지 서빙(`CategoryController::downloadImage`)도 동일하게 `CategoryImageService::download()` 스트리밍 경로를 공유합니다(전체 메모리 적재 `Storage::get()` 미사용).
+
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md
index 93186507..e2141763 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md
@@ -108,7 +108,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| item_ids | body | array | 아니오 | min 1 | item 식별자 배열 |
-| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/option_values/quantity, item_ids와 택일) |
+| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/product_option_id/quantity, item_ids와 택일) |
| coupon_issue_ids | body | array | 아니오 | — | coupon issue 식별자 배열 |
| use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 |
diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json
index 59be2a50..0ac4ee0a 100644
--- a/modules/_bundled/sirsoft-ecommerce/module.json
+++ b/modules/_bundled/sirsoft-ecommerce/module.json
@@ -5,13 +5,13 @@
"ko": "이커머스",
"en": "Ecommerce"
},
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
},
- "g7_version": ">=7.0.5",
+ "g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json
index 63316555..0b9e70b0 100644
--- a/modules/_bundled/sirsoft-ecommerce/package-lock.json
+++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.0.4",
+ "version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
- "version": "1.0.4",
+ "version": "1.0.5",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json
index 746ded67..9e058613 100644
--- a/modules/_bundled/sirsoft-ecommerce/package.json
+++ b/modules/_bundled/sirsoft-ecommerce/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.0.4",
+ "version": "1.0.5",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php
index 89d7e7c8..51602e09 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php
@@ -5,7 +5,6 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
-use Illuminate\Support\Facades\Storage;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CategoryListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CreateCategoryRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ReorderCategoriesRequest;
@@ -16,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\CategoryCollection;
use Modules\Sirsoft\Ecommerce\Http\Resources\CategoryResource;
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
use Modules\Sirsoft\Ecommerce\Services\CategoryService;
+use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 카테고리 관리 컨트롤러
@@ -30,8 +30,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 목록 조회
*
- * @param CategoryListRequest $request
- * @return JsonResponse
+ * @param CategoryListRequest $request 목록 조회 필터/정렬 조건
+ * @return JsonResponse 카테고리 목록 응답
*/
public function index(CategoryListRequest $request): JsonResponse
{
@@ -49,7 +49,7 @@ class CategoryController extends AdminBaseController
*
* 활성화된 카테고리만 트리 형태로 반환합니다.
*
- * @return JsonResponse
+ * @return JsonResponse 활성 카테고리 트리 응답
*/
public function tree(): JsonResponse
{
@@ -68,8 +68,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 생성
*
- * @param CreateCategoryRequest $request
- * @return JsonResponse
+ * @param CreateCategoryRequest $request 카테고리 생성 데이터
+ * @return JsonResponse 생성된 카테고리 응답
*/
public function store(CreateCategoryRequest $request): JsonResponse
{
@@ -86,8 +86,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 상세 조회
*
- * @param int $id
- * @return JsonResponse
+ * @param int $id 카테고리 ID
+ * @return JsonResponse 카테고리 상세 응답
*/
public function show(int $id): JsonResponse
{
@@ -111,9 +111,9 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 수정
*
- * @param UpdateCategoryRequest $request
- * @param int $category
- * @return JsonResponse
+ * @param UpdateCategoryRequest $request 카테고리 수정 데이터
+ * @param int $category 카테고리 ID
+ * @return JsonResponse 수정된 카테고리 응답
*/
public function update(UpdateCategoryRequest $request, int $category): JsonResponse
{
@@ -137,8 +137,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 삭제
*
- * @param int $category
- * @return JsonResponse
+ * @param int $category 카테고리 ID
+ * @return JsonResponse 삭제 결과 응답
*/
public function destroy(int $category): JsonResponse
{
@@ -162,9 +162,9 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 이미지 업로드
*
- * @param UploadCategoryImageRequest $request
- * @param int|null $categoryId
- * @return JsonResponse
+ * @param UploadCategoryImageRequest $request 이미지 업로드 데이터
+ * @param int|null $categoryId 카테고리 ID (임시 업로드 시 null)
+ * @return JsonResponse 업로드된 이미지 정보 응답
*/
public function uploadImage(UploadCategoryImageRequest $request, ?int $categoryId = null): JsonResponse
{
@@ -211,8 +211,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 이미지 삭제
*
- * @param int $id
- * @return JsonResponse
+ * @param int $id 이미지 ID
+ * @return JsonResponse 삭제 결과 응답
*/
public function deleteImage(int $id): JsonResponse
{
@@ -243,8 +243,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 이미지 순서 변경
*
- * @param ReorderCategoryImagesRequest $request
- * @return JsonResponse
+ * @param ReorderCategoryImagesRequest $request 이미지 순서 데이터
+ * @return JsonResponse 순서 변경 결과 응답
*/
public function reorderImages(ReorderCategoryImagesRequest $request): JsonResponse
{
@@ -269,15 +269,17 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 이미지 다운로드
*
- * @param string $hash
- * @return \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
+ * @param string $hash 이미지 해시
+ * @return StreamedResponse|JsonResponse 이미지 스트림 또는 404 응답
*/
public function downloadImage(string $hash)
{
try {
- $image = $this->categoryImageService->getByHash($hash);
+ // 서빙은 서비스의 StorageInterface::response() 스트리밍에 위임한다.
+ // (파일 전체를 메모리에 적재하는 streamDownload+Storage::get 안티패턴 금지)
+ $response = $this->categoryImageService->download($hash);
- if (! $image) {
+ if (! $response) {
return ResponseHelper::notFound(
'messages.category_images.not_found',
[],
@@ -285,11 +287,7 @@ class CategoryController extends AdminBaseController
);
}
- return response()->streamDownload(function () use ($image) {
- echo Storage::disk($image->disk)->get($image->path);
- }, $image->original_filename, [
- 'Content-Type' => $image->mime_type,
- ]);
+ return $response;
} catch (\Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
@@ -302,8 +300,8 @@ class CategoryController extends AdminBaseController
/**
* 카테고리 상태 토글
*
- * @param int $id
- * @return JsonResponse
+ * @param int $id 카테고리 ID
+ * @return JsonResponse 상태 변경 응답
*/
public function toggleStatus(int $id): JsonResponse
{
@@ -327,7 +325,7 @@ class CategoryController extends AdminBaseController
/**
* 파일 크기를 사람이 읽기 쉬운 형식으로 변환
*
- * @param int $bytes 바이트 단위 파일 크기
+ * @param int $bytes 바이트 단위 파일 크기
* @return string 포맷된 파일 크기 (예: "1.5 MB")
*/
private function formatFileSize(int $bytes): string
@@ -353,8 +351,8 @@ class CategoryController extends AdminBaseController
* "child_menus": { "1": [{ "id": 2, "order": 1 }, ...] }
* }
*
- * @param ReorderCategoriesRequest $request
- * @return JsonResponse
+ * @param ReorderCategoriesRequest $request 카테고리 순서 데이터
+ * @return JsonResponse 순서 변경 결과 응답
*/
public function reorder(ReorderCategoriesRequest $request): JsonResponse
{
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php
index 1b832cf8..aaf85218 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php
@@ -34,7 +34,8 @@ class BulkAddToCartRequest extends FormRequest
$rules = [
'product_id' => ['required', 'integer', Rule::exists(Product::class, 'id')],
'items' => 'required|array|min:1',
- 'items.*.option_values' => 'nullable|array',
+ // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석
+ 'items.*.product_option_id' => ['nullable', 'integer'],
'items.*.quantity' => 'required|integer|min:1|max:9999',
// 추가옵션 선택 (서버에서 value_id 기준 가격 재조회·검증 — 클라 가격 신뢰 금지)
'items.*.additional_option_selections' => 'nullable|array',
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php
index 85efdc9c..1869d730 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php
@@ -39,7 +39,8 @@ class CheckoutRequest extends FormRequest
'item_ids.*' => ['integer', Rule::exists(Cart::class, 'id')],
'direct_items' => [Rule::requiredIf($isDirect), 'array', 'min:1'],
'direct_items.*.product_id' => ['required_with:direct_items', 'integer'],
- 'direct_items.*.option_values' => ['nullable', 'array'],
+ // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석
+ 'direct_items.*.product_option_id' => ['nullable', 'integer'],
'direct_items.*.quantity' => ['required_with:direct_items', 'integer', 'min:1'],
// 바로구매 추가옵션 선택 (서버에서 value_id 기준 검증/가격 재조회)
'direct_items.*.additional_option_selections' => ['nullable', 'array'],
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php
index 9e81ce67..e43aba68 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php
@@ -4,7 +4,10 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\HookListenerInterface;
+use App\Enums\SitemapChangeFreq;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
+use App\Seo\SitemapIndexer;
use Illuminate\Support\Facades\Log;
/**
@@ -32,7 +35,7 @@ class SeoCategoryCacheListener implements HookListenerInterface
'priority' => 20,
],
'sirsoft-ecommerce.category.after_delete' => [
- 'method' => 'onCategoryChange',
+ 'method' => 'onCategoryDelete',
'priority' => 20,
],
];
@@ -87,5 +90,75 @@ class SeoCategoryCacheListener implements HookListenerInterface
'error' => $e->getMessage(),
]);
}
+
+ $this->syncSitemapIndex($category);
+ }
+
+ /**
+ * 카테고리 삭제 시 캐시를 무효화하고 사이트맵 색인을 제거합니다.
+ *
+ * after_delete 훅은 모델이 아닌 카테고리 ID(int)를 전달합니다.
+ *
+ * @param mixed ...$args 훅 인자 (첫 번째: 카테고리 ID)
+ */
+ public function onCategoryDelete(...$args): void
+ {
+ $this->onCategoryChange(...$args);
+
+ $categoryId = $args[0] ?? null;
+ if ($categoryId === null) {
+ return;
+ }
+
+ try {
+ app(SitemapIndexer::class)->deindexResource('category', is_object($categoryId) ? $categoryId->id : $categoryId);
+ GenerateSitemapJob::dispatch();
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Category sitemap index removal failed', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * 카테고리의 사이트맵 색인을 증분 갱신합니다.
+ *
+ * 활성(is_active) 이며 'SEO 제공 페이지(카테고리)' 토글이 켜져 있으면 색인(upsert),
+ * 아니면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다.
+ * 색인 규칙은 EcommerceSitemapContributor 의 카테고리 URL 규칙과 일치해야 합니다.
+ *
+ * @param mixed $category Category 모델 (create/update)
+ */
+ private function syncSitemapIndex(mixed $category): void
+ {
+ if (! is_object($category) || ! isset($category->id) || ! isset($category->slug)) {
+ return;
+ }
+
+ try {
+ $indexer = app(SitemapIndexer::class);
+
+ $visible = (bool) ($category->is_active ?? false)
+ && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true);
+
+ if ($visible) {
+ $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
+ $indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[
+ 'url' => "/{$routePath}/category/{$category->slug}",
+ 'lastmod' => $category->updated_at?->toW3cString(),
+ 'changefreq' => SitemapChangeFreq::Weekly->value,
+ 'priority' => 0.6,
+ ]]);
+ } else {
+ $indexer->deindexResource('category', $category->id);
+ }
+
+ GenerateSitemapJob::dispatch();
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Category sitemap index sync failed', [
+ 'error' => $e->getMessage(),
+ 'category_id' => $category->id ?? null,
+ ]);
+ }
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php
index ee2d86cb..97ad9d90 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php
@@ -4,9 +4,13 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\HookListenerInterface;
+use App\Enums\SitemapChangeFreq;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheRegenerator;
+use App\Seo\SitemapIndexer;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
/**
* 상품 변경 시 SEO 캐시 무효화 리스너
@@ -59,6 +63,7 @@ class SeoProductCacheListener implements HookListenerInterface
{
$this->invalidateRelatedCaches($args);
$this->regenerateDetailCache($args);
+ $this->syncSitemapIndex($args, false);
}
/**
@@ -70,6 +75,7 @@ class SeoProductCacheListener implements HookListenerInterface
{
$this->invalidateRelatedCaches($args);
$this->regenerateDetailCache($args);
+ $this->syncSitemapIndex($args, false);
}
/**
@@ -82,6 +88,7 @@ class SeoProductCacheListener implements HookListenerInterface
public function onProductDelete(...$args): void
{
$this->invalidateRelatedCaches($args);
+ $this->syncSitemapIndex($args, true);
}
/**
@@ -127,6 +134,53 @@ class SeoProductCacheListener implements HookListenerInterface
}
}
+ /**
+ * 상품의 사이트맵 색인을 증분 갱신합니다.
+ *
+ * 전시(display_status=VISIBLE)이며 'SEO 제공 페이지(상품 상세)' 토글이 켜져 있으면 색인(upsert),
+ * 비공개/토글 OFF/삭제이면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다.
+ * 색인 규칙은 EcommerceSitemapContributor 의 상품 URL 규칙과 일치해야 합니다.
+ *
+ * @param array $args 훅 인자 배열 (첫 번째: Product 모델)
+ * @param bool $deleted 삭제 이벤트 여부
+ */
+ private function syncSitemapIndex(array $args, bool $deleted): void
+ {
+ $product = $args[0] ?? null;
+
+ if (! $product || ! isset($product->id)) {
+ return;
+ }
+
+ try {
+ $indexer = app(SitemapIndexer::class);
+
+ $visible = ! $deleted
+ && ($product->display_status ?? null) === ProductDisplayStatus::VISIBLE
+ && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true);
+
+ if ($visible) {
+ $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
+ $indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[
+ 'url' => "/{$routePath}/products/{$product->id}",
+ 'lastmod' => $product->updated_at?->toW3cString(),
+ 'changefreq' => SitemapChangeFreq::Weekly->value,
+ 'priority' => 0.8,
+ ]]);
+ } else {
+ $indexer->deindexResource('product', $product->id);
+ }
+
+ // 파일 재작성은 잡에 위임 (ShouldBeUnique 로 디바운스). auto 모드 → 저장소 기반 증분.
+ GenerateSitemapJob::dispatch();
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Product sitemap index sync failed', [
+ 'error' => $e->getMessage(),
+ 'product_id' => $product->id ?? null,
+ ]);
+ }
+ }
+
/**
* 상품 상세 페이지의 SEO 캐시를 즉시 재생성합니다.
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php
index e22a4739..7999c461 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php
@@ -218,8 +218,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
// Sitemap 기여자 등록
$this->app->booted(function () {
if ($this->app->bound(SitemapGenerator::class)) {
+ // Repository 주입을 위해 컨테이너로 해석합니다.
$this->app->make(SitemapGenerator::class)->registerContributor(
- new EcommerceSitemapContributor
+ $this->app->make(EcommerceSitemapContributor::class)
);
}
});
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php
index deaad41b..b0357fd8 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php
@@ -15,6 +15,24 @@ class CategoryRepository implements CategoryRepositoryInterface
protected Category $model
) {}
+ /**
+ * Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다.
+ *
+ * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
+ * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 활성 카테고리 순회자 (id, slug, updated_at 만 조회)
+ */
+ public function streamActiveForSitemap(int $chunkSize = 500): iterable
+ {
+ return $this->model->newQuery()
+ ->where('is_active', true)
+ ->select(['id', 'slug', 'updated_at'])
+ ->orderBy('id')
+ ->lazyById($chunkSize);
+ }
+
/**
* {@inheritDoc}
*/
@@ -76,7 +94,7 @@ class CategoryRepository implements CategoryRepositoryInterface
{
$query = $this->model->newQuery();
- if (!empty($with)) {
+ if (! empty($with)) {
$query->with($with);
}
@@ -101,6 +119,7 @@ class CategoryRepository implements CategoryRepositoryInterface
{
$category = $this->findById($id);
$category->update($data);
+
return $category->fresh();
}
@@ -110,6 +129,7 @@ class CategoryRepository implements CategoryRepositoryInterface
public function delete(int $id): bool
{
$category = $this->findById($id);
+
return $category->delete();
}
@@ -127,6 +147,7 @@ class CategoryRepository implements CategoryRepositoryInterface
public function getProductCount(int $id): int
{
$category = $this->findById($id);
+
return $category->products()->count();
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php
index da2caf18..6762b532 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php
@@ -13,94 +13,104 @@ interface CategoryRepositoryInterface
/**
* 계층형 카테고리 목록 조회
*
- * @param array $filters 필터 조건
- * @param array $with Eager loading 관계
- * @return Collection
+ * @param array $filters 필터 조건
+ * @param array $with Eager loading 관계
+ * @return Collection 계층형 카테고리 목록
*/
public function getHierarchical(array $filters = [], array $with = []): Collection;
/**
* ID로 카테고리 조회
*
- * @param int $id 카테고리 ID
- * @param array $with Eager loading 관계
- * @return Category|null
+ * @param int $id 카테고리 ID
+ * @param array $with Eager loading 관계
+ * @return Category|null 카테고리 모델 (없으면 null)
*/
public function findById(int $id, array $with = []): ?Category;
/**
* 카테고리 생성
*
- * @param array $data 카테고리 데이터
- * @return Category
+ * @param array $data 카테고리 데이터
+ * @return Category 생성된 카테고리 모델
*/
public function create(array $data): Category;
/**
* 카테고리 수정
*
- * @param int $id 카테고리 ID
- * @param array $data 수정 데이터
- * @return Category
+ * @param int $id 카테고리 ID
+ * @param array $data 수정 데이터
+ * @return Category 수정된 카테고리 모델
*/
public function update(int $id, array $data): Category;
/**
* 카테고리 삭제
*
- * @param int $id 카테고리 ID
- * @return bool
+ * @param int $id 카테고리 ID
+ * @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
/**
* 하위 카테고리 존재 여부 확인
*
- * @param int $id 카테고리 ID
- * @return bool
+ * @param int $id 카테고리 ID
+ * @return bool 하위 카테고리 존재 여부
*/
public function hasChildren(int $id): bool;
/**
* 연결된 상품 수 조회
*
- * @param int $id 카테고리 ID
- * @return int
+ * @param int $id 카테고리 ID
+ * @return int 연결된 상품 수
*/
public function getProductCount(int $id): int;
/**
* 다음 정렬 순서 값 조회
*
- * @param int|null $parentId 부모 카테고리 ID
- * @return int
+ * @param int|null $parentId 부모 카테고리 ID
+ * @return int 다음 정렬 순서 값
*/
public function getNextSortOrder(?int $parentId = null): int;
/**
* 슬러그 중복 확인
*
- * @param string $slug 슬러그
- * @param int|null $excludeId 제외할 카테고리 ID
- * @return bool
+ * @param string $slug 슬러그
+ * @param int|null $excludeId 제외할 카테고리 ID
+ * @return bool 슬러그 중복 여부
*/
public function existsBySlug(string $slug, ?int $excludeId = null): bool;
/**
* slug로 카테고리 조회
*
- * @param string $slug 카테고리 slug
- * @param array $with Eager loading 관계
- * @return Category|null
+ * @param string $slug 카테고리 slug
+ * @param array $with Eager loading 관계
+ * @return Category|null 카테고리 모델 (없으면 null)
*/
public function findBySlug(string $slug, array $with = []): ?Category;
/**
* 평면 리스트로 카테고리 목록 조회 (TagInput 등에 사용)
*
- * @param array $filters 필터 조건
- * @param array $with Eager loading 관계
- * @return Collection
+ * @param array $filters 필터 조건
+ * @param array $with Eager loading 관계
+ * @return Collection 평면 카테고리 목록
*/
public function getFlatList(array $filters = [], array $with = []): Collection;
+
+ /**
+ * Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다.
+ *
+ * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 활성 카테고리 순회자 (id, slug, updated_at 만 조회)
+ */
+ public function streamActiveForSitemap(int $chunkSize = 500): iterable;
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php
index 397b02ff..3c9526ff 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php
@@ -29,6 +29,16 @@ interface ProductRepositoryInterface
*/
public function existsAny(): bool;
+ /**
+ * Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다.
+ *
+ * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 전시 중인 상품 순회자 (id, updated_at 만 조회)
+ */
+ public function streamVisibleForSitemap(int $chunkSize = 500): iterable;
+
/**
* 필터링된 상품 목록 조회 (페이지네이션)
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php
index 7020dd7b..28480573 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php
@@ -32,6 +32,24 @@ class ProductRepository implements ProductRepositoryInterface
return $this->model->newQuery()->withTrashed()->exists();
}
+ /**
+ * Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다.
+ *
+ * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
+ * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 전시 중인 상품 순회자 (id, updated_at 만 조회)
+ */
+ public function streamVisibleForSitemap(int $chunkSize = 500): iterable
+ {
+ return $this->model->newQuery()
+ ->where('display_status', ProductDisplayStatus::VISIBLE->value)
+ ->select(['id', 'updated_at'])
+ ->orderBy('id')
+ ->lazyById($chunkSize);
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php
index 59f8a540..c4c5b996 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php
@@ -2,18 +2,32 @@
namespace Modules\Sirsoft\Ecommerce\Seo;
-use App\Seo\Contracts\SitemapContributorInterface;
-use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
-use Modules\Sirsoft\Ecommerce\Models\Category;
-use Modules\Sirsoft\Ecommerce\Models\Product;
+use App\Enums\SitemapChangeFreq;
+use App\Seo\AbstractSitemapContributor;
+use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
+use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
/**
* Ecommerce 모듈 Sitemap 기여자
*
* 상품 및 카테고리 URL을 sitemap에 제공합니다.
+ * 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고
+ * 한 건씩 지연 yield 하여 대용량 상품(수만~수십만 건)에서도 메모리를 유계로 유지합니다.
*/
-class EcommerceSitemapContributor implements SitemapContributorInterface
+class EcommerceSitemapContributor extends AbstractSitemapContributor
{
+ /**
+ * EcommerceSitemapContributor 생성자
+ *
+ * @param CategoryRepositoryInterface $categoryRepository 카테고리 Repository
+ * @param ProductRepositoryInterface $productRepository 상품 Repository
+ */
+ public function __construct(
+ private readonly CategoryRepositoryInterface $categoryRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ ) {}
+
/**
* 확장 식별자를 반환합니다.
*
@@ -25,53 +39,80 @@ class EcommerceSitemapContributor implements SitemapContributorInterface
}
/**
- * Sitemap URL 항목 배열을 반환합니다.
+ * Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
- * 상품 목록, 카테고리별 페이지, 개별 상품 페이지의 URL을 생성합니다.
+ * 상품 목록, 카테고리별 페이지, 개별 상품 페이지의 URL을 순서대로 yield 합니다.
+ * 기여자당 안전 상한(seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고
+ * 경고를 남깁니다. 상한은 이 기여자가 내보내는 모든 URL 유형에 함께 적용됩니다.
*
- * @return array
+ * @return iterable
*/
- public function getUrls(): array
+ public function getUrlsLazy(): iterable
+ {
+ $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0);
+ $emitted = 0;
+
+ foreach ($this->collectUrls() as $entry) {
+ if ($maxUrls > 0 && $emitted >= $maxUrls) {
+ Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
+ 'contributor' => $this->getIdentifier(),
+ 'max_urls' => $maxUrls,
+ ]);
+
+ return;
+ }
+
+ yield $entry;
+ $emitted++;
+ }
+ }
+
+ /**
+ * 상한 적용 이전의 URL 후보를 순서대로 지연 생성합니다.
+ *
+ * @return iterable
+ */
+ private function collectUrls(): iterable
{
- $urls = [];
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop';
// 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) {
- $urls[] = [
+ yield [
'url' => "/{$routePath}/products",
- 'changefreq' => 'daily',
+ 'changefreq' => SitemapChangeFreq::Daily->value,
'priority' => 0.7,
+ 'resource_type' => 'shop_index',
+ 'resource_id' => null,
];
}
// 카테고리별 페이지 — 토글 OFF 시 제외
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) {
- $categories = Category::where('is_active', true)->get(['id', 'slug', 'updated_at']);
- foreach ($categories as $category) {
- $urls[] = [
+ foreach ($this->categoryRepository->streamActiveForSitemap() as $category) {
+ yield [
'url' => "/{$routePath}/category/{$category->slug}",
'lastmod' => $category->updated_at?->toW3cString(),
- 'changefreq' => 'weekly',
+ 'changefreq' => SitemapChangeFreq::Weekly->value,
'priority' => 0.6,
+ 'resource_type' => 'category',
+ 'resource_id' => (string) $category->id,
];
}
}
// 개별 상품 페이지 (전시 상태가 '전시'인 상품만) — 토글 OFF 시 제외
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) {
- $products = Product::where('display_status', ProductDisplayStatus::VISIBLE)
- ->get(['id', 'updated_at']);
- foreach ($products as $product) {
- $urls[] = [
+ foreach ($this->productRepository->streamVisibleForSitemap() as $product) {
+ yield [
'url' => "/{$routePath}/products/{$product->id}",
'lastmod' => $product->updated_at?->toW3cString(),
- 'changefreq' => 'weekly',
+ 'changefreq' => SitemapChangeFreq::Weekly->value,
'priority' => 0.8,
+ 'resource_type' => 'product',
+ 'resource_id' => (string) $product->id,
];
}
}
-
- return $urls;
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php
index aab1854e..f8935ce1 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php
@@ -247,9 +247,9 @@ class CartService
* 장바구니 일괄 담기
*
* 하나의 상품에 대해 여러 옵션 조합을 한 번에 장바구니에 담습니다.
- * option_values가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다.
+ * product_option_id가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다.
*
- * @param array $data {product_id, items: [{option_values?, quantity}], user_id?, cart_key?}
+ * @param array $data {product_id, items: [{product_option_id?, quantity}], user_id?, cart_key?}
* @return array{items: array, cart_count: int} 추가된 아이템 목록과 장바구니 총 수량
*/
public function bulkAddToCart(array $data): array
@@ -272,17 +272,15 @@ class CartService
$addedItems = [];
foreach ($data['items'] as $item) {
- $optionValues = $item['option_values'] ?? null;
+ $requestedOptionId = $item['product_option_id'] ?? null;
$quantity = $item['quantity'] ?? 1;
- // option_values로 product_option_id 조회 (로케일 기준 값으로 매칭)
- if (! empty($optionValues)) {
- $matchedOption = $productOptions->first(function ($option) use ($optionValues) {
- return $option->getLocalizedOptionValues() == $optionValues;
- });
+ // 옵션 ID 기반 식별. 상품 소속 옵션 집합 내에서만 조회해 임의 옵션 주입을 차단
+ if (! empty($requestedOptionId)) {
+ $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId);
if (! $matchedOption) {
- throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found'));
+ throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found'));
}
$productOptionId = $matchedOption->id;
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php
index d5a30d44..b9b94ccf 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php
@@ -169,7 +169,7 @@ class TempOrderService
* 생성합니다. 판매상태·재고·구매수량 한도·구매대상제한 검증은 장바구니 담기와 동일하게
* 적용하되, 구매수량 한도는 장바구니 기존 수량과 합산하지 않고 이번 선택 수량만으로 판정합니다.
*
- * @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}]
+ * @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}]
* @param int|null $userId 회원 ID
* @param string|null $cartKey 비회원 장바구니 키
* @param int $usePoints 사용할 마일리지
@@ -196,10 +196,10 @@ class TempOrderService
/**
* 직접 항목 배열을 미저장 Cart 모델 컬렉션으로 변환합니다.
*
- * option_values 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를
+ * product_option_id 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를
* set 한 미저장 Cart 모델을 만들어 기존 검증/계산/직렬화 로직을 그대로 재사용합니다.
*
- * @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}]
+ * @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}]
* @param int|null $userId 회원 ID
* @param string|null $cartKey 비회원 장바구니 키
* @return Collection 미저장 Cart 컬렉션
@@ -212,19 +212,17 @@ class TempOrderService
foreach ($items as $item) {
$productId = (int) ($item['product_id'] ?? 0);
- $optionValues = $item['option_values'] ?? null;
+ $requestedOptionId = $item['product_option_id'] ?? null;
$quantity = max(1, (int) ($item['quantity'] ?? 1));
- // 상품 옵션 매칭 (option_values 기준, 없으면 기본 옵션)
+ // 상품 옵션 매칭 (옵션 ID 기준, 없으면 기본 옵션)
$productOptions = $this->productOptionRepository->getByProductId($productId);
- if (! empty($optionValues)) {
- $matchedOption = $productOptions->first(
- fn ($option) => $option->getLocalizedOptionValues() == $optionValues
- );
+ if (! empty($requestedOptionId)) {
+ $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId);
if (! $matchedOption) {
- throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found'));
+ throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found'));
}
$optionId = $matchedOption->id;
diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php
index bc5b49c7..b542728d 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php
@@ -942,7 +942,6 @@ return [
'quantity_max' => 'Quantity cannot exceed 9,999.',
'items_required' => 'Please select items to add to cart.',
'items_min' => 'Please select at least one item.',
- 'option_values_not_found' => 'Matching option combination not found.',
],
// Wishlist validation messages
diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php
index 94434408..c77b55f3 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php
@@ -942,7 +942,6 @@ return [
'quantity_max' => '수량은 최대 9,999개까지 가능합니다.',
'items_required' => '장바구니에 담을 상품을 선택해주세요.',
'items_min' => '최소 1개 이상의 상품을 선택해주세요.',
- 'option_values_not_found' => '해당 옵션 조합을 찾을 수 없습니다.',
],
// 찜 검증 메시지
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php
new file mode 100644
index 00000000..d4f7b219
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php
@@ -0,0 +1,106 @@
+streamDownload(fn => echo Storage::get())
+ * 로 파일 전체를 메모리에 적재하는 안티패턴이었다. 서빙은 CategoryImageService::download()
+ * (StorageInterface::response() 기반 스트리밍)로 위임해야 한다.
+ *
+ * 이 테스트는 컨트롤러가 서비스의 스트리밍 경로로 위임하는지를 단언한다 —
+ * 전체 적재 경로(getByHash 조회 후 직접 Storage 접근)로 회귀하면 실패한다.
+ */
+class CategoryImageDownloadTest extends ModuleTestCase
+{
+ /** @var MockInterface&CategoryImageService */
+ private $categoryImageService;
+
+ private CategoryController $controller;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $categoryService = Mockery::mock(CategoryService::class);
+ $this->categoryImageService = Mockery::mock(CategoryImageService::class);
+
+ $this->controller = new CategoryController(
+ $categoryService,
+ $this->categoryImageService
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ /**
+ * 다운로드가 서비스의 스트리밍 응답으로 위임되는지 검증한다.
+ *
+ * 전체 적재 경로(getByHash + Storage::get)로 회귀하면 download() 미호출 +
+ * getByHash 호출로 이 테스트가 실패한다.
+ */
+ #[Test]
+ public function test_download_delegates_to_streaming_service(): void
+ {
+ $hash = 'abcdef012345';
+ $streamed = new StreamedResponse(function () {}, 200, [
+ 'Content-Type' => 'image/jpeg',
+ ]);
+
+ // 서비스의 스트리밍 다운로드가 호출되어야 한다.
+ $this->categoryImageService
+ ->shouldReceive('download')
+ ->once()
+ ->with($hash)
+ ->andReturn($streamed);
+
+ // 전체 적재 경로(getByHash 후 직접 Storage 접근)는 사용하면 안 된다.
+ $this->categoryImageService
+ ->shouldReceive('getByHash')
+ ->never();
+
+ $result = $this->controller->downloadImage($hash);
+
+ $this->assertInstanceOf(
+ StreamedResponse::class,
+ $result,
+ '관리자 다운로드는 스트리밍 응답을 반환해야 한다 (전체 메모리 적재 금지)'
+ );
+ $this->assertSame($streamed, $result, '서비스가 생성한 스트리밍 응답을 그대로 반환해야 한다');
+ }
+
+ /**
+ * 이미지가 없으면 404 를 반환한다 (기존 동작 회귀 유지).
+ */
+ #[Test]
+ public function test_download_returns_not_found_when_image_missing(): void
+ {
+ $hash = 'missinghash0';
+
+ $this->categoryImageService
+ ->shouldReceive('download')
+ ->once()
+ ->with($hash)
+ ->andReturn(null);
+
+ $result = $this->controller->downloadImage($hash);
+
+ $this->assertInstanceOf(JsonResponse::class, $result);
+ $this->assertSame(404, $result->getStatusCode());
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php
index b1067cd4..33bbf29c 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php
@@ -7,6 +7,7 @@ use Modules\Sirsoft\Ecommerce\Database\Factories\CartFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
+use Modules\Sirsoft\Ecommerce\Models\Cart;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
@@ -135,7 +136,7 @@ class CartControllerTest extends ModuleTestCase
$this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
- ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1],
+ ['product_option_id' => $data['option']->id, 'quantity' => 1],
],
], ['X-Cart-Key' => $cartKey])->assertStatus(201);
@@ -162,7 +163,7 @@ class CartControllerTest extends ModuleTestCase
$this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
- ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1],
+ ['product_option_id' => $data['option']->id, 'quantity' => 1],
],
], ['X-Cart-Key' => $cartKey])->assertStatus(201);
@@ -221,7 +222,7 @@ class CartControllerTest extends ModuleTestCase
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
- ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2],
+ ['product_option_id' => $data['option']->id, 'quantity' => 2],
],
], [
'X-Cart-Key' => $cartKey,
@@ -274,7 +275,7 @@ class CartControllerTest extends ModuleTestCase
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
- ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2],
+ ['product_option_id' => $data['option']->id, 'quantity' => 2],
],
], [
'X-Cart-Key' => $cartKey,
@@ -307,7 +308,7 @@ class CartControllerTest extends ModuleTestCase
$addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
- ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2],
+ ['product_option_id' => $data['option']->id, 'quantity' => 2],
],
], [
'X-Cart-Key' => $cartKey,
@@ -355,8 +356,8 @@ class CartControllerTest extends ModuleTestCase
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $product->id,
'items' => [
- ['option_values' => ['색상' => '빨강', '사이즈' => 'L'], 'quantity' => 2],
- ['option_values' => ['색상' => '파랑', '사이즈' => 'M'], 'quantity' => 1],
+ ['product_option_id' => $option1->id, 'quantity' => 2],
+ ['product_option_id' => $option2->id, 'quantity' => 1],
],
], [
'X-Cart-Key' => $cartKey,
@@ -384,7 +385,7 @@ class CartControllerTest extends ModuleTestCase
$data = $this->createProductWithOption();
$cartKey = 'ck_'.str_repeat('f', 32);
- // When: option_values 없이 담기
+ // When: product_option_id 없이 담기
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $data['product']->id,
'items' => [
@@ -399,6 +400,35 @@ class CartControllerTest extends ModuleTestCase
$this->assertEquals(1, $response->json('data.cart_count'));
}
+ /**
+ * 다른 상품에 속한 product_option_id 를 주입하면 담기가 거부되고 장바구니에 반영되지 않습니다.
+ *
+ * 회귀: 옵션 식별을 product_option_id 기반으로 전환하면서, 요청한 상품(product_id)의 옵션
+ * 집합(getByProductId) 안에서만 옵션 ID 를 조회해야 한다. 타 상품 옵션 ID 주입 시 거부되어야
+ * 서버가 임의 옵션(다른 상품/가격/재고)을 장바구니에 담는 위변조를 차단한다.
+ */
+ public function test_bulk_add_rejects_option_id_from_another_product(): void
+ {
+ // Given: 각각 옵션을 가진 두 상품
+ $target = $this->createProductWithOption();
+ $foreign = $this->createProductWithOption();
+ $cartKey = 'ck_'.str_repeat('h', 32);
+
+ // When: target 상품에 foreign 상품의 옵션 ID 를 실어 담기 시도
+ $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
+ 'product_id' => $target['product']->id,
+ 'items' => [
+ ['product_option_id' => $foreign['option']->id, 'quantity' => 1],
+ ],
+ ], [
+ 'X-Cart-Key' => $cartKey,
+ ]);
+
+ // Then: 담기 실패(비-201) + 장바구니에 어떤 행도 생성되지 않음
+ $this->assertNotSame(201, $response->getStatusCode());
+ $this->assertSame(0, Cart::where('cart_key', $cartKey)->count());
+ }
+
/**
* 일괄 담기 시 items가 비어있으면 422를 반환합니다.
*/
@@ -513,7 +543,7 @@ class CartControllerTest extends ModuleTestCase
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $product->id,
'items' => [
- ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1],
+ ['product_option_id' => $option->id, 'quantity' => 1],
],
], [
'X-Cart-Key' => $cartKey,
@@ -553,7 +583,7 @@ class CartControllerTest extends ModuleTestCase
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $product->id,
'items' => [
- ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 6],
+ ['product_option_id' => $option->id, 'quantity' => 6],
],
], [
'X-Cart-Key' => $cartKey,
@@ -591,7 +621,7 @@ class CartControllerTest extends ModuleTestCase
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $product->id,
'items' => [
- ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1],
+ ['product_option_id' => $option->id, 'quantity' => 1],
],
], ['X-Cart-Key' => $cartKey]);
@@ -620,7 +650,7 @@ class CartControllerTest extends ModuleTestCase
$response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [
'product_id' => $product->id,
'items' => [
- ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 5],
+ ['product_option_id' => $option->id, 'quantity' => 5],
],
], ['X-Cart-Key' => $cartKey]);
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php
new file mode 100644
index 00000000..2b6ed0e3
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php
@@ -0,0 +1,135 @@
+ 42, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()];
+
+ (new SeoProductCacheListener)->onProductCreate($product);
+
+ $row = SitemapUrl::where('resource_type', 'product')->where('resource_id', '42')->first();
+ $this->assertNotNull($row, '전시 상품은 사이트맵에 색인돼야 합니다.');
+ $this->assertStringEndsWith('/products/42', $row->loc);
+ $this->assertSame('sirsoft-ecommerce', $row->contributor);
+
+ Bus::assertDispatched(GenerateSitemapJob::class);
+ }
+
+ /**
+ * 숨김 상품으로 수정 시 사이트맵 색인이 제거(remove)된다.
+ */
+ public function test_hidden_product_update_removes_from_sitemap(): void
+ {
+ // 먼저 전시로 색인
+ (new SeoProductCacheListener)->onProductCreate(
+ (object) ['id' => 7, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]
+ );
+ $this->assertSame(1, SitemapUrl::where('resource_id', '7')->count());
+
+ // 숨김으로 전환
+ (new SeoProductCacheListener)->onProductUpdate(
+ (object) ['id' => 7, 'display_status' => ProductDisplayStatus::HIDDEN, 'updated_at' => now()]
+ );
+
+ $this->assertSame(0, SitemapUrl::where('resource_id', '7')->count(), '숨김 상품은 색인에서 제거돼야 합니다.');
+ }
+
+ /**
+ * 상품 삭제 시 사이트맵 색인이 제거된다.
+ */
+ public function test_product_delete_removes_from_sitemap(): void
+ {
+ (new SeoProductCacheListener)->onProductCreate(
+ (object) ['id' => 9, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]
+ );
+ $this->assertSame(1, SitemapUrl::where('resource_id', '9')->count());
+
+ (new SeoProductCacheListener)->onProductDelete((object) ['id' => 9]);
+
+ $this->assertSame(0, SitemapUrl::where('resource_type', 'product')->where('resource_id', '9')->count());
+ }
+
+ /**
+ * 활성 카테고리 변경 시 색인, 비활성 시 색인 제거.
+ */
+ public function test_category_visibility_toggles_index(): void
+ {
+ $listener = new SeoCategoryCacheListener;
+
+ $listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => true, 'updated_at' => now()]);
+ $row = SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->first();
+ $this->assertNotNull($row);
+ $this->assertStringContainsString('/category/shoes', $row->loc);
+
+ $listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => false, 'updated_at' => now()]);
+ $this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->count());
+ }
+
+ /**
+ * 카테고리 삭제(ID 전달) 시 색인이 제거된다.
+ */
+ public function test_category_delete_removes_index(): void
+ {
+ $listener = new SeoCategoryCacheListener;
+ $listener->onCategoryChange((object) ['id' => 4, 'slug' => 'bags', 'is_active' => true, 'updated_at' => now()]);
+ $this->assertSame(1, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count());
+
+ // after_delete 훅은 모델이 아닌 ID(int)를 전달
+ $listener->onCategoryDelete(4);
+
+ $this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count());
+ }
+
+ /**
+ * 'SEO 제공 페이지(상품 상세)' 토글이 OFF 면 전시 상품이어도 색인이 제거된다.
+ *
+ * 리스너의 공개상태 판정은 전시 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치).
+ */
+ public function test_product_detail_toggle_off_removes_index_even_when_visible(): void
+ {
+ $listener = new SeoProductCacheListener;
+
+ // 토글 ON 상태로 먼저 색인
+ $listener->onProductCreate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]);
+ $this->assertSame(1, SitemapUrl::where('resource_id', '50')->count());
+
+ // 토글 OFF → 전시 상태여도 색인 제거
+ Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', false);
+ $listener->onProductUpdate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]);
+
+ $this->assertSame(0, SitemapUrl::where('resource_id', '50')->count(), 'seo_product_detail OFF 면 전시 상품도 색인에서 제거돼야 합니다.');
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php
index 843b2c14..85af9afc 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php
@@ -2,8 +2,12 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
+use App\Seo\SitemapIndexer;
+use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
+use Mockery;
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
use Tests\TestCase;
@@ -19,9 +23,20 @@ class SeoCategoryCacheListenerTest extends TestCase
protected function setUp(): void
{
parent::setUp();
+
+ // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
+ $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
+ Bus::fake([GenerateSitemapJob::class]);
+
$this->listener = new SeoCategoryCacheListener;
}
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
// ========================================
// getSubscribedHooks() 테스트
// ========================================
@@ -33,18 +48,22 @@ class SeoCategoryCacheListenerTest extends TestCase
{
$hooks = SeoCategoryCacheListener::getSubscribedHooks();
- $expectedHooks = [
+ // create/update 는 onCategoryChange, delete 는 onCategoryDelete (모델 대신 ID 를 받으므로 분리)
+ $changeHooks = [
'sirsoft-ecommerce.category.after_create',
'sirsoft-ecommerce.category.after_update',
- 'sirsoft-ecommerce.category.after_delete',
];
- foreach ($expectedHooks as $hookName) {
+ foreach ($changeHooks as $hookName) {
$this->assertArrayHasKey($hookName, $hooks);
$this->assertEquals('onCategoryChange', $hooks[$hookName]['method']);
$this->assertEquals(20, $hooks[$hookName]['priority']);
}
+ $this->assertArrayHasKey('sirsoft-ecommerce.category.after_delete', $hooks);
+ $this->assertEquals('onCategoryDelete', $hooks['sirsoft-ecommerce.category.after_delete']['method']);
+ $this->assertEquals(20, $hooks['sirsoft-ecommerce.category.after_delete']['priority']);
+
$this->assertCount(3, $hooks);
}
@@ -110,6 +129,9 @@ class SeoCategoryCacheListenerTest extends TestCase
// When
$this->listener->onCategoryChange($category);
+
+ // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨
+ $this->addToAssertionCount(1);
}
/**
@@ -135,6 +157,9 @@ class SeoCategoryCacheListenerTest extends TestCase
// When
$this->listener->onCategoryChange($categoryId);
+
+ // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨
+ $this->addToAssertionCount(1);
}
/**
@@ -188,6 +213,9 @@ class SeoCategoryCacheListenerTest extends TestCase
// When & Then: 예외가 전파되지 않음
$this->listener->onCategoryChange($category);
+
+ // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨
+ $this->addToAssertionCount(1);
}
// ========================================
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php
index bd6bc091..81f84892 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php
@@ -2,8 +2,11 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheRegenerator;
+use App\Seo\SitemapIndexer;
+use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Mockery;
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
@@ -36,6 +39,10 @@ class SeoProductCacheListenerTest extends TestCase
$this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class);
$this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock);
+ // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
+ $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
+ Bus::fake([GenerateSitemapJob::class]);
+
$this->listener = new SeoProductCacheListener;
}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php
index 8abb37d6..ce9765dc 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Seo;
require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Seo\AbstractSitemapContributor;
+use App\Seo\Contracts\SitemapContributorInterface;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -31,7 +33,8 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
protected function setUp(): void
{
parent::setUp();
- $this->contributor = new EcommerceSitemapContributor;
+ // Repository 주입이 필요하므로 컨테이너로 해석합니다.
+ $this->contributor = $this->app->make(EcommerceSitemapContributor::class);
$this->routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop';
}
@@ -50,6 +53,14 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
]);
}
+ /**
+ * SitemapContributorInterface 를 구현한다
+ */
+ public function test_implements_sitemap_contributor_interface(): void
+ {
+ $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor);
+ }
+
/**
* getIdentifier: 'sirsoft-ecommerce' 반환
*/
@@ -58,6 +69,96 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
$this->assertSame('sirsoft-ecommerce', $this->contributor->getIdentifier());
}
+ /**
+ * getUrls: route_path 미설정 시 기본값 'shop' 을 사용한다
+ */
+ public function test_get_urls_uses_default_route_path_when_not_configured(): void
+ {
+ Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', null);
+
+ $contributor = $this->app->make(EcommerceSitemapContributor::class);
+ $urlPaths = array_column($contributor->getUrls(), 'url');
+
+ $this->assertContains('/shop/products', $urlPaths);
+ }
+
+ /**
+ * getUrls: 정적/카테고리/상품 URL 항목이 각각 올바른 키 구조를 가진다
+ */
+ public function test_url_entries_have_correct_structure(): void
+ {
+ $category = $this->createTestCategory();
+ $product = Product::factory()->create();
+
+ $urls = collect($this->contributor->getUrls());
+
+ // 정적 목록 URL — lastmod 없음
+ $index = $urls->firstWhere('url', "/{$this->routePath}/products");
+ $this->assertNotNull($index);
+ $this->assertSame('daily', $index['changefreq']);
+ $this->assertSame(0.7, $index['priority']);
+ $this->assertArrayNotHasKey('lastmod', $index);
+
+ // 카테고리 URL — lastmod 있음
+ $categoryEntry = $urls->firstWhere('url', "/{$this->routePath}/category/{$category->slug}");
+ $this->assertNotNull($categoryEntry);
+ $this->assertArrayHasKey('lastmod', $categoryEntry);
+ $this->assertSame('weekly', $categoryEntry['changefreq']);
+ $this->assertSame(0.6, $categoryEntry['priority']);
+
+ // 상품 URL — lastmod 있음
+ $productEntry = $urls->firstWhere('url', "/{$this->routePath}/products/{$product->id}");
+ $this->assertNotNull($productEntry);
+ $this->assertArrayHasKey('lastmod', $productEntry);
+ $this->assertSame('weekly', $productEntry['changefreq']);
+ $this->assertSame(0.8, $productEntry['priority']);
+ }
+
+ /**
+ * getUrls: 기여자당 URL 안전 상한을 초과하면 상품 URL이 잘린다
+ */
+ public function test_get_urls_truncates_at_max_urls_per_contributor(): void
+ {
+ Product::factory()->count(3)->create();
+
+ // 정적 목록 URL 1건이 이미 담기므로 상품은 1건까지만 담긴다.
+ // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다.
+ Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2);
+
+ $this->assertCount(2, $this->contributor->getUrls());
+ }
+
+ /**
+ * getUrls: 상한이 카테고리 루프에도 적용된다 (회귀)
+ *
+ * 상한 검사가 상품 루프에만 있으면, 카테고리만으로 상한을 초과해도 잘리지 않는다.
+ */
+ public function test_get_urls_truncates_within_category_loop(): void
+ {
+ $this->createTestCategory();
+ Category::create([
+ 'name' => ['ko' => '사이트맵 카테고리2', 'en' => 'Sitemap Category 2'],
+ 'slug' => 'sitemap-cat-2',
+ 'is_active' => true,
+ 'path' => '/',
+ 'depth' => 0,
+ 'sort_order' => 1,
+ ]);
+ Product::factory()->count(2)->create();
+
+ // 정적 목록 1건 + 카테고리 2건 = 3건이지만 상한은 2건.
+ Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2);
+
+ $urls = $this->contributor->getUrls();
+
+ $this->assertCount(2, $urls, '카테고리 루프에서도 상한이 지켜져야 합니다.');
+ // 상한 도달 후에는 상품 URL 이 하나도 담기지 않아야 한다
+ $this->assertEmpty(
+ array_filter($urls, fn (array $u): bool => str_contains($u['url'], '/products/')),
+ '상한 도달 후 상품 URL 이 추가되면 안 됩니다.'
+ );
+ }
+
/**
* getUrls: 기본(토글 ON) 상태에서 목록/카테고리/상품 URL이 모두 포함된다 (비파괴 회귀)
*/
@@ -114,4 +215,20 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
$this->assertNotContains("/{$this->routePath}/products/{$product->id}", $urlPaths);
}
+
+ /**
+ * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍)
+ */
+ public function test_get_urls_lazy_streams_entries(): void
+ {
+ $product = Product::factory()->create();
+
+ $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor);
+
+ $lazy = $this->contributor->getUrlsLazy();
+ $this->assertInstanceOf(\Traversable::class, $lazy);
+
+ $urlPaths = array_column(iterator_to_array($lazy, false), 'url');
+ $this->assertContains("/{$this->routePath}/products/{$product->id}", $urlPaths);
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
index 7af6ba79..e8f1c775 100644
--- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
+++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
- "generated_at": "2026-07-14T14:08:36+00:00",
+ "generated_at": "2026-07-16T15:36:56+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
- "composer_json_sha256": "a90f1bb9cceeb9542e84ca09a0945f31089879fdbb566044364058332627998a",
+ "composer_json_sha256": "892a78a4c180c410eeb2cafbdec3ee1ff87ea066db94dea7e1340678e91759f6",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
- "zip_sha256": "ffc7123cd61c7ca13a6dc29325aa31454005affd59fe97acf372d30ab016475d",
+ "zip_sha256": "e222da279ba452bd155fb68f06d6bcdd0c2f29041f231eeea3ef6229912e9dcc",
"zip_size": 435549,
"package_count": 1,
"php_requirement": "^8.2",
- "g7_version": "7.0.4",
+ "g7_version": "7.0.5",
"packages": [
{
"name": "ezyang/htmlpurifier",
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip
index b3698cc8..a8a0cc92 100644
Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index 11753b65..162271ab 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -4,6 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.2] - 2026-07-16
+
+### Added
+
+- 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
+- 페이지를 발행/미발행하거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 페이지가 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
+
+### Changed
+
+- 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
+- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
+
## [1.0.1] - 2026-07-08
### Fixed
diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json
index bb65c272..49e93a38 100644
--- a/modules/_bundled/sirsoft-page/composer.json
+++ b/modules/_bundled/sirsoft-page/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-page",
"description": "Page module for Gnuboard7",
"type": "library",
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json
index 93be60e8..f3e4bc5d 100644
--- a/modules/_bundled/sirsoft-page/module.json
+++ b/modules/_bundled/sirsoft-page/module.json
@@ -5,13 +5,13 @@
"ko": "페이지",
"en": "Page"
},
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"description": {
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
"en": "Static page (info/policy/guide) management module"
},
- "g7_version": ">=7.0.0",
+ "g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json
index 79e30c54..f4ab96db 100644
--- a/modules/_bundled/sirsoft-page/package-lock.json
+++ b/modules/_bundled/sirsoft-page/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.0.1",
+ "version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-page",
- "version": "1.0.1",
+ "version": "1.0.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json
index 070d07f5..6de095d7 100644
--- a/modules/_bundled/sirsoft-page/package.json
+++ b/modules/_bundled/sirsoft-page/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.0.1",
+ "version": "1.0.2",
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php
index 41d4d060..c333e40d 100644
--- a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php
+++ b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php
@@ -4,7 +4,10 @@ namespace Modules\Sirsoft\Page\Listeners;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\HookListenerInterface;
+use App\Enums\SitemapChangeFreq;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
+use App\Seo\SitemapIndexer;
use Illuminate\Support\Facades\Log;
/**
@@ -32,7 +35,7 @@ class SeoPageCacheListener implements HookListenerInterface
'priority' => 20,
],
'sirsoft-page.page.after_delete' => [
- 'method' => 'onPageChange',
+ 'method' => 'onPageDelete',
'priority' => 20,
],
// 버전 복원도 콘텐츠(title/content/seo_meta) 변경이므로 수정과 동일하게 무효화
@@ -94,5 +97,60 @@ class SeoPageCacheListener implements HookListenerInterface
'page_id' => is_object($page) ? ($page->id ?? null) : null,
]);
}
+
+ $this->syncSitemapIndex($page, false);
+ }
+
+ /**
+ * 페이지 삭제 시 SEO 캐시를 무효화하고 사이트맵 색인을 제거합니다.
+ *
+ * @param mixed ...$args 훅 인자 (첫 번째: Page 모델)
+ */
+ public function onPageDelete(...$args): void
+ {
+ $this->onPageChange(...$args);
+
+ $this->syncSitemapIndex($args[0] ?? null, true);
+ }
+
+ /**
+ * 페이지의 사이트맵 색인을 증분 갱신합니다.
+ *
+ * 발행(published) 상태이면 색인(upsert), 미발행/삭제이면 색인 해제(remove)한 뒤
+ * 사이트맵 재생성 잡을 디바운스 디스패치합니다.
+ * 색인 규칙은 PageSitemapContributor 의 페이지 URL 규칙과 일치해야 합니다.
+ *
+ * @param mixed $page Page 모델
+ * @param bool $deleted 삭제 이벤트 여부
+ */
+ private function syncSitemapIndex(mixed $page, bool $deleted): void
+ {
+ if (! is_object($page) || ! isset($page->id) || ! isset($page->slug)) {
+ return;
+ }
+
+ try {
+ $indexer = app(SitemapIndexer::class);
+
+ $visible = ! $deleted && (bool) ($page->published ?? false);
+
+ if ($visible) {
+ $indexer->indexResource('page', $page->id, 'sirsoft-page', [[
+ 'url' => "/page/{$page->slug}",
+ 'lastmod' => $page->updated_at?->toW3cString(),
+ 'changefreq' => SitemapChangeFreq::Monthly->value,
+ 'priority' => 0.5,
+ ]]);
+ } else {
+ $indexer->deindexResource('page', $page->id);
+ }
+
+ GenerateSitemapJob::dispatch();
+ } catch (\Throwable $e) {
+ Log::warning('[SEO] Page sitemap index sync failed', [
+ 'error' => $e->getMessage(),
+ 'page_id' => is_object($page) ? ($page->id ?? null) : null,
+ ]);
+ }
}
}
diff --git a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php
index b50de7e5..8f16dda5 100644
--- a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php
+++ b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php
@@ -63,8 +63,9 @@ class PageServiceProvider extends BaseModuleServiceProvider
// Sitemap 기여자 등록
$this->app->booted(function () {
if ($this->app->bound(SitemapGenerator::class)) {
+ // Repository 주입을 위해 컨테이너로 해석합니다.
$this->app->make(SitemapGenerator::class)->registerContributor(
- new PageSitemapContributor
+ $this->app->make(PageSitemapContributor::class)
);
}
});
diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php
index 9abd3586..3635fd25 100644
--- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php
@@ -2,6 +2,8 @@
namespace Modules\Sirsoft\Page\Repositories\Contracts;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Page\Models\Page;
@@ -35,13 +37,23 @@ interface PageRepositoryInterface
*/
public function findBySlug(string $slug): ?Page;
+ /**
+ * Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다.
+ *
+ * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 발행된 페이지 순회자 (id, slug, updated_at 만 조회)
+ */
+ public function streamPublishedForSitemap(int $chunkSize = 500): iterable;
+
/**
* ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다.
*
* @param int $id 페이지 ID
* @return Page 페이지 모델
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws ModelNotFoundException
*/
public function findOrFail(int $id): Page;
@@ -86,7 +98,7 @@ interface PageRepositoryInterface
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $limit 조회할 최대 항목 수
- * @return array{total: int, items: \Illuminate\Database\Eloquent\Collection}
+ * @return array{total: int, items: Collection}
*/
public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array;
diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php
index ac937438..be5c37f5 100644
--- a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php
+++ b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php
@@ -72,6 +72,24 @@ class PageRepository implements PageRepositoryInterface
return Page::where('slug', $slug)->first();
}
+ /**
+ * Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다.
+ *
+ * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
+ * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
+ *
+ * @param int $chunkSize 청크 크기
+ * @return iterable 발행된 페이지 순회자 (id, slug, updated_at 만 조회)
+ */
+ public function streamPublishedForSitemap(int $chunkSize = 500): iterable
+ {
+ return Page::query()
+ ->where('published', true)
+ ->select(['id', 'slug', 'updated_at'])
+ ->orderBy('id')
+ ->lazyById($chunkSize);
+ }
+
/**
* ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다.
*
diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php
index 4d6fb2c9..bb4d2116 100644
--- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php
+++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php
@@ -2,16 +2,29 @@
namespace Modules\Sirsoft\Page\Seo;
-use App\Seo\Contracts\SitemapContributorInterface;
-use Modules\Sirsoft\Page\Models\Page;
+use App\Enums\SitemapChangeFreq;
+use App\Seo\AbstractSitemapContributor;
+use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
/**
* Page 모듈 Sitemap 기여자
*
* 발행된 페이지 URL을 sitemap에 제공합니다.
+ * 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고
+ * 한 건씩 지연 yield 하여 페이지가 많아도 메모리를 유계로 유지합니다.
*/
-class PageSitemapContributor implements SitemapContributorInterface
+class PageSitemapContributor extends AbstractSitemapContributor
{
+ /**
+ * PageSitemapContributor 생성자
+ *
+ * @param PageRepositoryInterface $pageRepository 페이지 Repository
+ */
+ public function __construct(
+ private readonly PageRepositoryInterface $pageRepository,
+ ) {}
+
/**
* 확장 식별자를 반환합니다.
*
@@ -23,26 +36,37 @@ class PageSitemapContributor implements SitemapContributorInterface
}
/**
- * Sitemap URL 항목 배열을 반환합니다.
+ * Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
- * 발행된 페이지의 URL을 생성합니다.
+ * 발행된 페이지의 URL을 순서대로 yield 합니다. 기여자당 안전 상한
+ * (seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고 경고를 남깁니다.
*
- * @return array
+ * @return iterable
*/
- public function getUrls(): array
+ public function getUrlsLazy(): iterable
{
- $urls = [];
+ $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0);
+ $emitted = 0;
- $pages = Page::where('published', true)->get(['slug', 'updated_at']);
- foreach ($pages as $page) {
- $urls[] = [
+ foreach ($this->pageRepository->streamPublishedForSitemap() as $page) {
+ if ($maxUrls > 0 && $emitted >= $maxUrls) {
+ Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
+ 'contributor' => $this->getIdentifier(),
+ 'max_urls' => $maxUrls,
+ ]);
+
+ return;
+ }
+
+ yield [
'url' => "/page/{$page->slug}",
'lastmod' => $page->updated_at?->toW3cString(),
- 'changefreq' => 'monthly',
+ 'changefreq' => SitemapChangeFreq::Monthly->value,
'priority' => 0.5,
+ 'resource_type' => 'page',
+ 'resource_id' => (string) $page->id,
];
+ $emitted++;
}
-
- return $urls;
}
}
diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php
new file mode 100644
index 00000000..5ca854a2
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php
@@ -0,0 +1,80 @@
+app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class));
+ Bus::fake([GenerateSitemapJob::class]);
+
+ $this->listener = new SeoPageCacheListener;
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ /**
+ * 발행된 페이지 생성/수정 시 색인(append)된다.
+ */
+ public function test_published_page_is_indexed(): void
+ {
+ $page = (object) ['id' => 200, 'slug' => 'about', 'published' => true, 'updated_at' => now()];
+
+ $this->listener->onPageChange($page);
+
+ $row = SitemapUrl::where('resource_type', 'page')->where('resource_id', '200')->first();
+ $this->assertNotNull($row, '발행 페이지는 색인돼야 합니다.');
+ $this->assertStringEndsWith('/page/about', $row->loc);
+ Bus::assertDispatched(GenerateSitemapJob::class);
+ }
+
+ /**
+ * 미발행 페이지는 색인되지 않고, 발행→미발행 전환 시 색인이 제거된다.
+ */
+ public function test_unpublished_page_removes_index(): void
+ {
+ $this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => true, 'updated_at' => now()]);
+ $this->assertSame(1, SitemapUrl::where('resource_id', '201')->count());
+
+ $this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => false, 'updated_at' => now()]);
+ $this->assertSame(0, SitemapUrl::where('resource_id', '201')->count(), '미발행 전환 시 색인이 제거돼야 합니다.');
+ }
+
+ /**
+ * 페이지 삭제 시 색인이 제거된다.
+ */
+ public function test_page_delete_removes_index(): void
+ {
+ $this->listener->onPageChange((object) ['id' => 202, 'slug' => 'gone', 'published' => true, 'updated_at' => now()]);
+ $this->assertSame(1, SitemapUrl::where('resource_id', '202')->count());
+
+ $this->listener->onPageDelete((object) ['id' => 202, 'slug' => 'gone', 'published' => true]);
+
+ $this->assertSame(0, SitemapUrl::where('resource_type', 'page')->where('resource_id', '202')->count());
+ }
+}
diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php
index 1db416af..3db014c0 100644
--- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php
+++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php
@@ -3,8 +3,12 @@
namespace Modules\Sirsoft\Page\Tests\Unit\Listeners;
use App\Contracts\Extension\CacheInterface;
+use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheManager;
+use App\Seo\SitemapIndexer;
+use Illuminate\Support\Facades\Bus;
+use Mockery;
use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener;
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
@@ -25,9 +29,20 @@ class SeoPageCacheListenerTest extends ModuleTestCase
parent::setUp();
$this->cache = $this->app->make(SeoCacheManagerInterface::class);
+
+ // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
+ $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
+ Bus::fake([GenerateSitemapJob::class]);
+
$this->listener = new SeoPageCacheListener;
}
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
// ─── 훅 구독 등록 ──────────────────────────────────────
/**
@@ -45,7 +60,8 @@ class SeoPageCacheListenerTest extends ModuleTestCase
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_create']['method']);
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_update']['method']);
- $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_delete']['method']);
+ // delete 는 모델 상태와 무관하게 색인을 제거해야 하므로 전용 메서드로 분리
+ $this->assertEquals('onPageDelete', $hooks['sirsoft-page.page.after_delete']['method']);
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_restore']['method']);
$this->assertEquals(20, $hooks['sirsoft-page.page.after_create']['priority']);
diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php
new file mode 100644
index 00000000..180b89b5
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php
@@ -0,0 +1,137 @@
+contributor = $this->app->make(PageSitemapContributor::class);
+ }
+
+ /**
+ * SitemapContributorInterface 를 구현한다
+ */
+ public function test_implements_sitemap_contributor_interface(): void
+ {
+ $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor);
+ }
+
+ /**
+ * getIdentifier: 'sirsoft-page' 반환
+ */
+ public function test_get_identifier_returns_sirsoft_page(): void
+ {
+ $this->assertSame('sirsoft-page', $this->contributor->getIdentifier());
+ }
+
+ /**
+ * getUrls: 발행된 페이지 URL이 포함된다
+ */
+ public function test_get_urls_includes_published_page(): void
+ {
+ $page = Page::factory()->published()->create();
+
+ $urlPaths = array_column($this->contributor->getUrls(), 'url');
+
+ $this->assertContains("/page/{$page->slug}", $urlPaths);
+ }
+
+ /**
+ * getUrls: 미발행 페이지는 포함되지 않는다
+ */
+ public function test_get_urls_excludes_unpublished_page(): void
+ {
+ $page = Page::factory()->create(['published' => false]);
+
+ $urlPaths = array_column($this->contributor->getUrls(), 'url');
+
+ $this->assertNotContains("/page/{$page->slug}", $urlPaths);
+ }
+
+ /**
+ * getUrls: URL 항목이 올바른 키 구조를 가진다
+ */
+ public function test_url_entries_have_correct_structure(): void
+ {
+ $page = Page::factory()->published()->create();
+
+ $urls = $this->contributor->getUrls();
+ $entry = collect($urls)->firstWhere('url', "/page/{$page->slug}");
+
+ $this->assertNotNull($entry);
+ $this->assertArrayHasKey('lastmod', $entry);
+ $this->assertSame('monthly', $entry['changefreq']);
+ $this->assertSame(0.5, $entry['priority']);
+ }
+
+ /**
+ * getUrls: 발행된 페이지가 없으면 빈 배열을 반환한다
+ */
+ public function test_get_urls_returns_empty_array_when_no_published_pages(): void
+ {
+ Page::factory()->count(2)->create(['published' => false]);
+
+ $this->assertSame([], $this->contributor->getUrls());
+ }
+
+ /**
+ * getUrls: 기여자당 URL 안전 상한을 초과하면 잘린다
+ */
+ public function test_get_urls_truncates_at_max_urls_per_contributor(): void
+ {
+ Page::factory()->count(3)->published()->create();
+
+ // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다.
+ Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2);
+
+ $this->assertCount(2, $this->contributor->getUrls());
+ }
+
+ /**
+ * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍)
+ */
+ public function test_get_urls_lazy_streams_entries(): void
+ {
+ $page = Page::factory()->published()->create();
+
+ $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor);
+
+ $lazy = $this->contributor->getUrlsLazy();
+ $this->assertInstanceOf(\Traversable::class, $lazy);
+
+ $urlPaths = array_column(iterator_to_array($lazy, false), 'url');
+ $this->assertContains("/page/{$page->slug}", $urlPaths);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js
index 158395bf..0b93f32c 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js
+++ b/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js
@@ -88,4 +88,3 @@