@portosaur/core 0.6.3 → 0.9.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.
@@ -0,0 +1,354 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Portosaur Project Configuration",
4
+ "description": "Schema for config.yml — validated at build time by porto.",
5
+ "type": "object",
6
+ "properties": {
7
+ "site": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "url": {
12
+ "type": "string",
13
+ "default": "auto"
14
+ },
15
+ "path": {
16
+ "type": "string",
17
+ "default": "auto"
18
+ },
19
+ "title": {
20
+ "type": "string"
21
+ },
22
+ "favicon": {
23
+ "type": "string",
24
+ "default": ""
25
+ },
26
+ "tagline": {
27
+ "type": "string",
28
+ "default": "Short description about you, your passion, your goals etc."
29
+ },
30
+ "on_broken_anchors": {
31
+ "type": "string",
32
+ "default": "throw"
33
+ },
34
+ "on_broken_links": {
35
+ "type": "string",
36
+ "default": "throw"
37
+ },
38
+ "head_tags": {
39
+ "type": "array"
40
+ },
41
+ "social_card": {
42
+ "type": "string",
43
+ "default": ""
44
+ },
45
+ "robots_txt": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "properties": {
49
+ "enable": {
50
+ "type": "boolean",
51
+ "default": true
52
+ },
53
+ "rules": {
54
+ "type": "array"
55
+ },
56
+ "custom_lines": {
57
+ "type": "array"
58
+ }
59
+ }
60
+ },
61
+ "rss": {
62
+ "type": "object",
63
+ "additionalProperties": false,
64
+ "properties": {
65
+ "enable": {
66
+ "type": "boolean",
67
+ "default": true
68
+ },
69
+ "copyright": {
70
+ "type": "string"
71
+ },
72
+ "desc": {
73
+ "type": "string"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ },
79
+ "custom": {
80
+ "type": "object",
81
+ "additionalProperties": true
82
+ },
83
+ "theme": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "appearance": {
88
+ "type": "object",
89
+ "additionalProperties": false,
90
+ "properties": {
91
+ "default_mode": {
92
+ "type": "string",
93
+ "default": "dark"
94
+ },
95
+ "show_theme_switch": {
96
+ "type": "boolean",
97
+ "default": true
98
+ },
99
+ "disable_project_link": {
100
+ "type": "boolean",
101
+ "default": false
102
+ }
103
+ }
104
+ },
105
+ "navigation": {
106
+ "type": "object",
107
+ "additionalProperties": false,
108
+ "properties": {
109
+ "hide_navbar_on_scroll": {
110
+ "type": "boolean",
111
+ "default": true
112
+ },
113
+ "collapsable_sidebar": {
114
+ "type": "boolean",
115
+ "default": true
116
+ },
117
+ "breadcrumbs": {
118
+ "type": "boolean",
119
+ "default": true
120
+ }
121
+ }
122
+ },
123
+ "markdown": {
124
+ "type": "object",
125
+ "additionalProperties": false,
126
+ "properties": {
127
+ "mermaid": {
128
+ "type": "boolean",
129
+ "default": true
130
+ },
131
+ "render_emoji_shortcodes": {
132
+ "type": "boolean",
133
+ "default": true
134
+ },
135
+ "on_broken_links": {
136
+ "type": "string",
137
+ "default": "throw"
138
+ },
139
+ "on_broken_images": {
140
+ "type": "string",
141
+ "default": "throw"
142
+ }
143
+ }
144
+ },
145
+ "footer": {
146
+ "type": "object",
147
+ "additionalProperties": false,
148
+ "properties": {
149
+ "enable": {
150
+ "type": "boolean",
151
+ "default": true
152
+ },
153
+ "message": {
154
+ "type": "string"
155
+ },
156
+ "disable_project_link": {
157
+ "type": "boolean",
158
+ "default": false
159
+ }
160
+ }
161
+ }
162
+ }
163
+ },
164
+ "home_page": {
165
+ "type": "object",
166
+ "additionalProperties": false,
167
+ "properties": {
168
+ "hero": {
169
+ "type": "object",
170
+ "additionalProperties": false,
171
+ "properties": {
172
+ "title": {
173
+ "type": "string",
174
+ "default": "Your Name"
175
+ },
176
+ "profile_pic": {
177
+ "type": "string",
178
+ "default": ""
179
+ },
180
+ "desc": {
181
+ "type": "string",
182
+ "default": "Short description about you, your passion, your goals etc."
183
+ },
184
+ "intro": {
185
+ "type": "string",
186
+ "default": "Hello there, I'm"
187
+ },
188
+ "subtitle": {
189
+ "type": "string",
190
+ "default": "I am a"
191
+ },
192
+ "profession": {
193
+ "type": "string",
194
+ "default": "Your Profession"
195
+ },
196
+ "social": {
197
+ "type": "array"
198
+ },
199
+ "learn_more_button_txt": {
200
+ "type": "string",
201
+ "default": "Learn More"
202
+ }
203
+ }
204
+ },
205
+ "about": {
206
+ "type": "object",
207
+ "additionalProperties": false,
208
+ "properties": {
209
+ "enable": {
210
+ "type": "boolean",
211
+ "default": true
212
+ },
213
+ "heading": {
214
+ "type": "string",
215
+ "default": "About Me"
216
+ },
217
+ "image": {
218
+ "type": "string",
219
+ "default": ""
220
+ },
221
+ "bio": {
222
+ "type": "array"
223
+ },
224
+ "skills": {
225
+ "type": "array"
226
+ },
227
+ "skills_heading": {
228
+ "type": "string",
229
+ "default": "My Skills"
230
+ },
231
+ "resume": {
232
+ "type": "string",
233
+ "default": ""
234
+ }
235
+ }
236
+ },
237
+ "project_shelf": {
238
+ "type": "object",
239
+ "additionalProperties": false,
240
+ "properties": {
241
+ "enable": {
242
+ "type": "boolean",
243
+ "default": true
244
+ },
245
+ "heading": {
246
+ "type": "string",
247
+ "default": "My Projects"
248
+ },
249
+ "subheading": {
250
+ "type": "string",
251
+ "default": "A collection of all my works"
252
+ },
253
+ "autoplay": {
254
+ "type": "boolean",
255
+ "default": true
256
+ },
257
+ "projects": {
258
+ "type": "array"
259
+ }
260
+ }
261
+ },
262
+ "experience": {
263
+ "type": "object",
264
+ "additionalProperties": false,
265
+ "properties": {
266
+ "enable": {
267
+ "type": "boolean",
268
+ "default": false
269
+ },
270
+ "heading": {
271
+ "type": "string",
272
+ "default": "Experience"
273
+ },
274
+ "subheading": {
275
+ "type": "string",
276
+ "default": "My professional journey"
277
+ },
278
+ "list": {
279
+ "type": "array"
280
+ }
281
+ }
282
+ },
283
+ "social": {
284
+ "type": "object",
285
+ "additionalProperties": false,
286
+ "properties": {
287
+ "enable": {
288
+ "type": "boolean",
289
+ "default": true
290
+ },
291
+ "heading": {
292
+ "type": "string",
293
+ "default": "Get In Touch"
294
+ },
295
+ "subheading": {
296
+ "type": "string",
297
+ "default": "Feel free to reach out"
298
+ },
299
+ "links": {
300
+ "type": "array"
301
+ }
302
+ }
303
+ }
304
+ }
305
+ },
306
+ "tasks": {
307
+ "type": "object",
308
+ "additionalProperties": false,
309
+ "properties": {
310
+ "enable": {
311
+ "type": "boolean",
312
+ "default": false
313
+ },
314
+ "title": {
315
+ "type": "string",
316
+ "default": "Tasks"
317
+ },
318
+ "subtitle": {
319
+ "type": "string",
320
+ "default": "My current focus"
321
+ },
322
+ "list": {
323
+ "type": "array"
324
+ }
325
+ }
326
+ },
327
+ "tools": {
328
+ "type": "object",
329
+ "additionalProperties": false,
330
+ "properties": {
331
+ "link_shortener": {
332
+ "type": "object",
333
+ "additionalProperties": false,
334
+ "properties": {
335
+ "enable": {
336
+ "type": "boolean",
337
+ "default": false
338
+ },
339
+ "deploy_path": {
340
+ "type": "string",
341
+ "default": "/l"
342
+ },
343
+ "short_links": {
344
+ "type": "object",
345
+ "additionalProperties": true
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+ },
352
+ "required": [],
353
+ "additionalProperties": false
354
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portosaur/core",
3
- "version": "0.6.3",
3
+ "version": "0.9.1",
4
4
  "description": "The engine of portosaur that translates YAML configuration into Docusaurus structures.",
5
5
  "license": "GPL-3.0-only",
6
6
  "author": "soymadip",
@@ -10,8 +10,12 @@
10
10
  "url": "https://github.com/soymadip/portosaur"
11
11
  },
12
12
  "files": [
13
- "src"
13
+ "src",
14
+ "configSchema.json"
14
15
  ],
16
+ "scripts": {
17
+ "prepack": "bun ../cli/bin/porto.mjs schema"
18
+ },
15
19
  "type": "module",
16
20
  "exports": {
17
21
  ".": {
@@ -27,7 +31,7 @@
27
31
  },
28
32
  "types": "./src/index.d.ts",
29
33
  "dependencies": {
30
- "@portosaur/logger": "^0.6.3",
34
+ "@portosaur/logger": "^0.9.1",
31
35
  "favicons": "^7.2.0",
32
36
  "js-yaml": "^4.1.1",
33
37
  "rehype-katex": "7",
@@ -4,6 +4,7 @@ import { createRequire } from "module";
4
4
  import { getGitDate } from "../utils/system.mjs";
5
5
  import { porto } from "../app.mjs";
6
6
  import { resolveVars, getNestedValue } from "../utils/config.mjs";
7
+ import { getPortoDotDir } from "../utils/fs.mjs";
7
8
  import {
8
9
  resolveSiteUrl,
9
10
  resolveBasePath,
@@ -22,14 +23,17 @@ import rehypeKatex from "rehype-katex";
22
23
  export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
23
24
  const { portoPaths = {}, gitDate = null, env = process.env } = context;
24
25
 
26
+ const rawGet = (key, ...fallbacks) =>
27
+ getNestedValue(rawUserConfig, key, ...fallbacks);
28
+
25
29
  const portoVersion = porto.version ?? "0.0.0";
26
30
  const lastUpdated = gitDate ?? getGitDate(projectDir);
27
31
 
28
32
  const staticDir = path.resolve(projectDir, "static");
29
33
  const assetsDir = portoPaths.assets ?? "";
30
34
 
31
- const siteUrl = resolveSiteUrl(rawUserConfig.site?.url ?? "auto", env);
32
- const sitePath = resolveBasePath(rawUserConfig.site?.path ?? "auto", env);
35
+ const siteUrl = resolveSiteUrl(rawGet("site.url", "auto"), env);
36
+ const sitePath = resolveBasePath(rawGet("site.path", "auto"), env);
33
37
 
34
38
  const resolveAsset = createStaticAssetResolver(
35
39
  projectDir,
@@ -50,39 +54,44 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
50
54
  isProd: env.NODE_ENV === "production",
51
55
  isDev: env.NODE_ENV === "development",
52
56
  nodeEnv: env.NODE_ENV ?? "development",
53
- custom: rawUserConfig.custom ?? {},
57
+ custom: rawGet("custom", {}),
54
58
  });
55
59
 
56
60
  const get = (key, ...fallbacks) =>
57
61
  getNestedValue(userConfig, key, ...fallbacks);
58
62
 
59
- const siteName = get("site.title", "Your Name");
63
+ const defaultTheme =
64
+ get("theme.appearance.default_mode", "dark") === "light" ? "light" : "dark";
60
65
 
61
- // ------- Configuration Setup -------
66
+ const titleName = get("home_page.hero.title", "Your Name");
67
+ const siteName = get("site.title", titleName);
68
+ const siteFavicon = resolveAsset(
69
+ get("site.favicon", ""),
70
+ resolveAsset(get("home_page.hero.profile_pic", ""), "img/icon.png"),
71
+ );
72
+
73
+ const siteTagline = get(
74
+ "home_page.hero.desc",
75
+ "site.tagline",
76
+ "Short description about you, your passion, your goals etc.",
77
+ );
62
78
 
63
79
  // Collect static directories: local site static/, theme assets/, and portosaur dot-dir.
64
80
  const staticDirectories = [
65
81
  "static",
66
82
  assetsDir,
67
- path.join(projectDir, ".docusaurus", "portosaur"),
83
+ getPortoDotDir(projectDir),
68
84
  ].filter((dir) => dir && fs.existsSync(dir));
69
85
 
70
- const isDarkMode = get("theme.appearance.dark_mode", true);
71
- const disableSwitch = get("theme.appearance.disable_switch", false);
86
+ // ------- Configuration Setup -------
72
87
 
73
88
  return {
74
89
  projectName: siteName,
75
90
  title: siteName,
76
- tagline: get(
77
- "site.tagline",
78
- "Short description about you, your passion, your goals etc.",
79
- ),
91
+ tagline: siteTagline,
80
92
  url: siteUrl,
81
93
  baseUrl: sitePath,
82
- favicon: resolveAsset(
83
- get("site.favicon", ""),
84
- resolveAsset("favicon/favicon.ico", "img/icon.png"),
85
- ),
94
+ favicon: siteFavicon,
86
95
  organizationName: siteName,
87
96
  onBrokenAnchors: get("site.on_broken_anchors", "throw"),
88
97
  onBrokenLinks: get("site.on_broken_links", "throw"),
@@ -90,10 +99,20 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
90
99
 
91
100
  staticDirectories,
92
101
 
102
+ headTags: buildHeadTags([
103
+ ...(context.extraHeadTags || []),
104
+ ...get("site.head_tags", []),
105
+ ]),
106
+
93
107
  themeConfig: {
108
+ image: resolveAsset(get("site.social_card", "")) || undefined,
109
+ metadata: [
110
+ { name: "generator", content: `Portosaur v${porto.version}` },
111
+ { name: "theme-color", content: "var(--ifm-background-color)" },
112
+ ],
94
113
  colorMode: {
95
- defaultMode: isDarkMode ? "dark" : "light",
96
- disableSwitch,
114
+ defaultMode: defaultTheme,
115
+ disableSwitch: !get("theme.appearance.show_theme_switch", true),
97
116
  respectPrefersColorScheme: false,
98
117
  },
99
118
 
@@ -101,7 +120,7 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
101
120
  title: siteName,
102
121
  logo: {
103
122
  alt: `${siteName} logo`,
104
- src: resolveAsset(get("site.favicon", ""), "img/icon.png"),
123
+ src: siteFavicon,
105
124
  },
106
125
  hideOnScroll: get("theme.navigation.hide_navbar_on_scroll", true),
107
126
  items: [
@@ -161,14 +180,12 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
161
180
  ...(get("tasks.enable", false)
162
181
  ? [{ label: "Tasks", to: "/tasks" }]
163
182
  : []),
164
- ...(!get("theme.appearance.disable_branding", false)
183
+ ...(!get("theme.appearance.disable_project_link", false)
165
184
  ? [
166
185
  {
167
186
  label: `Portosaur v${portoVersion}`,
168
187
  className: "_nav-portosaur-version",
169
- href:
170
- porto?.homepage ||
171
- "https://github.com/soymadip/portosaur",
188
+ href: porto?.homepage ?? "#",
172
189
  },
173
190
  ]
174
191
  : []),
@@ -177,53 +194,45 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
177
194
  ],
178
195
  },
179
196
 
180
- footer: {
181
- style: "dark",
182
- copyright: get(
183
- "site.footer_text",
184
- `© ${new Date().getFullYear()} ${siteName}. Built with Portosaur.`,
185
- ),
197
+ docs: {
198
+ sidebar: {
199
+ hideable: get("theme.navigation.collapsable_sidebar", true),
200
+ },
186
201
  },
187
- },
188
202
 
189
- headTags: buildHeadTags([
190
- { meta: { name: "generator", content: `Portosaur v${porto.version}` } },
191
- { meta: { name: "theme-color", content: "var(--ifm-background-color)" } },
192
- ...(context.extraHeadTags || []),
193
- ...get("site.head_tags", []),
194
- ]),
203
+ tableOfContents: {
204
+ minHeadingLevel: 2,
205
+ maxHeadingLevel: 3,
206
+ },
207
+
208
+ markdown: {
209
+ mermaid: get("theme.markdown.mermaid", true),
210
+ emoji: get("theme.markdown.render_emoji_shortcodes", true),
211
+ on_broken_links: get("theme.markdown.on_broken_links", "throw"),
212
+ on_broken_images: get("theme.markdown.on_broken_images", "throw"),
213
+ },
214
+
215
+ ...(get("theme.footer.enable", true)
216
+ ? {
217
+ footer: {
218
+ copyright: get(
219
+ "theme.footer.message",
220
+ `© ${new Date().getFullYear()} ${titleName}.${
221
+ !get("theme.footer.disable_project_link", false)
222
+ ? ` | Built with <a href="${porto?.homepage ?? "#"}" target="_blank" rel="noopener noreferrer">Portosaur.</a>`
223
+ : ""
224
+ }`,
225
+ ),
226
+ },
227
+ }
228
+ : {}),
229
+ },
195
230
 
196
231
  // ------- Custom Fields -------
197
232
 
198
233
  customFields: {
199
234
  portoVersion,
200
235
 
201
- theme: {
202
- markdown: {
203
- mermaid: get("theme.markdown.mermaid", true),
204
- on_broken_links: get("theme.markdown.on_broken_links", "throw"),
205
- on_broken_images: get("theme.markdown.on_broken_images", "throw"),
206
- },
207
-
208
- navigation: {
209
- collapsable_sidebar: get(
210
- "theme.navigation.collapsable_sidebar",
211
- true,
212
- ),
213
-
214
- hide_navbar_on_scroll: get(
215
- "theme.navigation.hide_navbar_on_scroll",
216
- true,
217
- ),
218
- },
219
-
220
- appearance: {
221
- dark_mode: get("theme.appearance.dark_mode", true),
222
- disable_switch: get("theme.appearance.disable_switch", false),
223
- disable_branding: get("theme.appearance.disable_branding", false),
224
- },
225
- },
226
-
227
236
  heroSection: {
228
237
  profilePic: resolveAsset(
229
238
  get("home_page.hero.profile_pic", ""),
@@ -231,7 +240,7 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
231
240
  ),
232
241
 
233
242
  intro: get("home_page.hero.intro", "Hello there, I'm"),
234
- title: get("home_page.hero.title", "site.title", "Your Name"),
243
+ title: titleName,
235
244
  subtitle: get("home_page.hero.subtitle", "I am a"),
236
245
  profession: get("home_page.hero.profession", "Your Profession"),
237
246
  desc: get("home_page.hero.desc", "Welcome to my portfolio."),
@@ -290,6 +299,22 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
290
299
  subtitle: get("tasks.subtitle", "My current focus"),
291
300
  list: get("tasks.list", []),
292
301
  },
302
+
303
+ toolsConfig: {
304
+ linkShortener: {
305
+ enable: get("tools.link_shortener.enable", false),
306
+ deployPath: get("tools.link_shortener.deploy_path", "/l"),
307
+ shortLinks: get("tools.link_shortener.short_links", {}),
308
+ },
309
+ },
310
+
311
+ // site.robots_txt is consumed in build.mjs, but we pass it through here
312
+ // so the schema generator discovers it without hardcoding.
313
+ robotsTxt: {
314
+ enable: rawGet("site.robots_txt.enable", true),
315
+ rules: rawGet("site.robots_txt.rules", []),
316
+ customLines: rawGet("site.robots_txt.custom_lines", []),
317
+ },
293
318
  },
294
319
 
295
320
  // ------- Presets -------
@@ -301,6 +326,7 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
301
326
  docs: {
302
327
  routeBasePath: "notes",
303
328
  path: "notes",
329
+ breadcrumbs: get("theme.navigation.breadcrumbs", true),
304
330
  sidebarPath: path.resolve(
305
331
  portoPaths.theme ?? context.portoRoot ?? "",
306
332
  "config/sidebar.jsx",
@@ -313,6 +339,14 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
313
339
  showReadingTime: false,
314
340
  remarkPlugins: [remarkMath],
315
341
  rehypePlugins: [rehypeKatex],
342
+ feedOptions: {
343
+ type: get("site.rss.enable", true) ? "all" : null,
344
+ copyright: get(
345
+ "site.rss.copyright",
346
+ `Copyright © ${new Date().getFullYear()} ${siteName}.`,
347
+ ),
348
+ description: get("site.rss.desc", siteTagline),
349
+ },
316
350
  },
317
351
  theme: {
318
352
  customCss: path.resolve(
@@ -331,9 +365,10 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
331
365
  (() => {
332
366
  const require = createRequire(import.meta.url);
333
367
  return require.resolve("@easyops-cn/docusaurus-search-local", {
334
- paths: [portoPaths.theme ?? context.portoRoot ?? ""],
368
+ paths: [projectDir, portoPaths.theme ?? context.portoRoot ?? ""],
335
369
  });
336
370
  })(),
371
+
337
372
  {
338
373
  hashed: true,
339
374
  indexDocs: true,
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { getPortoDotDir } from "../utils/fs.mjs";
3
4
  import { favicons } from "favicons";
4
5
  import { downloadImage } from "../utils/imageDownloader.mjs";
5
6
  import { reshapeImage } from "../utils/imageProcessor.mjs";
@@ -121,7 +122,7 @@ export async function generateFavicons(siteDir, options = {}) {
121
122
  }
122
123
  }
123
124
 
124
- const cacheDir = path.join(siteDir, ".docusaurus", "portosaur", "cache");
125
+ const cacheDir = path.join(getPortoDotDir(siteDir), "cache");
125
126
  createDirectoryIfNotExists(cacheDir);
126
127
  const reshapedImagePath = path.join(cacheDir, "profile_pic_reshaped.png");
127
128
  const tempFiles = [];
package/src/index.d.ts CHANGED
@@ -61,3 +61,10 @@ export function getNestedValue(
61
61
  pathStr: string,
62
62
  ...fallbacks: string[]
63
63
  ): any;
64
+
65
+ /**
66
+ * Validates the raw user config against the generated JSON Schema.
67
+ * Returns dot-notation paths for any unknown keys found.
68
+ * Freeform blocks (custom, tools.link_shortener.short_links) are skipped.
69
+ */
70
+ export function validateUserConfig(rawConfig: Record<string, any>): string[];
package/src/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import yaml from "js-yaml";
4
+ import { validateUserConfig } from "./utils/validate.mjs";
4
5
 
5
- export { mirrorSync, loadPkg } from "./utils/fs.mjs";
6
+ export { mirrorSync, loadPkg, getPortoDotDir } from "./utils/fs.mjs";
6
7
 
7
8
  export {
8
9
  deepMerge,
@@ -11,6 +12,7 @@ export {
11
12
  useEnabled,
12
13
  openInBrowser,
13
14
  } from "./utils/system.mjs";
15
+ import pkg from "../package.json";
14
16
 
15
17
  export * from "./app.mjs";
16
18
 
@@ -28,11 +30,26 @@ export {
28
30
  } from "./utils/docusaurus.mjs";
29
31
 
30
32
  export { resolveVars, getNestedValue } from "./utils/config.mjs";
33
+ export { validateUserConfig } from "./utils/validate.mjs";
31
34
 
32
35
  export function loadUserConfig(projectDir) {
33
36
  const configPath = path.resolve(projectDir, "config.yml");
37
+
34
38
  if (!fs.existsSync(configPath)) {
35
39
  throw new Error(`No config.yml found at ${configPath}`);
36
40
  }
37
- return yaml.load(fs.readFileSync(configPath, "utf8"));
41
+
42
+ const rawConfig = yaml.load(fs.readFileSync(configPath, "utf8"));
43
+
44
+ // Validate for unknown keys and error early with a clear message.
45
+ const violations = validateUserConfig(rawConfig);
46
+
47
+ if (violations.length > 0) {
48
+ const list = violations.map((v) => ` - ${v}`).join("\n");
49
+ throw new Error(
50
+ `Unknown key(s) in config:\n${list}\n\nCheck the config reference: ${pkg.homepage}/guide/config`,
51
+ );
52
+ }
53
+
54
+ return rawConfig;
38
55
  }
package/src/utils/fs.mjs CHANGED
@@ -2,6 +2,15 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { text } from "../app.mjs";
4
4
 
5
+ /**
6
+ * Gets the standardized path to the hidden Portosaur data directory.
7
+ * @param {string} siteDir - The project root directory.
8
+ * @returns {string} The resolved path.
9
+ */
10
+ export function getPortoDotDir(siteDir) {
11
+ return path.join(siteDir, ".docusaurus", ".portosaur");
12
+ }
13
+
5
14
  /**
6
15
  * Loads a package.json file from a directory.
7
16
  * @param {string} dir - The directory to look in.
@@ -0,0 +1,103 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ // ------- Schema Loading -------
6
+
7
+ let _cachedSchema = null;
8
+
9
+ /**
10
+ * Loads and caches the generated configSchema.json from the package root.
11
+ * The schema is committed to the repo and shipped with @portosaur/core.
12
+ *
13
+ * @returns {object} The parsed JSON Schema object.
14
+ */
15
+ function loadSchema() {
16
+ if (_cachedSchema) {
17
+ return _cachedSchema;
18
+ }
19
+
20
+ const schemaPath = path.resolve(
21
+ fileURLToPath(new URL("../..", import.meta.url)),
22
+ "configSchema.json",
23
+ );
24
+
25
+ if (!fs.existsSync(schemaPath)) {
26
+ throw new Error(
27
+ `Config schema not found at ${schemaPath}. Run \`porto schema\` to regenerate it.`,
28
+ );
29
+ }
30
+
31
+ _cachedSchema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
32
+ return _cachedSchema;
33
+ }
34
+
35
+ // ------- Validation Walker -------
36
+
37
+ /**
38
+ * Recursively walks the user's raw config and collects dot-path strings for
39
+ * any key not present in the provided JSON Schema node.
40
+ *
41
+ * @param {object} configNode - Current slice of the user's raw config.
42
+ * @param {object} schemaNode - Corresponding JSON Schema node.
43
+ * @param {string} prefix - Dot-notation prefix accumulated so far.
44
+ * @param {string[]} violations - Accumulated list of unknown key paths.
45
+ */
46
+ function walk(configNode, schemaNode, prefix, violations) {
47
+ if (
48
+ !configNode ||
49
+ typeof configNode !== "object" ||
50
+ Array.isArray(configNode)
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ const schemaProperties = schemaNode?.properties ?? {};
56
+ const allowsAdditional = schemaNode?.additionalProperties !== false;
57
+
58
+ // Skip validation for freeform blocks (custom, short_links, etc.)
59
+ if (allowsAdditional) {
60
+ return;
61
+ }
62
+
63
+ for (const key of Object.keys(configNode)) {
64
+ const dotPath = prefix ? `${prefix}.${key}` : key;
65
+
66
+ if (!(key in schemaProperties)) {
67
+ violations.push(dotPath);
68
+ continue;
69
+ }
70
+
71
+ const childSchema = schemaProperties[key];
72
+
73
+ // Recurse into object nodes (skip arrays — no item schema)
74
+ if (childSchema?.type === "object") {
75
+ walk(configNode[key], childSchema, dotPath, violations);
76
+ }
77
+ }
78
+ }
79
+
80
+ // ------- Public API -------
81
+
82
+ /**
83
+ * Validates the raw user config object against the generated JSON Schema.
84
+ * Returns a list of dot-notation paths for any unknown keys found.
85
+ *
86
+ * Unknown keys inside freeform blocks (custom, tools.link_shortener.short_links)
87
+ * are intentionally ignored — those blocks allow arbitrary content.
88
+ *
89
+ * @param {object} rawConfig - The parsed config.yml object.
90
+ * @returns {string[]} Array of unknown key paths, empty if config is valid.
91
+ */
92
+ export function validateUserConfig(rawConfig) {
93
+ if (!rawConfig || typeof rawConfig !== "object") {
94
+ return [];
95
+ }
96
+
97
+ const schema = loadSchema();
98
+ const violations = [];
99
+
100
+ walk(rawConfig, schema, "", violations);
101
+
102
+ return violations;
103
+ }