@portosaur/core 0.8.0 → 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.8.0",
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.8.0",
34
+ "@portosaur/logger": "^0.9.1",
31
35
  "favicons": "^7.2.0",
32
36
  "js-yaml": "^4.1.1",
33
37
  "rehype-katex": "7",
@@ -23,14 +23,17 @@ import rehypeKatex from "rehype-katex";
23
23
  export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
24
24
  const { portoPaths = {}, gitDate = null, env = process.env } = context;
25
25
 
26
+ const rawGet = (key, ...fallbacks) =>
27
+ getNestedValue(rawUserConfig, key, ...fallbacks);
28
+
26
29
  const portoVersion = porto.version ?? "0.0.0";
27
30
  const lastUpdated = gitDate ?? getGitDate(projectDir);
28
31
 
29
32
  const staticDir = path.resolve(projectDir, "static");
30
33
  const assetsDir = portoPaths.assets ?? "";
31
34
 
32
- const siteUrl = resolveSiteUrl(rawUserConfig.site?.url ?? "auto", env);
33
- 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);
34
37
 
35
38
  const resolveAsset = createStaticAssetResolver(
36
39
  projectDir,
@@ -51,7 +54,7 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
51
54
  isProd: env.NODE_ENV === "production",
52
55
  isDev: env.NODE_ENV === "development",
53
56
  nodeEnv: env.NODE_ENV ?? "development",
54
- custom: rawUserConfig.custom ?? {},
57
+ custom: rawGet("custom", {}),
55
58
  });
56
59
 
57
60
  const get = (key, ...fallbacks) =>
@@ -296,6 +299,22 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
296
299
  subtitle: get("tasks.subtitle", "My current focus"),
297
300
  list: get("tasks.list", []),
298
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
+ },
299
318
  },
300
319
 
301
320
  // ------- Presets -------
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,6 +1,7 @@
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
6
  export { mirrorSync, loadPkg, getPortoDotDir } from "./utils/fs.mjs";
6
7
 
@@ -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
  }
@@ -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
+ }