@portosaur/core 0.1.0

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/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @portosaur/core
2
+
3
+ The core logic engine of the Portosaur ecosystem.
4
+
5
+ ## 🧩 Features
6
+
7
+ - **⚙️ Configuration Engine** — Translates YAML input into Docusaurus-compatible structures.
8
+ - **🔄 Variable Resolution** — Handles recursive template resolution and system overrides.
9
+ - **🛠️ Utility Suite** — File system, git metadata, and path resolution helpers.
10
+
11
+ ## API
12
+
13
+ - `buildDocuConfig` — Generates Docusaurus configuration objects.
14
+ - `resolveVars` — Resolves template tags within configuration structures.
15
+
16
+ ---
17
+
18
+ Built with 🦖 by [soymadip](https://github.com/soymadip)
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@portosaur/core",
3
+ "version": "0.1.0",
4
+ "description": "Portosaur Core: The engine that translates YAML configuration into Docusaurus structures.",
5
+ "license": "GPL-3.0-only",
6
+ "author": "soymadip",
7
+ "homepage": "https://soymadip.github.io/portosaur",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/soymadip/portosaur"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./src/index.d.ts",
19
+ "import": "./src/index.mjs",
20
+ "default": "./src/index.mjs"
21
+ },
22
+ "./constants": {
23
+ "types": "./src/constants.d.ts",
24
+ "import": "./src/constants.mjs",
25
+ "default": "./src/constants.mjs"
26
+ }
27
+ },
28
+ "types": "./src/index.d.ts",
29
+ "dependencies": {
30
+ "@portosaur/logger": "workspace:*",
31
+ "favicons": "^7.2.0",
32
+ "js-yaml": "^4.1.1",
33
+ "sharp": "^0.34.5"
34
+ }
35
+ }
package/src/app.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+
4
+ /**
5
+ * Portosaur application metadata and configuration
6
+ */
7
+ export const porto = (() => {
8
+ try {
9
+ const pkgPath = resolve(import.meta.dirname, "../../../package.json");
10
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
11
+
12
+ return {
13
+ name: pkg.name || "Portosaur",
14
+ version: pkg.version || "0.0.0",
15
+ description: pkg.description || "",
16
+ license: pkg.license || "",
17
+ homepage: pkg.homepage || "",
18
+ repository: pkg.repository?.url || "",
19
+ engines: pkg.engines || {},
20
+
21
+ // Derived/computed fields
22
+ engineName: `${pkg.name || "Portosaur"}`,
23
+ };
24
+ } catch (error) {
25
+ console.warn(
26
+ "Failed to read Portosaur metadata from package.json:",
27
+ error.message,
28
+ );
29
+
30
+ return {
31
+ name: "Portosaur",
32
+ version: "0.0.0",
33
+ description: "",
34
+ license: "",
35
+ homepage: "",
36
+ repository: "",
37
+ engines: {},
38
+ engineName: "Portosaur",
39
+ };
40
+ }
41
+ })();
42
+
43
+ /**
44
+ * Git operations and formats
45
+ */
46
+ export const git = {
47
+ dateFormat: 'git log -1 --format=%cd --date=format:"%B %d, %Y"',
48
+ };
49
+
50
+ /**
51
+ * Text file extensions for processing
52
+ */
53
+ export const text = {
54
+ extensions: new Set([
55
+ ".js",
56
+ ".ts",
57
+ ".tsx",
58
+ ".mjs",
59
+ ".json",
60
+ ".md",
61
+ ".mdx",
62
+ ".yml",
63
+ ".yaml",
64
+ ".css",
65
+ ".html",
66
+ ".txt",
67
+ ]),
68
+ };
69
+
70
+ /**
71
+ * System limits and constraints
72
+ */
73
+ export const limits = {
74
+ maxResolveDepth: 10,
75
+ };
@@ -0,0 +1,228 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getGitDate } from "../utils/system.mjs";
4
+ import { porto } from "../app.mjs";
5
+ import { resolveVars, getNestedValue } from "../utils/config.mjs";
6
+ import {
7
+ resolveSiteUrl,
8
+ resolveBasePath,
9
+ createStaticAssetResolver,
10
+ buildHeadTags,
11
+ } from "../utils/docusaurus.mjs";
12
+
13
+ // ------- Main Configuration Generator -------
14
+
15
+ /**
16
+ * Generates a Docusaurus configuration object from raw user config
17
+ */
18
+ export function generateDocusaurusConfig(
19
+ rawUserConfig,
20
+ projectDir,
21
+ context = {},
22
+ ) {
23
+ const {
24
+ portoPkg = {},
25
+ portoPaths = {},
26
+ gitDate = null,
27
+ env = process.env,
28
+ } = context;
29
+
30
+ const portoVersion = portoPkg.version ?? "0.0.0";
31
+ const lastUpdated = gitDate ?? getGitDate(projectDir);
32
+
33
+ const staticDir = path.resolve(projectDir, "static");
34
+ const assetsDir = portoPaths.assets ?? "";
35
+
36
+ const siteUrl = resolveSiteUrl(rawUserConfig.site?.url ?? "auto", env);
37
+ const sitePath = resolveBasePath(rawUserConfig.site?.path ?? "auto", env);
38
+
39
+ const resolveAsset = createStaticAssetResolver(
40
+ projectDir,
41
+ staticDir,
42
+ assetsDir,
43
+ );
44
+
45
+ const userConfig = resolveVars(rawUserConfig, rawUserConfig, {
46
+ siteRoot: projectDir,
47
+ portoRoot: context.portoRoot ?? "",
48
+ compileYear: new Date().getFullYear(),
49
+ compileDate: new Date().toLocaleDateString(),
50
+ portoVersion,
51
+ projectVersion: context.projectVersion ?? "0.0.0",
52
+ siteUrl,
53
+ baseUrl: sitePath,
54
+ lastUpdated,
55
+ isProd: env.NODE_ENV === "production",
56
+ isDev: env.NODE_ENV === "development",
57
+ nodeEnv: env.NODE_ENV ?? "development",
58
+ custom: rawUserConfig.custom ?? {},
59
+ });
60
+
61
+ const get = (key, ...fallbacks) =>
62
+ getNestedValue(userConfig, key, ...fallbacks);
63
+
64
+ const siteName = get("site.title", "Your Name");
65
+
66
+ // ------- Configuration Setup -------
67
+
68
+ return {
69
+ projectName: siteName,
70
+ title: siteName,
71
+ tagline: get(
72
+ "site.tagline",
73
+ "Short description about you, your passion, your goals etc.",
74
+ ),
75
+ url: siteUrl,
76
+ baseUrl: sitePath,
77
+ favicon: resolveAsset(
78
+ get("site.favicon", ""),
79
+ resolveAsset("favicon/favicon.ico", "img/icon.png"),
80
+ ),
81
+ organizationName: siteName,
82
+ onBrokenAnchors: get("site.on_broken_anchors", "throw"),
83
+ onBrokenLinks: get("site.on_broken_links", "throw"),
84
+ i18n: { defaultLocale: "en", locales: ["en"] },
85
+
86
+ headTags: buildHeadTags([
87
+ { meta: { name: "generator", content: `Portosaur v${porto.version}` } },
88
+ { meta: { name: "theme-color", content: "var(--ifm-background-color)" } },
89
+ ...(context.extraHeadTags || []),
90
+ ...get("site.head_tags", []),
91
+ ]),
92
+
93
+ // ------- Custom Fields -------
94
+
95
+ customFields: {
96
+ portoVersion,
97
+
98
+ theme: {
99
+ markdown: {
100
+ mermaid: get("theme.markdown.mermaid", true),
101
+ on_broken_links: get("theme.markdown.on_broken_links", "throw"),
102
+ on_broken_images: get("theme.markdown.on_broken_images", "throw"),
103
+ },
104
+
105
+ navigation: {
106
+ collapsable_sidebar: get(
107
+ "theme.navigation.collapsable_sidebar",
108
+ true,
109
+ ),
110
+
111
+ hide_navbar_on_scroll: get(
112
+ "theme.navigation.hide_navbar_on_scroll",
113
+ true,
114
+ ),
115
+ },
116
+
117
+ appearance: {
118
+ dark_mode: get("theme.appearance.dark_mode", true),
119
+ disable_switch: get("theme.appearance.disable_switch", false),
120
+ disable_branding: get("theme.appearance.disable_branding", false),
121
+ },
122
+ },
123
+
124
+ heroSection: {
125
+ profilePic: resolveAsset(
126
+ get("home_page.hero.profile_pic", ""),
127
+ "img/icon.png",
128
+ ),
129
+
130
+ intro: get("home_page.hero.intro", "Hello there, I'm"),
131
+ title: get("home_page.hero.title", "site.title", "Your Name"),
132
+ subtitle: get("home_page.hero.subtitle", "I am a"),
133
+ profession: get("home_page.hero.profession", "Your Profession"),
134
+ desc: get("home_page.hero.desc", "Welcome to my portfolio."),
135
+ },
136
+
137
+ aboutSection: {
138
+ enable: get("home_page.about.enable", true),
139
+ heading: get("home_page.about.heading", "About Me"),
140
+ image: resolveAsset(get("home_page.about.image", "")),
141
+ bio: get("home_page.about.bio", []),
142
+ skills: get("home_page.about.skills", []),
143
+ skillsHeading: get("home_page.about.skills_heading", "My Skills"),
144
+ resume: get("home_page.about.resume", ""),
145
+ },
146
+
147
+ projectShelf: {
148
+ enable: get("home_page.project_shelf.enable", true),
149
+ heading: get("home_page.project_shelf.heading", "My Projects"),
150
+ subheading: get(
151
+ "home_page.project_shelf.subheading",
152
+ "A collection of all my works",
153
+ ),
154
+ autoplay: get("home_page.project_shelf.autoplay", true),
155
+ projects: get("home_page.project_shelf.projects", []),
156
+ },
157
+
158
+ experienceSection: {
159
+ enable: get("home_page.experience.enable", false),
160
+ heading: get("home_page.experience.heading", "Experience"),
161
+ subheading: get(
162
+ "home_page.experience.subheading",
163
+ "My professional journey",
164
+ ),
165
+ list: get("home_page.experience.list", []),
166
+ },
167
+
168
+ socialSection: {
169
+ enable: get("home_page.social.enable", true),
170
+ heading: get("home_page.social.heading", "Get In Touch"),
171
+ subheading: get(
172
+ "home_page.social.subheading",
173
+ "Feel free to reach out",
174
+ ),
175
+ links: get("home_page.social.links", []),
176
+ },
177
+
178
+ tasks: {
179
+ enable: get("tasks.enable", false),
180
+ title: get("tasks.title", "Tasks"),
181
+ subtitle: get("tasks.subtitle", "My current focus"),
182
+ list: get("tasks.list", []),
183
+ },
184
+ },
185
+
186
+ // ------- Presets -------
187
+
188
+ presets: [
189
+ [
190
+ "@docusaurus/preset-classic",
191
+ {
192
+ docs: {
193
+ routeBasePath: "notes",
194
+ path: "notes",
195
+ sidebarPath: path.resolve(
196
+ portoPaths.theme ?? context.portoRoot ?? "",
197
+ "theme/config/sidebar.js",
198
+ ),
199
+ },
200
+ blog: { path: "blog", showReadingTime: false },
201
+ theme: {
202
+ customCss: path.resolve(
203
+ portoPaths.theme ?? context.portoRoot ?? "",
204
+ "theme/css/custom.css",
205
+ ),
206
+ },
207
+ },
208
+ ],
209
+ ],
210
+
211
+ // ------- Plugins -------
212
+
213
+ plugins: [
214
+ [
215
+ "@docusaurus/plugin-pwa",
216
+ {
217
+ debug: !env.NODE_ENV || env.NODE_ENV === "development",
218
+ offlineModeActivationStrategies: [
219
+ "always",
220
+ "deviceRetroactive",
221
+ "query",
222
+ "checkRedirect",
223
+ ],
224
+ },
225
+ ],
226
+ ],
227
+ };
228
+ }
@@ -0,0 +1,228 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { favicons } from "favicons";
4
+ import { downloadImage } from "../utils/imageDownloader.mjs";
5
+ import { reshapeImage } from "../utils/imageProcessor.mjs";
6
+ import { extractSvg } from "../utils/iconExtractor.mjs";
7
+ import { logger } from "@portosaur/logger";
8
+
9
+ // Helper Functions
10
+
11
+ function createDirectoryIfNotExists(dir) {
12
+ if (!fs.existsSync(dir)) {
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ return true;
15
+ }
16
+ return false;
17
+ }
18
+
19
+ function cleanupFile(filePath) {
20
+ if (filePath && fs.existsSync(filePath)) {
21
+ try {
22
+ fs.unlinkSync(filePath);
23
+ return true;
24
+ } catch (e) {}
25
+ }
26
+ return false;
27
+ }
28
+
29
+ function processManifest(manifestFile, outputDir, appVersion) {
30
+ try {
31
+ const manifest = JSON.parse(manifestFile.contents);
32
+ manifest.version = appVersion;
33
+ fs.writeFileSync(
34
+ path.join(outputDir, manifestFile.name),
35
+ JSON.stringify(manifest, null, 2),
36
+ );
37
+ return true;
38
+ } catch (err) {
39
+ logger.error(`Failed to process manifest: ${err.message}`);
40
+ fs.writeFileSync(
41
+ path.join(outputDir, manifestFile.name),
42
+ manifestFile.contents,
43
+ );
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Main Generation Function
49
+
50
+ export async function generateFavicons(siteDir, options = {}) {
51
+ logger.info("Generating favicons...");
52
+
53
+ // Setup options and paths
54
+ const profilePicUrl = options.imagePath || "img/icon.png";
55
+ const appVersion = options.appVersion || "1.0";
56
+ const circular = options.circular !== false;
57
+ const shape = options.shape || "circle";
58
+ const proxies = options.proxies || [];
59
+ const staticBaseDir = path.resolve(siteDir, "static");
60
+ const imgDir = path.join(staticBaseDir, "img", "svg");
61
+ const outputDir = path.join(
62
+ siteDir,
63
+ ".docusaurus/portosaur",
64
+ options.outputPath || "favicon",
65
+ );
66
+ const configHash = Buffer.from(
67
+ JSON.stringify({
68
+ profilePicUrl,
69
+ shape,
70
+ circular,
71
+ outputPath: options.outputPath,
72
+ }),
73
+ ).toString("base64");
74
+ const hashFilePath = path.join(outputDir, ".favicon.hash");
75
+
76
+ // Check cache hash and skip if unchanged
77
+ if (fs.existsSync(hashFilePath)) {
78
+ const existingHash = fs.readFileSync(hashFilePath, "utf-8");
79
+ if (existingHash === configHash) {
80
+ if (fs.existsSync(path.join(outputDir, "favicon.ico"))) {
81
+ logger.info("Favicons are up to date, skipping generation.");
82
+ return { success: true, html: [] };
83
+ }
84
+ }
85
+ }
86
+
87
+ const cacheDir = path.join(siteDir, ".docusaurus", "portosaur", "cache");
88
+ createDirectoryIfNotExists(cacheDir);
89
+ const reshapedImagePath = path.join(cacheDir, "profile_pic_reshaped.png");
90
+ const tempFiles = [];
91
+
92
+ try {
93
+ // Setup theme colors and configuration
94
+ const primaryColor = options.themeColor || "#3578e5";
95
+ const bgColor = options.backgroundColor || "#ffffff";
96
+ const iconColor = { color: primaryColor };
97
+ const iconsToGenerate = ["note", "blog"];
98
+ for (const icon of iconsToGenerate) {
99
+ try {
100
+ await extractSvg(icon, imgDir, iconColor);
101
+ } catch (e) {}
102
+ }
103
+
104
+ // Build favicon configuration
105
+ const configuration = {
106
+ path: `/${options.outputPath || "favicon"}/`,
107
+ appName: options.siteTitle || "Portfolio",
108
+ appDescription: options.siteTagline || "Portfolio",
109
+ background: bgColor,
110
+ theme_color: primaryColor,
111
+ appleStatusBarStyle: "black-translucent",
112
+ display: "standalone",
113
+ scope: "/",
114
+ start_url: "/",
115
+ version: appVersion,
116
+ orientation: "natural",
117
+ logging: false,
118
+ loadManifestWithCredentials: true,
119
+ manifestMaskable: true,
120
+ icons: {
121
+ android: {
122
+ offset: 0,
123
+ background: false,
124
+ mask: true,
125
+ overlayGlow: false,
126
+ androidPlayStore: true,
127
+ },
128
+ favicons: true,
129
+ appleIcon: true,
130
+ appleStartup: false,
131
+ windows: false,
132
+ yandex: false,
133
+ },
134
+ };
135
+
136
+ // Resolve image source (remote or local)
137
+ let downloadedRes;
138
+ const isRemote = /^https?:\/\//.test(profilePicUrl);
139
+ if (isRemote) {
140
+ downloadedRes = await downloadImage(
141
+ profilePicUrl,
142
+ cacheDir,
143
+ "profile_pic_src.png",
144
+ { proxies, cacheDir: path.join(cacheDir, "downloads") },
145
+ );
146
+ tempFiles.push(downloadedRes);
147
+ } else {
148
+ let localPath = null;
149
+ const staticDirs = options.staticDirs || ["static"];
150
+ for (const sDir of staticDirs) {
151
+ const fullPath = path.resolve(siteDir, sDir, profilePicUrl);
152
+ if (fs.existsSync(fullPath)) {
153
+ localPath = fullPath;
154
+ break;
155
+ }
156
+ }
157
+ if (!localPath && options.portoAssetsDir) {
158
+ const portoPath = path.resolve(options.portoAssetsDir, profilePicUrl);
159
+ if (fs.existsSync(portoPath)) {
160
+ localPath = portoPath;
161
+ }
162
+ }
163
+ if (!localPath) {
164
+ throw new Error(`Local profile picture not found: ${profilePicUrl}`);
165
+ }
166
+ downloadedRes = localPath;
167
+ }
168
+
169
+ // Process image shape and dimensions
170
+ let finalImagePath = downloadedRes;
171
+ if (circular) {
172
+ finalImagePath = await reshapeImage(
173
+ downloadedRes,
174
+ reshapedImagePath,
175
+ shape,
176
+ );
177
+ if (finalImagePath !== downloadedRes) {
178
+ tempFiles.push(finalImagePath);
179
+ }
180
+ }
181
+
182
+ // Generate favicon assets using favicons library
183
+ createDirectoryIfNotExists(outputDir);
184
+ logger.info(`Generating favicon assets from ${finalImagePath}`);
185
+ const response = await favicons(finalImagePath, configuration);
186
+
187
+ // Process generated images and files
188
+ let imageCount = 0,
189
+ fileCount = 0;
190
+ if (Array.isArray(response.images)) {
191
+ for (const image of response.images) {
192
+ fs.writeFileSync(path.join(outputDir, image.name), image.contents);
193
+ imageCount++;
194
+ }
195
+ }
196
+ if (Array.isArray(response.files)) {
197
+ for (const file of response.files) {
198
+ if (file.name.includes("manifest")) {
199
+ processManifest(file, outputDir, appVersion);
200
+ } else {
201
+ fs.writeFileSync(path.join(outputDir, file.name), file.contents);
202
+ }
203
+ fileCount++;
204
+ }
205
+ }
206
+
207
+ // Update cache and cleanup temporary files
208
+ logger.success(
209
+ `Generated ${imageCount} favicon images and ${fileCount} support files`,
210
+ );
211
+ fs.writeFileSync(hashFilePath, configHash, "utf-8");
212
+ const htmlFilePath = path.join(outputDir, ".favicon.html");
213
+ fs.writeFileSync(htmlFilePath, JSON.stringify(response.html), "utf-8");
214
+ tempFiles.forEach(cleanupFile);
215
+ return { success: true, html: response.html || [] };
216
+ } catch (error) {
217
+ logger.warn(`Favicon generation skipped: ${error.message}`);
218
+ tempFiles.forEach(cleanupFile);
219
+ const htmlFilePath = path.join(outputDir, ".favicon.html");
220
+ if (fs.existsSync(htmlFilePath)) {
221
+ try {
222
+ const cachedHtml = JSON.parse(fs.readFileSync(htmlFilePath, "utf-8"));
223
+ return { success: false, html: cachedHtml };
224
+ } catch (e) {}
225
+ }
226
+ return { success: false, html: [] };
227
+ }
228
+ }
@@ -0,0 +1,42 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { logger } from "@portosaur/logger";
4
+ export function generateRobotsTxt(siteDir, options = {}) {
5
+ if (options.enable === false) {
6
+ return;
7
+ }
8
+ logger.info("Generating robots.txt...");
9
+ const staticDir = path.resolve(siteDir, "static");
10
+ const robotsPath = path.join(staticDir, "robots.txt");
11
+ let content = `User-agent: *
12
+ `;
13
+ if (options.rules) {
14
+ for (const rule of options.rules) {
15
+ if (rule.allow) {
16
+ for (const p of [].concat(rule.allow)) {
17
+ content += `Allow: ${p}
18
+ `;
19
+ }
20
+ }
21
+ if (rule.disallow) {
22
+ for (const p of [].concat(rule.disallow)) {
23
+ content += `Disallow: ${p}
24
+ `;
25
+ }
26
+ }
27
+ }
28
+ }
29
+ if (options.customLines) {
30
+ for (const line of options.customLines) {
31
+ content += `${line}
32
+ `;
33
+ }
34
+ }
35
+ if (options.siteUrl) {
36
+ content += `Sitemap: ${options.siteUrl}${options.baseUrl || "/"}sitemap.xml
37
+ `;
38
+ }
39
+ fs.mkdirSync(staticDir, { recursive: true });
40
+ fs.writeFileSync(robotsPath, content);
41
+ logger.success(`Generated robots.txt at ${robotsPath}`);
42
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export { colors, logger } from "@portosaur/logger";
2
+
3
+ export interface PortoPkg {
4
+ version?: string;
5
+ [key: string]: any;
6
+ }
7
+
8
+ export interface PortoPaths {
9
+ assets?: string;
10
+ [key: string]: any;
11
+ }
12
+
13
+ export interface DocusaurusContext {
14
+ portoPkg?: PortoPkg;
15
+ portoPaths?: PortoPaths;
16
+ gitDate?: string;
17
+ env?: Record<string, string | undefined>;
18
+ }
19
+
20
+ /**
21
+ * Loads and parses the user's config.yml file.
22
+ */
23
+ export function loadUserConfig(projectDir: string): any;
24
+
25
+ export { mirrorSync, loadPkg } from "./utils/fs.mjs";
26
+ export {
27
+ deepMerge,
28
+ getGitDate,
29
+ hasCommand,
30
+ useEnabled,
31
+ openInBrowser,
32
+ } from "./utils/system.mjs";
33
+ export { porto, git, text, limits } from "./app.mjs";
34
+ export { generateFavicons } from "./generators/generateFavicons.mjs";
35
+ export { generateRobotsTxt } from "./generators/generateRobots.mjs";
36
+ export {
37
+ generateDocusaurusConfig,
38
+ buildDocuConfig,
39
+ resolveSiteUrl,
40
+ resolveBasePath,
41
+ createStaticAssetResolver,
42
+ buildHeadTags,
43
+ } from "./generators/docusaurusConfig.mjs";
44
+
45
+ /**
46
+ * Resolves template variables like {{site.name}} or {{env.VAR}} within a string or object.
47
+ */
48
+ export function resolveVars<T>(
49
+ obj: T,
50
+ userConfig: any,
51
+ systemVars?: Record<string, any>,
52
+ pathStack?: Set<string>,
53
+ depth?: number,
54
+ ): T;
55
+
56
+ /**
57
+ * Gets a nested value from an object using a dot-notated string path.
58
+ */
59
+ export function getNestedValue(
60
+ obj: any,
61
+ pathStr: string,
62
+ ...fallbacks: string[]
63
+ ): any;
package/src/index.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import yaml from "js-yaml";
4
+
5
+ export { mirrorSync, loadPkg } from "./utils/fs.mjs";
6
+
7
+ export {
8
+ deepMerge,
9
+ getGitDate,
10
+ hasCommand,
11
+ useEnabled,
12
+ openInBrowser,
13
+ } from "./utils/system.mjs";
14
+
15
+ export * from "./app.mjs";
16
+
17
+ export { generateFavicons } from "./generators/generateFavicons.mjs";
18
+ export { generateRobotsTxt } from "./generators/generateRobots.mjs";
19
+ export { generateDocusaurusConfig } from "./generators/docusaurusConfig.mjs";
20
+
21
+ export {
22
+ resolveSiteUrl,
23
+ resolveBasePath,
24
+ createStaticAssetResolver,
25
+ buildHeadTags,
26
+ } from "./utils/docusaurus.mjs";
27
+
28
+ export { resolveVars, getNestedValue } from "./utils/config.mjs";
29
+
30
+ export function loadUserConfig(projectDir) {
31
+ const configPath = path.resolve(projectDir, "config.yml");
32
+ if (!fs.existsSync(configPath)) {
33
+ throw new Error(`No config.yml found at ${configPath}`);
34
+ }
35
+ return yaml.load(fs.readFileSync(configPath, "utf8"));
36
+ }
@@ -0,0 +1,138 @@
1
+ import { limits } from "../app.mjs";
2
+
3
+ /**
4
+ * Gets a nested value from an object using a dot-notated string path.
5
+ * Falls back to alternative paths or fallback values if not found.
6
+ */
7
+ export function getNestedValue(obj, pathStr, ...fallbacks) {
8
+ // ------- Traverse nested path ----------
9
+ const parts = pathStr.split(".");
10
+ let current = obj;
11
+
12
+ for (const part of parts) {
13
+ if (current == null || typeof current !== "object") {
14
+ current = undefined;
15
+ break;
16
+ }
17
+ current = current[part];
18
+ }
19
+
20
+ // Return if value found at requested path
21
+ if (current !== undefined) return current;
22
+
23
+ // ------- Try fallback paths ----------
24
+ for (const fallback of fallbacks) {
25
+ if (fallback.includes(".")) {
26
+ const val = getNestedValue(obj, fallback);
27
+
28
+ if (val !== undefined) {
29
+ return val;
30
+ }
31
+ } else if (obj[fallback] !== undefined) {
32
+ return obj[fallback];
33
+ }
34
+ }
35
+
36
+ return;
37
+ }
38
+
39
+ /**
40
+ * Resolves template variables like {{key}} recursively within objects, arrays, and strings.
41
+ * Supports environment variables ({{env.VAR}}) and escaped tags (\\{{...}}).
42
+ */
43
+ export function resolveVars(
44
+ obj,
45
+ userConfig,
46
+ systemVars = {},
47
+ pathStack = new Set(),
48
+ depth = 0,
49
+ ) {
50
+ // Check recursion depth limit
51
+ if (depth > limits.maxResolveDepth) {
52
+ return obj;
53
+ }
54
+
55
+ // ------- Handle String Values --------
56
+ if (typeof obj === "string") {
57
+ // Try exact match first (single template variable)
58
+ const exactMatch = obj.match(/^\{\{([^}]+)\}\}$/);
59
+
60
+ if (exactMatch) {
61
+ const refPath = exactMatch[1];
62
+
63
+ // Check system variables
64
+ if (systemVars[refPath] !== undefined) {
65
+ return systemVars[refPath];
66
+ }
67
+
68
+ // Check environment variables
69
+ if (refPath.startsWith("env.")) {
70
+ return process.env[refPath.slice(4)] ?? "";
71
+ }
72
+
73
+ // Resolve from user config
74
+ const value = getNestedValue(userConfig, refPath);
75
+ if (value !== undefined && value !== obj) {
76
+ return resolveVars(value, userConfig, systemVars, pathStack, depth + 1);
77
+ }
78
+ }
79
+
80
+ // Handle partial template variables with regex replacement
81
+ return obj.replace(/(\\)?\{\{([^}]+)\}\}/g, (match, escape, refPath) => {
82
+ // Handle escaped tags
83
+ if (escape === "\\") {
84
+ return `{{${refPath}}}`;
85
+ }
86
+
87
+ // Check system variables
88
+ if (systemVars[refPath] !== undefined) {
89
+ return String(systemVars[refPath]);
90
+ }
91
+
92
+ // Check environment variables
93
+ if (refPath.startsWith("env.")) {
94
+ return process.env[refPath.slice(4)] ?? "";
95
+ }
96
+
97
+ // Prevent circular references
98
+ if (pathStack.has(refPath)) {
99
+ return match;
100
+ }
101
+
102
+ // Resolve from user config
103
+ const value = getNestedValue(userConfig, refPath);
104
+ if (value === undefined) {
105
+ return match;
106
+ }
107
+
108
+ // Handle nested resolution
109
+ const newStack = new Set(pathStack);
110
+ newStack.add(refPath);
111
+ if (typeof value === "string" && value.includes("{{")) {
112
+ return resolveVars(value, userConfig, systemVars, newStack, depth + 1);
113
+ }
114
+
115
+ return String(value);
116
+ });
117
+ }
118
+
119
+ // ------- Handle Arrays ------------
120
+ if (Array.isArray(obj)) {
121
+ return obj.map((item) =>
122
+ resolveVars(item, userConfig, systemVars, pathStack, depth),
123
+ );
124
+ }
125
+
126
+ // ------- Handle Objects ------------
127
+ if (obj && typeof obj === "object") {
128
+ return Object.fromEntries(
129
+ Object.entries(obj).map(([key, value]) => [
130
+ key,
131
+ resolveVars(value, userConfig, systemVars, pathStack, depth),
132
+ ]),
133
+ );
134
+ }
135
+
136
+ // Return primitive values as-is
137
+ return obj;
138
+ }
@@ -0,0 +1,56 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ /**
5
+ * Resolves site URL based on config or environment
6
+ */
7
+ export function resolveSiteUrl(configValue, env = process.env) {
8
+ if (configValue === "auto") {
9
+ if (env.CI_PAGES_URL) return new URL(env.CI_PAGES_URL).origin;
10
+ if (env.GITHUB_ACTIONS === "true")
11
+ return `https://${env.GITHUB_REPOSITORY_OWNER}.github.io`;
12
+ return "http://localhost";
13
+ }
14
+ return configValue;
15
+ }
16
+
17
+ /**
18
+ * Resolves base path based on config or environment
19
+ */
20
+ export function resolveBasePath(configValue, env = process.env) {
21
+ if (configValue === "auto") {
22
+ if (env.CI_PAGES_URL) return new URL(env.CI_PAGES_URL).pathname;
23
+ if (env.GITHUB_ACTIONS === "true") {
24
+ const repo = env.GITHUB_REPOSITORY ?? "";
25
+ const [, name] = repo.split("/");
26
+ return `/${name}/`;
27
+ }
28
+ return "/";
29
+ }
30
+ return configValue;
31
+ }
32
+
33
+ /**
34
+ * Creates a function to resolve static asset paths
35
+ */
36
+ export function createStaticAssetResolver(_projectDir, staticDir, assetsDir) {
37
+ return function (primaryPath, fallbackPath = "") {
38
+ if (!primaryPath) return fallbackPath;
39
+ if (/^https?:\/\//.test(primaryPath)) return primaryPath;
40
+ if (fs.existsSync(path.resolve(staticDir, primaryPath))) return primaryPath;
41
+ if (assetsDir && fs.existsSync(path.resolve(assetsDir, primaryPath)))
42
+ return primaryPath;
43
+ return fallbackPath || primaryPath;
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Transforms tag objects into Docusaurus head tag format
49
+ */
50
+ export function buildHeadTags(tags = []) {
51
+ return tags.map((tag) => {
52
+ if (tag.tagName && tag.attributes) return tag;
53
+ const [tagName, attributes] = Object.entries(tag)[0];
54
+ return { tagName, attributes };
55
+ });
56
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { text } from "../app.mjs";
4
+
5
+ /**
6
+ * Loads a package.json file from a directory.
7
+ * @param {string} dir - The directory to look in.
8
+ * @returns {Object} The parsed package.json or an empty object if not found or invalid.
9
+ */
10
+ export function loadPkg(dir) {
11
+ try {
12
+ const pkgPath = path.resolve(dir, "package.json");
13
+
14
+ if (!fs.existsSync(pkgPath)) {
15
+ return {};
16
+ }
17
+
18
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")) || {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Recursively copies a directory while performing variable replacements in text files.
26
+ * @param {string} src - Source directory path.
27
+ * @param {string} dest - Destination directory path.
28
+ * @param {Record<string, string>} [replacements={}] - Map of {{key}} to replacement values.
29
+ * @param {string[]} [ignores=[]] - List of file names or relative paths to skip.
30
+ * @param {string} [_baseSrc=src] - Internal tracking of the root source path.
31
+ */
32
+ export function mirrorSync(
33
+ src,
34
+ dest,
35
+ replacements = {},
36
+ ignores = [],
37
+ _baseSrc = src,
38
+ ) {
39
+ const ignoreSet = new Set(ignores);
40
+ if (!fs.existsSync(dest)) {
41
+ fs.mkdirSync(dest, { recursive: true });
42
+ }
43
+
44
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
45
+ const srcPath = path.join(src, entry.name);
46
+
47
+ // Rename gitignore to .gitignore on copy
48
+ const targetName = entry.name === "gitignore" ? ".gitignore" : entry.name;
49
+ const destPath = path.join(dest, targetName);
50
+ const relativePath = path.relative(_baseSrc, srcPath);
51
+
52
+ if (ignoreSet.has(entry.name) || ignoreSet.has(relativePath)) {
53
+ continue;
54
+ }
55
+
56
+ if (entry.isDirectory()) {
57
+ mirrorSync(srcPath, destPath, replacements, ignores, _baseSrc);
58
+ } else {
59
+ const ext = path.extname(entry.name).toLowerCase();
60
+
61
+ if (Object.keys(replacements).length > 0 && text.extensions.has(ext)) {
62
+ let content = fs.readFileSync(srcPath, "utf8");
63
+
64
+ for (const [key, value] of Object.entries(replacements)) {
65
+ content = content.replace(
66
+ new RegExp(`\\{\\{${key}\\}\\}`, "g"),
67
+ value,
68
+ );
69
+ }
70
+
71
+ fs.writeFileSync(destPath, content);
72
+ } else {
73
+ fs.copyFileSync(srcPath, destPath);
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,35 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { logger } from "@portosaur/logger";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ /**
9
+ * Extracts specific SVGs from the internal assets and saves them to the project.
10
+ */
11
+ export async function extractSvg(iconName, destDir, options = {}) {
12
+ const srcPath = path.resolve(
13
+ __dirname,
14
+ `../../assets/img/svg/icon-${iconName}.svg`,
15
+ );
16
+
17
+ const destPath = path.join(destDir, `icon-${iconName}.svg`);
18
+
19
+ if (!fs.existsSync(srcPath)) {
20
+ logger.warn(`Source icon not found: ${srcPath}`);
21
+ return false;
22
+ }
23
+
24
+ fs.mkdirSync(destDir, { recursive: true });
25
+
26
+ let content = fs.readFileSync(srcPath, "utf8");
27
+
28
+ if (options.color) {
29
+ content = content.replace(/fill="[^"]*"/g, `fill="${options.color}"`);
30
+ }
31
+ fs.writeFileSync(destPath, content);
32
+
33
+ logger.info(`Generated SVG icon: ${destPath}`);
34
+ return destPath;
35
+ }
@@ -0,0 +1,154 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import https from "https";
4
+ import http from "http";
5
+ import crypto from "crypto";
6
+ import { logger } from "@portosaur/logger";
7
+
8
+ // Cache & Utilities
9
+ function getCacheKey(url) {
10
+ return crypto.createHash("md5").update(url).digest("hex");
11
+ }
12
+
13
+ // Main Download Function
14
+ export async function downloadImage(url, destDir, fileName, options = {}) {
15
+ const {
16
+ proxies = [],
17
+ redirectCount = 0,
18
+ cacheDir,
19
+ cacheTTL = 43200000,
20
+ } = options;
21
+
22
+ if (redirectCount > 5) {
23
+ throw new Error("Too many redirects");
24
+ }
25
+
26
+ // Create destination directory and resolve paths
27
+ const destPath = path.join(destDir, fileName);
28
+
29
+ fs.mkdirSync(destDir, { recursive: true });
30
+
31
+ // Check cache first
32
+ const forceDownload = process.env.PORTO_FORCE_DOWNLOAD === "1";
33
+
34
+ if (cacheDir && !forceDownload) {
35
+ const cacheKey = getCacheKey(url);
36
+ const cachedPath = path.join(cacheDir, `${cacheKey}-${fileName}`);
37
+
38
+ if (fs.existsSync(cachedPath)) {
39
+ const stats = fs.statSync(cachedPath);
40
+ const age = Date.now() - stats.mtimeMs;
41
+
42
+ if (age < cacheTTL) {
43
+ logger.info(`Using cached image (${Math.round(age / 60000)}m old)`);
44
+ fs.copyFileSync(cachedPath, destPath);
45
+ return destPath;
46
+ }
47
+ }
48
+ }
49
+
50
+ // Download the image with HTTP protocol
51
+ return new Promise((resolve, reject) => {
52
+ const protocol = url.startsWith("https") ? https : http;
53
+
54
+ protocol
55
+ .get(url, (response) => {
56
+ // Handle redirects
57
+ if (
58
+ response.statusCode &&
59
+ [301, 302, 303, 307, 308].includes(response.statusCode) &&
60
+ response.headers.location
61
+ ) {
62
+ const redirectUrl = new URL(
63
+ response.headers.location,
64
+ url,
65
+ ).toString();
66
+
67
+ logger.info(
68
+ `Following redirect (${response.statusCode}) to: ${redirectUrl}`,
69
+ );
70
+
71
+ resolve(
72
+ downloadImage(redirectUrl, destDir, fileName, {
73
+ ...options,
74
+ redirectCount: redirectCount + 1,
75
+ }),
76
+ );
77
+
78
+ return;
79
+ }
80
+
81
+ // Validate HTTP status
82
+ if (response.statusCode !== 200) {
83
+ tryProxyFallback(new Error(`HTTP ${response.statusCode}`));
84
+ return;
85
+ }
86
+
87
+ // Validate content type
88
+ const contentType = response.headers["content-type"] || "";
89
+
90
+ if (!contentType.startsWith("image/") && !url.includes("raw=true")) {
91
+ logger.warn(`Expected image but got ${contentType} from ${url}`);
92
+ tryProxyFallback(new Error(`Invalid content type: ${contentType}`));
93
+ return;
94
+ }
95
+
96
+ // Write file to disk
97
+ const file = fs.createWriteStream(destPath);
98
+ response.pipe(file);
99
+
100
+ file.on("finish", () => {
101
+ file.close((err) => {
102
+ if (err) {
103
+ reject(err);
104
+ return;
105
+ }
106
+
107
+ // Cache the downloaded image
108
+ if (cacheDir) {
109
+ try {
110
+ const cacheKey = getCacheKey(url);
111
+ const cachedPath = path.join(
112
+ cacheDir,
113
+ `${cacheKey}-${fileName}`,
114
+ );
115
+ fs.mkdirSync(cacheDir, { recursive: true });
116
+ fs.copyFileSync(destPath, cachedPath);
117
+ } catch {}
118
+ }
119
+
120
+ resolve(destPath);
121
+ });
122
+ });
123
+
124
+ file.on("error", (err) => {
125
+ fs.unlink(destPath, () => reject(err));
126
+ });
127
+ })
128
+ .on("error", (err) => {
129
+ tryProxyFallback(err);
130
+ });
131
+
132
+ // Handle proxy fallback when download fails
133
+ function tryProxyFallback(originalError) {
134
+ if (proxies.length > 0) {
135
+ const nextProxy = proxies[0];
136
+ const remainingProxies = proxies.slice(1);
137
+ const proxyUrl = `${nextProxy}${encodeURIComponent(url)}`;
138
+
139
+ logger.info(
140
+ `Download failed or invalid content. Retrying via proxy: ${nextProxy}`,
141
+ );
142
+
143
+ resolve(
144
+ downloadImage(proxyUrl, destDir, fileName, {
145
+ ...options,
146
+ proxies: remainingProxies,
147
+ }),
148
+ );
149
+ } else {
150
+ reject(originalError || new Error(`Failed to download image: ${url}`));
151
+ }
152
+ }
153
+ });
154
+ }
@@ -0,0 +1,54 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import sharp from "sharp";
4
+ import { logger } from "@portosaur/logger";
5
+
6
+ // Image Processing Pipeline
7
+ export async function reshapeImage(inputPath, outputPath, shape = "circle") {
8
+ try {
9
+ // Validate input file
10
+ if (!fs.existsSync(inputPath)) {
11
+ throw new Error(`Input file not found: ${inputPath}`);
12
+ }
13
+
14
+ // Create output directory
15
+ const outputDir = path.dirname(outputPath);
16
+ if (!fs.existsSync(outputDir)) {
17
+ fs.mkdirSync(outputDir, { recursive: true });
18
+ }
19
+
20
+ // Load and analyze image
21
+ const image = sharp(inputPath);
22
+ const metadata = await image.metadata();
23
+
24
+ if (!metadata.width || !metadata.height) {
25
+ throw new Error("Could not extract image metadata (width/height)");
26
+ }
27
+
28
+ // Extract and crop to square
29
+ const size = Math.min(metadata.width, metadata.height);
30
+
31
+ let pipeline = image.extract({
32
+ left: Math.floor((metadata.width - size) / 2),
33
+ top: Math.floor((metadata.height - size) / 2),
34
+ width: size,
35
+ height: size,
36
+ });
37
+
38
+ if (shape === "circle") {
39
+ const radius = size / 2;
40
+ const circleSvg = Buffer.from(
41
+ `<svg><circle cx="${radius}" cy="${radius}" r="${radius}" /></svg>`,
42
+ );
43
+
44
+ pipeline = pipeline.composite([{ input: circleSvg, blend: "dest-in" }]);
45
+ }
46
+
47
+ // Write output file
48
+ await pipeline.png().toFile(outputPath);
49
+ return outputPath;
50
+ } catch (err) {
51
+ logger.error(`Image processing failed: ${err.message}`);
52
+ throw err;
53
+ }
54
+ }
@@ -0,0 +1,119 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { git } from "../app.mjs";
3
+
4
+ /**
5
+ * Performs a deep merge of two objects.
6
+ * @param {Object} target - The target object.
7
+ * @param {Object} source - The source object.
8
+ * @returns {Object} The merged object.
9
+ */
10
+ export function deepMerge(target, source) {
11
+ const result = { ...target };
12
+
13
+ for (const key of Object.keys(source)) {
14
+ if (
15
+ source[key] &&
16
+ typeof source[key] === "object" &&
17
+ !Array.isArray(source[key]) &&
18
+ target[key] &&
19
+ typeof target[key] === "object" &&
20
+ !Array.isArray(target[key])
21
+ ) {
22
+ result[key] = deepMerge(target[key], source[key]);
23
+ } else if (source[key] !== undefined) {
24
+ result[key] = source[key];
25
+ }
26
+ }
27
+
28
+ return result;
29
+ }
30
+
31
+ /**
32
+ * Gets the last modification date of a git project.
33
+ * @param {string} siteDir - The project directory.
34
+ * @returns {string} Formatted date string.
35
+ */
36
+ export function getGitDate(siteDir) {
37
+ try {
38
+ const result = execSync(git.dateFormat, {
39
+ cwd: siteDir,
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ });
43
+
44
+ return result.trim();
45
+ } catch {
46
+ return new Date().toLocaleDateString("en-US", {
47
+ year: "numeric",
48
+ month: "long",
49
+ day: "numeric",
50
+ });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Checks if a command exists in the system PATH.
56
+ * @param {string} command - The command to check.
57
+ * @returns {boolean} True if the command is available.
58
+ */
59
+ export function hasCommand(command) {
60
+ const cmd =
61
+ process.platform === "win32" ? `where ${command}` : `which ${command}`;
62
+
63
+ try {
64
+ execSync(cmd, { stdio: "ignore" });
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Filters a list of items, only including those that are enabled.
73
+ * Supports both raw values and { enable: boolean, value: any } objects.
74
+ * @param {Array} items - The items to filter.
75
+ * @returns {Array} The enabled values.
76
+ */
77
+ export function useEnabled(items) {
78
+ if (!Array.isArray(items)) {
79
+ return [];
80
+ }
81
+
82
+ return items.flatMap((item) => {
83
+ if (
84
+ item &&
85
+ typeof item === "object" &&
86
+ "enable" in item &&
87
+ "value" in item
88
+ ) {
89
+ return item.enable === true ? [item.value] : [];
90
+ }
91
+ return [item];
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Opens a URL in the user's default browser.
97
+ * @param {string} url - The URL to open.
98
+ * @returns {boolean} True if successful.
99
+ */
100
+ export function openInBrowser(url) {
101
+ try {
102
+ if (process.platform === "darwin") {
103
+ execSync(`open "${url}"`, { stdio: "ignore" });
104
+ } else if (process.platform === "win32") {
105
+ // Windows 'start' treats the first quoted arg as a title, so we pass an empty one first
106
+ execSync(`start "" "${url}"`, { stdio: "ignore" });
107
+ } else {
108
+ // Linux: use spawn + detached to avoid hanging/silent failures
109
+ const child = spawn("xdg-open", [url], {
110
+ detached: true,
111
+ stdio: "ignore",
112
+ });
113
+ child.unref();
114
+ }
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }