Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
339
scripts/sync-templates-repo.mjs
Executable file
339
scripts/sync-templates-repo.mjs
Executable file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Sync templates from this monorepo to the standalone emdash-cms/templates repo.
|
||||
*
|
||||
* - Clones emdash-cms/templates to a temp directory
|
||||
* - Copies each template, excluding build artifacts
|
||||
* - Resolves workspace:* and catalog: versions to real published versions
|
||||
* - Commits and pushes a branch, then opens a PR
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/sync-templates-repo.mjs # full run: clone, sync, PR
|
||||
* node scripts/sync-templates-repo.mjs --dry-run # sync to temp dir, print diff, don't push
|
||||
* node scripts/sync-templates-repo.mjs --local /path/to/repo # sync to a local checkout
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const TEMPLATES_DIR = join(ROOT, "templates");
|
||||
const REPO = "emdash-cms/templates";
|
||||
|
||||
const TEMPLATES = [
|
||||
"blog",
|
||||
"blog-cloudflare",
|
||||
"marketing",
|
||||
"marketing-cloudflare",
|
||||
"portfolio",
|
||||
"portfolio-cloudflare",
|
||||
"starter",
|
||||
"starter-cloudflare",
|
||||
];
|
||||
|
||||
const EXCLUDE = new Set(["node_modules", "dist", ".astro", ".emdash", "CHANGELOG.md"]);
|
||||
|
||||
const RE_NON_WHITESPACE_START = /^\S/;
|
||||
const RE_CATALOG_ENTRY = /^\s+"?([^"]+)"?:\s+(.+)$/;
|
||||
|
||||
function parseCatalog() {
|
||||
const yaml = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
|
||||
const catalog = {};
|
||||
let inCatalog = false;
|
||||
for (const line of yaml.split("\n")) {
|
||||
if (line.startsWith("catalog:")) {
|
||||
inCatalog = true;
|
||||
continue;
|
||||
}
|
||||
if (inCatalog && RE_NON_WHITESPACE_START.test(line)) break;
|
||||
if (!inCatalog) continue;
|
||||
|
||||
const match = line.match(RE_CATALOG_ENTRY);
|
||||
if (match) catalog[match[1]] = match[2];
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function collectWorkspaceVersions() {
|
||||
const versions = {};
|
||||
const dirs = [join(ROOT, "packages"), join(ROOT, "packages/plugins")];
|
||||
for (const base of dirs) {
|
||||
if (!existsSync(base)) continue;
|
||||
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const pkgPath = join(base, entry.name, "package.json");
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
if (pkg.name && pkg.version) {
|
||||
versions[pkg.name] = pkg.version;
|
||||
}
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
function resolveDeps(deps, catalog, workspace) {
|
||||
if (!deps) return deps;
|
||||
const resolved = {};
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (version === "catalog:") {
|
||||
resolved[name] = catalog[name] || version;
|
||||
} else if (version.startsWith("workspace:")) {
|
||||
resolved[name] = workspace[name] ? `^${workspace[name]}` : version;
|
||||
} else {
|
||||
resolved[name] = version;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function transformPackageJson(srcPath, catalog, workspace) {
|
||||
const pkg = JSON.parse(readFileSync(srcPath, "utf8"));
|
||||
pkg.dependencies = resolveDeps(pkg.dependencies, catalog, workspace);
|
||||
pkg.devDependencies = resolveDeps(pkg.devDependencies, catalog, workspace);
|
||||
if (pkg.peerDependencies) {
|
||||
pkg.peerDependencies = resolveDeps(pkg.peerDependencies, catalog, workspace);
|
||||
if (Object.keys(pkg.peerDependencies).length === 0) delete pkg.peerDependencies;
|
||||
}
|
||||
if (pkg.optionalDependencies) {
|
||||
pkg.optionalDependencies = resolveDeps(pkg.optionalDependencies, catalog, workspace);
|
||||
if (Object.keys(pkg.optionalDependencies).length === 0) delete pkg.optionalDependencies;
|
||||
}
|
||||
return JSON.stringify(pkg, null, "\t") + "\n";
|
||||
}
|
||||
|
||||
function lexists(p) {
|
||||
try {
|
||||
lstatSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirRecursive(src, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
||||
const srcPath = join(src, entry.name);
|
||||
const destPath = join(dest, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
if (lexists(destPath)) unlinkSync(destPath);
|
||||
symlinkSync(readlinkSync(srcPath), destPath);
|
||||
} else if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
cpSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyTemplateDir(src, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
||||
if (EXCLUDE.has(entry.name)) continue;
|
||||
// Don't overwrite target-only files
|
||||
if (entry.name === "README.md") continue;
|
||||
// package.json is handled separately
|
||||
if (entry.name === "package.json") continue;
|
||||
|
||||
const srcPath = join(src, entry.name);
|
||||
const destPath = join(dest, entry.name);
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
if (lexists(destPath)) unlinkSync(destPath);
|
||||
symlinkSync(readlinkSync(srcPath), destPath);
|
||||
} else if (entry.isDirectory()) {
|
||||
if (existsSync(destPath)) rmSync(destPath, { recursive: true });
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
cpSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove files in dest that don't exist in src (except preserved ones)
|
||||
const preserved = new Set(["README.md", "package.json"]);
|
||||
for (const entry of readdirSync(dest, { withFileTypes: true })) {
|
||||
if (preserved.has(entry.name)) continue;
|
||||
if (EXCLUDE.has(entry.name)) continue;
|
||||
const srcPath = join(src, entry.name);
|
||||
if (!existsSync(srcPath)) {
|
||||
const destPath = join(dest, entry.name);
|
||||
rmSync(destPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync("git", args, { encoding: "utf8", stdio: "pipe", cwd }).trim();
|
||||
}
|
||||
|
||||
// --- main ---
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const localIdx = args.indexOf("--local");
|
||||
const localPath = localIdx !== -1 ? args[localIdx + 1] : null;
|
||||
if (localIdx !== -1 && !localPath) {
|
||||
console.error("Error: --local requires a path argument");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const catalog = parseCatalog();
|
||||
const workspace = collectWorkspaceVersions();
|
||||
|
||||
console.log("Workspace packages:");
|
||||
for (const [name, version] of Object.entries(workspace)) {
|
||||
console.log(` ${name} = ${String(version)}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
let targetDir;
|
||||
let tempDir;
|
||||
|
||||
if (localPath) {
|
||||
targetDir = resolve(localPath);
|
||||
if (!existsSync(join(targetDir, ".git"))) {
|
||||
console.error(`Error: ${targetDir} is not a git repository`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "emdash-templates-"));
|
||||
console.log(`Cloning ${REPO} to ${tempDir}...`);
|
||||
execFileSync("gh", ["repo", "clone", REPO, tempDir, "--", "--depth", "1"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
// Configure git credential helper so push works with GH_TOKEN
|
||||
execFileSync("gh", ["auth", "setup-git"], { stdio: "pipe" });
|
||||
targetDir = tempDir;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const template of TEMPLATES) {
|
||||
const srcDir = join(TEMPLATES_DIR, template);
|
||||
const destDir = join(targetDir, template);
|
||||
|
||||
if (!existsSync(srcDir)) {
|
||||
console.log(`Skipping ${template} (not in monorepo)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Syncing ${template}`);
|
||||
copyTemplateDir(srcDir, destDir);
|
||||
|
||||
const srcPkg = join(srcDir, "package.json");
|
||||
if (existsSync(srcPkg)) {
|
||||
writeFileSync(
|
||||
join(destDir, "package.json"),
|
||||
transformPackageJson(srcPkg, catalog, workspace),
|
||||
);
|
||||
console.log(" Transformed package.json");
|
||||
}
|
||||
}
|
||||
|
||||
// Also sync screenshots.json
|
||||
const screenshotsJson = join(TEMPLATES_DIR, "screenshots.json");
|
||||
if (existsSync(screenshotsJson)) {
|
||||
cpSync(screenshotsJson, join(targetDir, "screenshots.json"));
|
||||
console.log("\nSynced screenshots.json");
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
const diff = git(["diff", "--stat"], targetDir);
|
||||
const untracked = git(["ls-files", "--others", "--exclude-standard"], targetDir);
|
||||
if (!diff && !untracked) {
|
||||
console.log("No changes to sync.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("Changes:");
|
||||
console.log(diff);
|
||||
console.log("");
|
||||
|
||||
if (dryRun) {
|
||||
console.log("Dry run — not pushing.");
|
||||
if (tempDir) {
|
||||
console.log(`Temp dir preserved at: ${tempDir}`);
|
||||
tempDir = undefined; // preserve for inspection
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get the emdash version for the branch/commit message
|
||||
const emdashVersion = workspace["emdash"] || "unknown";
|
||||
const branch = `sync/emdash-v${emdashVersion}`;
|
||||
|
||||
// Configure git identity in CI
|
||||
if (process.env.CI) {
|
||||
git(["config", "user.name", "github-actions[bot]"], targetDir);
|
||||
git(["config", "user.email", "github-actions[bot]@users.noreply.github.com"], targetDir);
|
||||
}
|
||||
|
||||
git(["checkout", "-B", branch], targetDir);
|
||||
git(["add", "-A"], targetDir);
|
||||
git(["commit", "-m", `chore: sync templates from emdash v${emdashVersion}`], targetDir);
|
||||
git(["push", "--force", "-u", "origin", branch], targetDir);
|
||||
|
||||
console.log(`Pushed branch: ${branch}`);
|
||||
|
||||
const prBody = [
|
||||
"## Summary",
|
||||
"",
|
||||
`Synced templates from [emdash v${emdashVersion}](https://github.com/emdash-cms/emdash).`,
|
||||
"",
|
||||
"Auto-generated by `scripts/sync-templates-repo.mjs`.",
|
||||
].join("\n");
|
||||
|
||||
// Reuse existing PR if one is already open for this branch
|
||||
const existingPrs = JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
["pr", "list", "--repo", REPO, "--head", branch, "--state", "open", "--json", "url"],
|
||||
{ encoding: "utf8", stdio: "pipe", cwd: targetDir },
|
||||
),
|
||||
);
|
||||
|
||||
if (existingPrs.length > 0) {
|
||||
console.log(`PR already exists: ${existingPrs[0].url}`);
|
||||
} else {
|
||||
const prUrl = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"create",
|
||||
"--repo",
|
||||
REPO,
|
||||
"--head",
|
||||
branch,
|
||||
"--title",
|
||||
`chore: sync templates from emdash v${emdashVersion}`,
|
||||
"--body",
|
||||
prBody,
|
||||
],
|
||||
{ encoding: "utf8", stdio: "pipe", cwd: targetDir },
|
||||
).trim();
|
||||
|
||||
console.log(`PR: ${prUrl}`);
|
||||
}
|
||||
} finally {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user