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}