@portosaur/core 0.8.0 → 0.9.2

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
+ "head_tags": {
31
+ "type": "array"
32
+ },
33
+ "on_broken_anchors": {
34
+ "type": "string",
35
+ "default": "throw"
36
+ },
37
+ "on_broken_links": {
38
+ "type": "string",
39
+ "default": "throw"
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.2",
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.2",
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) =>
@@ -80,6 +83,18 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
80
83
  getPortoDotDir(projectDir),
81
84
  ].filter((dir) => dir && fs.existsSync(dir));
82
85
 
86
+ // Process all head tags (from plugins and user config)
87
+ const allHeadTags = buildHeadTags([
88
+ ...(context.extraHeadTags || []),
89
+ ...get("site.head_tags", []),
90
+ ]);
91
+
92
+ // Separate regular head tags from meta tags
93
+ const regularHeadTags = allHeadTags.filter((t) => t.tagName !== "meta");
94
+ const customMetaTags = allHeadTags
95
+ .filter((t) => t.tagName === "meta")
96
+ .map((t) => t.attributes);
97
+
83
98
  // ------- Configuration Setup -------
84
99
 
85
100
  return {
@@ -96,16 +111,14 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
96
111
 
97
112
  staticDirectories,
98
113
 
99
- headTags: buildHeadTags([
100
- ...(context.extraHeadTags || []),
101
- ...get("site.head_tags", []),
102
- ]),
114
+ headTags: regularHeadTags,
103
115
 
104
116
  themeConfig: {
105
117
  image: resolveAsset(get("site.social_card", "")) || undefined,
106
118
  metadata: [
107
119
  { name: "generator", content: `Portosaur v${porto.version}` },
108
120
  { name: "theme-color", content: "var(--ifm-background-color)" },
121
+ ...customMetaTags,
109
122
  ],
110
123
  colorMode: {
111
124
  defaultMode: defaultTheme,
@@ -296,6 +309,22 @@ export function buildDocuConfig(rawUserConfig, projectDir, context = {}) {
296
309
  subtitle: get("tasks.subtitle", "My current focus"),
297
310
  list: get("tasks.list", []),
298
311
  },
312
+
313
+ toolsConfig: {
314
+ linkShortener: {
315
+ enable: get("tools.link_shortener.enable", false),
316
+ deployPath: get("tools.link_shortener.deploy_path", "/l"),
317
+ shortLinks: get("tools.link_shortener.short_links", {}),
318
+ },
319
+ },
320
+
321
+ // site.robots_txt is consumed in build.mjs, but we pass it through here
322
+ // so the schema generator discovers it without hardcoding.
323
+ robotsTxt: {
324
+ enable: rawGet("site.robots_txt.enable", true),
325
+ rules: rawGet("site.robots_txt.rules", []),
326
+ customLines: rawGet("site.robots_txt.custom_lines", []),
327
+ },
299
328
  },
300
329
 
301
330
  // ------- 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
+ }