Compare commits
2 Commits
redesign/o
...
9124761d8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9124761d8d | ||
|
|
4a5c54e922 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,7 +6,10 @@ coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.env*
|
||||
config/site.production.json
|
||||
config/site.production.build.json
|
||||
dist-cloudflare/
|
||||
.cloudflare-stage-*/
|
||||
.wrangler/
|
||||
release-evidence/
|
||||
*.release.tar
|
||||
*.release.tar.partial-*
|
||||
|
||||
5
config/site.production.json
Normal file
5
config/site.production.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"environment": "production",
|
||||
"canonicalUrl": "https://lucapislaru.ro/",
|
||||
"buildId": "production-template"
|
||||
}
|
||||
144
docs/deployment/cloudflare-setup.md
Normal file
144
docs/deployment/cloudflare-setup.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Publish the portfolio with Cloudflare and ROTLD
|
||||
|
||||
The primary address is **https://lucapislaru.ro/**. The accented domain **lucapîslaru.ro** redirects to it. Its ASCII/Punycode form is **xn--lucapslaru-z8a.ro**; these are two spellings of the same accented domain, not a third domain.
|
||||
|
||||
Use Cloudflare Workers Static Assets on the free plan. Keep both registrations and renewals at ROTLD. There is no application server, database, or request-time SSR to provision.
|
||||
|
||||
## 1. Export locally
|
||||
|
||||
Use Node.js 22.22.1 or a newer compatible Node 22 release. From the Portfolio project directory, run:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:production
|
||||
npm run verify:cloudflare
|
||||
```
|
||||
|
||||
`npm ci` is only needed when setting up a fresh checkout or when dependencies change. It requires registry access. The current machine already has the dependencies.
|
||||
|
||||
The finished upload directory is **dist-cloudflare/**. The export command:
|
||||
|
||||
- checks the public content/asset references and prepared asset hashes;
|
||||
- type-checks application source and Vite configuration using `tsconfig.production.json`;
|
||||
- builds the client and build-time SSR code, then pre-renders the complete document and hydration snapshot;
|
||||
- reads `config/site.production.json` and creates an ignored `config/site.production.build.json` with a fresh production build ID;
|
||||
- verifies the exact artifact inventory, bytes, public content, metadata, and transfer budgets;
|
||||
- scans the export for test hooks, fault/tier URL switches, source-map references, known private evidence fields, recognizable credential patterns, and local source paths;
|
||||
- copies only the verified HTML, robots, sitemap, and referenced assets into the upload directory, and adds a real Cloudflare `_headers` file.
|
||||
|
||||
Production exports reject `PORTFOLIO_TEST_*`, `VITE_*` environment overrides and a non-production `NODE_ENV`. The export-specific Vite mode does not load local `.env` files or copy a `public/` folder. Benchmark commands continue to use their existing separate build mode.
|
||||
|
||||
Only `dist-cloudflare/` is configured for upload. Do not upload the repository, `dist-client/`, `dist-ssr/`, or the preview output. The generated `_headers` file is Cloudflare configuration, not a page served to visitors. It supplies security headers and one revalidation cache policy, avoiding stale media at stable filenames. Unknown URLs return 404; there is no catch-all homepage fallback.
|
||||
|
||||
The production config is public and committed; it contains no credentials. Its `production-template` build ID is replaced for each export. The preview config remains `noindex` and uses its existing localhost canonical URL.
|
||||
|
||||
## 2. Add both domains to Cloudflare
|
||||
|
||||
In [Cloudflare](https://dash.cloudflare.com/), add each domain as a separate website/domain and select the **Free** plan:
|
||||
|
||||
1. `lucapislaru.ro`
|
||||
2. `xn--lucapslaru-z8a.ro` (Cloudflare may display `lucapîslaru.ro`)
|
||||
|
||||
Review the imported DNS records. Preserve any existing MX and email-related TXT records if you use domain email. Cloudflare will show an assigned pair of nameservers for each domain. Copy the exact pair for that domain; the pairs can differ. This is a DNS change, not a registrar transfer.
|
||||
|
||||
## 3. Set the nameservers at ROTLD
|
||||
|
||||
Open [ROTLD](https://www.rotld.ro/) and use **Domenii .ro → Administrare Domenii → On-Line**. Log in for the first domain and open **Nameservers**. Replace the old nameservers with the exact pair Cloudflare assigned to that domain, save, and complete any confirmation ROTLD requests. Repeat for the accented domain.
|
||||
|
||||
If DNSSEC is already enabled, remove/disable the old DNSSEC delegation at ROTLD before changing nameservers. Once Cloudflare reports the domain Active, enable DNSSEC in Cloudflare and publish its new DS details at ROTLD. Do not reuse the previous provider's DS record. [Cloudflare nameserver/DNSSEC instructions](https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/), [ROTLD administration guidance](https://www.rotld.ro/faq-general-questions/).
|
||||
|
||||
Wait until **both** domains show **Active** in Cloudflare before proceeding. No hosting IP address is needed for the main site.
|
||||
|
||||
## 4. Deploy the main site
|
||||
|
||||
Log in to Cloudflare's deployment tool from the project directory:
|
||||
|
||||
```sh
|
||||
npx --yes wrangler@4.129.0 login
|
||||
```
|
||||
|
||||
The browser asks you to authorize Wrangler. Credentials stay in Wrangler's local login storage, outside the site export. Then publish:
|
||||
|
||||
```sh
|
||||
npm run deploy:cloudflare
|
||||
```
|
||||
|
||||
This command **publishes publicly**. Wrangler first runs the entire production export again, so a failed build stops deployment. It creates/updates the Worker **luca-pislaru-portfolio**, uploads only `dist-cloudflare/`, and attaches **lucapislaru.ro** through the custom-domain entry in `wrangler.jsonc`.
|
||||
|
||||
Cloudflare manages the main site's DNS record and HTTPS certificate. If it reports a conflicting old website A/AAAA/CNAME record at the root, inspect that exact record and remove the obsolete website record before retrying; preserve email and other unrelated records. Do not create a placeholder A record for the main site's root.
|
||||
|
||||
The `workers.dev` address and version preview URLs are disabled in the configuration. This keeps alternate deployment URLs from exposing/indexing another copy of the site. Keep those settings disabled in the dashboard too. [Workers custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/).
|
||||
|
||||
To validate the deployment configuration without uploading anything:
|
||||
|
||||
```sh
|
||||
npx --yes wrangler@4.129.0 deploy --config wrangler.jsonc --dry-run
|
||||
```
|
||||
|
||||
## 5. Redirect the accented domain
|
||||
|
||||
Select the **xn--lucapslaru-z8a.ro** domain in Cloudflare. Under **DNS → Records**, create these redirect-only records, with the orange cloud/proxy enabled:
|
||||
|
||||
| Type | Name | Address | Proxy |
|
||||
| --- | --- | --- | --- |
|
||||
| A | @ | 192.0.2.1 | Proxied |
|
||||
| A | www | 192.0.2.1 | Proxied |
|
||||
|
||||
`192.0.2.1` is Cloudflare's documented placeholder for redirect-only hostnames; it is not your server's address. [Cloudflare domain redirect instructions](https://developers.cloudflare.com/fundamentals/manage-domains/redirect-domain/).
|
||||
|
||||
Open **Rules → Overview → Create rule → Redirect Rule** (also called Single Redirect). Name it `Accented domain to primary`. Use this custom filter expression:
|
||||
|
||||
```text
|
||||
(http.host eq "xn--lucapslaru-z8a.ro" or http.host eq "www.xn--lucapslaru-z8a.ro")
|
||||
```
|
||||
|
||||
For the destination, select **Dynamic** and enter:
|
||||
|
||||
```text
|
||||
concat("https://lucapislaru.ro", http.request.uri.path)
|
||||
```
|
||||
|
||||
Choose **308 Permanent Redirect**, enable **Preserve query string**, and deploy the rule. It handles both HTTP and HTTPS without adding another copy of the website. Wait for the accented domain's Cloudflare edge certificate to become Active before checking HTTPS redirects.
|
||||
|
||||
## 6. Redirect www and HTTP on the main domain
|
||||
|
||||
Switch to the **lucapislaru.ro** domain. Add a proxied `A` record named `www` pointing to `192.0.2.1`. Leave the root record managed by Workers.
|
||||
|
||||
Create a second Redirect Rule named `Primary HTTPS canonical address` with this filter:
|
||||
|
||||
```text
|
||||
(http.host eq "www.lucapislaru.ro" or (http.host eq "lucapislaru.ro" and not ssl))
|
||||
```
|
||||
|
||||
Use the same dynamic destination expression, **308** status, and **Preserve query string** setting as above. This sends HTTP and www requests directly to the preferred HTTPS address. The HTTPS root must not match this rule, or it would loop. A separate HTTP-upgrade rule is not needed.
|
||||
|
||||
## 7. Check the live site and indexing
|
||||
|
||||
After HTTPS is active, verify:
|
||||
|
||||
| Address | Expected result |
|
||||
| --- | --- |
|
||||
| https://lucapislaru.ro/ | Portfolio loads with HTTP 200 |
|
||||
| http://lucapislaru.ro/ | One 308 to https://lucapislaru.ro/ |
|
||||
| https://www.lucapislaru.ro/ | One 308 to the primary domain |
|
||||
| http://xn--lucapslaru-z8a.ro/ | One 308 to the primary domain |
|
||||
| https://xn--lucapslaru-z8a.ro/ | One 308 to the primary domain |
|
||||
| https://www.xn--lucapslaru-z8a.ro/ | One 308 to the primary domain |
|
||||
| https://lucapislaru.ro/robots.txt | Allow crawling; sitemap names the main domain |
|
||||
| https://lucapislaru.ro/sitemap.xml | Contains https://lucapislaru.ro/ |
|
||||
| https://lucapislaru.ro/not-a-real-page | HTTP 404 |
|
||||
| https://lucapislaru.ro/.env | HTTP 404 |
|
||||
|
||||
Check an accented-domain URL with a path and query, too: `/not-a-real-page?check=1` should redirect to the identical path/query on the primary domain, which then correctly returns 404. Disable JavaScript in your browser and reload the homepage: the actual portfolio text and contact disclosure should remain usable. Re-enable JavaScript and check the animated experience.
|
||||
|
||||
View the homepage source and confirm `index, follow`, a canonical URL of `https://lucapislaru.ro/`, and social image/structured-data URLs on that same origin. Do not place a Cloudflare Access login or a blanket bot challenge in front of the public domain.
|
||||
|
||||
In [Google Search Console](https://search.google.com/search-console/), add a **Domain property** for `lucapislaru.ro`, verify it by adding Google's TXT record in Cloudflare DNS, and submit `https://lucapislaru.ro/sitemap.xml`. Inspect the homepage URL and request indexing. The site becomes eligible for indexing; Google decides whether and when to index it. [Google sitemap guidance](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap).
|
||||
|
||||
## Later updates and verification scope
|
||||
|
||||
For future updates, run `npm run deploy:cloudflare` from the intended checkout. It rebuilds and validates before publishing. Avoid building a preview and production export concurrently because they share intermediate `dist*` directories.
|
||||
|
||||
This is a static Cloudflare deployment workflow. The existing Nginx signed-release/activation scripts are separate and are not executed by this command. The production type-check intentionally excludes test files; the general `npm run typecheck` and full test suite remain separate quality checks. The old `smoke:production` command assumes the Nginx signed-manifest header and a literal pre-rendered `mailto:` link, so it is not the Cloudflare acceptance command.
|
||||
|
||||
Export checks establish the exact public file boundary and detect known prohibited surfaces. They do not certify private source history, all possible secrets, or the semantic privacy of every screenshot. Existing public portfolio descriptions, approved screenshots, contact details and social links remain public. The encoded email is recoverable by visitors, as required for the contact feature. No credentials belong in public content or frontend source.
|
||||
@@ -7,6 +7,9 @@
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"build:production": "node scripts/export-cloudflare.mjs",
|
||||
"verify:cloudflare": "node scripts/export-cloudflare.mjs --verify",
|
||||
"deploy:cloudflare": "npx --yes wrangler@4.129.0 deploy --config wrangler.jsonc",
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test:unit": "vitest run",
|
||||
|
||||
142
scripts/export-cloudflare.mjs
Normal file
142
scripts/export-cloudflare.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { parseSiteConfig } from '../src/content/schemas.ts';
|
||||
import { serializeStaticHostHeaders } from '../src/security/security-policy.ts';
|
||||
import { parseBuildAuthority } from './build-contract.mjs';
|
||||
import { prerender } from './prerender.mjs';
|
||||
import { verifyArtifacts } from './verify-artifacts.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const root = resolve(dirname(scriptPath), '..');
|
||||
const configPath = 'config/site.production.build.json';
|
||||
const output = resolve(root, 'dist-cloudflare');
|
||||
const rootFiles = ['index.html', 'robots.txt', 'sitemap.xml'];
|
||||
const prohibited = /__portfolio(?:_|JourneyInstrumentation)|PORTFOLIO_TEST_(?:BUILD|TOKEN)|drive-trace\.v1|sourceMappingURL|-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----|\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b|\bAKIA[A-Z0-9]{16}\b|\/home\/luca\//iu;
|
||||
|
||||
export const assertProductionEnvironment = (environment) => {
|
||||
if (Object.entries(environment).some(([key, value]) =>
|
||||
value && (/^(?:PORTFOLIO_TEST_|VITE_)/u.test(key) || (key === 'NODE_ENV' && value !== 'production')))) {
|
||||
throw new Error('Production export rejects test flags, VITE_* overrides and non-production NODE_ENV.');
|
||||
}
|
||||
};
|
||||
|
||||
export const assertPublicFiles = (files, expectedPaths) => {
|
||||
const expected = [...new Set(expectedPaths)].sort();
|
||||
if (JSON.stringify([...files.keys()].sort()) !== JSON.stringify(expected)) {
|
||||
throw new Error('Cloudflare export inventory differs from the verified public closure.');
|
||||
}
|
||||
if (files.size > 20_000) throw new Error('Cloudflare free static file limit exceeded.');
|
||||
for (const [path, bytes] of files) {
|
||||
if (bytes.length > 25 * 1024 * 1024) throw new Error(`Cloudflare asset exceeds 25 MiB: ${path}`);
|
||||
if (prohibited.test(bytes.toString('utf8'))) throw new Error(`Prohibited production surface in ${path}.`);
|
||||
}
|
||||
};
|
||||
|
||||
// Revalidate all URLs: approved media have stable, unhashed names. One rule avoids
|
||||
// Cloudflare combining contradictory Cache-Control values from matching rules.
|
||||
export const renderCloudflareHeaders = (site, hash) => serializeStaticHostHeaders(site, hash);
|
||||
|
||||
const regularBytes = async (path) => {
|
||||
const stats = await lstat(path);
|
||||
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) throw new Error('Export input must be a regular, non-linked file.');
|
||||
return readFile(path);
|
||||
};
|
||||
|
||||
const expectedExport = async () => {
|
||||
await verifyArtifacts(configPath);
|
||||
const site = parseSiteConfig(JSON.parse(await regularBytes(resolve(root, configPath))));
|
||||
if (site.environment !== 'production') throw new Error('Cloudflare export requires production inputs.');
|
||||
const authority = parseBuildAuthority(JSON.parse(await regularBytes(resolve(root, 'dist-ssr/artifact-authority.json'))));
|
||||
const files = new Map();
|
||||
for (const path of [...rootFiles, ...authority.artifactContract.files.map((file) => file.path)]) {
|
||||
files.set(path, await regularBytes(resolve(root, 'dist', path)));
|
||||
}
|
||||
const html = files.get('index.html').toString('utf8');
|
||||
const jsonLd = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/u)?.[1];
|
||||
if (!jsonLd) throw new Error('Production structured data is missing.');
|
||||
const hash = `sha256-${createHash('sha256').update(jsonLd).digest('base64')}`;
|
||||
files.set('_headers', Buffer.from(renderCloudflareHeaders(site, hash)));
|
||||
assertPublicFiles(files, [...rootFiles, '_headers', ...authority.artifactContract.files.map((file) => file.path)]);
|
||||
return files;
|
||||
};
|
||||
|
||||
const readExport = async (directory) => {
|
||||
const stats = await lstat(directory);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('Export directory must not be linked.');
|
||||
const files = new Map();
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.name === 'assets' && entry.isDirectory()) {
|
||||
for (const asset of await readdir(resolve(directory, 'assets'))) {
|
||||
files.set(`assets/${asset}`, await regularBytes(resolve(directory, 'assets', asset)));
|
||||
}
|
||||
} else {
|
||||
files.set(entry.name, await regularBytes(resolve(directory, entry.name)));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const verifyExportBytes = async (directory, expected) => {
|
||||
const actual = await readExport(directory);
|
||||
assertPublicFiles(actual, [...expected.keys()]);
|
||||
for (const [path, bytes] of expected) {
|
||||
if (!actual.get(path).equals(bytes)) throw new Error(`Cloudflare export bytes changed: ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyCloudflareExport = async () => {
|
||||
const expected = await expectedExport();
|
||||
await verifyExportBytes(output, expected);
|
||||
console.log(`Verified ${expected.size} production files; no benchmark hooks or prohibited export surfaces found.`);
|
||||
};
|
||||
|
||||
const run = (script, args = []) => {
|
||||
const result = spawnSync(process.execPath, [resolve(root, script), ...args], {
|
||||
cwd: root, stdio: 'inherit', env: { ...process.env, NODE_ENV: 'production' },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Production build step failed: ${script}`);
|
||||
};
|
||||
|
||||
export const buildCloudflareExport = async () => {
|
||||
assertProductionEnvironment(process.env);
|
||||
const site = parseSiteConfig(JSON.parse(await regularBytes(resolve(root, 'config/site.production.json'))));
|
||||
if (site.environment !== 'production') throw new Error('Production site config is required.');
|
||||
// Refuse unsafe paths before Vite writes its intermediate build directories.
|
||||
for (const name of ['dist-client', 'dist-ssr', 'dist', 'dist-cloudflare']) {
|
||||
try {
|
||||
const stats = await lstat(resolve(root, name));
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error(`Unsafe build directory: ${name}`);
|
||||
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
}
|
||||
run('scripts/materialize-empty-asset-set.mjs');
|
||||
run('node_modules/vue-tsc/bin/vue-tsc.js', ['--noEmit', '--project', 'tsconfig.production.json']);
|
||||
run('node_modules/vite/bin/vite.js', ['build', '--mode', 'production-export', '--outDir', 'dist-client', '--manifest']);
|
||||
run('node_modules/vite/bin/vite.js', ['build', '--mode', 'production-export', '--ssr', 'src/entry-server.ts', '--outDir', 'dist-ssr']);
|
||||
// Every export receives a distinct identity, including uncommitted local builds.
|
||||
const generatedSite = { ...site, buildId: `production-${randomUUID()}` };
|
||||
await writeFile(resolve(root, configPath), `${JSON.stringify(generatedSite, null, 2)}\n`);
|
||||
await prerender(configPath);
|
||||
run('scripts/check-bundle-budgets.mjs', ['dist', '--transfer-only']);
|
||||
const expected = await expectedExport();
|
||||
const stage = await mkdtemp(resolve(root, '.cloudflare-stage-'));
|
||||
try {
|
||||
await mkdir(resolve(stage, 'assets'));
|
||||
for (const [path, bytes] of expected) await writeFile(resolve(stage, path), bytes, { flag: 'wx' });
|
||||
await verifyExportBytes(stage, expected);
|
||||
await rm(output, { recursive: true, force: true });
|
||||
await rename(stage, output);
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`Exported ${expected.size} verified files to dist-cloudflare for ${site.canonicalUrl}`);
|
||||
};
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) {
|
||||
if (process.argv.slice(2).join(' ') === '--verify') await verifyCloudflareExport();
|
||||
else if (process.argv.length === 2) await buildCloudflareExport();
|
||||
else throw new Error('Usage: node scripts/export-cloudflare.mjs [--verify]');
|
||||
}
|
||||
@@ -321,7 +321,7 @@ const siteConfigSchema = z.object({
|
||||
if (site.environment === 'preview' && site.canonicalUrl !== 'https://portfolio.localhost/') {
|
||||
context.addIssue({ code: 'custom', message: 'Preview canonical URL must be exactly https://portfolio.localhost/.', path: ['canonicalUrl'] });
|
||||
}
|
||||
if (site.environment === 'production') {
|
||||
if (site.environment === 'production' && site.canonicalUrl !== 'https://lucapislaru.ro/') {
|
||||
context.addIssue({ code: 'custom', message: 'Production hostname is not approved.', path: ['canonicalUrl'] });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* text. The frame keeps the journey loop running while the needle settles,
|
||||
* during a flash and for the ignition sweep; otherwise scroll events and the
|
||||
* navigator drive it. Below 1280 the bay is the summary's and the gauge is
|
||||
* display: none, so the frame does nothing there.
|
||||
* display: none, but the nav bar still mirrors its speed
|
||||
* (composables/useNavBarSignals.ts), so the frame keeps --speed, --throttle,
|
||||
* data-engine, data-brake and data-jump live there and skips the rest.
|
||||
*/
|
||||
import type { BeamSample } from '@/journey/headlight-beam';
|
||||
import { readIgnition } from '@/journey/ignition-feed';
|
||||
@@ -104,7 +106,6 @@ export function createSpeedometerDial(el: HTMLElement, sources: DialSources): Sp
|
||||
|
||||
return {
|
||||
frame(now, journey) {
|
||||
if (!wide) return false;
|
||||
const navigation = sources.navigation();
|
||||
input.driveSpeed = journey.driveSpeed;
|
||||
input.direction = journey.direction;
|
||||
@@ -115,20 +116,23 @@ export function createSpeedometerDial(el: HTMLElement, sources: DialSources): Sp
|
||||
input.arrived = journey.arrived;
|
||||
const out = model.step(now, input, sources.animated());
|
||||
|
||||
// What the nav bar reads, at every width.
|
||||
prop(1, '--speed', Math.min(1, out.kmh / MAX_KMH), 0.002, 3);
|
||||
prop(2, '--throttle', out.throttle, 0.01, 2);
|
||||
const engine = out.engine === 'off' && input.arrived ? 'parked' : out.engine;
|
||||
if (strings[3] !== engine) { strings[3] = engine; el.dataset.engine = engine; }
|
||||
flag(0, 'data-brake', out.brake);
|
||||
flag(1, 'data-jump', input.jump);
|
||||
if (!wide) return !out.settled;
|
||||
|
||||
const beam = sources.sampleBeam(now);
|
||||
const flashing = flashOf(beam === null ? 0 : beam.intensity) && out.engine === 'on';
|
||||
|
||||
const units = sources.units();
|
||||
prop(0, '--needle', needleDegrees(out.kmh), 0.15, 1);
|
||||
prop(1, '--speed', Math.min(1, out.kmh / MAX_KMH), 0.002, 3);
|
||||
prop(2, '--throttle', out.throttle, 0.01, 2);
|
||||
text(0, '.speedo__digits', formatSpeed(out.kmh, units));
|
||||
text(1, '.speedo__gear', out.gear);
|
||||
text(2, '.speedo__trip', formatTrip(out.tripKm, units));
|
||||
const engine = out.engine === 'off' && input.arrived ? 'parked' : out.engine;
|
||||
if (strings[3] !== engine) { strings[3] = engine; el.dataset.engine = engine; }
|
||||
flag(0, 'data-brake', out.brake);
|
||||
flag(1, 'data-jump', input.jump);
|
||||
flag(2, 'data-flash', flashing);
|
||||
if (detailOn) {
|
||||
const minutes = Math.max(1, Math.round((now - mountedAt) / 60_000));
|
||||
|
||||
36
tests/integration/cloudflare-export.test.mjs
Normal file
36
tests/integration/cloudflare-export.test.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { assertProductionEnvironment, assertPublicFiles, renderCloudflareHeaders } from '../../scripts/export-cloudflare.mjs';
|
||||
|
||||
describe('Cloudflare production export boundary', () => {
|
||||
it('rejects benchmark flags and client-exposed environment overrides', () => {
|
||||
expect(() => assertProductionEnvironment({})).not.toThrow();
|
||||
for (const environment of [
|
||||
{ PORTFOLIO_TEST_BUILD: '1' },
|
||||
{ PORTFOLIO_TEST_TOKEN_DIGEST: 'a'.repeat(64) },
|
||||
{ VITE_PRIVATE_TOKEN: 'do-not-publish' },
|
||||
{ NODE_ENV: 'development' },
|
||||
]) expect(() => assertProductionEnvironment(environment)).toThrow(/production/i);
|
||||
});
|
||||
|
||||
it('rejects unexpected files, benchmark hooks and recognizable credentials', () => {
|
||||
const allowed = ['index.html', 'robots.txt', 'sitemap.xml', '_headers', 'assets/app-AbCd1234.js'];
|
||||
const files = new Map(allowed.map((path) => [path, Buffer.from('public content')]));
|
||||
expect(() => assertPublicFiles(files, allowed)).not.toThrow();
|
||||
for (const path of ['.env', 'assets/app.js.map', 'benchmark.json', '_headers.example', 'src/entry-client.ts']) {
|
||||
expect(() => assertPublicFiles(new Map([...files, [path, Buffer.from('private')]]), allowed)).toThrow(/inventory/i);
|
||||
}
|
||||
for (const payload of ['__portfolio_fault', '__portfolio_tier', '__portfolio_test_token', '__PORTFOLIO_PERFORMANCE__', '-----BEGIN PRIVATE KEY-----', 'ghp_' + 'x'.repeat(36)]) {
|
||||
expect(() => assertPublicFiles(new Map([...files, ['assets/app-AbCd1234.js', Buffer.from(payload)]]), allowed)).toThrow(/prohibited/i);
|
||||
}
|
||||
expect(() => assertPublicFiles(new Map([...files, ['assets/app-AbCd1234.js', Buffer.alloc(25 * 1024 * 1024 + 1)]]), allowed)).toThrow(/25 MiB/i);
|
||||
});
|
||||
|
||||
it('emits one cache policy and a CSP hash of the actual structured data', () => {
|
||||
const site = { environment: 'production', canonicalUrl: 'https://lucapislaru.ro/', buildId: 'production-test' };
|
||||
const headers = renderCloudflareHeaders(site, 'sha256-' + 'A'.repeat(43) + '=');
|
||||
expect(headers.match(/Cache-Control:/g)).toHaveLength(1);
|
||||
expect(headers).toContain('max-age=0, must-revalidate');
|
||||
expect(headers).toContain("script-src 'self' 'sha256-" + 'A'.repeat(43) + "='");
|
||||
expect(headers).not.toMatch(/noindex|immutable/);
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,13 @@ describe('content schemas', () => {
|
||||
})).toThrow(/unrecognized/i);
|
||||
});
|
||||
|
||||
it('accepts only the fixed preview canonical URL and no production hostname', () => {
|
||||
it('accepts only the fixed preview canonical URL and approved production hostname', () => {
|
||||
expect(parseSiteConfig({
|
||||
environment: 'production', canonicalUrl: 'https://lucapislaru.ro/', buildId: 'production-1',
|
||||
}).canonicalUrl).toBe('https://lucapislaru.ro/');
|
||||
for (const canonicalUrl of ['http://lucapislaru.ro/', 'https://www.lucapislaru.ro/', 'https://lucapîslaru.ro/', 'https://lucapislaru.ro/path', 'https://lucapislaru.ro/?', 'https://lucapislaru.ro.evil.test/']) {
|
||||
expect(() => parseSiteConfig({ environment: 'production', canonicalUrl, buildId: 'production-1' })).toThrow();
|
||||
}
|
||||
expect(() => parseSiteConfig({
|
||||
environment: 'preview', canonicalUrl: 'https://portfolio.example.test/', buildId: 'preview-1',
|
||||
})).toThrow(/portfolio\.localhost/i);
|
||||
|
||||
5
tsconfig.production.json
Normal file
5
tsconfig.production.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
|
||||
"exclude": ["tests", "node_modules", "dist", "dist-client", "dist-ssr", "dist-cloudflare"]
|
||||
}
|
||||
@@ -23,9 +23,12 @@ const sanitizeGltfLoaderInternalSurface = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig(() => {
|
||||
export default defineConfig(({ mode }) => {
|
||||
const testBuild = process.env.PORTFOLIO_TEST_BUILD === '1';
|
||||
const testTokenDigest = process.env.PORTFOLIO_TEST_TOKEN_DIGEST ?? '';
|
||||
if (mode === 'production-export' && (testBuild || testTokenDigest !== '')) {
|
||||
throw new Error('Production exports cannot include authenticated test instrumentation.');
|
||||
}
|
||||
if (testBuild && !/^[a-f0-9]{64}$/u.test(testTokenDigest)) {
|
||||
throw new Error('Authenticated test builds require one SHA-256 token digest.');
|
||||
}
|
||||
@@ -34,6 +37,8 @@ export default defineConfig(() => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Export only the explicit asset closure; never load local .env or public files.
|
||||
...(mode === 'production-export' ? { envDir: false as const, publicDir: false as const } : {}),
|
||||
plugins: [sanitizeGltfLoaderInternalSurface(), vue()],
|
||||
define: {
|
||||
__PORTFOLIO_TEST_BUILD__: JSON.stringify(testBuild),
|
||||
|
||||
16
wrangler.jsonc
Normal file
16
wrangler.jsonc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/cloudflare/workers-sdk/main/packages/wrangler/config-schema.json",
|
||||
"name": "luca-pislaru-portfolio",
|
||||
"compatibility_date": "2026-09-05",
|
||||
"workers_dev": false,
|
||||
"preview_urls": false,
|
||||
"routes": [
|
||||
{ "pattern": "lucapislaru.ro", "custom_domain": true }
|
||||
],
|
||||
"build": { "command": "npm run build:production" },
|
||||
"assets": {
|
||||
"directory": "./dist-cloudflare",
|
||||
"html_handling": "auto-trailing-slash",
|
||||
"not_found_handling": "none"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user