create-portfoliable 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/create-portfoliable.mjs +425 -0
  2. package/cli/portfoliable.mjs +836 -0
  3. package/index.html +18 -0
  4. package/package.json +102 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/favicon.png +0 -0
  7. package/scripts/check-updates.mjs +31 -0
  8. package/scripts/ensure-valence-index-css.mjs +245 -0
  9. package/scripts/generate-password-hash.mjs +25 -0
  10. package/scripts/release-orchestrator.mjs +363 -0
  11. package/scripts/scaffold-case.mjs +460 -0
  12. package/scripts/scaffold-consumer.mjs +87 -0
  13. package/scripts/smoke-homeview.mjs +133 -0
  14. package/scripts/smoke-initializer.mjs +129 -0
  15. package/scripts/smoke-packed.mjs +184 -0
  16. package/scripts/switch-valence-source.mjs +162 -0
  17. package/scripts/sync-template-shared.mjs +136 -0
  18. package/scripts/terminal-ui.mjs +274 -0
  19. package/scripts/verify-integration.mjs +72 -0
  20. package/src/App.js +10015 -0
  21. package/src/header-visibility.contract.js +31 -0
  22. package/src/header-visibility.contract.test.js +41 -0
  23. package/src/main.js +12 -0
  24. package/src/parser/markdown.js +855 -0
  25. package/src/style.css +88 -0
  26. package/templates/README.md +242 -0
  27. package/templates/configs/i18n/i18n.config.js +230 -0
  28. package/templates/configs/i18n/i18n.labels.js +188 -0
  29. package/templates/configs/portfoliable.a11y.config.js +262 -0
  30. package/templates/configs/portfoliable.design.config.js +651 -0
  31. package/templates/gitignore +9 -0
  32. package/templates/index.html +20 -0
  33. package/templates/public/api/.htaccess +4 -0
  34. package/templates/public/api/README.md +27 -0
  35. package/templates/public/api/password.config.example.json +16 -0
  36. package/templates/public/api/password.config.json +16 -0
  37. package/templates/public/api/unlock-case.php +56 -0
  38. package/templates/public/favicon.ico +0 -0
  39. package/templates/public/favicon.png +0 -0
  40. package/templates/public/portfoliable-logo.svg +3 -0
  41. package/templates/scripts/add-language.mjs +253 -0
  42. package/templates/scripts/convert-video-to-mp4.mjs +207 -0
  43. package/templates/scripts/convert-wav-to-mp3.mjs +184 -0
  44. package/templates/scripts/delete-case.mjs +320 -0
  45. package/templates/scripts/delete-language.mjs +195 -0
  46. package/templates/scripts/generate-password-hash.mjs +98 -0
  47. package/templates/scripts/i18n-config-utils.mjs +123 -0
  48. package/templates/scripts/scaffold-case.mjs +444 -0
  49. package/templates/scripts/sync-locales.mjs +1048 -0
  50. package/templates/scripts/validate-content.mjs +257 -0
  51. package/templates/scripts/validate-protection.mjs +187 -0
  52. package/templates/src/cases/index.js +188 -0
  53. package/templates/src/content/about/ABOUTME.md +202 -0
  54. package/templates/src/content/cases/template-case/case.md +479 -0
  55. package/templates/src/content/cases/template-case/cover.png +0 -0
  56. package/templates/src/content/cases/template-case/he-cover.png +0 -0
  57. package/templates/src/content/cases/template-case/template-audio.mp3 +0 -0
  58. package/templates/src/content/cases/template-case/template-video.mp4 +0 -0
  59. package/templates/src/content/thumbnail-options.generated.json +1044 -0
  60. package/templates/src/main.js +23 -0
  61. package/templates/src/parser/markdown.js +805 -0
  62. package/vite.config.js +111 -0
@@ -0,0 +1,363 @@
1
+ // File: create-portfoliable/scripts/release-orchestrator.mjs
2
+ // Purpose: Compute semantic release plans and perform package/changelog/tag mutations for releases.
3
+ // Author: Lio Schimanko
4
+
5
+ // MARK: IMPORTS
6
+ import { execFileSync } from "node:child_process";
7
+ import { readFileSync, writeFileSync, appendFileSync, existsSync } from "node:fs";
8
+
9
+ // MARK: RELEASE CONFIGURATION
10
+ // Defines canonical repository URL used for changelog compare and commit links.
11
+ const REPO_URL = "https://github.com/portfoliablejs/portfoliable";
12
+ // Enables dry-run behavior when --dry-run is provided on process args.
13
+ const isDryRun = process.argv.includes("--dry-run");
14
+ // Enables signing release commit and tag objects when RELEASE_SIGN=true.
15
+ const shouldSign = process.env.RELEASE_SIGN === "true";
16
+
17
+ // Declares releasable package configurations included in the orchestrator run.
18
+ const packages = [
19
+ {
20
+ key: "package",
21
+ displayName: "create-portfoliable",
22
+ packageJsonPath: "create-portfoliable/package.json",
23
+ changelogPath: "CHANGELOG.md",
24
+ tagPrefix: "v",
25
+ paths: ["create-portfoliable"],
26
+ },
27
+ ];
28
+
29
+ // MARK: GIT HELPERS
30
+ // Executes git with standardized options and optional allow-failure behavior.
31
+ function git(args, options = {}) {
32
+ try {
33
+ return execFileSync("git", args, {
34
+ encoding: "utf8",
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ ...options,
37
+ }).trim();
38
+ } catch (error) {
39
+ if (options.allowFailure) {
40
+ return "";
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ // Resolves the latest tag matching a package-specific prefix.
47
+ function getLastTag(tagPrefix) {
48
+ // Builds wildcard match expression for tag lookup.
49
+ const match = `${tagPrefix}*`;
50
+ return git(["describe", "--tags", "--match", match, "--abbrev=0"], { allowFailure: true });
51
+ }
52
+
53
+ // Computes the commit range from baseline tag (or first commit) to HEAD.
54
+ function getCommitRange(lastTag) {
55
+ if (lastTag) {
56
+ return `${lastTag}..HEAD`;
57
+ }
58
+ // Resolves initial commit hash for repositories without prior release tags.
59
+ const firstCommit = git(["rev-list", "--max-parents=0", "HEAD"]);
60
+ return `${firstCommit}..HEAD`;
61
+ }
62
+
63
+ // MARK: COMMIT CLASSIFICATION
64
+ // Parses conventional commit headers and detects explicit breaking marker in header.
65
+ function parseCommitType(subject) {
66
+ // Matches conventional commit headers with optional scope and breaking marker.
67
+ const match = subject.match(/^([a-z]+)(\([^)]+\))?(!)?:\s+/i);
68
+ if (!match) {
69
+ return { type: "", breakingByHeader: false };
70
+ }
71
+ return {
72
+ type: match[1].toLowerCase(),
73
+ breakingByHeader: Boolean(match[3]),
74
+ };
75
+ }
76
+
77
+ // Classifies a commit into semantic bump level and changelog section.
78
+ function classifyCommit(subject, body) {
79
+ // Extracts parsed type and breaking-by-header signal from commit subject.
80
+ const { type, breakingByHeader } = parseCommitType(subject);
81
+ // Detects BREAKING CHANGE trailer in commit body.
82
+ const hasBreakingBody = /BREAKING CHANGE:/i.test(body);
83
+
84
+ if (breakingByHeader || hasBreakingBody) {
85
+ return { level: "major", section: "Breaking Changes" };
86
+ }
87
+
88
+ if (type === "feat") {
89
+ return { level: "minor", section: "Features" };
90
+ }
91
+
92
+ // Defines all commit types that map to patch-level release bumps.
93
+ const patchTypes = new Set([
94
+ "fix",
95
+ "perf",
96
+ "refactor",
97
+ "chore",
98
+ "docs",
99
+ "style",
100
+ "test",
101
+ "build",
102
+ "ci",
103
+ ]);
104
+
105
+ if (patchTypes.has(type)) {
106
+ return { level: "patch", section: "Bug Fixes" };
107
+ }
108
+
109
+ return { level: "none", section: "Other" };
110
+ }
111
+
112
+ // Returns the highest semantic bump level from a list of levels.
113
+ function maxLevel(levels) {
114
+ // Defines precedence ordering for semantic bump levels.
115
+ const rank = { none: 0, patch: 1, minor: 2, major: 3 };
116
+ // Tracks the highest level found during iteration.
117
+ let best = "none";
118
+ for (const level of levels) {
119
+ if (rank[level] > rank[best]) {
120
+ best = level;
121
+ }
122
+ }
123
+ return best;
124
+ }
125
+
126
+ // Reads commits in a range and returns normalized records with release classification metadata.
127
+ function readCommits(range, paths) {
128
+ // Configures git log delimiters for robust field splitting.
129
+ const format = "%H%x01%s%x01%b%x02";
130
+ // Reads scoped commit log output.
131
+ const output = git(["log", range, `--pretty=format:${format}`, "--", ...paths], { allowFailure: true });
132
+ if (!output) {
133
+ return [];
134
+ }
135
+
136
+ return output
137
+ .split("\x02")
138
+ .map((record) => record.trim())
139
+ .filter(Boolean)
140
+ .map((record) => {
141
+ // Splits serialized git record into hash, subject, and body fields.
142
+ const [hash = "", subject = "", body = ""] = record.split("\x01");
143
+ // Classifies commit into semantic release level and changelog section.
144
+ const classification = classifyCommit(subject, body);
145
+ return {
146
+ hash,
147
+ shortHash: hash.slice(0, 7),
148
+ subject,
149
+ body,
150
+ level: classification.level,
151
+ section: classification.section,
152
+ };
153
+ });
154
+ }
155
+
156
+ // MARK: VERSION AND FILE MUTATION HELPERS
157
+ // Computes the next semantic version string from current semver and bump level.
158
+ function bumpVersion(currentVersion, level) {
159
+ // Supports prerelease strings (for example, 1.0.0-alpha) by parsing numeric core semver.
160
+ const match = String(currentVersion).match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
161
+ if (!match) {
162
+ throw new Error(`Invalid semver version: ${currentVersion}`);
163
+ }
164
+
165
+ const major = Number(match[1]);
166
+ const minor = Number(match[2]);
167
+ const patch = Number(match[3]);
168
+
169
+ if (level === "major") {
170
+ return `${major + 1}.0.0`;
171
+ }
172
+ if (level === "minor") {
173
+ return `${major}.${minor + 1}.0`;
174
+ }
175
+ return `${major}.${minor}.${patch + 1}`;
176
+ }
177
+
178
+ // Updates package.json version field with computed release version.
179
+ function updatePackageJson(filePath, nextVersion) {
180
+ // Reads and parses package manifest.
181
+ const raw = readFileSync(filePath, "utf8");
182
+ // Parses package JSON structure.
183
+ const pkg = JSON.parse(raw);
184
+ pkg.version = nextVersion;
185
+ writeFileSync(filePath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
186
+ }
187
+
188
+ // Builds markdown changelog release entry from classified commit list.
189
+ function buildReleaseEntry({ nextVersion, previousTag, nextTag, commits }) {
190
+ // Captures release date in YYYY-MM-DD format.
191
+ const date = new Date().toISOString().slice(0, 10);
192
+ // Builds compare or release URL depending on baseline-tag availability.
193
+ const compareUrl = previousTag
194
+ ? `${REPO_URL}/compare/${previousTag}...${nextTag}`
195
+ : `${REPO_URL}/releases/tag/${nextTag}`;
196
+
197
+ // Initializes release heading and spacer line.
198
+ const lines = [`## [${nextVersion}](${compareUrl}) (${date})`, ""];
199
+
200
+ // Defines changelog sections in desired output order.
201
+ const sections = ["Breaking Changes", "Features", "Bug Fixes", "Other"];
202
+ for (const section of sections) {
203
+ // Selects commits assigned to current changelog section.
204
+ const sectionCommits = commits.filter((commit) => commit.section === section);
205
+ if (!sectionCommits.length) {
206
+ continue;
207
+ }
208
+
209
+ lines.push(`### ${section}`);
210
+ lines.push("");
211
+
212
+ for (const commit of sectionCommits) {
213
+ // Normalizes subject whitespace for markdown list readability.
214
+ const safeSubject = commit.subject.replace(/\s+/g, " ").trim();
215
+ lines.push(`* ${safeSubject} ([${commit.shortHash}](${REPO_URL}/commit/${commit.hash}))`);
216
+ }
217
+
218
+ lines.push("");
219
+ }
220
+
221
+ return lines.join("\n").trim();
222
+ }
223
+
224
+ // Prepends new release entry to changelog while preserving existing content below.
225
+ function prependChangelog(filePath, releaseEntry) {
226
+ // Reads existing changelog text, or initializes a minimal changelog when missing.
227
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "# Changelog\n\n";
228
+ // Prepends release entry while preserving existing history below.
229
+ const next = `${releaseEntry}\n\n${existing.trimStart()}\n`;
230
+ writeFileSync(filePath, next, "utf8");
231
+ }
232
+
233
+ // Writes workflow outputs for downstream GitHub Actions steps.
234
+ function setGithubOutput(name, value) {
235
+ if (!process.env.GITHUB_OUTPUT) {
236
+ return;
237
+ }
238
+ appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
239
+ }
240
+
241
+ // MARK: RELEASE PLANNING
242
+ // Creates release plan for a single package configuration.
243
+ function createReleasePlan(pkg) {
244
+ // Resolves previous tag baseline and scoped commit range.
245
+ const previousTag = getLastTag(pkg.tagPrefix);
246
+ // Computes commit range from baseline to HEAD.
247
+ const range = getCommitRange(previousTag);
248
+ // Reads and classifies scoped commits.
249
+ const commits = readCommits(range, pkg.paths);
250
+ // Filters to commits that map to release levels.
251
+ const releasableCommits = commits.filter((commit) => commit.level !== "none");
252
+ // Selects highest release bump level among releasable commits.
253
+ const bump = maxLevel(releasableCommits.map((commit) => commit.level));
254
+
255
+ if (bump === "none") {
256
+ return {
257
+ ...pkg,
258
+ released: false,
259
+ previousTag,
260
+ range,
261
+ commits,
262
+ releasableCommits,
263
+ };
264
+ }
265
+
266
+ // Reads current package version and computes next semantic version/tag.
267
+ const currentVersion = JSON.parse(readFileSync(pkg.packageJsonPath, "utf8")).version;
268
+ // Computes next version using selected bump level.
269
+ const nextVersion = bumpVersion(currentVersion, bump);
270
+ // Builds package tag string from prefix and next version.
271
+ const nextTag = `${pkg.tagPrefix}${nextVersion}`;
272
+
273
+ return {
274
+ ...pkg,
275
+ released: true,
276
+ previousTag,
277
+ range,
278
+ commits,
279
+ releasableCommits,
280
+ bump,
281
+ currentVersion,
282
+ nextVersion,
283
+ nextTag,
284
+ };
285
+ }
286
+
287
+ // MARK: ORCHESTRATION ENTRYPOINT
288
+ // Coordinates planning, optional mutation, commit creation, and tag creation.
289
+ function run() {
290
+ // Builds release plans for all configured packages and filters to releasable subset.
291
+ const plans = packages.map(createReleasePlan);
292
+ // Filters plans to packages that require release mutation.
293
+ const releases = plans.filter((plan) => plan.released);
294
+
295
+ for (const plan of plans) {
296
+ setGithubOutput(`released_${plan.key}`, String(plan.released));
297
+ if (plan.released) {
298
+ setGithubOutput(`${plan.key}_version`, plan.nextVersion);
299
+ setGithubOutput(`${plan.key}_tag`, plan.nextTag);
300
+ }
301
+ }
302
+
303
+ // Exports aggregate release flag for workflow conditions.
304
+ setGithubOutput("released_any", String(releases.length > 0));
305
+
306
+ if (!releases.length) {
307
+ console.log("No releasable commits detected.");
308
+ return;
309
+ }
310
+
311
+ // Applies version and changelog mutations for releasable packages.
312
+ for (const plan of releases) {
313
+ console.log(`${plan.displayName}: ${plan.currentVersion} -> ${plan.nextVersion} (${plan.bump})`);
314
+ }
315
+
316
+ if (isDryRun) {
317
+ console.log("Dry run enabled; skipping file updates, commit, and tags.");
318
+ return;
319
+ }
320
+
321
+ for (const plan of releases) {
322
+ updatePackageJson(plan.packageJsonPath, plan.nextVersion);
323
+ // Builds markdown release entry from releasable commits.
324
+ const releaseEntry = buildReleaseEntry({
325
+ nextVersion: plan.nextVersion,
326
+ previousTag: plan.previousTag,
327
+ nextTag: plan.nextTag,
328
+ commits: plan.releasableCommits,
329
+ });
330
+ prependChangelog(plan.changelogPath, releaseEntry);
331
+ }
332
+
333
+ // Stages mutated assets and creates one release commit summarizing package bumps.
334
+ const assets = releases.flatMap((plan) => [plan.packageJsonPath, plan.changelogPath]);
335
+ git(["add", ...assets]);
336
+
337
+ // Formats release summary list used in release commit message.
338
+ const summary = releases.map((plan) => `${plan.displayName}@${plan.nextVersion}`).join(", ");
339
+ // Builds git commit arguments for release mutation commit.
340
+ const commitArgs = ["commit", "-m", `chore(release): ${summary} [skip ci]`];
341
+ if (shouldSign) {
342
+ commitArgs.splice(1, 0, "-S");
343
+ }
344
+ git(commitArgs);
345
+
346
+ // Creates annotated or signed tags for each release if tag is not already present.
347
+ for (const plan of releases) {
348
+ // Checks whether target tag already exists in repository.
349
+ const tagExists = git(["rev-parse", "-q", "--verify", `refs/tags/${plan.nextTag}`], {
350
+ allowFailure: true,
351
+ });
352
+ if (!tagExists) {
353
+ // Selects signed or annotated tag command based on RELEASE_SIGN flag.
354
+ const tagArgs = shouldSign
355
+ ? ["tag", "-s", plan.nextTag, "-m", `Release ${plan.nextTag}`]
356
+ : ["tag", "-a", plan.nextTag, "-m", `Release ${plan.nextTag}`];
357
+ git(tagArgs);
358
+ }
359
+ }
360
+ }
361
+
362
+ // Executes orchestrator logic as the script main entrypoint.
363
+ run();