build: configure release-it for automated releases

- Add release-it with conventional changelog support
- Configure automatic GitHub releases with asset uploads
- Add custom Obsidian version bumper plugin
- Add zip script for packaging releases
- Update package.json with release scripts
- Install required dependencies (jszip, globby, release-it plugins)
This commit is contained in:
quorafind 2025-08-09 10:26:32 +08:00
parent 9e240d77c6
commit 912789918b
9 changed files with 2715 additions and 5 deletions

5
.gitignore vendored
View file

@ -36,12 +36,9 @@ package-lock.json
.env
# translations
scripts
translation-templates
._data.json
styles.css
CLAUDE.md
.kiro
.claude
.claude

58
.release-it.cjs Normal file
View file

@ -0,0 +1,58 @@
module.exports = {
hooks: {
"before:init": ["pnpm test", "pnpm run build"],
"after:bump": [
"pnpm run build",
"node ./scripts/zip.mjs",
"git add .",
],
"after:release":
"echo Successfully released Task Genius v${version} to ${repo.repository}.",
},
git: {
requireBranch: "master",
requireCleanWorkingDir: true,
pushArgs: "--follow-tags -o ci.skip",
commitMessage: "chore(release): bump version to ${version}",
tagName: "${version}",
tagAnnotation: "Release v${version}",
addUntrackedFiles: true,
},
plugins: {
"@release-it/conventional-changelog": {
preset: {
name: "conventionalcommits",
types: [
{ type: "feat", section: "✨ Features" },
{ type: "fix", section: "🐛 Bug Fixes" },
{ type: "perf", section: "⚡ Performance" },
{ type: "refactor", section: "♻️ Refactors" },
{ type: "chore", section: "🔧 Chores" },
{ type: "docs", section: "📝 Documentation" },
{ type: "style", section: "💄 Styles" },
{ type: "test", section: "✅ Tests" }
]
},
infile: "CHANGELOG.md",
header: "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
},
"./scripts/ob-bumper.mjs": {
indent: 2,
copyTo: "./dist",
},
},
npm: {
publish: false,
},
github: {
release: true,
assets: [
"dist/main.js",
"dist/manifest.json",
"dist/styles.css",
"dist/task-genius-${version}.zip",
],
proxy: process.env.HTTPS_PROXY,
releaseName: "v${version}",
},
};

View file

@ -11,7 +11,12 @@
"g-l": "cross-env node scripts/generate-locale-files.cjs",
"test": "jest",
"test:watch": "jest --watch",
"prepare": "husky"
"prepare": "husky",
"release": "release-it",
"release:patch": "release-it patch",
"release:minor": "release-it minor",
"release:major": "release-it major",
"release:dry": "release-it --dry-run"
},
"keywords": [
"obsidian",
@ -32,6 +37,8 @@
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.7",
"@datastructures-js/queue": "^4.2.3",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.1",
"@types/jest": "^29.5.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0",
@ -41,12 +48,15 @@
"cross-env": "^7.0.3",
"esbuild": "0.13.12",
"esbuild-plugin-inline-worker": "https://github.com/mitschabaude/esbuild-plugin-inline-worker",
"globby": "^14.1.0",
"husky": "^9.1.7",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jszip": "^3.10.1",
"monkey-around": "^3.0.0",
"obsidian": "^1.8.7",
"regexp-match-indices": "^1.0.2",
"release-it": "^19.0.4",
"rrule": "^2.8.1",
"ts-jest": "^29.1.0",
"tslib": "2.4.0",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,239 @@
const fs = require("node:fs");
const path = require("node:path");
const ts = require("typescript");
const SRC_DIR = path.resolve(__dirname, "../src");
const TRANSLATIONS_DIR = path.resolve(SRC_DIR, "translations/locale");
const BASE_LOCALE = "en";
// Define supported locales
const SUPPORTED_LOCALES = [
"ar",
"cs",
"da",
"de",
"en",
"en-gb",
"es",
"fr",
"hi",
"id",
"it",
"ja",
"ko",
"nl",
"no",
"pl",
"pt",
"pt-br",
"ro",
"ru",
"tr",
"zh-cn",
"zh-tw",
"uk",
];
function extractTranslationKeys(sourceFile) {
const keys = [];
function visit(node) {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "t"
) {
const firstArg = node.arguments[0];
if (ts.isStringLiteral(firstArg)) {
keys.push({
key: firstArg.text,
location: `${sourceFile.fileName}:${
sourceFile.getLineAndCharacterOfPosition(
node.getStart()
).line + 1
}`,
});
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return keys;
}
function scanDirectory(dir) {
const allKeys = [];
function scan(currentDir) {
const entries = fs.readdirSync(currentDir);
for (const entry of entries) {
const fullPath = path.join(currentDir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
scan(fullPath);
} else if (stat.isFile() && /\.(ts|tsx)$/.test(entry)) {
const sourceFile = ts.createSourceFile(
fullPath,
fs.readFileSync(fullPath, "utf-8"),
ts.ScriptTarget.Latest,
true
);
allKeys.push(...extractTranslationKeys(sourceFile));
}
}
}
scan(dir);
return allKeys;
}
function loadTranslations(locale) {
try {
const filePath = path.join(TRANSLATIONS_DIR, `${locale}.ts`);
if (!fs.existsSync(filePath)) {
return {};
}
const content = fs.readFileSync(filePath, "utf-8");
const sourceFile = ts.createSourceFile(
filePath,
content,
ts.ScriptTarget.Latest,
true
);
// Simple AST parsing to extract the translations object
let translations = {};
ts.forEachChild(sourceFile, (node) => {
if (ts.isVariableStatement(node)) {
const declarations = node.declarationList.declarations;
if (declarations.length > 0) {
const declaration = declarations[0];
const initializer = declaration.initializer;
if (
initializer &&
ts.isObjectLiteralExpression(initializer)
) {
initializer.properties.forEach((prop) => {
if (
ts.isPropertyAssignment(prop) &&
ts.isStringLiteral(prop.initializer) &&
ts.isIdentifier(prop.name)
) {
translations[prop.name.text] =
prop.initializer.text;
}
});
}
}
}
});
return translations;
} catch (error) {
console.warn(`Failed to load translations for ${locale}:`, error);
return {};
}
}
function generateTemplates(extractedKeys) {
const baseTranslations = loadTranslations(BASE_LOCALE);
// First, deduplicate keys while preserving all unique locations
const uniqueKeys = extractedKeys.reduce((acc, { key, location }) => {
if (!acc[key]) {
acc[key] = { key, locations: [location] };
} else {
acc[key].locations.push(location);
}
return acc;
}, {});
// Generate template for each supported locale
SUPPORTED_LOCALES.forEach((locale) => {
const templatePath = path.resolve(
__dirname,
`../translation-templates/${locale}.json`
);
// Load existing template if it exists
let existingTemplate = {};
try {
if (fs.existsSync(templatePath)) {
existingTemplate = JSON.parse(
fs.readFileSync(templatePath, "utf-8")
);
}
} catch (error) {
console.warn(
`Failed to load existing template for ${locale}:`,
error
);
}
const currentTranslations = loadTranslations(locale);
const existingEntries = existingTemplate.entries || [];
// Create a map of existing translations for quick lookup
const existingTranslationsMap = existingEntries.reduce((acc, entry) => {
acc[entry.key] = entry;
return acc;
}, {});
// Merge existing and new translations
const entries = Object.values(uniqueKeys).map(({ key, locations }) => {
const existing = existingTranslationsMap[key];
let status = "UNTRANSLATED";
let target = "";
if (existing) {
// Preserve existing translation and status
status = existing.status;
target = existing.target;
} else if (currentTranslations[key]) {
status = "TRANSLATED";
target = currentTranslations[key];
} else if (locale !== BASE_LOCALE && baseTranslations[key]) {
status = "UNTRANSLATED";
}
return {
key,
status,
locations,
source: baseTranslations[key] || key,
target,
};
});
const template = {
language: locale,
entries,
lastUpdated: new Date().toISOString(),
};
// Create directory if it doesn't exist
const outputDir = path.dirname(templatePath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(templatePath, JSON.stringify(template, null, 2));
console.log(
`Translation template for ${locale} written to: ${templatePath}`
);
});
}
function main() {
console.log("Scanning for translation keys...");
const extractedKeys = scanDirectory(SRC_DIR);
console.log(`Found ${extractedKeys.length} translation keys`);
generateTemplates(extractedKeys);
}
main();

View file

@ -0,0 +1,124 @@
const fs = require("fs");
const path = require("path");
const LOCALE_DIR = path.resolve(__dirname, "../src/translations/locale");
const TEMPLATE_DIR = path.resolve(__dirname, "../translation-templates");
// Only generate the following locales
const LOCALES = {
"en-gb": "British English",
en: "English",
ja: "Japanese",
"pt-br": "Brazilian Portuguese",
ru: "Russian",
uk: "Ukrainian",
"zh-cn": "Simplified Chinese",
"zh-tw": "Traditional Chinese",
};
const TEMPLATE = (lang, translations) => `// ${lang} translations
const translations = ${JSON.stringify(translations, null, 2)};
export default translations;
`;
function loadTemplateTranslations(locale) {
try {
const templatePath = path.join(TEMPLATE_DIR, `${locale}.json`);
if (!fs.existsSync(templatePath)) {
return {};
}
const template = JSON.parse(fs.readFileSync(templatePath, "utf-8"));
// Try to load existing translations file if it exists
let existingTranslations = {};
const existingFilePath = path.join(LOCALE_DIR, `${locale}.ts`);
if (fs.existsSync(existingFilePath)) {
const content = fs.readFileSync(existingFilePath, "utf-8");
const match = content.match(
/const translations = (\{[\s\S]*?\n\});/
);
if (match && match[1]) {
try {
// Convert the matched string to a JavaScript object
existingTranslations = eval(`(${match[1]})`);
} catch (e) {
console.warn(
`Failed to parse existing translations for ${locale}:`,
e
);
}
}
}
const translations = { ...existingTranslations };
// Only add untranslated entries to the end
template.entries.forEach((entry) => {
// Skip if already in existing translations
if (translations[entry.key] !== undefined) {
return;
}
// Use the source text as the target if target is empty
translations[entry.key] = entry.target || entry.source;
// Mark all generated entries as TRANSLATED
if (template.entries.indexOf(entry) !== -1) {
entry.status = "TRANSLATED";
}
});
// Write back the updated template with TRANSLATED status
fs.writeFileSync(
templatePath,
JSON.stringify(template, null, 2),
"utf-8"
);
return translations;
} catch (error) {
console.warn(`Failed to load template for ${locale}:`, error);
return {};
}
}
function generateLocaleFiles() {
// Create locale directory if it doesn't exist
if (!fs.existsSync(LOCALE_DIR)) {
fs.mkdirSync(LOCALE_DIR, { recursive: true });
}
// Process each locale
for (const [locale, language] of Object.entries(LOCALES)) {
const templatePath = path.join(TEMPLATE_DIR, `${locale}.json`);
if (fs.existsSync(templatePath)) {
console.log(
`\nProcessing translations for ${language} (${locale})...`
);
// Generate the locale file
const filename = `${locale}.ts`;
const filepath = path.join(LOCALE_DIR, filename);
const translations = loadTemplateTranslations(locale);
fs.writeFileSync(filepath, TEMPLATE(language, translations));
console.log(`Generated ${filename}`);
} else {
console.warn(`Template file not found for ${locale}`);
}
}
}
// Main function
function main() {
try {
generateLocaleFiles();
} catch (error) {
console.error("Error during locale file generation:", error);
process.exit(1);
}
}
main();

121
scripts/ob-bumper.mjs Normal file
View file

@ -0,0 +1,121 @@
import { copyFile, readFile, writeFile } from "fs/promises";
import { join } from "path";
import { Plugin } from "release-it";
import semverPrerelease from "semver/functions/prerelease.js";
const mainManifest = "manifest.json",
betaManifest = "manifest-beta.json",
versionsList = "versions.json";
const targets = [mainManifest, betaManifest, versionsList];
const isPreRelease = (version) => semverPrerelease(version) !== null;
class ObsidianVersionBump extends Plugin {
async readJson(path) {
// const { isDryRun } = this.config;
try {
const result = JSON.parse(await readFile(path, "utf8"));
return result;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
async writeJson(file, data) {
// const { isDryRun } = this.config;
const { indent = 4 } = this.getContext();
await writeFile(file, JSON.stringify(data, null, indent));
// this.log.exec(`Write to ${file}`, isDryRun);
}
/**
* always read from previous version of manifest
*/
async readManifest() {
const { isDryRun } = this.config;
const latest = isPreRelease(this.config.contextOptions.latestVersion);
let manifestToRead = this.getManifest(latest);
this.log.exec(`Reading manifest from ${manifestToRead}`, isDryRun);
let manifest;
if (!(manifest = await this.readJson(manifestToRead))) {
manifestToRead = this.getManifest(!latest);
this.log.exec(`retry reading manifest from ${manifestToRead}`, isDryRun);
manifest = await this.readJson(manifestToRead);
}
if (!manifest) throw new Error("Missing manifest data");
return manifest;
}
async writeManifest(targetVersion, manifest) {
const { isDryRun } = this.config;
const manifestToWrite = this.getManifest(isPreRelease(targetVersion));
const updatedMainfest = { ...manifest, version: targetVersion };
!isDryRun && (await this.writeJson(manifestToWrite, updatedMainfest));
this.log.exec(
`Wrote version ${targetVersion} to ${manifestToWrite}`,
isDryRun,
);
await this.syncManifest(targetVersion);
}
/**
* if bump to official release
* sync manifest-beta.json with manifest.json
*/
async syncManifest(targetVersion) {
const target = isPreRelease(targetVersion);
const { isDryRun } = this.config;
if (!target) {
!isDryRun && (await copyFile(mainManifest, betaManifest));
this.log.exec(`Syncing ${betaManifest} with ${mainManifest}`, isDryRun);
}
}
async copyToRoot() {
const { copyTo } = this.getContext();
if (!copyTo) return;
const { isDryRun } = this.config;
if (!isDryRun) {
await Promise.all(
targets.map((file) =>
copyFile(file, join(copyTo, file)).catch((err) => {
if (err.code !== "ENOENT") throw err;
}),
),
);
}
this.log.exec(`Copied ${targets.join(", ")} to ${copyTo}`, isDryRun);
}
/**
* update versions.json with target version and minAppVersion from manifest.json
*/
async writeVersion(targetVersion, { minAppVersion }) {
const { isDryRun } = this.config;
const versions = await this.readJson(versionsList);
versions[targetVersion] = minAppVersion;
!isDryRun && (await this.writeJson(versionsList, versions));
this.log.exec(
`Wrote version ${targetVersion} to ${versionsList}`,
isDryRun,
);
}
getManifest(isPreRelease) {
return isPreRelease ? betaManifest : mainManifest;
}
async bump(targetVersion) {
// read minAppVersion from manifest and bump version to target version
const manifest = await this.readManifest(targetVersion);
this.log.info(`min obsidian app version: ${manifest.minAppVersion}`);
await Promise.all([
this.writeManifest(targetVersion, manifest),
this.writeVersion(targetVersion, manifest),
]);
await this.copyToRoot();
}
}
export default ObsidianVersionBump;

45
scripts/ob.esbuild.mjs Normal file
View file

@ -0,0 +1,45 @@
import { copyFile, rename, writeFile } from "fs/promises";
import { dirname, join } from "path";
/**
* @param {{ hotreload?: boolean, beta?:boolean }} config
* @returns {import("esbuild").Plugin}
*/
const obPlugin = (config = {}) => ({
name: "obsidian-plugin",
setup: (build) => {
const { hotreload = true, beta = false } = config;
build.onEnd(async () => {
const outDir = dirname(build.initialOptions.outfile ?? "main.js");
// fix default css output file name
const { outfile } = build.initialOptions;
try {
await rename(
outfile.replace(/\.js$/, ".css"),
outfile.replace(/main\.js$/, "styles.css"),
);
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
// copy manifest.json to build dir
if (!beta) {
await copyFile("manifest.json", join(outDir, "manifest.json"));
} else {
await copyFile("manifest-beta.json", join(outDir, "manifest.json"));
}
// create .hotreload if it doesn't exist
if (hotreload) {
try {
await writeFile(join(outDir, ".hotreload"), "", { flag: "wx" });
} catch (err) {
if (err.code !== "EEXIST") throw err;
}
}
console.log("build finished");
});
},
});
export default obPlugin;

22
scripts/zip.mjs Normal file
View file

@ -0,0 +1,22 @@
import {createReadStream, createWriteStream, readFileSync} from "fs";
import {rm} from "fs/promises";
import JSZip from "jszip";
import {join} from "path";
import {pipeline} from "stream/promises";
import {globby} from "globby"
const assets = ["main.js", "styles.css", "manifest.json"];
await globby("dist/*.zip").then(files => Promise.all(files.map(f => rm(f))))
const zip = new JSZip();
for (const filename of assets) {
zip.file(filename, createReadStream(join("dist", filename)));
}
const version = JSON.parse(readFileSync(join("package.json"), "utf-8")).version;
const out = join("dist", `task-genius-${version}.zip`)
await pipeline(
zip.generateNodeStream({type: "nodebuffer", streamFiles: true, compression: "DEFLATE"}),
createWriteStream(out),
);
console.log(out + " written.");