@nuxtjp/localized-site 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-ASSETS +35 -0
  3. package/LICENSE-PREVIOUS +21 -0
  4. package/NOTICE +4 -0
  5. package/README.ja.md +66 -0
  6. package/README.md +65 -2
  7. package/bin/nuxtjp-localized-site.mjs +7 -0
  8. package/dist/module.d.mts +62 -0
  9. package/dist/module.json +12 -0
  10. package/dist/module.mjs +74 -0
  11. package/dist/runtime/app/assets/localized-site.css +1 -0
  12. package/dist/runtime/app/components/LocalizedDocument.d.vue.ts +7 -0
  13. package/dist/runtime/app/components/LocalizedDocument.vue +23 -0
  14. package/dist/runtime/app/components/LocalizedDocument.vue.d.ts +7 -0
  15. package/dist/runtime/app/components/LocalizedLanding.d.vue.ts +3 -0
  16. package/dist/runtime/app/components/LocalizedLanding.vue +25 -0
  17. package/dist/runtime/app/components/LocalizedLanding.vue.d.ts +3 -0
  18. package/dist/runtime/app/components/LocalizedLanguageSwitcher.d.vue.ts +3 -0
  19. package/dist/runtime/app/components/LocalizedLanguageSwitcher.vue +21 -0
  20. package/dist/runtime/app/components/LocalizedLanguageSwitcher.vue.d.ts +3 -0
  21. package/dist/runtime/app/components/PublicMaintenanceNotice.d.vue.ts +3 -0
  22. package/dist/runtime/app/components/PublicMaintenanceNotice.vue +29 -0
  23. package/dist/runtime/app/components/PublicMaintenanceNotice.vue.d.ts +3 -0
  24. package/dist/runtime/app/composables/useLocalizedMessages.d.ts +10 -0
  25. package/dist/runtime/app/composables/useLocalizedMessages.js +14 -0
  26. package/dist/runtime/app/composables/useLocalizedSite.d.ts +9 -0
  27. package/dist/runtime/app/composables/useLocalizedSite.js +25 -0
  28. package/dist/runtime/app/layouts/localized-site.d.vue.ts +13 -0
  29. package/dist/runtime/app/layouts/localized-site.vue +37 -0
  30. package/dist/runtime/app/layouts/localized-site.vue.d.ts +13 -0
  31. package/dist/runtime/app/pages/LocalizedContentPage.d.vue.ts +3 -0
  32. package/dist/runtime/app/pages/LocalizedContentPage.vue +13 -0
  33. package/dist/runtime/app/pages/LocalizedContentPage.vue.d.ts +3 -0
  34. package/dist/runtime/app/plugins/localized-head.d.ts +2 -0
  35. package/dist/runtime/app/plugins/localized-head.js +7 -0
  36. package/dist/runtime/core/index.d.ts +8 -0
  37. package/dist/runtime/core/index.js +14 -0
  38. package/dist/runtime/core/maintenance.d.ts +25 -0
  39. package/dist/runtime/core/maintenance.js +51 -0
  40. package/dist/runtime/core/messages.d.ts +13 -0
  41. package/dist/runtime/core/messages.js +11 -0
  42. package/dist/runtime/core/path.d.ts +6 -0
  43. package/dist/runtime/core/path.js +43 -0
  44. package/dist/runtime/core/seo.d.ts +20 -0
  45. package/dist/runtime/core/seo.js +52 -0
  46. package/dist/runtime/shared/nuxt-imports.d.ts +18 -0
  47. package/dist/types.d.mts +3 -0
  48. package/examples/content/site.config.json +90 -0
  49. package/package.json +75 -3
  50. package/schemas/localized-site-v1.schema.json +139 -0
  51. package/schemas/public-maintenance-state-v1.schema.json +50 -0
  52. package/validator/content/cli.d.ts +5 -0
  53. package/validator/content/cli.js +31 -0
  54. package/validator/content/errors.d.ts +4 -0
  55. package/validator/content/errors.js +8 -0
  56. package/validator/content/index.d.ts +4 -0
  57. package/validator/content/index.js +3 -0
  58. package/validator/content/load.d.ts +2 -0
  59. package/validator/content/load.js +25 -0
  60. package/validator/content/validation/completeness.d.ts +2 -0
  61. package/validator/content/validation/completeness.js +21 -0
  62. package/validator/content/validation/locale-content.d.ts +2 -0
  63. package/validator/content/validation/locale-content.js +86 -0
  64. package/validator/content/validation/primitives.d.ts +9 -0
  65. package/validator/content/validation/primitives.js +48 -0
  66. package/validator/content/validation/site-config.d.ts +3 -0
  67. package/validator/content/validation/site-config.js +88 -0
  68. package/validator/contracts/types.d.ts +53 -0
  69. package/validator/contracts/types.js +3 -0
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@nuxtjp/localized-site",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Contract-first Japanese and English public-site module for Nuxt",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": "^22.19.0 || ^24.11.0",
9
+ "pnpm": ">=10 <11"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "dist",
14
+ "examples",
15
+ "schemas",
16
+ "validator",
17
+ "LICENSE",
18
+ "README.md",
19
+ "NOTICE",
20
+ "LICENSE-ASSETS",
21
+ "LICENSE-PREVIOUS",
22
+ "README.ja.md"
23
+ ],
24
+ "bin": {
25
+ "nuxtjp-localized-site": "./bin/nuxtjp-localized-site.mjs"
26
+ },
27
+ "main": "./dist/module.mjs",
28
+ "types": "./dist/types.d.mts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/types.d.mts",
32
+ "import": "./dist/module.mjs"
33
+ },
34
+ "./core": {
35
+ "types": "./dist/runtime/core/index.d.ts",
36
+ "import": "./dist/runtime/core/index.js"
37
+ },
38
+ "./validator": {
39
+ "types": "./validator/content/index.d.ts",
40
+ "import": "./validator/content/index.js"
41
+ },
42
+ "./schema": "./schemas/localized-site-v1.schema.json"
43
+ },
44
+ "peerDependencies": {
45
+ "nuxt": "^4.5.1",
46
+ "vue": "^3.5.40"
47
+ },
48
+ "dependencies": {
49
+ "@nuxt/kit": "4.5.1",
50
+ "@nuxt/schema": "4.5.1"
51
+ },
52
+ "devDependencies": {
53
+ "@nuxt/module-builder": "1.0.2",
54
+ "@types/node": "24.12.0",
55
+ "nuxt": "4.5.1",
56
+ "typescript": "5.9.3",
57
+ "vitest": "4.1.11",
58
+ "vue": "3.5.43",
59
+ "vue-tsc": "3.2.8"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/nuxtjp/nuxt-localized-site.git"
64
+ },
65
+ "publishConfig": {
66
+ "registry": "https://registry.npmjs.org",
67
+ "access": "public",
68
+ "provenance": true
69
+ },
70
+ "scripts": {
71
+ "build:module": "nuxt-module-build build",
72
+ "build:validator": "tsc -p tsconfig.validator.json",
73
+ "build": "pnpm run build:validator && pnpm run build:module && nuxt build playground",
74
+ "typecheck": "vue-tsc --noEmit --project tsconfig.json",
75
+ "test": "vitest run",
76
+ "validate:example": "node ./bin/nuxtjp-localized-site.mjs validate --content-root ./examples/content"
77
+ }
6
78
  }
@@ -0,0 +1,139 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "nuxtjp://localized-site/site-config/v1",
4
+ "title": "NuxtJP localized public site",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema",
9
+ "siteId",
10
+ "serviceId",
11
+ "origin",
12
+ "audienceScope",
13
+ "defaultLocale",
14
+ "locales",
15
+ "externalActions",
16
+ "content"
17
+ ],
18
+ "properties": {
19
+ "schema": { "const": "nuxtjp://localized-site/site-config/v1" },
20
+ "siteId": { "$ref": "#/$defs/id" },
21
+ "serviceId": { "$ref": "#/$defs/id" },
22
+ "origin": { "type": "string", "format": "uri", "maxLength": 500 },
23
+ "audienceScope": { "enum": ["japan", "global"] },
24
+ "defaultLocale": { "$ref": "#/$defs/locale" },
25
+ "fallbackLocale": { "$ref": "#/$defs/locale" },
26
+ "prefixDefaultLocale": { "type": "boolean" },
27
+ "locales": {
28
+ "type": "array",
29
+ "prefixItems": [{ "const": "ja" }, { "const": "en" }],
30
+ "minItems": 2,
31
+ "maxItems": 2
32
+ },
33
+ "externalActions": { "const": false },
34
+ "content": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["ja", "en"],
38
+ "properties": {
39
+ "ja": { "$ref": "#/$defs/localeContent" },
40
+ "en": { "$ref": "#/$defs/localeContent" }
41
+ }
42
+ }
43
+ },
44
+ "$defs": {
45
+ "locale": { "enum": ["ja", "en"] },
46
+ "id": {
47
+ "type": "string",
48
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
49
+ "minLength": 1,
50
+ "maxLength": 80
51
+ },
52
+ "text": { "type": "string", "minLength": 1, "maxLength": 4000 },
53
+ "notice": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["label", "summary"],
57
+ "properties": {
58
+ "label": { "$ref": "#/$defs/text" },
59
+ "summary": { "$ref": "#/$defs/text" }
60
+ }
61
+ },
62
+ "feature": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "required": ["id", "title", "summary"],
66
+ "properties": {
67
+ "id": { "$ref": "#/$defs/id" },
68
+ "title": { "$ref": "#/$defs/text" },
69
+ "summary": { "$ref": "#/$defs/text" }
70
+ }
71
+ },
72
+ "section": {
73
+ "type": "object",
74
+ "additionalProperties": false,
75
+ "required": ["id", "title", "body"],
76
+ "properties": {
77
+ "id": { "$ref": "#/$defs/id" },
78
+ "title": { "$ref": "#/$defs/text" },
79
+ "body": {
80
+ "type": "array",
81
+ "items": { "$ref": "#/$defs/text" },
82
+ "minItems": 1
83
+ }
84
+ }
85
+ },
86
+ "document": {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "required": ["slug", "title", "summary", "sections"],
90
+ "properties": {
91
+ "slug": { "type": "string", "pattern": "^/docs(?:/.*)?$" },
92
+ "title": { "$ref": "#/$defs/text" },
93
+ "summary": { "$ref": "#/$defs/text" },
94
+ "sections": {
95
+ "type": "array",
96
+ "items": { "$ref": "#/$defs/section" },
97
+ "minItems": 1
98
+ }
99
+ }
100
+ },
101
+ "docs": {
102
+ "type": "object",
103
+ "additionalProperties": false,
104
+ "required": ["overview", "gettingStarted", "security"],
105
+ "properties": {
106
+ "overview": { "$ref": "#/$defs/document" },
107
+ "gettingStarted": { "$ref": "#/$defs/document" },
108
+ "security": { "$ref": "#/$defs/document" }
109
+ }
110
+ },
111
+ "localeContent": {
112
+ "type": "object",
113
+ "additionalProperties": false,
114
+ "required": [
115
+ "brand",
116
+ "tagline",
117
+ "summary",
118
+ "features",
119
+ "docs",
120
+ "privacy",
121
+ "contact"
122
+ ],
123
+ "properties": {
124
+ "brand": { "$ref": "#/$defs/text" },
125
+ "tagline": { "$ref": "#/$defs/text" },
126
+ "summary": { "$ref": "#/$defs/text" },
127
+ "features": {
128
+ "type": "array",
129
+ "items": { "$ref": "#/$defs/feature" },
130
+ "minItems": 3,
131
+ "maxItems": 3
132
+ },
133
+ "docs": { "$ref": "#/$defs/docs" },
134
+ "privacy": { "$ref": "#/$defs/notice" },
135
+ "contact": { "$ref": "#/$defs/notice" }
136
+ }
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "nuxtjp://public/maintenance-state/v1",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["schema", "generatedAt", "expiresAt", "global", "services"],
7
+ "properties": {
8
+ "schema": { "const": "nuxtjp://public/maintenance-state/v1" },
9
+ "generatedAt": { "type": "string", "format": "date-time" },
10
+ "expiresAt": { "type": "string", "format": "date-time" },
11
+ "global": { "$ref": "#/$defs/status" },
12
+ "services": {
13
+ "type": "array",
14
+ "maxItems": 128,
15
+ "items": { "$ref": "#/$defs/target" }
16
+ }
17
+ },
18
+ "$defs": {
19
+ "message": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "required": ["ja", "en"],
23
+ "properties": {
24
+ "ja": { "type": "string", "minLength": 1, "maxLength": 240 },
25
+ "en": { "type": "string", "minLength": 1, "maxLength": 240 }
26
+ }
27
+ },
28
+ "status": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "required": ["state", "message", "expiresAt"],
32
+ "properties": {
33
+ "state": { "enum": ["operational", "maintenance"] },
34
+ "message": { "$ref": "#/$defs/message" },
35
+ "expiresAt": { "type": "string", "format": "date-time" }
36
+ }
37
+ },
38
+ "target": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["serviceId", "state", "message", "expiresAt"],
42
+ "properties": {
43
+ "serviceId": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" },
44
+ "state": { "enum": ["operational", "maintenance"] },
45
+ "message": { "$ref": "#/$defs/message" },
46
+ "expiresAt": { "type": "string", "format": "date-time" }
47
+ }
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,5 @@
1
+ export interface CliOutput {
2
+ out(value: string): void;
3
+ error(value: string): void;
4
+ }
5
+ export declare function runValidatorCli(args: string[], output: CliOutput): Promise<number>;
@@ -0,0 +1,31 @@
1
+ import { resolve } from 'node:path';
2
+ import { loadSiteConfig } from './load.js';
3
+ function contentRoot(args) {
4
+ if (args[0] !== 'validate')
5
+ throw new Error('expected the validate command');
6
+ const flagIndex = args.indexOf('--content-root');
7
+ const value = flagIndex >= 0 ? args[flagIndex + 1] : undefined;
8
+ if (!value || args.length !== 3) {
9
+ throw new Error('usage: nuxtjp-localized-site validate --content-root <path>');
10
+ }
11
+ return resolve(value);
12
+ }
13
+ export async function runValidatorCli(args, output) {
14
+ try {
15
+ const config = loadSiteConfig(contentRoot(args));
16
+ output.out(JSON.stringify({
17
+ schema: 'nuxtjp://localized-site/validation-result/v1',
18
+ valid: true,
19
+ siteId: config.siteId,
20
+ serviceId: config.serviceId,
21
+ locales: config.locales,
22
+ routes: ['/', '/docs', '/docs/getting-started', '/docs/security'],
23
+ externalActions: config.externalActions
24
+ }, null, 2));
25
+ return 0;
26
+ }
27
+ catch (error) {
28
+ output.error(error instanceof Error ? error.message : 'site validation failed');
29
+ return 1;
30
+ }
31
+ }
@@ -0,0 +1,4 @@
1
+ export declare class SiteContractError extends Error {
2
+ readonly issues: string[];
3
+ constructor(issues: string[]);
4
+ }
@@ -0,0 +1,8 @@
1
+ export class SiteContractError extends Error {
2
+ issues;
3
+ constructor(issues) {
4
+ super(`site contract is invalid:\n- ${issues.join('\n- ')}`);
5
+ this.name = 'SiteContractError';
6
+ this.issues = issues;
7
+ }
8
+ }
@@ -0,0 +1,4 @@
1
+ export { SiteContractError } from './errors.js';
2
+ export { loadSiteConfig } from './load.js';
3
+ export { validateSiteConfig, defineSiteConfig } from './validation/site-config.js';
4
+ export type { ResolvedSiteConfig, SiteConfig, SupportedLocale } from '../contracts/types.js';
@@ -0,0 +1,3 @@
1
+ export { SiteContractError } from './errors.js';
2
+ export { loadSiteConfig } from './load.js';
3
+ export { validateSiteConfig, defineSiteConfig } from './validation/site-config.js';
@@ -0,0 +1,2 @@
1
+ import type { ResolvedSiteConfig } from '../contracts/types.js';
2
+ export declare function loadSiteConfig(contentRoot: string): ResolvedSiteConfig;
@@ -0,0 +1,25 @@
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { SiteContractError } from './errors.js';
4
+ import { validateSiteConfig } from './validation/site-config.js';
5
+ export function loadSiteConfig(contentRoot) {
6
+ let root;
7
+ try {
8
+ root = realpathSync(contentRoot);
9
+ if (!statSync(root).isDirectory())
10
+ throw new Error('not a directory');
11
+ }
12
+ catch {
13
+ throw new SiteContractError([`content root is not a readable directory: ${contentRoot}`]);
14
+ }
15
+ const file = join(root, 'site.config.json');
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
19
+ }
20
+ catch (error) {
21
+ const detail = error instanceof Error ? error.message : 'unknown read error';
22
+ throw new SiteContractError([`cannot read ${file}: ${detail}`]);
23
+ }
24
+ return validateSiteConfig(parsed);
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { LocaleContent } from '../../contracts/types.js';
2
+ export declare function validateCompleteness(ja: LocaleContent, en: LocaleContent, issues: string[]): void;
@@ -0,0 +1,21 @@
1
+ import { DOCUMENT_IDS } from '../../contracts/types.js';
2
+ function sameIds(left, right, path, issues) {
3
+ const leftIds = left.map(item => item.id);
4
+ const rightIds = right.map(item => item.id);
5
+ if (JSON.stringify(leftIds) !== JSON.stringify(rightIds)) {
6
+ issues.push(`${path} must use the same ids and order in ja and en`);
7
+ }
8
+ }
9
+ function compareDocument(id, ja, en, issues) {
10
+ const jaDocument = ja.docs[id];
11
+ const enDocument = en.docs[id];
12
+ if (jaDocument.slug !== enDocument.slug) {
13
+ issues.push(`content.${id}.slug must match in ja and en`);
14
+ }
15
+ sameIds(jaDocument.sections, enDocument.sections, `content.${id}.sections`, issues);
16
+ }
17
+ export function validateCompleteness(ja, en, issues) {
18
+ sameIds(ja.features, en.features, 'content.features', issues);
19
+ for (const id of DOCUMENT_IDS)
20
+ compareDocument(id, ja, en, issues);
21
+ }
@@ -0,0 +1,2 @@
1
+ import type { LocaleContent } from '../../contracts/types.js';
2
+ export declare function localeContent(value: unknown, path: string, issues: string[]): LocaleContent;
@@ -0,0 +1,86 @@
1
+ import { DOCUMENT_IDS } from '../../contracts/types.js';
2
+ import { identifier, onlyKeys, record, text, textList } from './primitives.js';
3
+ const documentSlugs = {
4
+ overview: '/docs',
5
+ gettingStarted: '/docs/getting-started',
6
+ security: '/docs/security'
7
+ };
8
+ function feature(value, path, issues) {
9
+ const item = record(value, path, issues);
10
+ onlyKeys(item, ['id', 'title', 'summary'], path, issues);
11
+ return {
12
+ id: identifier(item.id, `${path}.id`, issues),
13
+ title: text(item.title, `${path}.title`, issues, 160),
14
+ summary: text(item.summary, `${path}.summary`, issues, 500)
15
+ };
16
+ }
17
+ function features(value, path, issues) {
18
+ if (!Array.isArray(value) || value.length !== 3) {
19
+ issues.push(`${path} must contain exactly three features`);
20
+ }
21
+ const result = Array.isArray(value)
22
+ ? value.map((item, index) => feature(item, `${path}[${index}]`, issues))
23
+ : [];
24
+ if (new Set(result.map(item => item.id)).size !== result.length) {
25
+ issues.push(`${path} feature ids must be unique`);
26
+ }
27
+ return result;
28
+ }
29
+ function section(value, path, issues) {
30
+ const item = record(value, path, issues);
31
+ onlyKeys(item, ['id', 'title', 'body'], path, issues);
32
+ return {
33
+ id: identifier(item.id, `${path}.id`, issues),
34
+ title: text(item.title, `${path}.title`, issues, 160),
35
+ body: textList(item.body, `${path}.body`, issues)
36
+ };
37
+ }
38
+ function document(value, id, path, issues) {
39
+ const item = record(value, path, issues);
40
+ onlyKeys(item, ['slug', 'title', 'summary', 'sections'], path, issues);
41
+ const slug = text(item.slug, `${path}.slug`, issues, 80);
42
+ if (slug !== documentSlugs[id]) {
43
+ issues.push(`${path}.slug must be ${documentSlugs[id]}`);
44
+ }
45
+ const sections = Array.isArray(item.sections)
46
+ ? item.sections.map((entry, index) => section(entry, `${path}.sections[${index}]`, issues))
47
+ : [];
48
+ if (sections.length === 0)
49
+ issues.push(`${path}.sections must be a non-empty array`);
50
+ if (new Set(sections.map(item => item.id)).size !== sections.length) {
51
+ issues.push(`${path}.section ids must be unique`);
52
+ }
53
+ return {
54
+ slug,
55
+ title: text(item.title, `${path}.title`, issues, 160),
56
+ summary: text(item.summary, `${path}.summary`, issues, 500),
57
+ sections
58
+ };
59
+ }
60
+ function notice(value, path, issues) {
61
+ const item = record(value, path, issues);
62
+ onlyKeys(item, ['label', 'summary'], path, issues);
63
+ return {
64
+ label: text(item.label, `${path}.label`, issues, 160),
65
+ summary: text(item.summary, `${path}.summary`, issues, 1000)
66
+ };
67
+ }
68
+ export function localeContent(value, path, issues) {
69
+ const item = record(value, path, issues);
70
+ onlyKeys(item, ['brand', 'tagline', 'summary', 'features', 'docs', 'privacy', 'contact'], path, issues);
71
+ const docs = record(item.docs, `${path}.docs`, issues);
72
+ onlyKeys(docs, DOCUMENT_IDS, `${path}.docs`, issues);
73
+ return {
74
+ brand: text(item.brand, `${path}.brand`, issues, 160),
75
+ tagline: text(item.tagline, `${path}.tagline`, issues, 240),
76
+ summary: text(item.summary, `${path}.summary`, issues, 1000),
77
+ features: features(item.features, `${path}.features`, issues),
78
+ docs: {
79
+ overview: document(docs.overview, 'overview', `${path}.docs.overview`, issues),
80
+ gettingStarted: document(docs.gettingStarted, 'gettingStarted', `${path}.docs.gettingStarted`, issues),
81
+ security: document(docs.security, 'security', `${path}.docs.security`, issues)
82
+ },
83
+ privacy: notice(item.privacy, `${path}.privacy`, issues),
84
+ contact: notice(item.contact, `${path}.contact`, issues)
85
+ };
86
+ }
@@ -0,0 +1,9 @@
1
+ import type { SupportedLocale } from '../../contracts/types.js';
2
+ export type UnknownRecord = Record<string, unknown>;
3
+ export declare function record(value: unknown, path: string, issues: string[]): UnknownRecord;
4
+ export declare function onlyKeys(value: UnknownRecord, allowed: readonly string[], path: string, issues: string[]): void;
5
+ export declare function text(value: unknown, path: string, issues: string[], maximum?: number): string;
6
+ export declare function identifier(value: unknown, path: string, issues: string[]): string;
7
+ export declare function locale(value: unknown, path: string, issues: string[]): SupportedLocale;
8
+ export declare function textList(value: unknown, path: string, issues: string[]): string[];
9
+ export declare function exactBoolean(value: unknown, expected: boolean, path: string, issues: string[]): boolean;
@@ -0,0 +1,48 @@
1
+ export function record(value, path, issues) {
2
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
3
+ issues.push(`${path} must be an object`);
4
+ return {};
5
+ }
6
+ return value;
7
+ }
8
+ export function onlyKeys(value, allowed, path, issues) {
9
+ for (const key of Object.keys(value)) {
10
+ if (!allowed.includes(key))
11
+ issues.push(`${path}.${key} is not allowed`);
12
+ }
13
+ }
14
+ export function text(value, path, issues, maximum = 4000) {
15
+ if (typeof value !== 'string' || value.trim() === '') {
16
+ issues.push(`${path} must be a non-empty string`);
17
+ return '';
18
+ }
19
+ if (value.length > maximum)
20
+ issues.push(`${path} exceeds ${maximum} characters`);
21
+ return value;
22
+ }
23
+ export function identifier(value, path, issues) {
24
+ const result = text(value, path, issues, 80);
25
+ if (result && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result)) {
26
+ issues.push(`${path} must be a kebab-case identifier`);
27
+ }
28
+ return result;
29
+ }
30
+ export function locale(value, path, issues) {
31
+ if (value !== 'ja' && value !== 'en') {
32
+ issues.push(`${path} must be ja or en`);
33
+ return 'ja';
34
+ }
35
+ return value;
36
+ }
37
+ export function textList(value, path, issues) {
38
+ if (!Array.isArray(value) || value.length === 0) {
39
+ issues.push(`${path} must be a non-empty array`);
40
+ return [];
41
+ }
42
+ return value.map((entry, index) => text(entry, `${path}[${index}]`, issues));
43
+ }
44
+ export function exactBoolean(value, expected, path, issues) {
45
+ if (value !== expected)
46
+ issues.push(`${path} must be ${String(expected)}`);
47
+ return expected;
48
+ }
@@ -0,0 +1,3 @@
1
+ import type { ResolvedSiteConfig, SiteConfig } from '../../contracts/types.js';
2
+ export declare function validateSiteConfig(value: unknown): ResolvedSiteConfig;
3
+ export declare function defineSiteConfig<const Config extends SiteConfig>(config: Config): Config;
@@ -0,0 +1,88 @@
1
+ import { SITE_CONFIG_SCHEMA } from '../../contracts/types.js';
2
+ import { SiteContractError } from '../errors.js';
3
+ import { validateCompleteness } from './completeness.js';
4
+ import { localeContent } from './locale-content.js';
5
+ import { exactBoolean, identifier, locale, onlyKeys, record, text } from './primitives.js';
6
+ const rootKeys = [
7
+ 'schema',
8
+ 'siteId',
9
+ 'serviceId',
10
+ 'origin',
11
+ 'audienceScope',
12
+ 'defaultLocale',
13
+ 'fallbackLocale',
14
+ 'prefixDefaultLocale',
15
+ 'locales',
16
+ 'externalActions',
17
+ 'content'
18
+ ];
19
+ function origin(value, issues) {
20
+ const input = text(value, 'origin', issues, 500);
21
+ try {
22
+ const parsed = new URL(input);
23
+ if (!['http:', 'https:'].includes(parsed.protocol))
24
+ throw new Error('protocol');
25
+ if (parsed.username || parsed.password || parsed.search || parsed.hash)
26
+ throw new Error('parts');
27
+ parsed.pathname = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '');
28
+ return parsed.toString().replace(/\/$/, '');
29
+ }
30
+ catch {
31
+ issues.push('origin must be an HTTP(S) URL without credentials, query, or fragment');
32
+ return 'http://localhost';
33
+ }
34
+ }
35
+ function audience(value, issues) {
36
+ if (value !== 'japan' && value !== 'global') {
37
+ issues.push('audienceScope must be japan or global');
38
+ return 'japan';
39
+ }
40
+ return value;
41
+ }
42
+ function locales(value, issues) {
43
+ if (!Array.isArray(value) || value.length !== 2 || value[0] !== 'ja' || value[1] !== 'en') {
44
+ issues.push('locales must be exactly ["ja", "en"]');
45
+ }
46
+ return ['ja', 'en'];
47
+ }
48
+ export function validateSiteConfig(value) {
49
+ const issues = [];
50
+ const root = record(value, 'site.config.json', issues);
51
+ onlyKeys(root, rootKeys, 'site.config.json', issues);
52
+ if (root.schema !== SITE_CONFIG_SCHEMA) {
53
+ issues.push(`schema must be ${SITE_CONFIG_SCHEMA}`);
54
+ }
55
+ const defaultLocale = locale(root.defaultLocale, 'defaultLocale', issues);
56
+ const fallbackLocale = root.fallbackLocale === undefined
57
+ ? (defaultLocale === 'ja' ? 'en' : 'ja')
58
+ : locale(root.fallbackLocale, 'fallbackLocale', issues);
59
+ const prefixDefaultLocale = root.prefixDefaultLocale === undefined
60
+ ? false
61
+ : exactBoolean(root.prefixDefaultLocale, root.prefixDefaultLocale === true, 'prefixDefaultLocale', issues);
62
+ const content = record(root.content, 'content', issues);
63
+ onlyKeys(content, ['ja', 'en'], 'content', issues);
64
+ const ja = localeContent(content.ja, 'content.ja', issues);
65
+ const en = localeContent(content.en, 'content.en', issues);
66
+ validateCompleteness(ja, en, issues);
67
+ exactBoolean(root.externalActions, false, 'externalActions', issues);
68
+ const result = {
69
+ schema: SITE_CONFIG_SCHEMA,
70
+ siteId: identifier(root.siteId, 'siteId', issues),
71
+ serviceId: identifier(root.serviceId, 'serviceId', issues),
72
+ origin: origin(root.origin, issues),
73
+ audienceScope: audience(root.audienceScope, issues),
74
+ defaultLocale,
75
+ fallbackLocale,
76
+ prefixDefaultLocale,
77
+ locales: locales(root.locales, issues),
78
+ externalActions: false,
79
+ content: { ja, en }
80
+ };
81
+ if (issues.length > 0)
82
+ throw new SiteContractError(issues);
83
+ return result;
84
+ }
85
+ export function defineSiteConfig(config) {
86
+ validateSiteConfig(config);
87
+ return config;
88
+ }