feat: reworked portfolio
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
PROJECT_ROOT,
|
||||
DEFAULT_ALLOWED_EXTENSIONS,
|
||||
collectSourceFiles,
|
||||
toProjectRelativePath,
|
||||
} = require('./source-files.cjs');
|
||||
|
||||
const SOURCE_DIRECTORIES = [
|
||||
path.join(PROJECT_ROOT, 'backend', 'src'),
|
||||
path.join(PROJECT_ROOT, 'src'),
|
||||
path.join(PROJECT_ROOT, 'scripts'),
|
||||
];
|
||||
const IGNORED_RELATIVE_PATHS = new Set([
|
||||
'scripts/check-source-quality.cjs',
|
||||
'scripts/fix-source-text.cjs',
|
||||
]);
|
||||
const ALLOWED_EXTENSIONS = DEFAULT_ALLOWED_EXTENSIONS;
|
||||
|
||||
const MOJIBAKE_PATTERNS = [
|
||||
{ label: 'UTF8_AS_LATIN1', regex: /\u00C3[\u0080-\u00BF]/u },
|
||||
{ label: 'REPLACEMENT_CHAR', regex: /\uFFFD/u },
|
||||
{ label: 'MISDECODED_QUESTION_MARK', regex: /\u00EF\u00BF\u00BD/u },
|
||||
{ label: 'CP1252_ARTIFACT', regex: /\u00C2[\u0080-\u00BF]?/u },
|
||||
{
|
||||
label: 'DOUBLE_DECODED_APOSTROPHE',
|
||||
regex: /\u00E2\u20AC\u2122|\u00E2\u20AC\u0153|\u00E2\u20AC\u009D/u,
|
||||
},
|
||||
];
|
||||
|
||||
const ASCII_TRANSLITERATION_PATTERNS = [
|
||||
{
|
||||
label: 'ASCII_TRANSLITERATION',
|
||||
regex:
|
||||
/\b(?:fuer|Fuer|gueltig|Gueltig|ungueltig|Ungueltig|zurueck|Zurueck|ueberein|Ueberein|waehlen|Waehlen|zaehler|Zaehler|ausfuellen|Ausfuellen|geschuetzt|Geschuetzt|verschluesselt|Verschluesselt)\b/u,
|
||||
},
|
||||
];
|
||||
|
||||
const VAR_PATTERN = /\bvar\s+[A-Za-z_$][A-Za-z0-9_$]*/u;
|
||||
|
||||
/** @type {Array<{file: string, rule: string, detail: string}>} */
|
||||
const issues = [];
|
||||
|
||||
for (const directoryPath of SOURCE_DIRECTORIES) {
|
||||
const files = collectSourceFiles(directoryPath, ALLOWED_EXTENSIONS);
|
||||
for (const filePath of files) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const relativePath = toProjectRelativePath(filePath);
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (IGNORED_RELATIVE_PATHS.has(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const pattern of MOJIBAKE_PATTERNS) {
|
||||
if (pattern.regex.test(source)) {
|
||||
issues.push({
|
||||
file: relativePath,
|
||||
rule: pattern.label,
|
||||
detail: 'possible mojibake detected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of ASCII_TRANSLITERATION_PATTERNS) {
|
||||
if (pattern.regex.test(source)) {
|
||||
issues.push({
|
||||
file: relativePath,
|
||||
rule: pattern.label,
|
||||
detail: 'possible ASCII transliteration detected',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (extension !== '.html' && VAR_PATTERN.test(source)) {
|
||||
issues.push({
|
||||
file: relativePath,
|
||||
rule: 'NO_VAR',
|
||||
detail: 'use const/let instead of var',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log('Source quality check passed: no mojibake artifacts and no var declarations found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error('Source quality check failed:');
|
||||
for (const issue of issues) {
|
||||
console.error(`- ${issue.file} [${issue.rule}] ${issue.detail}`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,40 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const sources = [
|
||||
{
|
||||
source: path.join(process.cwd(), 'src', 'robots.txt'),
|
||||
fileName: 'robots.txt',
|
||||
},
|
||||
{
|
||||
source: path.join(process.cwd(), 'src', 'sitemap.xml'),
|
||||
fileName: 'sitemap.xml',
|
||||
},
|
||||
];
|
||||
|
||||
const distRoot = path.join(process.cwd(), 'dist');
|
||||
const browserDir = path.join(distRoot, 'browser');
|
||||
|
||||
const targetDirectories = [distRoot, browserDir]
|
||||
.filter((targetDirectory) => fs.existsSync(targetDirectory))
|
||||
.filter((targetDirectory, index, list) => list.indexOf(targetDirectory) === index);
|
||||
|
||||
if (targetDirectories.length === 0) {
|
||||
console.warn('Skipping static root file copy: dist directory not found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const { source, fileName } of sources) {
|
||||
if (!fs.existsSync(source)) {
|
||||
console.warn(`Skipping ${fileName} copy: source file not found.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const targetDirectory of targetDirectories) {
|
||||
const targetFile = path.join(targetDirectory, fileName);
|
||||
fs.copyFileSync(source, targetFile);
|
||||
console.log(
|
||||
`Copied ${path.relative(process.cwd(), source)} -> ${path.relative(process.cwd(), targetFile)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ENV_FILE_CANDIDATES = [
|
||||
path.join(process.cwd(), '.env'),
|
||||
path.join(process.cwd(), 'backend', '.env'),
|
||||
];
|
||||
|
||||
function applyEnvFile(filePath, env = process.env) {
|
||||
const rawFile = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/u, '');
|
||||
const lines = rawFile.split(/\r?\n/u);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const withoutExport = trimmedLine.startsWith('export ')
|
||||
? trimmedLine.slice('export '.length).trim()
|
||||
: trimmedLine;
|
||||
const separatorIndex = withoutExport.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = withoutExport.slice(0, separatorIndex).trim();
|
||||
let value = withoutExport.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) || key in env) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value
|
||||
.slice(1, -1)
|
||||
.replace(/\\n/gu, '\n')
|
||||
.replace(/\\r/gu, '\r')
|
||||
.replace(/\\t/gu, '\t')
|
||||
.replace(/\\"/gu, '"')
|
||||
.replace(/\\\\/gu, '\\');
|
||||
} else if (value.startsWith("'") && value.endsWith("'")) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
value = value.replace(/\s+#.*$/u, '').trim();
|
||||
}
|
||||
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function loadEnvFromFileCandidates(envFileCandidates = ENV_FILE_CANDIDATES, env = process.env) {
|
||||
for (const envFilePath of envFileCandidates) {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
applyEnvFile(envFilePath, env);
|
||||
return envFilePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePositiveIntEnv(names, fallback, env = process.env) {
|
||||
const candidates = Array.isArray(names) ? names : [names];
|
||||
|
||||
for (const name of candidates) {
|
||||
const rawValue = env[name];
|
||||
if (rawValue === undefined || rawValue === null || rawValue.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive integer. Received "${rawValue}".`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseTimeZoneEnv(name, fallback, env = process.env) {
|
||||
const rawValue = env[name];
|
||||
const value =
|
||||
rawValue === undefined || rawValue === null || rawValue.trim().length === 0
|
||||
? fallback
|
||||
: rawValue.trim();
|
||||
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: value,
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`${name} must be a valid IANA timezone. Received "${rawValue}".`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadEnvFromFileCandidates,
|
||||
parsePositiveIntEnv,
|
||||
parseTimeZoneEnv,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
PROJECT_ROOT,
|
||||
DEFAULT_ALLOWED_EXTENSIONS,
|
||||
collectSourceFiles,
|
||||
toProjectRelativePath,
|
||||
} = require('./source-files.cjs');
|
||||
|
||||
const SOURCE_DIRECTORIES = [
|
||||
path.join(PROJECT_ROOT, 'backend', 'src'),
|
||||
path.join(PROJECT_ROOT, 'src'),
|
||||
];
|
||||
const ALLOWED_EXTENSIONS = DEFAULT_ALLOWED_EXTENSIONS;
|
||||
const WRITE_MODE = process.argv.includes('--write');
|
||||
|
||||
const DIRECT_REPLACEMENTS = [
|
||||
['Ä', 'Ä'],
|
||||
['Ö', 'Ö'],
|
||||
['Ü', 'Ü'],
|
||||
['ä', 'ä'],
|
||||
['ö', 'ö'],
|
||||
['ü', 'ü'],
|
||||
['ß', 'ß'],
|
||||
['’', '’'],
|
||||
['“', '“'],
|
||||
['â€', '”'],
|
||||
['–', '–'],
|
||||
['—', '—'],
|
||||
['…', '…'],
|
||||
['§', '§'],
|
||||
['Ä', 'Ä'],
|
||||
['Ö', 'Ö'],
|
||||
['Ü', 'Ü'],
|
||||
['ä', 'ä'],
|
||||
['ö', 'ö'],
|
||||
['ü', 'ü'],
|
||||
['ß', 'ß'],
|
||||
['§', '§'],
|
||||
];
|
||||
|
||||
const WORD_REPLACEMENTS = [
|
||||
[/\bAufrufzaehler\b/gu, 'Aufrufzähler'],
|
||||
[/\baufzaehlen\b/gu, 'aufzählen'],
|
||||
[/\bAusfuellen\b/gu, 'Ausfüllen'],
|
||||
[/\bausfuellen\b/gu, 'ausfüllen'],
|
||||
[/\bAuswaehlen\b/gu, 'Auswählen'],
|
||||
[/\bauswaehlen\b/gu, 'auswählen'],
|
||||
[/\bEnthaelt\b/gu, 'Enthält'],
|
||||
[/\benthaelt\b/gu, 'enthält'],
|
||||
[/\bFuer\b/gu, 'Für'],
|
||||
[/\bfuer\b/gu, 'für'],
|
||||
[/\bGeschuetzt\b/gu, 'Geschützt'],
|
||||
[/\bgeschuetzt\b/gu, 'geschützt'],
|
||||
[/\bGueltig\b/gu, 'Gültig'],
|
||||
[/\bgueltig\b/gu, 'gültig'],
|
||||
[/\bOesterreich\b/gu, 'Österreich'],
|
||||
[/\boesterreich\b/gu, 'österreich'],
|
||||
[/\bUeberein\b/gu, 'Überein'],
|
||||
[/\bueberein\b/gu, 'überein'],
|
||||
[/\bUngueltig\b/gu, 'Ungültig'],
|
||||
[/\bungueltig\b/gu, 'ungültig'],
|
||||
[/\bVerschluesselt\b/gu, 'Verschlüsselt'],
|
||||
[/\bverschluesselt\b/gu, 'verschlüsselt'],
|
||||
[/\bWaehlen\b/gu, 'Wählen'],
|
||||
[/\bwaehlen\b/gu, 'wählen'],
|
||||
[/\bZaehler\b/gu, 'Zähler'],
|
||||
[/\bzaehler\b/gu, 'zähler'],
|
||||
[/\bZurueck\b/gu, 'Zurück'],
|
||||
[/\bzurueck\b/gu, 'zurück'],
|
||||
];
|
||||
|
||||
function applyTextFixes(source) {
|
||||
let nextSource = source;
|
||||
|
||||
for (const [searchValue, replaceValue] of DIRECT_REPLACEMENTS) {
|
||||
if (nextSource.includes(searchValue)) {
|
||||
nextSource = nextSource.split(searchValue).join(replaceValue);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pattern, replaceValue] of WORD_REPLACEMENTS) {
|
||||
nextSource = nextSource.replace(pattern, replaceValue);
|
||||
}
|
||||
|
||||
return nextSource;
|
||||
}
|
||||
|
||||
const changedFiles = [];
|
||||
|
||||
for (const directoryPath of SOURCE_DIRECTORIES) {
|
||||
for (const filePath of collectSourceFiles(directoryPath, ALLOWED_EXTENSIONS)) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const nextSource = applyTextFixes(source);
|
||||
if (nextSource === source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changedFiles.push(toProjectRelativePath(filePath));
|
||||
if (WRITE_MODE) {
|
||||
fs.writeFileSync(filePath, nextSource, 'utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log(`Source text fixer: no changes ${WRITE_MODE ? 'written' : 'needed'}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const actionLabel = WRITE_MODE ? 'updated' : 'would update';
|
||||
console.log(`Source text fixer ${actionLabel} ${changedFiles.length} file(s):`);
|
||||
for (const filePath of changedFiles) {
|
||||
console.log(`- ${filePath}`);
|
||||
}
|
||||
|
||||
process.exit(WRITE_MODE ? 0 : 1);
|
||||
@@ -0,0 +1,117 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadEnvFromFileCandidates } = require('./env-utils.cjs');
|
||||
const staticSiteConfig = require('./static-site.config.cjs');
|
||||
|
||||
loadEnvFromFileCandidates();
|
||||
|
||||
const outputDir = path.join(process.cwd(), 'src');
|
||||
const robotsPath = path.join(outputDir, 'robots.txt');
|
||||
const sitemapPath = path.join(outputDir, 'sitemap.xml');
|
||||
|
||||
const configuredSiteUrl = process.env.PUBLIC_SITE_URL || staticSiteConfig.siteUrl;
|
||||
const siteUrl = normalizeSiteUrl(configuredSiteUrl);
|
||||
|
||||
if (!siteUrl) {
|
||||
throw new Error(
|
||||
'Missing site URL for static file generation. Set PUBLIC_SITE_URL or scripts/static-site.config.cjs.',
|
||||
);
|
||||
}
|
||||
|
||||
const robotsContent = createRobotsContent(staticSiteConfig.robots, siteUrl);
|
||||
const sitemapContent = createSitemapContent(staticSiteConfig.sitemap.routes, siteUrl);
|
||||
|
||||
fs.writeFileSync(robotsPath, `${robotsContent}\n`, 'utf8');
|
||||
fs.writeFileSync(sitemapPath, `${sitemapContent}\n`, 'utf8');
|
||||
|
||||
console.log(`Generated ${path.relative(process.cwd(), robotsPath)}`);
|
||||
console.log(`Generated ${path.relative(process.cwd(), sitemapPath)}`);
|
||||
|
||||
function normalizeSiteUrl(rawSiteUrl) {
|
||||
if (typeof rawSiteUrl !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmedSiteUrl = rawSiteUrl.trim().replace(/\/+$/u, '');
|
||||
if (!trimmedSiteUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedUrl = new URL(trimmedSiteUrl);
|
||||
return normalizedUrl.toString().replace(/\/+$/u, '');
|
||||
} catch {
|
||||
throw new Error(`Invalid site URL "${rawSiteUrl}" in static site configuration.`);
|
||||
}
|
||||
}
|
||||
|
||||
function createRobotsContent(robotsConfig, resolvedSiteUrl) {
|
||||
const lines = ['User-agent: *', ''];
|
||||
|
||||
for (const allowedPath of robotsConfig.allow) {
|
||||
lines.push(`Allow: ${allowedPath}`);
|
||||
}
|
||||
|
||||
for (const disallowedPath of robotsConfig.disallow) {
|
||||
lines.push(`Disallow: ${disallowedPath}`);
|
||||
}
|
||||
|
||||
lines.push('', `Sitemap: ${resolvedSiteUrl}/sitemap.xml`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function createSitemapContent(routes, resolvedSiteUrl) {
|
||||
const entries = routes
|
||||
.map((route) => {
|
||||
const normalizedPath = normalizeRoutePath(route.path);
|
||||
const parts = [
|
||||
' <url>',
|
||||
` <loc>${escapeXml(`${resolvedSiteUrl}${normalizedPath}`)}</loc>`,
|
||||
];
|
||||
|
||||
if (route.lastModified) {
|
||||
parts.push(` <lastmod>${escapeXml(route.lastModified)}</lastmod>`);
|
||||
}
|
||||
|
||||
if (route.changeFrequency) {
|
||||
parts.push(` <changefreq>${escapeXml(route.changeFrequency)}</changefreq>`);
|
||||
}
|
||||
|
||||
if (route.priority) {
|
||||
parts.push(` <priority>${escapeXml(route.priority)}</priority>`);
|
||||
}
|
||||
|
||||
parts.push(' </url>');
|
||||
return parts.join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
entries,
|
||||
'</urlset>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function normalizeRoutePath(routePath) {
|
||||
if (routePath === '/') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof routePath !== 'string' || !routePath.startsWith('/')) {
|
||||
throw new Error(`Invalid sitemap route "${routePath}". Routes must start with "/".`);
|
||||
}
|
||||
|
||||
return routePath;
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
.replace(/&/gu, '&')
|
||||
.replace(/"/gu, '"')
|
||||
.replace(/'/gu, ''')
|
||||
.replace(/</gu, '<')
|
||||
.replace(/>/gu, '>');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||
const DEFAULT_ALLOWED_EXTENSIONS = new Set(['.ts', '.js', '.cjs', '.html', '.css']);
|
||||
|
||||
/**
|
||||
* @param {string} directoryPath
|
||||
* @param {Set<string>} [allowedExtensions]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function collectSourceFiles(directoryPath, allowedExtensions = DEFAULT_ALLOWED_EXTENSIONS) {
|
||||
/** @type {string[]} */
|
||||
const results = [];
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const queue = [directoryPath];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentPath = queue.pop();
|
||||
if (!currentPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(absolutePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = path.extname(entry.name).toLowerCase();
|
||||
if (allowedExtensions.has(extension)) {
|
||||
results.push(absolutePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} absolutePath
|
||||
* @returns {string}
|
||||
*/
|
||||
function toProjectRelativePath(absolutePath) {
|
||||
return path.relative(PROJECT_ROOT, absolutePath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROJECT_ROOT,
|
||||
DEFAULT_ALLOWED_EXTENSIONS,
|
||||
collectSourceFiles,
|
||||
toProjectRelativePath,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
siteUrl: 'https://fraujulian.xyz',
|
||||
sitemap: {
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
lastModified: '2026-04-17',
|
||||
changeFrequency: 'monthly',
|
||||
priority: '1.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
robots: {
|
||||
allow: ['/'],
|
||||
disallow: [],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||
const PACKAGE_JSON_PATH = path.join(PROJECT_ROOT, 'package.json');
|
||||
const PACKAGE_LOCK_PATH = path.join(PROJECT_ROOT, 'package-lock.json');
|
||||
const VERSION_PLACEHOLDER = 'YYDDDhhmm+GIT_HASH';
|
||||
const RESOLVED_BUILD_PATTERN = /^\d{2}\d{3}\d{4}\+[A-Za-z0-9_-]+$/;
|
||||
const MODES = new Set(['--set', '--ensure', '--check', '--place']);
|
||||
|
||||
function readJson(location) {
|
||||
return JSON.parse(fs.readFileSync(location, 'utf8'));
|
||||
}
|
||||
|
||||
function writeJson(location, value) {
|
||||
fs.writeFileSync(location, `${JSON.stringify(value, null, 4)}\n`);
|
||||
}
|
||||
|
||||
function getSelectedMode() {
|
||||
let selectedModes = process.argv.slice(2).filter((value) => MODES.has(value));
|
||||
|
||||
if (selectedModes.length !== 1) {
|
||||
throw new Error('Use exactly one mode: --set, --ensure, --check or --place.');
|
||||
}
|
||||
|
||||
return selectedModes[0];
|
||||
}
|
||||
|
||||
function getVersionParts(version) {
|
||||
let parts = String(version).split('.');
|
||||
|
||||
if (parts.length < 3) {
|
||||
throw new Error(`Version "${version}" does not follow the expected major.minor.build format.`);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function getVersionPrefix(version) {
|
||||
let [major, minor] = getVersionParts(version);
|
||||
return `${major}.${minor}.`;
|
||||
}
|
||||
|
||||
function getBuildPart(version) {
|
||||
let [, , buildPart] = getVersionParts(version);
|
||||
return buildPart;
|
||||
}
|
||||
|
||||
function hasPlaceholderVersion(version) {
|
||||
return version === VERSION_PLACEHOLDER;
|
||||
}
|
||||
|
||||
function hasResolvedVersion(version) {
|
||||
let buildPart = getBuildPart(version);
|
||||
return buildPart !== VERSION_PLACEHOLDER && RESOLVED_BUILD_PATTERN.test(buildPart);
|
||||
}
|
||||
|
||||
function getExplicitVersion() {
|
||||
return process.env.BUILD_VERSION?.trim() || '';
|
||||
}
|
||||
|
||||
function getGitHash() {
|
||||
let explicitHash = process.env.BUILD_GIT_HASH?.trim() || process.env.GITHUB_SHA?.trim();
|
||||
|
||||
if (explicitHash) {
|
||||
return explicitHash.slice(0, 7);
|
||||
}
|
||||
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return 'nogit';
|
||||
}
|
||||
}
|
||||
|
||||
function getBuildNumber() {
|
||||
let now = new Date();
|
||||
let year = String(now.getFullYear()).slice(-2);
|
||||
let startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
let dayOfYear = Math.floor((now - startOfYear) / (1000 * 60 * 60 * 24)) + 1;
|
||||
let hours = String(now.getHours()).padStart(2, '0');
|
||||
let minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}${String(dayOfYear).padStart(3, '0')}${hours}${minutes}`;
|
||||
}
|
||||
|
||||
function resolveVersion(currentVersion) {
|
||||
let explicitVersion = getExplicitVersion();
|
||||
|
||||
if (explicitVersion) {
|
||||
return explicitVersion;
|
||||
}
|
||||
|
||||
return `${getVersionPrefix(currentVersion)}${getBuildNumber()}-${getGitHash()}`;
|
||||
}
|
||||
|
||||
function isAlreadyEnsured(currentVersion) {
|
||||
let explicitVersion = getExplicitVersion();
|
||||
|
||||
if (explicitVersion) {
|
||||
return currentVersion === explicitVersion;
|
||||
}
|
||||
|
||||
return hasResolvedVersion(currentVersion);
|
||||
}
|
||||
|
||||
function writeResolvedVersion(resolvedVersion) {
|
||||
let packageJson = readJson(PACKAGE_JSON_PATH);
|
||||
packageJson.version = resolvedVersion;
|
||||
writeJson(PACKAGE_JSON_PATH, packageJson);
|
||||
|
||||
if (!fs.existsSync(PACKAGE_LOCK_PATH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let packageLock = readJson(PACKAGE_LOCK_PATH);
|
||||
packageLock.version = resolvedVersion;
|
||||
|
||||
if (packageLock.packages && packageLock.packages['']) {
|
||||
packageLock.packages[''].version = resolvedVersion;
|
||||
}
|
||||
|
||||
writeJson(PACKAGE_LOCK_PATH, packageLock);
|
||||
}
|
||||
|
||||
function runCheck(currentVersion) {
|
||||
if (hasPlaceholderVersion(currentVersion)) {
|
||||
console.log(`Placeholder version detected: ${currentVersion}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Resolved build version detected: ${currentVersion}\nShould be: ${VERSION_PLACEHOLDER}`,
|
||||
);
|
||||
}
|
||||
|
||||
function runEnsure(currentVersion) {
|
||||
if (isAlreadyEnsured(currentVersion)) {
|
||||
console.log(`Version already set: ${currentVersion}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPlaceholderVersion(currentVersion)) {
|
||||
throw new Error(`Placeholder version detected: ${currentVersion}`);
|
||||
}
|
||||
|
||||
if (getExplicitVersion()) {
|
||||
throw new Error(
|
||||
`Version "${currentVersion}" does not match BUILD_VERSION "${getExplicitVersion()}".`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Version "${currentVersion}" is not a resolved build version.`);
|
||||
}
|
||||
|
||||
function runSet(currentVersion) {
|
||||
let resolvedVersion = resolveVersion(currentVersion);
|
||||
writeResolvedVersion(resolvedVersion);
|
||||
console.log(`Version set to: ${resolvedVersion}`);
|
||||
}
|
||||
|
||||
function runSetPlace() {
|
||||
writeResolvedVersion(VERSION_PLACEHOLDER);
|
||||
console.log(`Version set to: ${VERSION_PLACEHOLDER}`);
|
||||
}
|
||||
|
||||
try {
|
||||
let selectedMode = getSelectedMode();
|
||||
let packageJson = readJson(PACKAGE_JSON_PATH);
|
||||
let packageVersion = String(packageJson.version);
|
||||
let currentVersion = String(packageJson.currentVersion);
|
||||
|
||||
switch (selectedMode) {
|
||||
case '--check':
|
||||
runCheck(packageVersion);
|
||||
break;
|
||||
case '--ensure':
|
||||
runEnsure(packageVersion);
|
||||
break;
|
||||
case '--set':
|
||||
runSet(currentVersion);
|
||||
break;
|
||||
case '--place':
|
||||
runSetPlace();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown mode: ${selectedMode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user