consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
90
src/Tools/StellaOps.DevPortal.Site/scripts/build-offline.mjs
Normal file
90
src/Tools/StellaOps.DevPortal.Site/scripts/build-offline.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const moduleRoot = path.resolve(__dirname, '..');
|
||||
const outDir = path.join(moduleRoot, 'dist');
|
||||
const bundleDir = path.join(moduleRoot, 'out');
|
||||
const bundleFile = path.join(bundleDir, 'devportal-offline.tar.gz');
|
||||
const specPath = path.join(moduleRoot, 'public', 'api', 'stella.yaml');
|
||||
const sdkDir = path.join(moduleRoot, 'public', 'sdk');
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
function ensureSpec() {
|
||||
if (!fs.existsSync(specPath)) {
|
||||
throw new Error(`[devportal:offline] missing spec at ${specPath}; run npm run sync:spec`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSdkFolder() {
|
||||
if (!fs.existsSync(sdkDir)) {
|
||||
fs.mkdirSync(sdkDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sdkDir, 'README.txt'),
|
||||
'Place SDK archives here (e.g., stellaops-sdk-node-vX.Y.Z.tgz, stellaops-sdk-python-vX.Y.Z.tar.gz).\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runBuild() {
|
||||
console.log('[devportal:offline] running astro build');
|
||||
if (process.platform === 'win32') {
|
||||
execSync('npm run build', { stdio: 'inherit', cwd: moduleRoot, shell: true });
|
||||
return;
|
||||
}
|
||||
|
||||
execFileSync(npmCmd, ['run', 'build'], { stdio: 'inherit', cwd: moduleRoot });
|
||||
}
|
||||
|
||||
function packageBundle() {
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
if (fs.existsSync(bundleFile)) {
|
||||
fs.rmSync(bundleFile);
|
||||
}
|
||||
const deterministicArgs = [
|
||||
'--sort=name',
|
||||
'--mtime', '@0',
|
||||
'--owner', '0',
|
||||
'--group', '0',
|
||||
'--numeric-owner',
|
||||
'-czf', bundleFile,
|
||||
'-C', moduleRoot,
|
||||
'dist',
|
||||
'public/api/stella.yaml',
|
||||
'public/sdk'
|
||||
];
|
||||
const portableArgs = [
|
||||
'-czf', bundleFile,
|
||||
'-C', moduleRoot,
|
||||
'dist',
|
||||
'public/api/stella.yaml',
|
||||
'public/sdk'
|
||||
];
|
||||
console.log(`[devportal:offline] creating ${bundleFile}`);
|
||||
try {
|
||||
execFileSync('tar', deterministicArgs, { stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
// Some tar implementations (notably Windows bsdtar) don't support GNU deterministic flags.
|
||||
if (process.platform !== 'win32') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn('[devportal:offline] deterministic tar flags unsupported on host tar; falling back to portable archive flags.');
|
||||
execFileSync('tar', portableArgs, { stdio: 'inherit' });
|
||||
}
|
||||
const size = (fs.statSync(bundleFile).size / 1024 / 1024).toFixed(2);
|
||||
console.log(`[devportal:offline] bundle ready (${size} MiB)`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureSpec();
|
||||
ensureSdkFolder();
|
||||
runBuild();
|
||||
packageBundle();
|
||||
}
|
||||
|
||||
main();
|
||||
153
src/Tools/StellaOps.DevPortal.Site/scripts/check-links.mjs
Normal file
153
src/Tools/StellaOps.DevPortal.Site/scripts/check-links.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as wait } from 'node:timers/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { LinkChecker } from 'linkinator';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HOST = process.env.DEVPORT_HOST ?? '127.0.0.1';
|
||||
const PORT = process.env.DEVPORT_PORT ?? '4321';
|
||||
const BASE = `http://${HOST}:${PORT}`;
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const moduleRoot = path.resolve(path.dirname(__filename), '..');
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
function killPreviewIfRunning() {
|
||||
if (process.platform === 'win32') return;
|
||||
const child = spawn('pkill', ['-f', `astro preview --host ${HOST} --port ${PORT}`], { stdio: 'ignore' });
|
||||
child.on('error', () => {
|
||||
// best effort
|
||||
});
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.platform === 'win32' ? 'npm' : npmCmd, ['run', 'preview', '--', '--host', HOST, '--port', PORT], {
|
||||
cwd: moduleRoot,
|
||||
stdio: 'ignore',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.once('error', reject);
|
||||
resolve(child);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const url = `${BASE}/`;
|
||||
const clientFor = (u) => (u.protocol === 'https:' ? https : http);
|
||||
const probe = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const target = new URL(url);
|
||||
const req = clientFor(target).request(
|
||||
target,
|
||||
{ method: 'GET', timeout: 2000 },
|
||||
(res) => {
|
||||
resolve(res.statusCode ?? 503);
|
||||
res.resume();
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('timeout'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
for (let i = 0; i < 120; i++) {
|
||||
try {
|
||||
const status = await probe();
|
||||
if (status < 500) {
|
||||
await wait(500); // small buffer after first success
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
await wait(500);
|
||||
}
|
||||
// If we couldn't confirm readiness, proceed; link checker will surface real failures.
|
||||
}
|
||||
|
||||
async function checkLinks() {
|
||||
const checker = new LinkChecker();
|
||||
const failures = [];
|
||||
|
||||
checker.on('link', (event) => {
|
||||
if (event.state !== 'BROKEN') return;
|
||||
failures.push({ url: event.url, status: event.status });
|
||||
});
|
||||
|
||||
await checker.check({
|
||||
path: `${BASE}/docs/`,
|
||||
recurse: true,
|
||||
maxDepth: 3,
|
||||
concurrency: 16,
|
||||
linksToSkip: [/mailto:/, /tel:/, /devportal\\.stellaops\\.local/, /git\\.stella-ops\\.org/],
|
||||
});
|
||||
|
||||
// Astro preview on some hosts can serve directory pages only via /index.html.
|
||||
// For internal trailing-slash links, re-probe /index.html before flagging broken.
|
||||
const normalized = [];
|
||||
for (const failure of failures) {
|
||||
if (
|
||||
failure.url.startsWith(BASE) &&
|
||||
failure.url.endsWith('/')
|
||||
) {
|
||||
const indexUrl = `${failure.url}index.html`;
|
||||
try {
|
||||
const target = new URL(indexUrl);
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const req = (target.protocol === 'https:' ? https : http).request(
|
||||
target,
|
||||
{ method: 'GET', timeout: 2000 },
|
||||
(res) => {
|
||||
resolve(res.statusCode ?? 503);
|
||||
res.resume();
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
req.end();
|
||||
});
|
||||
if (status < 400) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// keep original failure
|
||||
}
|
||||
}
|
||||
|
||||
normalized.push(failure);
|
||||
}
|
||||
|
||||
const filtered = normalized.filter(
|
||||
(f) =>
|
||||
!f.url.includes('devportal.stellaops.local') &&
|
||||
!f.url.includes('git.stella-ops.org')
|
||||
);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
console.error('[links] broken links found');
|
||||
filtered.forEach((f) => console.error(`- ${f.status} ${f.url}`));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('[links] no broken links detected');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
killPreviewIfRunning();
|
||||
const server = await startPreview();
|
||||
try {
|
||||
await waitForServer();
|
||||
await checkLinks();
|
||||
} finally {
|
||||
server.kill('SIGINT');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
79
src/Tools/StellaOps.DevPortal.Site/scripts/check-perf.mjs
Normal file
79
src/Tools/StellaOps.DevPortal.Site/scripts/check-perf.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const moduleRoot = path.resolve(path.dirname(__filename), '..');
|
||||
const distDir = path.join(moduleRoot, 'dist');
|
||||
|
||||
function folderSize(dir) {
|
||||
let total = 0;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += folderSize(full);
|
||||
} else {
|
||||
total += fs.statSync(full).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function largestFile(dir) {
|
||||
let max = { size: 0, file: '' };
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const child = largestFile(full);
|
||||
if (child.size > max.size) max = child;
|
||||
} else {
|
||||
const size = fs.statSync(full).size;
|
||||
if (size > max.size) {
|
||||
max = { size, file: full };
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function formatMB(bytes) {
|
||||
return (bytes / 1024 / 1024).toFixed(2);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(distDir)) {
|
||||
console.error('[budget] dist/ not found; run `npm run build` first');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const total = folderSize(distDir);
|
||||
const largest = largestFile(distDir);
|
||||
|
||||
const budgetTotal = 30 * 1024 * 1024; // 30 MB
|
||||
const budgetSingle = 1 * 1024 * 1024; // 1 MB
|
||||
|
||||
console.log(`[budget] dist size ${formatMB(total)} MiB (budget <= ${formatMB(budgetTotal)} MiB)`);
|
||||
console.log(`[budget] largest file ${formatMB(largest.size)} MiB -> ${path.relative(moduleRoot, largest.file)} (budget <= ${formatMB(budgetSingle)} MiB)`);
|
||||
|
||||
let fail = false;
|
||||
if (total > budgetTotal) {
|
||||
console.error('[budget] total size exceeds budget');
|
||||
fail = true;
|
||||
}
|
||||
if (largest.size > budgetSingle) {
|
||||
console.error('[budget] single-asset size exceeds budget');
|
||||
fail = true;
|
||||
}
|
||||
|
||||
if (fail) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('[budget] budgets satisfied');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
156
src/Tools/StellaOps.DevPortal.Site/scripts/run-a11y.mjs
Normal file
156
src/Tools/StellaOps.DevPortal.Site/scripts/run-a11y.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as wait } from 'node:timers/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HOST = process.env.DEVPORT_HOST ?? '127.0.0.1';
|
||||
const PORT = process.env.DEVPORT_PORT ?? '4321';
|
||||
const BASE = `http://${HOST}:${PORT}`;
|
||||
const PAGES = ['/docs/', '/docs/api-reference/', '/docs/try-it-console/'];
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const moduleRoot = path.resolve(path.dirname(__filename), '..');
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
function hasSystemDeps() {
|
||||
if (process.platform === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const out = execSync('ldconfig -p', { encoding: 'utf-8' });
|
||||
return out.includes('libnss3') && out.includes('libnspr4') && out.match(/libasound2|libasound\.so/);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function killPreviewIfRunning() {
|
||||
if (process.platform === 'win32') return;
|
||||
const child = spawn('pkill', ['-f', `astro preview --host ${HOST} --port ${PORT}`], { stdio: 'ignore' });
|
||||
child.on('error', () => {
|
||||
// best effort
|
||||
});
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.platform === 'win32' ? 'npm' : npmCmd, ['run', 'preview', '--', '--host', HOST, '--port', PORT], {
|
||||
cwd: moduleRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.once('error', reject);
|
||||
resolve(child);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const url = `${BASE}/`;
|
||||
const clientFor = (u) => (u.protocol === 'https:' ? https : http);
|
||||
const probe = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const target = new URL(url);
|
||||
const req = clientFor(target).request(
|
||||
target,
|
||||
{ method: 'GET', timeout: 2000 },
|
||||
(res) => {
|
||||
resolve(res.statusCode ?? 503);
|
||||
res.resume();
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
req.end();
|
||||
});
|
||||
for (let i = 0; i < 120; i++) {
|
||||
try {
|
||||
const status = await probe();
|
||||
if (status < 500) {
|
||||
await wait(500);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
await wait(500);
|
||||
}
|
||||
// proceed even if probe failed; a11y run will surface real issues
|
||||
}
|
||||
|
||||
async function runA11y() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
} catch (err) {
|
||||
console.warn('[a11y] skipped: Playwright browser failed to launch (missing system deps? libnss3/libnspr4/libasound2).', err.message);
|
||||
return { skipped: true, failed: false };
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
const violationsAll = [];
|
||||
|
||||
for (const path of PAGES) {
|
||||
const url = `${BASE}${path}`;
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const axe = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
const results = await axe.analyze();
|
||||
if (results.violations.length > 0) {
|
||||
violationsAll.push({ path, violations: results.violations });
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (violationsAll.length > 0) {
|
||||
console.error('[a11y] violations found');
|
||||
for (const { path, violations } of violationsAll) {
|
||||
console.error(`- ${path}`);
|
||||
violations.forEach((v) => {
|
||||
console.error(` • ${v.id}: ${v.description}`);
|
||||
});
|
||||
}
|
||||
return { skipped: false, failed: true };
|
||||
}
|
||||
|
||||
console.log('[a11y] no violations detected');
|
||||
return { skipped: false, failed: false };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
killPreviewIfRunning();
|
||||
if (!hasSystemDeps()) {
|
||||
console.warn('[a11y] skipped: host missing system deps (libnss3/libnspr4/libasound2).');
|
||||
return;
|
||||
}
|
||||
const server = await startPreview();
|
||||
try {
|
||||
await waitForServer();
|
||||
const result = await runA11y();
|
||||
if (result?.failed) process.exitCode = 1;
|
||||
} finally {
|
||||
server.kill('SIGINT');
|
||||
killPreviewIfRunning();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const msg = err?.message ?? '';
|
||||
const missingDeps =
|
||||
msg.includes('Host system is missing dependencies') ||
|
||||
msg.includes('libnss3') ||
|
||||
msg.includes('libnspr4') ||
|
||||
msg.includes('libasound2');
|
||||
if (missingDeps) {
|
||||
console.warn('[a11y] skipped: host missing Playwright runtime deps (libnss3/libnspr4/libasound2).');
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
32
src/Tools/StellaOps.DevPortal.Site/scripts/sync-spec.mjs
Normal file
32
src/Tools/StellaOps.DevPortal.Site/scripts/sync-spec.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const moduleRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(moduleRoot, '..', '..', '..');
|
||||
const sourceSpec = path.join(repoRoot, 'src/Api/StellaOps.Api.OpenApi/stella.yaml');
|
||||
const targetDir = path.join(moduleRoot, 'public', 'api');
|
||||
const targetSpec = path.join(targetDir, 'stella.yaml');
|
||||
|
||||
function hashFile(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceSpec)) {
|
||||
console.error(`[devportal:sync-spec] missing source spec at ${sourceSpec}`);
|
||||
process.exitCode = 1;
|
||||
process.exit();
|
||||
}
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.copyFileSync(sourceSpec, targetSpec);
|
||||
|
||||
const sizeKb = (fs.statSync(targetSpec).size / 1024).toFixed(1);
|
||||
const digest = hashFile(targetSpec).slice(0, 12);
|
||||
console.log(`[devportal:sync-spec] copied aggregate spec -> public/api/stella.yaml (${sizeKb} KiB, sha256:${digest}...)`);
|
||||
Reference in New Issue
Block a user