master
1import { ParseFrontmatterOptions, parseFrontmatter } from '@astrojs/markdown-remark';
2import { slug as githubSlug } from 'github-slugger';
3import yaml, { type DumpOptions } from 'js-yaml';
4import fs from 'node:fs/promises';
5import path from 'node:path';
6
7/**
8 * Converts a string into a URL-friendly slug.
9 *
10 * - Converts to lowercase.
11 * - Replaces spaces and non-alphanumeric characters (excluding hyphens) with hyphens.
12 * - Removes leading/trailing hyphens.
13 * - Collapses multiple consecutive hyphens into a single hyphen.
14 * @param text The string to slugify.
15 * @param hard A boolean indicating the type of slugification.
16 * - `true`: Use github-slugger to create a github-like slug.
17 * - `false`: Only replaces invalid path characters (e.g., `\ / : * ? " < > |`) with hyphens.
18 * @returns The slugified string.
19 */
20export function slugify(text: string, hard: boolean = false): string {
21 if (!text) {
22 return '';
23 }
24 const newText = text
25 .toString()
26 .toLowerCase()
27 .trim()
28 .replace(/\s+/g, '-') // Replace spaces with -
29 .replace(/[\\/:*?"<>|]/g, '-') // Replace invalid path characters with -
30 .replace(/--+/g, '-') // Replace multiple - with single -
31 .replace(/^-+/, '') // Trim - from start of text
32 .replace(/-+$/, ''); // Trim - from end of text
33 if (hard) return githubSlug(newText);
34 return newText;
35}
36
37/**
38 * Change the frontmatter of a given markdown string and returns the changed string.
39 *
40 * @param code The markdown string to be changed.
41 * @param changedPairs The KV pairs to be changed in the frontmatter.
42 * @param parseOption The option for parsing the frontmatter.
43 * @param dumpOption The option for dumping the frontmatter.
44 * @returns The changed markdown string.
45 */
46export function changeFrontmatter(
47 code: string,
48 changedPairs: Record<string, unknown>,
49 parseOption?: ParseFrontmatterOptions,
50 dumpOption?: DumpOptions
51): string {
52 const { frontmatter, content } = parseFrontmatter(code, parseOption);
53 const newFrontmatter = { ...frontmatter, ...changedPairs };
54 const raw = `---\n${yaml.dump(newFrontmatter, dumpOption)}---${content}`;
55 return raw;
56}
57
58/**
59 * Information about an available file name.
60 */
61export interface AvailableFileNameInfo {
62 /** The full file name including extension and counter if used (e.g., 'my-article-1.md') */
63 fileName: string;
64 /** The file name without extension, including counter if used (e.g., 'my-article-1') */
65 nameWithoutExt: string;
66 /** The counter used, or null if the original name was available. */
67 counter: number | null;
68}
69
70/**
71 * Finds an available filename in a directory by appending a counter if the base name already exists.
72 * e.g., if 'file.md' exists, it will try 'file-1.md', 'file-2.md', and so on.
73 * The baseName is the initial slug, and the counter is appended to this slug.
74 * @param directory The directory to check for the file.
75 * @param baseSlug The initial slug of the file (without extension or counter, e.g., 'my-article').
76 * @param extension The file extension (e.g., '.md').
77 * @returns A promise that resolves to an object containing the available full file name, name without extension, and the counter used.
78 */
79export async function findAvailableFileName(
80 directory: string,
81 baseSlug: string,
82 extension: string
83): Promise<AvailableFileNameInfo> {
84 let counter = 0;
85 let currentNameWithoutExt = baseSlug;
86 let currentFileName = baseSlug + extension;
87 let filePath = path.join(directory, currentFileName);
88 let usedCounter: number | null = null;
89
90 // Check if the initial name (without counter) is available
91 try {
92 await fs.access(filePath);
93 // File exists, start counter from 1
94 counter = 1;
95 usedCounter = counter;
96 currentNameWithoutExt = `${baseSlug}-${counter}`;
97 currentFileName = currentNameWithoutExt + extension;
98 filePath = path.join(directory, currentFileName);
99 } catch {
100 // Initial file does not exist, return it
101 return { fileName: currentFileName, nameWithoutExt: currentNameWithoutExt, counter: null };
102 }
103
104 // If initial name was taken, find next available
105 while (true) {
106 try {
107 await fs.access(filePath);
108 counter++;
109 usedCounter = counter;
110 currentNameWithoutExt = `${baseSlug}-${counter}`;
111 currentFileName = currentNameWithoutExt + extension;
112 filePath = path.join(directory, currentFileName);
113 } catch {
114 // File does not exist, this name is available
115 return {
116 fileName: currentFileName,
117 nameWithoutExt: currentNameWithoutExt,
118 counter: usedCounter,
119 };
120 }
121 }
122}
123
124/**
125 * Finds the monorepo root directory by searching upwards for a 'pnpm-workspace.yaml' file.
126 * @param startDir The directory to start searching from. Defaults to the current working directory of the script.
127 * @returns The path to the monorepo root directory.
128 * @throws Error if 'pnpm-workspace.yaml' is not found after searching up to the filesystem root.
129 */
130export async function findMonorepoRoot(
131 startDir: string = path.dirname(new URL(import.meta.url).pathname)
132): Promise<string> {
133 let currentDir = startDir;
134 // Adjust for Windows if path starts with /C: -> C:
135 if (process.platform === 'win32' && currentDir.match(/^\/[A-Za-z]:/)) {
136 currentDir = currentDir.substring(1);
137 }
138
139 while (true) {
140 const workspaceFilePath = path.join(currentDir, 'astro.config.mjs');
141 try {
142 await fs.access(workspaceFilePath);
143 return currentDir; // Found the file, this is the root
144 } catch {
145 // File not found, move up one directory
146 const parentDir = path.dirname(currentDir);
147 if (parentDir === currentDir) {
148 // Reached the filesystem root and haven't found the file
149 throw new Error("Could not find 'astro.config.mjs'.");
150 }
151 currentDir = parentDir;
152 }
153 }
154}