create-portfoliable 1.2.11 → 1.2.13
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.
- package/cli/portfoliable.mjs +10 -1
- package/package.json +1 -1
- package/scripts/consumer-runtime-aliases.mjs +33 -0
- package/src/App.js +5 -25
- package/src/runtime-media.contract.js +61 -0
- package/src/runtime-media.contract.test.js +96 -0
- package/vite.config.js +6 -0
package/cli/portfoliable.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
isInteractiveTerminal,
|
|
27
27
|
readProjectUiPreferences
|
|
28
28
|
} from '../scripts/terminal-ui.mjs';
|
|
29
|
+
import { resolveConsumerRuntimeAliases } from '../scripts/consumer-runtime-aliases.mjs';
|
|
29
30
|
|
|
30
31
|
// MARK: TERMINAL STYLES
|
|
31
32
|
// ANSI color code used for success-status text.
|
|
@@ -562,8 +563,13 @@ async function runDevServer(flags, options = {}) {
|
|
|
562
563
|
process.env.PORTFOLIABLE_PHP_API_PROXY = phpRuntime.proxyTarget;
|
|
563
564
|
}
|
|
564
565
|
|
|
566
|
+
const runtimeAliases = resolveConsumerRuntimeAliases(process.cwd());
|
|
567
|
+
|
|
565
568
|
// Creates Vite development server instance.
|
|
566
569
|
const server = await createServer({
|
|
570
|
+
resolve: runtimeAliases.length > 0
|
|
571
|
+
? { alias: runtimeAliases }
|
|
572
|
+
: undefined,
|
|
567
573
|
optimizeDeps: {
|
|
568
574
|
exclude: ['create-portfoliable']
|
|
569
575
|
},
|
|
@@ -642,7 +648,10 @@ async function runBuild() {
|
|
|
642
648
|
printRailSegment();
|
|
643
649
|
printRailSegment({ dot: true, label: `${cyan}${bold}Starting build...${reset}` });
|
|
644
650
|
console.log(`${cyan}${bold}Building Portfoliable...${reset}`);
|
|
645
|
-
|
|
651
|
+
const runtimeAliases = resolveConsumerRuntimeAliases(process.cwd());
|
|
652
|
+
await viteBuild(runtimeAliases.length > 0
|
|
653
|
+
? { resolve: { alias: runtimeAliases } }
|
|
654
|
+
: undefined);
|
|
646
655
|
console.log(`${green}Build complete.${reset}`);
|
|
647
656
|
printRailSegment({ dot: true, label: `${green}${bold}Build complete.${reset}` });
|
|
648
657
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// File: scripts/consumer-runtime-aliases.mjs
|
|
2
|
+
// Purpose: Resolve consumer-local runtime aliases that override template defaults when files exist.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const TEMPLATE_TO_CONSUMER_TARGETS = [
|
|
8
|
+
['../templates/src/cases/index.js', 'src/cases/index.js'],
|
|
9
|
+
['../templates/src/content/about/ABOUTME.md', 'src/content/about/ABOUTME.md'],
|
|
10
|
+
['../templates/configs/portfoliable.design.config.js', 'configs/portfoliable.design.config.js'],
|
|
11
|
+
['../templates/configs/portfoliable.a11y.config.js', 'configs/portfoliable.a11y.config.js'],
|
|
12
|
+
['../templates/configs/i18n/i18n.config.js', 'configs/i18n/i18n.config.js'],
|
|
13
|
+
['../templates/configs/i18n/i18n.labels.js', 'configs/i18n/i18n.labels.js']
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Builds Vite resolve.alias entries for consumer files that are present in the current project.
|
|
17
|
+
export function resolveConsumerRuntimeAliases(projectRoot = process.cwd()) {
|
|
18
|
+
const aliases = [];
|
|
19
|
+
|
|
20
|
+
for (const [templateSpecifier, consumerRelativePath] of TEMPLATE_TO_CONSUMER_TARGETS) {
|
|
21
|
+
const consumerAbsolutePath = path.resolve(projectRoot, consumerRelativePath);
|
|
22
|
+
if (!fs.existsSync(consumerAbsolutePath)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
aliases.push({
|
|
27
|
+
find: templateSpecifier,
|
|
28
|
+
replacement: consumerAbsolutePath
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return aliases;
|
|
33
|
+
}
|
package/src/App.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveHeaderVisibilityAttributeValue,
|
|
27
27
|
resolveReturnOnlyVisibilityOverrides
|
|
28
28
|
} from './header-visibility.contract.js';
|
|
29
|
+
import { pickReachableRuntimeMediaUrl } from './runtime-media.contract.js';
|
|
29
30
|
import Lenis from 'lenis';
|
|
30
31
|
import {
|
|
31
32
|
AudioPlayer,
|
|
@@ -5435,9 +5436,7 @@ class AppShell extends HTMLElement {
|
|
|
5435
5436
|
});
|
|
5436
5437
|
}
|
|
5437
5438
|
|
|
5438
|
-
const videoSrcCandidates = this._buildRuntimeMediaCandidates(rawVideoSrc
|
|
5439
|
-
preferTemplatePrefix: true
|
|
5440
|
-
});
|
|
5439
|
+
const videoSrcCandidates = this._buildRuntimeMediaCandidates(rawVideoSrc);
|
|
5441
5440
|
|
|
5442
5441
|
if (videoSrcCandidates.length > 1) {
|
|
5443
5442
|
const resolveToken = `${activeCaseId}:${activeLocale}:${Date.now()}`;
|
|
@@ -5791,28 +5790,9 @@ class AppShell extends HTMLElement {
|
|
|
5791
5790
|
return candidates[0] || '';
|
|
5792
5791
|
}
|
|
5793
5792
|
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
if (/^(?:https?:|data:|blob:)/i.test(candidate)) {
|
|
5798
|
-
return candidate;
|
|
5799
|
-
}
|
|
5800
|
-
|
|
5801
|
-
try {
|
|
5802
|
-
const response = await fetch(candidate, {
|
|
5803
|
-
method: 'HEAD',
|
|
5804
|
-
cache: 'no-store'
|
|
5805
|
-
});
|
|
5806
|
-
|
|
5807
|
-
if (response.ok) {
|
|
5808
|
-
return candidate;
|
|
5809
|
-
}
|
|
5810
|
-
} catch {
|
|
5811
|
-
// Try next candidate when HEAD checks are unavailable.
|
|
5812
|
-
}
|
|
5813
|
-
}
|
|
5814
|
-
|
|
5815
|
-
return candidates[0] || '';
|
|
5793
|
+
// Resolves to '' when no candidate is confirmed so callers keep the already bound source.
|
|
5794
|
+
_pickReachableRuntimeMediaUrl(candidates = []) {
|
|
5795
|
+
return pickReachableRuntimeMediaUrl(candidates);
|
|
5816
5796
|
}
|
|
5817
5797
|
|
|
5818
5798
|
_buildRuntimeImageCandidates(value) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// File: src/runtime-media.contract.js
|
|
2
|
+
// Purpose: Confirm that a runtime media candidate URL points at real media before it is bound.
|
|
3
|
+
// Author: Lio Schimanko
|
|
4
|
+
|
|
5
|
+
// MARK: RESPONSE CLASSIFICATION
|
|
6
|
+
// Dev servers answer unknown paths with an HTML SPA fallback at status 200, so HTML must never count as media.
|
|
7
|
+
export function isRuntimeMediaResponse(response) {
|
|
8
|
+
if (!response || !response.ok) return false;
|
|
9
|
+
|
|
10
|
+
const contentType = String(response.headers?.get?.('content-type') || '').toLowerCase();
|
|
11
|
+
if (!contentType) return true;
|
|
12
|
+
|
|
13
|
+
return !(contentType.startsWith('text/html') || contentType.startsWith('application/xhtml'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// MARK: CANDIDATE PROBING
|
|
17
|
+
// Returns '' when no candidate is confirmed so callers keep the already bound source.
|
|
18
|
+
export async function pickReachableRuntimeMediaUrl(candidates = [], fetchImpl = globalThis.fetch) {
|
|
19
|
+
if (typeof fetchImpl !== 'function') return '';
|
|
20
|
+
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if (!candidate) continue;
|
|
23
|
+
if (/^(?:https?:|data:|blob:)/i.test(candidate)) {
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let headResponse = null;
|
|
28
|
+
try {
|
|
29
|
+
headResponse = await fetchImpl(candidate, {
|
|
30
|
+
method: 'HEAD',
|
|
31
|
+
cache: 'no-store'
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
headResponse = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (isRuntimeMediaResponse(headResponse)) {
|
|
38
|
+
return candidate;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const headUnsupported = !headResponse || headResponse.status === 405 || headResponse.status === 501;
|
|
42
|
+
if (!headUnsupported) continue;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Single-byte range confirms the resource without downloading the media payload.
|
|
46
|
+
const rangedResponse = await fetchImpl(candidate, {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
cache: 'no-store',
|
|
49
|
+
headers: { Range: 'bytes=0-0' }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (isRuntimeMediaResponse(rangedResponse)) {
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Try next candidate when ranged probing is unavailable.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// File: src/runtime-media.contract.test.js
|
|
2
|
+
// Purpose: Validate that media candidate probing rejects SPA fallbacks and never guesses a source.
|
|
3
|
+
// Author: Lio Schimanko
|
|
4
|
+
|
|
5
|
+
// MARK: IMPORTS
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
isRuntimeMediaResponse,
|
|
10
|
+
pickReachableRuntimeMediaUrl,
|
|
11
|
+
} from './runtime-media.contract.js';
|
|
12
|
+
|
|
13
|
+
// MARK: TEST HELPERS
|
|
14
|
+
// Builds a minimal fetch Response stand-in with the headers the probe inspects.
|
|
15
|
+
function makeResponse({ status = 200, contentType = '' } = {}) {
|
|
16
|
+
return {
|
|
17
|
+
ok: status >= 200 && status < 300,
|
|
18
|
+
status,
|
|
19
|
+
headers: { get: (name) => (name.toLowerCase() === 'content-type' ? contentType : null) },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CANONICAL = '/src/content/cases/template-case/template-video.mp4';
|
|
24
|
+
const TEMPLATE = '/templates/src/content/cases/template-case/template-video.mp4';
|
|
25
|
+
|
|
26
|
+
// MARK: RESPONSE CLASSIFICATION TESTS
|
|
27
|
+
describe('isRuntimeMediaResponse', () => {
|
|
28
|
+
// Vite answers unknown dev paths with index.html at status 200.
|
|
29
|
+
it('rejects HTML responses served as a dev-server SPA fallback', () => {
|
|
30
|
+
expect(isRuntimeMediaResponse(makeResponse({ contentType: 'text/html; charset=utf-8' }))).toBe(false);
|
|
31
|
+
expect(isRuntimeMediaResponse(makeResponse({ contentType: 'application/xhtml+xml' }))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Real media and untyped static responses must stay acceptable.
|
|
35
|
+
it('accepts media and content-type-less responses', () => {
|
|
36
|
+
expect(isRuntimeMediaResponse(makeResponse({ contentType: 'video/mp4' }))).toBe(true);
|
|
37
|
+
expect(isRuntimeMediaResponse(makeResponse({ contentType: 'application/octet-stream' }))).toBe(true);
|
|
38
|
+
expect(isRuntimeMediaResponse(makeResponse({ contentType: '' }))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Non-success statuses and missing responses are never media.
|
|
42
|
+
it('rejects failed and absent responses', () => {
|
|
43
|
+
expect(isRuntimeMediaResponse(makeResponse({ status: 404, contentType: 'video/mp4' }))).toBe(false);
|
|
44
|
+
expect(isRuntimeMediaResponse(null)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// MARK: CANDIDATE PROBING TESTS
|
|
49
|
+
describe('pickReachableRuntimeMediaUrl', () => {
|
|
50
|
+
// Regression: the SPA fallback used to win and overwrite the working source.
|
|
51
|
+
it('skips a candidate answered by the SPA fallback and keeps probing', async () => {
|
|
52
|
+
const fetchImpl = vi.fn(async (url) =>
|
|
53
|
+
url === CANONICAL
|
|
54
|
+
? makeResponse({ contentType: 'video/mp4' })
|
|
55
|
+
: makeResponse({ contentType: 'text/html' }));
|
|
56
|
+
|
|
57
|
+
await expect(pickReachableRuntimeMediaUrl([TEMPLATE, CANONICAL], fetchImpl)).resolves.toBe(CANONICAL);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Absolute URLs are trusted without a network round-trip.
|
|
61
|
+
it('returns absolute URLs without probing', async () => {
|
|
62
|
+
const fetchImpl = vi.fn();
|
|
63
|
+
|
|
64
|
+
await expect(pickReachableRuntimeMediaUrl(['https://cdn.test/video.mp4'], fetchImpl))
|
|
65
|
+
.resolves.toBe('https://cdn.test/video.mp4');
|
|
66
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Some static hosts answer HEAD with 405, so a single-byte range confirms the file instead.
|
|
70
|
+
it('falls back to a ranged GET when HEAD is unsupported', async () => {
|
|
71
|
+
const fetchImpl = vi.fn(async (_url, options) =>
|
|
72
|
+
options.method === 'HEAD'
|
|
73
|
+
? makeResponse({ status: 405 })
|
|
74
|
+
: makeResponse({ status: 206, contentType: 'video/mp4' }));
|
|
75
|
+
|
|
76
|
+
await expect(pickReachableRuntimeMediaUrl([CANONICAL], fetchImpl)).resolves.toBe(CANONICAL);
|
|
77
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
78
|
+
expect(fetchImpl.mock.calls[1][1].headers).toEqual({ Range: 'bytes=0-0' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Returning '' is what stops the caller from replacing an already bound source.
|
|
82
|
+
it('returns an empty string when no candidate is confirmed', async () => {
|
|
83
|
+
const fetchImpl = vi.fn(async () => makeResponse({ contentType: 'text/html' }));
|
|
84
|
+
|
|
85
|
+
await expect(pickReachableRuntimeMediaUrl([TEMPLATE, CANONICAL], fetchImpl)).resolves.toBe('');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Network failures must not promote an unverified candidate either.
|
|
89
|
+
it('returns an empty string when probing throws', async () => {
|
|
90
|
+
const fetchImpl = vi.fn(async () => {
|
|
91
|
+
throw new Error('network down');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await expect(pickReachableRuntimeMediaUrl([TEMPLATE, CANONICAL], fetchImpl)).resolves.toBe('');
|
|
95
|
+
});
|
|
96
|
+
});
|
package/vite.config.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { defineConfig } from 'vite';
|
|
9
|
+
import { resolveConsumerRuntimeAliases } from './scripts/consumer-runtime-aliases.mjs';
|
|
9
10
|
|
|
10
11
|
// Detects when @portfoliablejs/valence is installed as a local symlink.
|
|
11
12
|
function isLocalLinkedValence() {
|
|
@@ -63,6 +64,7 @@ function getManualChunk(id) {
|
|
|
63
64
|
export default defineConfig(({ command }) => {
|
|
64
65
|
const usingLocalValence = isLocalLinkedValence();
|
|
65
66
|
const phpApiProxy = String(process.env.PORTFOLIABLE_PHP_API_PROXY || '').trim();
|
|
67
|
+
const runtimeAliases = resolveConsumerRuntimeAliases(process.cwd());
|
|
66
68
|
|
|
67
69
|
const serverConfig = (command === 'serve' && usingLocalValence)
|
|
68
70
|
? {
|
|
@@ -88,6 +90,10 @@ export default defineConfig(({ command }) => {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
return {
|
|
93
|
+
resolve: runtimeAliases.length > 0
|
|
94
|
+
? { alias: runtimeAliases }
|
|
95
|
+
: undefined,
|
|
96
|
+
|
|
91
97
|
// In local-link mode, avoid prebundling Valence so edits in the linked package
|
|
92
98
|
// are reflected immediately during development.
|
|
93
99
|
optimizeDeps: (command === 'serve' && usingLocalValence)
|