diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..d76e739 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,16 @@ +name: Checks +on: [push, pull_request] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run check + - run: git diff --exit-code -- theme.css diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c23df17 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +name: Release Obsidian theme +on: + push: + tags: ["*"] +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run check + - run: node scripts/check-theme.mjs --release-tag "$GITHUB_REF_NAME" + - run: git diff --exit-code -- theme.css + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes --draft manifest.json theme.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e607bd --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Aera + +A quiet interface for clear thinking. + +Aera is an Obsidian theme for calm, readable Chinese-first notes with light, +dark, desktop, and mobile support. + +![Aera screenshot](screenshot.png) + +## Development + +Run `npm install`, `npm run build`, and `npm run check`. + +For live preview, set `AERA_TEST_VAULT`, then run `npm run link:vault` and +`npm run dev`. Changes to `manifest.json` require an Obsidian restart, while +compiled CSS changes automatically reload in the linked vault. + +## Release assets + +Every release contains `manifest.json` and `theme.css`. + +## License + +[MIT](LICENSE) diff --git a/package.json b/package.json index 3f5eee4..c841b55 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "sass --watch --no-source-map --style=expanded --no-error-css src/theme.scss:theme.css", "build": "sass --no-source-map --style=expanded --no-error-css src/theme.scss theme.css", "link:vault": "node scripts/link-vault.mjs", + "version:sync": "node scripts/version-bump.mjs", "test": "node --test", "check": "npm run build && node --test && node scripts/check-theme.mjs && node scripts/contrast.mjs" }, diff --git a/scripts/version-bump.mjs b/scripts/version-bump.mjs new file mode 100644 index 0000000..1615616 --- /dev/null +++ b/scripts/version-bump.mjs @@ -0,0 +1,184 @@ +import { randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { open, readFile, rename, rm } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { validateManifest, validateVersions } from "./theme-policy.mjs"; + +const modulePath = fileURLToPath(import.meta.url); + +function throwValidationErrors(errors, context = "") { + if (errors.length) { + const prefix = context ? `${context}: ` : ""; + throw new Error(`${prefix}${errors.join("; ")}`); + } +} + +export function bumpMetadata(manifest, versions, targetVersion) { + throwValidationErrors(validateManifest(manifest), "manifest.json"); + throwValidationErrors(validateVersions(manifest, versions), "versions.json"); + + const targetErrors = validateManifest({ ...manifest, version: targetVersion }); + if (targetErrors.includes("manifest version must use x.y.z")) { + throw new Error("target version must use x.y.z"); + } + throwValidationErrors(targetErrors); + + const nextManifest = { ...manifest, version: targetVersion }; + const nextVersions = { + ...versions, + [targetVersion]: manifest.minAppVersion, + }; + throwValidationErrors([ + ...validateManifest(nextManifest), + ...validateVersions(nextManifest, nextVersions), + ]); + + return { manifest: nextManifest, versions: nextVersions }; +} + +function jsonContents(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +async function readJson(path) { + let contents; + try { + contents = await readFile(path, "utf8"); + } catch (error) { + throw new Error(`could not read ${path}: ${error.message}`); + } + + try { + return JSON.parse(contents); + } catch (error) { + throw new Error(`could not parse ${path}: ${error.message}`); + } +} + +async function stageFile(path, contents, id) { + const temporaryPath = join(dirname(path), `.${basename(path)}.aera-${id}.tmp`); + let temporaryFile; + let ownsTemporaryPath = false; + + try { + temporaryFile = await open(temporaryPath, "wx"); + ownsTemporaryPath = true; + await temporaryFile.writeFile(contents); + await temporaryFile.close(); + temporaryFile = undefined; + + return { + path: temporaryPath, + release() { + ownsTemporaryPath = false; + }, + async cleanup() { + await temporaryFile?.close().catch(() => {}); + if (ownsTemporaryPath) { + await rm(temporaryPath, { force: true }); + ownsTemporaryPath = false; + } + }, + }; + } catch (error) { + await temporaryFile?.close().catch(() => {}); + if (ownsTemporaryPath) await rm(temporaryPath, { force: true }).catch(() => {}); + throw error; + } +} + +async function replaceTogether(replacements) { + const transactionId = randomUUID(); + const staged = []; + + try { + for (const { path, contents } of replacements) { + staged.push({ + destination: path, + backup: join(dirname(path), `.${basename(path)}.aera-${transactionId}.bak`), + temporary: await stageFile(path, contents, transactionId), + backupOwned: false, + replacementInstalled: false, + }); + } + } catch (error) { + await Promise.all(staged.map((file) => file.temporary.cleanup())); + throw error; + } + + try { + for (const file of staged) { + await rename(file.destination, file.backup); + file.backupOwned = true; + } + for (const file of staged) { + await rename(file.temporary.path, file.destination); + file.temporary.release(); + file.replacementInstalled = true; + } + } catch (error) { + for (const file of staged.toReversed()) { + if (file.replacementInstalled) { + await rm(file.destination, { force: true }).catch(() => {}); + file.replacementInstalled = false; + } + if (file.backupOwned) { + try { + await rename(file.backup, file.destination); + file.backupOwned = false; + } catch {} + } + } + throw error; + } finally { + await Promise.all(staged.map((file) => file.temporary.cleanup())); + } + + for (const file of staged) { + await rm(file.backup); + file.backupOwned = false; + } +} + +export async function syncVersion(directory = process.cwd()) { + const packagePath = join(directory, "package.json"); + const manifestPath = join(directory, "manifest.json"); + const versionsPath = join(directory, "versions.json"); + const [packageJson, manifest, versions] = await Promise.all([ + readJson(packagePath), + readJson(manifestPath), + readJson(versionsPath), + ]); + const next = bumpMetadata(manifest, versions, packageJson?.version); + + await replaceTogether([ + { path: manifestPath, contents: jsonContents(next.manifest) }, + { path: versionsPath, contents: jsonContents(next.versions) }, + ]); +} + +function singleLine(value) { + return String(value).replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); +} + +function isDirectRun() { + if (process.argv[1] === undefined) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(modulePath); + } catch { + return false; + } +} + +if (isDirectRun()) { + try { + await syncVersion(); + console.log("Theme metadata version synchronized"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`ERROR: ${singleLine(message)}`); + process.exitCode = 1; + } +} diff --git a/tests/version-bump.test.mjs b/tests/version-bump.test.mjs new file mode 100644 index 0000000..43bd397 --- /dev/null +++ b/tests/version-bump.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { bumpMetadata } from "../scripts/version-bump.mjs"; + +const manifest = { + name: "Aera", + version: "0.1.0", + minAppVersion: "1.12.7", + author: "Peter", + authorUrl: "https://example.com/peter", +}; +const versions = { "0.1.0": "1.12.7" }; +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const cliPath = join(repoRoot, "scripts/version-bump.mjs"); + +function createFixture(t, packageVersion) { + const directory = mkdtempSync(join(tmpdir(), "aera-version-bump-")); + writeFileSync( + join(directory, "package.json"), + `${JSON.stringify({ version: packageVersion }, null, 2)}\n`, + ); + writeFileSync( + join(directory, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + writeFileSync( + join(directory, "versions.json"), + `${JSON.stringify(versions, null, 2)}\n`, + ); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function runCli(cwd) { + return spawnSync(process.execPath, [cliPath], { cwd, encoding: "utf8" }); +} + +test("bumpMetadata updates the manifest and appends the versions mapping", () => { + assert.deepEqual(bumpMetadata(manifest, versions, "1.0.0"), { + manifest: { ...manifest, version: "1.0.0" }, + versions: { + "0.1.0": "1.12.7", + "1.0.0": "1.12.7", + }, + }); +}); + +test("bumpMetadata does not mutate its inputs", () => { + const manifestInput = structuredClone(manifest); + const versionsInput = structuredClone(versions); + const originalManifest = structuredClone(manifestInput); + const originalVersions = structuredClone(versionsInput); + + const result = bumpMetadata(manifestInput, versionsInput, "1.0.0"); + + assert.deepEqual(manifestInput, originalManifest); + assert.deepEqual(versionsInput, originalVersions); + assert.notStrictEqual(result.manifest, manifestInput); + assert.notStrictEqual(result.versions, versionsInput); +}); + +test("bumpMetadata rejects invalid target versions", () => { + for (const target of ["1.0", "01.0.0", "v1.0.0", "", null]) { + assert.throws( + () => bumpMetadata(manifest, versions, target), + /target version must use x\.y\.z/, + ); + } +}); + +test("bumpMetadata rejects invalid manifest and versions inputs", () => { + for (const invalidManifest of [null, [], { ...manifest, name: "Other" }]) { + assert.throws( + () => bumpMetadata(invalidManifest, versions, "1.0.0"), + /manifest\.json/, + ); + } + + for (const invalidVersions of [null, [], { "0.1.0": "1.12" }]) { + assert.throws( + () => bumpMetadata(manifest, invalidVersions, "1.0.0"), + /versions\.json/, + ); + } +}); + +test("version bump CLI syncs metadata to package.json version", (t) => { + const directory = createFixture(t, "1.0.0"); + + const result = runCli(directory); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Theme metadata version synchronized\n"); + assert.equal( + readFileSync(join(directory, "manifest.json"), "utf8"), + `${JSON.stringify({ ...manifest, version: "1.0.0" }, null, 2)}\n`, + ); + assert.equal( + readFileSync(join(directory, "versions.json"), "utf8"), + `${JSON.stringify({ ...versions, "1.0.0": "1.12.7" }, null, 2)}\n`, + ); +}); + +test("version bump CLI reports invalid metadata without modifying files", (t) => { + const directory = createFixture(t, "1.0"); + const manifestPath = join(directory, "manifest.json"); + const versionsPath = join(directory, "versions.json"); + const originalManifest = readFileSync(manifestPath, "utf8"); + const originalVersions = readFileSync(versionsPath, "utf8"); + + const result = runCli(directory); + + assert.equal(result.status, 1); + assert.match(result.stderr, /^ERROR: target version must use x\.y\.z\n$/); + assert.doesNotMatch(result.stderr, /\n\s+at /); + assert.equal(readFileSync(manifestPath, "utf8"), originalManifest); + assert.equal(readFileSync(versionsPath, "utf8"), originalVersions); +}); diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs new file mode 100644 index 0000000..f53064c --- /dev/null +++ b/tests/workflows.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const repoRoot = new URL("../", import.meta.url); + +async function read(path) { + return readFile(new URL(path, repoRoot), "utf8"); +} + +test("README documents Aera development and release essentials", async () => { + const readme = await read("README.md"); + + assert.match(readme, /^# Aera$/m); + assert.match(readme, /A quiet interface for clear thinking\./); + assert.match(readme, /Chinese-first/i); + assert.match(readme, /!\[[^\]]*Aera[^\]]*\]\(screenshot\.png\)/i); + for (const command of [ + "npm install", + "npm run build", + "npm run check", + "npm run link:vault", + "npm run dev", + ]) { + assert.match(readme, new RegExp(`\\b${command.replaceAll(" ", "\\s+")}\\b`)); + } + assert.match(readme, /AERA_TEST_VAULT/); + assert.match(readme, /manifest[^\n]*restart/i); + assert.match(readme, /CSS[^\n]*auto(?:matically)?[^\n]*reload/i); + assert.match(readme, /manifest\.json[^\n]*theme\.css/); + assert.match(readme, /\[MIT\]\(LICENSE\)/); +}); + +test("checks workflow runs repository checks on pushes and pull requests", async () => { + const workflow = await read(".github/workflows/checks.yml"); + + assert.match(workflow, /^name: Checks$/m); + assert.match(workflow, /^on:\s*\[push, pull_request\]$/m); + assert.match(workflow, /^permissions:\n contents: read$/m); + assert.match(workflow, /actions\/checkout@v4/); + assert.match(workflow, /actions\/setup-node@v4/); + assert.match(workflow, /node-version: 24/); + assert.match(workflow, /cache: npm/); + assert.match(workflow, /run: npm ci/); + assert.match(workflow, /run: npm run check/); + assert.match(workflow, /run: git diff --exit-code -- theme\.css/); +}); + +test("release workflow validates tags and creates a draft with theme assets", async () => { + const workflow = await read(".github/workflows/release.yml"); + + assert.match(workflow, /^name: Release Obsidian theme$/m); + assert.match(workflow, /^on:\n push:\n tags: \["\*"\]$/m); + assert.match(workflow, /^permissions:\n contents: write$/m); + assert.match(workflow, /actions\/checkout@v4/); + assert.match(workflow, /actions\/setup-node@v4/); + assert.match(workflow, /node-version: 24/); + assert.match(workflow, /cache: npm/); + assert.match(workflow, /run: npm ci/); + assert.match(workflow, /run: npm run check/); + assert.match( + workflow, + /node scripts\/check-theme\.mjs --release-tag "\$GITHUB_REF_NAME"/, + ); + assert.match(workflow, /run: git diff --exit-code -- theme\.css/); + assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.match( + workflow, + /gh release create "\$GITHUB_REF_NAME" --title "\$GITHUB_REF_NAME" --generate-notes --draft manifest\.json theme\.css/, + ); +});