const { createHash } = require('node:crypto'); function storybookDestination({ branch, sha, runId, runAttempt, bucket, baseUrl }) { if (typeof branch !== 'string' || !branch || /[\x00-\x20\x7f]/.test(branch)) { throw new Error('A non-empty repository branch name is required.'); } if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('A full source commit SHA is required.'); if (![runId, runAttempt].every((value) => /^[1-9]\d*$/.test(String(value)))) { throw new Error('A valid workflow run and attempt are required.'); } if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) throw new Error('Invalid Storybook S3 bucket.'); const base = new URL(baseUrl); if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') { throw new Error('Storybook base URL must be a credential-free HTTPS origin.'); } const label = branch.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0,60) || 'branch'; const digest = createHash('sha256').update(branch).digest('hex').slice(0,16); const branchKey = `${label}-${digest}`; const prefix = `storybook/branches/${branchKey}`; const buildPrefix = `${prefix}/builds/${runId}-${runAttempt}`; // Use one reversible path segment: slashes and special characters become // ~HH UTF-8 bytes, so feature/foo and feature-foo never share a bookmark. let bookmarkKey = [...Buffer.from(branch)].map((byte) => /[A-Za-z0-9_-]/.test(String.fromCharCode(byte)) ? String.fromCharCode(byte) : `~${byte.toString(16).toUpperCase().padStart(2, '0')}`).join(''); // Reserve the existing hashed directories, including all immutable builds. bookmarkKey = bookmarkKey.replace(/-([a-f0-9]{16})$/, '~2D$1'); // Keep arbitrarily long ref names within S3's object-key limit. ~long cannot // occur in the reversible encoding, whose escapes contain only hex digits. if (bookmarkKey.length > 900) bookmarkKey = `${bookmarkKey.slice(0, 800)}~long-${digest}`; const bookmarkPrefix = `storybook/branches/${bookmarkKey}`; return { branch, sha, bucket, branchKey, prefix, buildPrefix, bookmarkPrefix, url: `${base.origin}/${bookmarkPrefix}/`, legacyUrl: `${base.origin}/${prefix}/index.html`, buildUrl: `${base.origin}/${buildPrefix}/index.html`, }; } function branchIndex(buildUrl) { // The target is generated from a validated origin and ASCII path segments. const target = JSON.stringify(buildUrl).replace(/Storybook preview \n`; } module.exports = { storybookDestination, branchIndex };