@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.
- package/LICENSE +202 -0
- package/LICENSE-ASSETS +35 -0
- package/LICENSE-PREVIOUS +21 -0
- package/NOTICE +4 -0
- package/README.ja.md +66 -0
- package/README.md +65 -2
- package/bin/nuxtjp-localized-site.mjs +7 -0
- package/dist/module.d.mts +62 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +74 -0
- package/dist/runtime/app/assets/localized-site.css +1 -0
- package/dist/runtime/app/components/LocalizedDocument.d.vue.ts +7 -0
- package/dist/runtime/app/components/LocalizedDocument.vue +23 -0
- package/dist/runtime/app/components/LocalizedDocument.vue.d.ts +7 -0
- package/dist/runtime/app/components/LocalizedLanding.d.vue.ts +3 -0
- package/dist/runtime/app/components/LocalizedLanding.vue +25 -0
- package/dist/runtime/app/components/LocalizedLanding.vue.d.ts +3 -0
- package/dist/runtime/app/components/LocalizedLanguageSwitcher.d.vue.ts +3 -0
- package/dist/runtime/app/components/LocalizedLanguageSwitcher.vue +21 -0
- package/dist/runtime/app/components/LocalizedLanguageSwitcher.vue.d.ts +3 -0
- package/dist/runtime/app/components/PublicMaintenanceNotice.d.vue.ts +3 -0
- package/dist/runtime/app/components/PublicMaintenanceNotice.vue +29 -0
- package/dist/runtime/app/components/PublicMaintenanceNotice.vue.d.ts +3 -0
- package/dist/runtime/app/composables/useLocalizedMessages.d.ts +10 -0
- package/dist/runtime/app/composables/useLocalizedMessages.js +14 -0
- package/dist/runtime/app/composables/useLocalizedSite.d.ts +9 -0
- package/dist/runtime/app/composables/useLocalizedSite.js +25 -0
- package/dist/runtime/app/layouts/localized-site.d.vue.ts +13 -0
- package/dist/runtime/app/layouts/localized-site.vue +37 -0
- package/dist/runtime/app/layouts/localized-site.vue.d.ts +13 -0
- package/dist/runtime/app/pages/LocalizedContentPage.d.vue.ts +3 -0
- package/dist/runtime/app/pages/LocalizedContentPage.vue +13 -0
- package/dist/runtime/app/pages/LocalizedContentPage.vue.d.ts +3 -0
- package/dist/runtime/app/plugins/localized-head.d.ts +2 -0
- package/dist/runtime/app/plugins/localized-head.js +7 -0
- package/dist/runtime/core/index.d.ts +8 -0
- package/dist/runtime/core/index.js +14 -0
- package/dist/runtime/core/maintenance.d.ts +25 -0
- package/dist/runtime/core/maintenance.js +51 -0
- package/dist/runtime/core/messages.d.ts +13 -0
- package/dist/runtime/core/messages.js +11 -0
- package/dist/runtime/core/path.d.ts +6 -0
- package/dist/runtime/core/path.js +43 -0
- package/dist/runtime/core/seo.d.ts +20 -0
- package/dist/runtime/core/seo.js +52 -0
- package/dist/runtime/shared/nuxt-imports.d.ts +18 -0
- package/dist/types.d.mts +3 -0
- package/examples/content/site.config.json +90 -0
- package/package.json +75 -3
- package/schemas/localized-site-v1.schema.json +139 -0
- package/schemas/public-maintenance-state-v1.schema.json +50 -0
- package/validator/content/cli.d.ts +5 -0
- package/validator/content/cli.js +31 -0
- package/validator/content/errors.d.ts +4 -0
- package/validator/content/errors.js +8 -0
- package/validator/content/index.d.ts +4 -0
- package/validator/content/index.js +3 -0
- package/validator/content/load.d.ts +2 -0
- package/validator/content/load.js +25 -0
- package/validator/content/validation/completeness.d.ts +2 -0
- package/validator/content/validation/completeness.js +21 -0
- package/validator/content/validation/locale-content.d.ts +2 -0
- package/validator/content/validation/locale-content.js +86 -0
- package/validator/content/validation/primitives.d.ts +9 -0
- package/validator/content/validation/primitives.js +48 -0
- package/validator/content/validation/site-config.d.ts +3 -0
- package/validator/content/validation/site-config.js +88 -0
- package/validator/contracts/types.d.ts +53 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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,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 { 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,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,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,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,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
|
+
}
|