create-portfoliable 1.2.15 → 1.2.17

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.
@@ -456,6 +456,37 @@ function runThumbnailOptions(flags) {
456
456
  return 0;
457
457
  }
458
458
 
459
+ // Emits case media files so authored src/content URLs survive production builds.
460
+ function createCaseContentAssetPlugin() {
461
+ return {
462
+ name: 'portfoliable-case-content-assets',
463
+ generateBundle() {
464
+ const casesRoot = path.resolve(process.cwd(), 'src', 'content', 'cases');
465
+ if (!fs.existsSync(casesRoot)) return;
466
+
467
+ const emitDirectory = (directory) => {
468
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
469
+ const entryPath = path.join(directory, entry.name);
470
+ if (entry.isDirectory()) {
471
+ emitDirectory(entryPath);
472
+ continue;
473
+ }
474
+ if (entry.name.toLowerCase().endsWith('.md')) continue;
475
+
476
+ const relativePath = path.relative(process.cwd(), entryPath).replace(/\\/g, '/');
477
+ this.emitFile({
478
+ type: 'asset',
479
+ fileName: relativePath,
480
+ source: fs.readFileSync(entryPath)
481
+ });
482
+ }
483
+ };
484
+
485
+ emitDirectory(casesRoot);
486
+ }
487
+ };
488
+ }
489
+
459
490
  // Prints startup summary box for dev server sessions.
460
491
  function printStartupBox({ localUrl, networkUrl, localVersion, latestVersion }) {
461
492
  const lines = [
@@ -649,9 +680,10 @@ async function runBuild() {
649
680
  printRailSegment({ dot: true, label: `${cyan}${bold}Starting build...${reset}` });
650
681
  console.log(`${cyan}${bold}Building Portfoliable...${reset}`);
651
682
  const runtimeAliases = resolveConsumerRuntimeAliases(process.cwd());
652
- await viteBuild(runtimeAliases.length > 0
653
- ? { resolve: { alias: runtimeAliases } }
654
- : undefined);
683
+ await viteBuild({
684
+ ...(runtimeAliases.length > 0 ? { resolve: { alias: runtimeAliases } } : {}),
685
+ plugins: [createCaseContentAssetPlugin()]
686
+ });
655
687
  console.log(`${green}Build complete.${reset}`);
656
688
  printRailSegment({ dot: true, label: `${green}${bold}Build complete.${reset}` });
657
689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-portfoliable",
3
- "version": "1.2.15",
3
+ "version": "1.2.17",
4
4
  "description": "Unified Portfoliable package with initializer and runtime CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { pathToFileURL } from "node:url";
12
12
  const REPO_URL = "https://github.com/portfoliablejs/portfoliable";
13
13
  // Enables dry-run behavior when --dry-run is provided on process args.
14
14
  const isDryRun = process.argv.includes("--dry-run");
15
+ const isPreparePr = process.argv.includes("--prepare-pr");
15
16
  // Enables signing release commit and tag objects when RELEASE_SIGN=true.
16
17
  const shouldSign = process.env.RELEASE_SIGN === "true";
17
18
 
@@ -410,6 +411,11 @@ function run() {
410
411
  }
411
412
  git(commitArgs);
412
413
 
414
+ if (isPreparePr) {
415
+ console.log("Release PR preparation enabled; skipping tag creation until merge.");
416
+ return;
417
+ }
418
+
413
419
  // Creates annotated or signed tags for each release if tag is not already present.
414
420
  for (const plan of releases) {
415
421
  // Checks whether target tag already exists in repository.
package/src/App.js CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  A11Y_THEME_TOKEN_KEYS,
36
36
  Header,
37
37
  HomeView,
38
+ MermaidDiagram,
38
39
  Summary,
39
40
  Thumbnail,
40
41
  Toast,
@@ -49,6 +50,7 @@ void Summary;
49
50
  void Thumbnail;
50
51
  void Toast;
51
52
  void VideoPlayer;
53
+ void MermaidDiagram;
52
54
 
53
55
  // MARK: HEADER DEFAULTS
54
56
  // Resolves runtime defaults and persistence keys used across navigation and resume flows.
@@ -4662,7 +4664,7 @@ class AppShell extends HTMLElement {
4662
4664
  ].join('::');
4663
4665
 
4664
4666
  if (this._caseViewRenderSignature !== caseRenderSignature) {
4665
- caseView.innerHTML = caseMarkup;
4667
+ caseView.innerHTML = this._normalizeArticleMediaUrls(caseMarkup);
4666
4668
  this._caseViewRenderSignature = caseRenderSignature;
4667
4669
  }
4668
4670
 
@@ -5365,7 +5367,9 @@ class AppShell extends HTMLElement {
5365
5367
  const thumbBrand = activeCase.thumbBrand || 'apple';
5366
5368
  const thumbModel = activeCase.thumbModel || 'Apple iPhone 12';
5367
5369
  const thumbColor = activeCase.thumbColor || 'Black';
5368
- const subtitleSrc = String(this.getLangCurrentLocaleOnly(this._resolveCasePrimaryVttProp(activeCase)) || '').trim();
5370
+ const subtitleSrc = this._resolveRuntimeMediaUrl(
5371
+ this.getLangCurrentLocaleOnly(this._resolveCasePrimaryVttProp(activeCase))
5372
+ );
5369
5373
  const rawSubtitleConfig = this._resolveCasePrimaryVttProp(activeCase);
5370
5374
  const {
5371
5375
  breadcrumbMenuLabels,
@@ -5547,7 +5551,7 @@ class AppShell extends HTMLElement {
5547
5551
  const composedHtml = this.getLang(aboutPayload.bodyHtml) || '';
5548
5552
 
5549
5553
  if (aboutArticle.dataset.aboutHtml !== composedHtml) {
5550
- aboutArticle.innerHTML = composedHtml;
5554
+ aboutArticle.innerHTML = this._normalizeArticleMediaUrls(composedHtml);
5551
5555
  aboutArticle.dataset.aboutHtml = composedHtml;
5552
5556
  }
5553
5557
 
@@ -5648,6 +5652,34 @@ class AppShell extends HTMLElement {
5648
5652
  });
5649
5653
  }
5650
5654
 
5655
+ _normalizeArticleMediaUrls(html) {
5656
+ if (typeof document === 'undefined') return String(html || '');
5657
+
5658
+ const template = document.createElement('template');
5659
+ template.innerHTML = String(html || '');
5660
+ template.content.querySelectorAll('[src]').forEach((element) => {
5661
+ const source = element.getAttribute('src');
5662
+ if (source) element.setAttribute('src', this._resolveRuntimeMediaUrl(source));
5663
+ });
5664
+ template.content.querySelectorAll('[poster]').forEach((element) => {
5665
+ const poster = element.getAttribute('poster');
5666
+ if (poster) element.setAttribute('poster', this._resolveRuntimeMediaUrl(poster));
5667
+ });
5668
+ template.content.querySelectorAll('[srcset]').forEach((element) => {
5669
+ const srcset = element.getAttribute('srcset');
5670
+ if (!srcset) return;
5671
+ const normalizedSrcset = srcset.split(',').map((candidate) => {
5672
+ const parts = candidate.trim().split(/\s+/);
5673
+ if (!parts[0]) return candidate;
5674
+ parts[0] = this._resolveRuntimeMediaUrl(parts[0]);
5675
+ return parts.join(' ');
5676
+ }).join(', ');
5677
+ element.setAttribute('srcset', normalizedSrcset);
5678
+ });
5679
+
5680
+ return template.innerHTML;
5681
+ }
5682
+
5651
5683
  _resolveLocalizedConfigText(value) {
5652
5684
  if (typeof value === 'string') return value;
5653
5685
  if (!value || typeof value !== 'object') return '';
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { renderLocalizedMarkdownHtml } from './markdown.js';
4
+
5
+ describe('case markdown rendering', () => {
6
+ it('preserves authored HTML image sizing attributes', () => {
7
+ const html = renderLocalizedMarkdownHtml(
8
+ '<img src="src/content/cases/example/image.avif" alt="Example" width="300">',
9
+ ['en']
10
+ ).htmlByLocale.en;
11
+
12
+ expect(html).toContain('<img src="src/content/cases/example/image.avif" alt="Example" width="300">');
13
+ });
14
+
15
+ it('renders Mermaid fences as diagram elements', () => {
16
+ const html = renderLocalizedMarkdownHtml('```mermaid\ngraph TD\n A --> B\n```', ['en'])
17
+ .htmlByLocale.en;
18
+
19
+ expect(html).toContain('<mermaid-diagram>');
20
+ expect(html).toContain('graph TD');
21
+ expect(html).not.toContain('<pre><code>');
22
+ });
23
+
24
+ it('keeps ordinary fenced code blocks as code', () => {
25
+ const html = renderLocalizedMarkdownHtml('```javascript\nconst answer = 42;\n```', ['en'])
26
+ .htmlByLocale.en;
27
+
28
+ expect(html).toContain('<pre><code>');
29
+ expect(html).toContain('const answer = 42;');
30
+ });
31
+ });
@@ -10,7 +10,7 @@ import markdownItTaskLists from 'markdown-it-task-lists';
10
10
  // MARK: MARKDOWN RENDERER
11
11
  // Uses markdown-it + plugins to support broad markdown syntax for case content.
12
12
  const markdownRenderer = new MarkdownIt({
13
- html: false,
13
+ html: true,
14
14
  linkify: true,
15
15
  typographer: true,
16
16
  breaks: false
@@ -56,6 +56,16 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
56
56
  return self.renderToken(tokens, idx, options);
57
57
  };
58
58
 
59
+ // Emits Mermaid fences as the Valence custom element instead of a code block.
60
+ markdownRenderer.renderer.rules.fence = (tokens, idx) => {
61
+ const token = tokens[idx];
62
+ if (String(token.info || '').trim().split(/\s+/)[0].toLowerCase() !== 'mermaid') {
63
+ return `<pre><code>${markdownRenderer.utils.escapeHtml(token.content)}</code></pre>\n`;
64
+ }
65
+
66
+ return `<mermaid-diagram>${markdownRenderer.utils.escapeHtml(token.content)}</mermaid-diagram>\n`;
67
+ };
68
+
59
69
  // MARK: VALIDATION CONTRACTS
60
70
  // Lists required scalar config fields that every case must define.
61
71
  const REQUIRED_SCALAR_FIELDS = ['id', 'thumbCategory', 'thumbBrand', 'thumbModel', 'thumbColor'];
@@ -10,7 +10,7 @@ import markdownItTaskLists from 'markdown-it-task-lists';
10
10
  // MARK: MARKDOWN RENDERER
11
11
  // Uses markdown-it + plugins to support broad markdown syntax for case content.
12
12
  const markdownRenderer = new MarkdownIt({
13
- html: false,
13
+ html: true,
14
14
  linkify: true,
15
15
  typographer: true,
16
16
  breaks: false
@@ -56,6 +56,16 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
56
56
  return self.renderToken(tokens, idx, options);
57
57
  };
58
58
 
59
+ // Emits Mermaid fences as the Valence custom element instead of a code block.
60
+ markdownRenderer.renderer.rules.fence = (tokens, idx) => {
61
+ const token = tokens[idx];
62
+ if (String(token.info || '').trim().split(/\s+/)[0].toLowerCase() !== 'mermaid') {
63
+ return `<pre><code>${markdownRenderer.utils.escapeHtml(token.content)}</code></pre>\n`;
64
+ }
65
+
66
+ return `<mermaid-diagram>${markdownRenderer.utils.escapeHtml(token.content)}</mermaid-diagram>\n`;
67
+ };
68
+
59
69
  // MARK: VALIDATION CONTRACTS
60
70
  // Lists required scalar config fields that every case must define.
61
71
  const REQUIRED_SCALAR_FIELDS = ['id', 'thumbCategory', 'thumbBrand', 'thumbModel', 'thumbColor'];
@@ -107,6 +117,56 @@ function markdownToHtml(markdown) {
107
117
  return markdownRenderer.render(source);
108
118
  }
109
119
 
120
+ // Renders localized markdown sections into HTML using the shared markdown-it pipeline.
121
+ export function renderLocalizedMarkdownHtml(bodyText, localeCodes = DEFAULT_LOCALE_CODES) {
122
+ const normalizedBodyText = String(bodyText || '').replace(/<!--\s*config\s*[\s\S]*?-->/gi, '').trim();
123
+ const targetLocales = normalizeLocaleCodes(localeCodes);
124
+ const sections = Object.fromEntries(targetLocales.map((localeCode) => [localeCode, '']));
125
+ const markerLocales = new Set();
126
+ const langRegex = /<!--\s*lang:([a-z0-9-]+)\s*-->/gi;
127
+ let activeLang = null;
128
+ let lastIndex = 0;
129
+ let match;
130
+
131
+ while ((match = langRegex.exec(normalizedBodyText)) !== null) {
132
+ const markerLocale = String(match[1] || '').trim().toLowerCase();
133
+ if (!markerLocale) continue;
134
+ markerLocales.add(markerLocale);
135
+ if (!(markerLocale in sections)) {
136
+ sections[markerLocale] = '';
137
+ }
138
+
139
+ if (activeLang) {
140
+ sections[activeLang] += normalizedBodyText.slice(lastIndex, match.index);
141
+ }
142
+ activeLang = markerLocale;
143
+ lastIndex = langRegex.lastIndex;
144
+ }
145
+
146
+ if (activeLang) {
147
+ sections[activeLang] += normalizedBodyText.slice(lastIndex);
148
+ } else {
149
+ Object.keys(sections).forEach((localeCode) => {
150
+ sections[localeCode] = normalizedBodyText;
151
+ });
152
+ }
153
+
154
+ const resolvedLocales = normalizeLocaleCodes([...targetLocales, ...Object.keys(sections)]);
155
+ const htmlByLocale = Object.fromEntries(resolvedLocales.map((localeCode) => {
156
+ const sectionRaw = String(sections[localeCode] || '').trim();
157
+ return [localeCode, markdownToHtml(sectionRaw)];
158
+ }));
159
+
160
+ return {
161
+ htmlByLocale,
162
+ meta: {
163
+ localeCodes: resolvedLocales,
164
+ hasAnyLangMarker: markerLocales.size > 0,
165
+ hasLangMarkerByLocale: Object.fromEntries(resolvedLocales.map((localeCode) => [localeCode, markerLocales.has(localeCode)]))
166
+ }
167
+ };
168
+ }
169
+
110
170
  // MARK: CONFIG BLOCK PARSING
111
171
  // Parses a top-level config JSON comment block and returns metadata + remaining body text.
112
172
  function parseConfigMetadata(rawText, contextLabel) {