create-portfoliable 1.2.14 → 1.2.16
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/package.json +1 -1
- package/scripts/release-orchestrator.mjs +6 -0
- package/scripts/scaffold-case.mjs +122 -12
- package/src/App.js +33 -3
- package/src/parser/markdown-rendering.test.js +31 -0
- package/src/parser/markdown.js +11 -1
- package/templates/scripts/scaffold-case.mjs +147 -18
- package/templates/src/parser/markdown.js +61 -1
package/package.json
CHANGED
|
@@ -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.
|
|
@@ -10,11 +10,13 @@ import { readLocaleConfigFromI18nConfig, resolveI18nConfigPath } from '../templa
|
|
|
10
10
|
|
|
11
11
|
// MARK: DEFAULTS
|
|
12
12
|
// Defines default output path used when no known case root can be resolved.
|
|
13
|
-
const DEFAULT_OUTPUT = 'src/content/cases/my-case/case.md';
|
|
13
|
+
const DEFAULT_OUTPUT = 'src/content/cases/my-case/my-case.md';
|
|
14
14
|
// Defines default case name used when no name is provided.
|
|
15
15
|
const DEFAULT_NAME = 'My Case';
|
|
16
16
|
// Defines candidate case roots in priority order for local repo and consumer apps.
|
|
17
17
|
const DEFAULT_CASES_ROOTS = ['templates/src/content/cases', 'src/content/cases'];
|
|
18
|
+
// Defines order increment applied when choosing the next generated case order value.
|
|
19
|
+
const CASE_ORDER_STEP = 10;
|
|
18
20
|
|
|
19
21
|
// MARK: DEFAULT CASE TEMPLATE
|
|
20
22
|
// Centralized defaults used when scaffolding new cases.
|
|
@@ -178,15 +180,110 @@ function resolveCasesRoots(cwd) {
|
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
// Computes default output markdown path from first discovered cases root.
|
|
181
|
-
function
|
|
182
|
-
const caseSlug = toSlug(name);
|
|
183
|
+
function resolvePrimaryCasesRoot(cwd) {
|
|
183
184
|
const roots = resolveCasesRoots(cwd);
|
|
184
185
|
if (roots.length > 0) {
|
|
185
|
-
|
|
186
|
-
return path.relative(cwd, target);
|
|
186
|
+
return roots[0];
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
return
|
|
189
|
+
return path.resolve(cwd, DEFAULT_CASES_ROOTS[0]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function walkMarkdownFiles(rootDir, files = []) {
|
|
193
|
+
if (!fs.existsSync(rootDir)) {
|
|
194
|
+
return files;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
198
|
+
entries.forEach((entry) => {
|
|
199
|
+
const entryPath = path.join(rootDir, entry.name);
|
|
200
|
+
if (entry.isDirectory()) {
|
|
201
|
+
walkMarkdownFiles(entryPath, files);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
206
|
+
files.push(entryPath);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function collectExistingCaseMetadata(caseRoot) {
|
|
214
|
+
const usedSlugs = new Set();
|
|
215
|
+
const caseOrders = [];
|
|
216
|
+
const markdownFiles = walkMarkdownFiles(caseRoot, []);
|
|
217
|
+
|
|
218
|
+
markdownFiles.forEach((filePath) => {
|
|
219
|
+
const relativePath = path.relative(caseRoot, filePath);
|
|
220
|
+
const pathSegments = relativePath.split(path.sep).filter(Boolean);
|
|
221
|
+
const folderSlug = pathSegments.length > 1 ? pathSegments[0] : '';
|
|
222
|
+
if (folderSlug) {
|
|
223
|
+
usedSlugs.add(toSlug(folderSlug));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const fileSlug = toSlug(path.basename(filePath, '.md'));
|
|
227
|
+
if (fileSlug) {
|
|
228
|
+
usedSlugs.add(fileSlug);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let source = '';
|
|
232
|
+
try {
|
|
233
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
234
|
+
} catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const idMatch = source.match(/"id"\s*:\s*"([^"]+)"/);
|
|
239
|
+
if (idMatch?.[1]) {
|
|
240
|
+
usedSlugs.add(toSlug(idMatch[1]));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const slugBlockMatch = source.match(/"slugByLocale"\s*:\s*\{([\s\S]*?)\}/);
|
|
244
|
+
if (slugBlockMatch?.[1]) {
|
|
245
|
+
const slugValues = slugBlockMatch[1].matchAll(/:\s*"([^"]+)"/g);
|
|
246
|
+
for (const slugMatch of slugValues) {
|
|
247
|
+
if (slugMatch?.[1]) {
|
|
248
|
+
usedSlugs.add(toSlug(slugMatch[1]));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const orderMatch = source.match(/"caseOrder"\s*:\s*(\d+)/);
|
|
254
|
+
if (orderMatch?.[1]) {
|
|
255
|
+
const parsedOrder = Number.parseInt(orderMatch[1], 10);
|
|
256
|
+
if (Number.isFinite(parsedOrder)) {
|
|
257
|
+
caseOrders.push(parsedOrder);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return { usedSlugs, caseOrders };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveUniqueSlug(baseSlug, usedSlugs) {
|
|
266
|
+
if (!usedSlugs.has(baseSlug)) {
|
|
267
|
+
return baseSlug;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let index = 2;
|
|
271
|
+
while (usedSlugs.has(`${baseSlug}-${index}`)) {
|
|
272
|
+
index += 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return `${baseSlug}-${index}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function resolveDefaultScaffoldValues(cwd, name) {
|
|
279
|
+
const caseRoot = resolvePrimaryCasesRoot(cwd);
|
|
280
|
+
const { usedSlugs, caseOrders } = collectExistingCaseMetadata(caseRoot);
|
|
281
|
+
const baseSlug = toSlug(name);
|
|
282
|
+
const slug = resolveUniqueSlug(baseSlug, usedSlugs);
|
|
283
|
+
const maxOrder = caseOrders.length > 0 ? Math.max(...caseOrders) : DEFAULT_CASE_TEMPLATE.caseOrder - CASE_ORDER_STEP;
|
|
284
|
+
const caseOrder = maxOrder + CASE_ORDER_STEP;
|
|
285
|
+
const outFile = path.relative(cwd, path.join(caseRoot, slug, `${slug}.md`));
|
|
286
|
+
return { outFile, slug, caseOrder };
|
|
190
287
|
}
|
|
191
288
|
|
|
192
289
|
// Derives case slug from output path patterns or fallback case name.
|
|
@@ -277,7 +374,7 @@ function formatCaseConfigBlock(caseConfig) {
|
|
|
277
374
|
}
|
|
278
375
|
|
|
279
376
|
// Build the markdown scaffold body used for new cases.
|
|
280
|
-
function buildTemplate({ name, slug, localeCodes }) {
|
|
377
|
+
function buildTemplate({ name, slug, caseOrder, localeCodes }) {
|
|
281
378
|
// Derives normalized title for localized title fields.
|
|
282
379
|
const title = toTitle(name);
|
|
283
380
|
// Derives normalized slug for id and localized route slug fields.
|
|
@@ -292,7 +389,7 @@ function buildTemplate({ name, slug, localeCodes }) {
|
|
|
292
389
|
|
|
293
390
|
const caseConfig = {
|
|
294
391
|
id: cleanSlug,
|
|
295
|
-
caseOrder
|
|
392
|
+
caseOrder,
|
|
296
393
|
slugByLocale: localizedValue({ default: cleanSlug }),
|
|
297
394
|
socialImage: localizedValue({ default: DEFAULT_CASE_TEMPLATE.socialImage }),
|
|
298
395
|
title: {
|
|
@@ -421,11 +518,24 @@ function buildTemplate({ name, slug, localeCodes }) {
|
|
|
421
518
|
export function runCaseScaffold(options = {}) {
|
|
422
519
|
// Resolves working directory used for output file path calculation.
|
|
423
520
|
const cwd = options.cwd || process.cwd();
|
|
424
|
-
// Resolves output filename using option or context-aware default fallback.
|
|
425
|
-
const outFile = options.outFile || resolveDefaultOutFile(cwd, options.name || DEFAULT_NAME);
|
|
426
521
|
// Resolves case display name using option or default fallback.
|
|
427
522
|
const name = options.name || DEFAULT_NAME;
|
|
428
|
-
const
|
|
523
|
+
const hasExplicitOutFile = typeof options.outFile === 'string' && options.outFile.trim().length > 0;
|
|
524
|
+
|
|
525
|
+
const defaultScaffold = hasExplicitOutFile
|
|
526
|
+
? null
|
|
527
|
+
: resolveDefaultScaffoldValues(cwd, name);
|
|
528
|
+
|
|
529
|
+
// Resolves output filename using option or context-aware default fallback.
|
|
530
|
+
const outFile = hasExplicitOutFile
|
|
531
|
+
? options.outFile
|
|
532
|
+
: (defaultScaffold?.outFile || DEFAULT_OUTPUT);
|
|
533
|
+
|
|
534
|
+
const caseSlug = hasExplicitOutFile
|
|
535
|
+
? resolveCaseSlugFromOutput(outFile, name)
|
|
536
|
+
: defaultScaffold.slug;
|
|
537
|
+
|
|
538
|
+
const caseOrder = defaultScaffold?.caseOrder ?? DEFAULT_CASE_TEMPLATE.caseOrder;
|
|
429
539
|
// Coerces force flag to explicit boolean.
|
|
430
540
|
const force = Boolean(options.force);
|
|
431
541
|
// Resolves absolute output path for scaffold file.
|
|
@@ -444,7 +554,7 @@ export function runCaseScaffold(options = {}) {
|
|
|
444
554
|
}
|
|
445
555
|
|
|
446
556
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
447
|
-
fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, localeCodes }), 'utf8');
|
|
557
|
+
fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, caseOrder, localeCodes }), 'utf8');
|
|
448
558
|
console.log(`[scaffold-case] Created starter case file: ${outputPath}`);
|
|
449
559
|
return 0;
|
|
450
560
|
}
|
package/src/App.js
CHANGED
|
@@ -4662,7 +4662,7 @@ class AppShell extends HTMLElement {
|
|
|
4662
4662
|
].join('::');
|
|
4663
4663
|
|
|
4664
4664
|
if (this._caseViewRenderSignature !== caseRenderSignature) {
|
|
4665
|
-
caseView.innerHTML = caseMarkup;
|
|
4665
|
+
caseView.innerHTML = this._normalizeArticleMediaUrls(caseMarkup);
|
|
4666
4666
|
this._caseViewRenderSignature = caseRenderSignature;
|
|
4667
4667
|
}
|
|
4668
4668
|
|
|
@@ -5365,7 +5365,9 @@ class AppShell extends HTMLElement {
|
|
|
5365
5365
|
const thumbBrand = activeCase.thumbBrand || 'apple';
|
|
5366
5366
|
const thumbModel = activeCase.thumbModel || 'Apple iPhone 12';
|
|
5367
5367
|
const thumbColor = activeCase.thumbColor || 'Black';
|
|
5368
|
-
const subtitleSrc =
|
|
5368
|
+
const subtitleSrc = this._resolveRuntimeMediaUrl(
|
|
5369
|
+
this.getLangCurrentLocaleOnly(this._resolveCasePrimaryVttProp(activeCase))
|
|
5370
|
+
);
|
|
5369
5371
|
const rawSubtitleConfig = this._resolveCasePrimaryVttProp(activeCase);
|
|
5370
5372
|
const {
|
|
5371
5373
|
breadcrumbMenuLabels,
|
|
@@ -5547,7 +5549,7 @@ class AppShell extends HTMLElement {
|
|
|
5547
5549
|
const composedHtml = this.getLang(aboutPayload.bodyHtml) || '';
|
|
5548
5550
|
|
|
5549
5551
|
if (aboutArticle.dataset.aboutHtml !== composedHtml) {
|
|
5550
|
-
aboutArticle.innerHTML = composedHtml;
|
|
5552
|
+
aboutArticle.innerHTML = this._normalizeArticleMediaUrls(composedHtml);
|
|
5551
5553
|
aboutArticle.dataset.aboutHtml = composedHtml;
|
|
5552
5554
|
}
|
|
5553
5555
|
|
|
@@ -5648,6 +5650,34 @@ class AppShell extends HTMLElement {
|
|
|
5648
5650
|
});
|
|
5649
5651
|
}
|
|
5650
5652
|
|
|
5653
|
+
_normalizeArticleMediaUrls(html) {
|
|
5654
|
+
if (typeof document === 'undefined') return String(html || '');
|
|
5655
|
+
|
|
5656
|
+
const template = document.createElement('template');
|
|
5657
|
+
template.innerHTML = String(html || '');
|
|
5658
|
+
template.content.querySelectorAll('[src]').forEach((element) => {
|
|
5659
|
+
const source = element.getAttribute('src');
|
|
5660
|
+
if (source) element.setAttribute('src', this._resolveRuntimeMediaUrl(source));
|
|
5661
|
+
});
|
|
5662
|
+
template.content.querySelectorAll('[poster]').forEach((element) => {
|
|
5663
|
+
const poster = element.getAttribute('poster');
|
|
5664
|
+
if (poster) element.setAttribute('poster', this._resolveRuntimeMediaUrl(poster));
|
|
5665
|
+
});
|
|
5666
|
+
template.content.querySelectorAll('[srcset]').forEach((element) => {
|
|
5667
|
+
const srcset = element.getAttribute('srcset');
|
|
5668
|
+
if (!srcset) return;
|
|
5669
|
+
const normalizedSrcset = srcset.split(',').map((candidate) => {
|
|
5670
|
+
const parts = candidate.trim().split(/\s+/);
|
|
5671
|
+
if (!parts[0]) return candidate;
|
|
5672
|
+
parts[0] = this._resolveRuntimeMediaUrl(parts[0]);
|
|
5673
|
+
return parts.join(' ');
|
|
5674
|
+
}).join(', ');
|
|
5675
|
+
element.setAttribute('srcset', normalizedSrcset);
|
|
5676
|
+
});
|
|
5677
|
+
|
|
5678
|
+
return template.innerHTML;
|
|
5679
|
+
}
|
|
5680
|
+
|
|
5651
5681
|
_resolveLocalizedConfigText(value) {
|
|
5652
5682
|
if (typeof value === 'string') return value;
|
|
5653
5683
|
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
|
+
});
|
package/src/parser/markdown.js
CHANGED
|
@@ -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:
|
|
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'];
|
|
@@ -127,10 +127,14 @@ function readSupportedLocales(cwd = process.cwd()) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// === DEFAULTS ===
|
|
130
|
-
// Defines the default output path used when
|
|
131
|
-
const DEFAULT_OUTPUT = 'src/content/cases/my-case/case.md';
|
|
130
|
+
// Defines the default output path fallback used when no cases root is available.
|
|
131
|
+
const DEFAULT_OUTPUT = 'src/content/cases/my-case/my-case.md';
|
|
132
132
|
// Defines fallback title text used when callers do not provide --name.
|
|
133
133
|
const DEFAULT_NAME = 'My Case';
|
|
134
|
+
// Defines candidate case roots in priority order for local repo and consumer apps.
|
|
135
|
+
const DEFAULT_CASES_ROOTS = ['src/content/cases', 'templates/src/content/cases'];
|
|
136
|
+
// Defines order increment applied when choosing the next generated case order value.
|
|
137
|
+
const CASE_ORDER_STEP = 10;
|
|
134
138
|
|
|
135
139
|
// === STRING NORMALIZATION HELPERS ===
|
|
136
140
|
// Converts arbitrary text into a markdown-safe slug used for id and localized slug fields.
|
|
@@ -165,7 +169,7 @@ function parseArgs(argv) {
|
|
|
165
169
|
const args = argv.slice(2);
|
|
166
170
|
// Initializes parser output with defaults so missing flags still produce valid output.
|
|
167
171
|
const options = {
|
|
168
|
-
outFile:
|
|
172
|
+
outFile: '',
|
|
169
173
|
name: DEFAULT_NAME,
|
|
170
174
|
force: false
|
|
171
175
|
};
|
|
@@ -194,9 +198,130 @@ function parseArgs(argv) {
|
|
|
194
198
|
return options;
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
function resolveCasesRoots(cwd) {
|
|
202
|
+
return DEFAULT_CASES_ROOTS
|
|
203
|
+
.map((relativeRoot) => path.resolve(cwd, relativeRoot))
|
|
204
|
+
.filter((absoluteRoot) => fs.existsSync(absoluteRoot) && fs.statSync(absoluteRoot).isDirectory());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolvePrimaryCasesRoot(cwd) {
|
|
208
|
+
const discoveredRoots = resolveCasesRoots(cwd);
|
|
209
|
+
if (discoveredRoots.length > 0) {
|
|
210
|
+
return discoveredRoots[0];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return path.resolve(cwd, DEFAULT_CASES_ROOTS[0]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function walkMarkdownFiles(rootDir, files = []) {
|
|
217
|
+
if (!fs.existsSync(rootDir)) {
|
|
218
|
+
return files;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
222
|
+
entries.forEach((entry) => {
|
|
223
|
+
const entryPath = path.join(rootDir, entry.name);
|
|
224
|
+
if (entry.isDirectory()) {
|
|
225
|
+
walkMarkdownFiles(entryPath, files);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
230
|
+
files.push(entryPath);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return files;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function collectExistingCaseMetadata(caseRoot) {
|
|
238
|
+
const usedSlugs = new Set();
|
|
239
|
+
const caseOrders = [];
|
|
240
|
+
const markdownFiles = walkMarkdownFiles(caseRoot, []);
|
|
241
|
+
|
|
242
|
+
markdownFiles.forEach((filePath) => {
|
|
243
|
+
const relativePath = path.relative(caseRoot, filePath);
|
|
244
|
+
const pathSegments = relativePath.split(path.sep).filter(Boolean);
|
|
245
|
+
const folderSlug = pathSegments.length > 1 ? pathSegments[0] : '';
|
|
246
|
+
if (folderSlug) {
|
|
247
|
+
usedSlugs.add(toSlug(folderSlug));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const fileSlug = toSlug(path.basename(filePath, '.md'));
|
|
251
|
+
if (fileSlug) {
|
|
252
|
+
usedSlugs.add(fileSlug);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let source = '';
|
|
256
|
+
try {
|
|
257
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
258
|
+
} catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const idMatch = source.match(/"id"\s*:\s*"([^"]+)"/);
|
|
263
|
+
if (idMatch?.[1]) {
|
|
264
|
+
usedSlugs.add(toSlug(idMatch[1]));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const slugBlockMatch = source.match(/"slugByLocale"\s*:\s*\{([\s\S]*?)\}/);
|
|
268
|
+
if (slugBlockMatch?.[1]) {
|
|
269
|
+
const slugValues = slugBlockMatch[1].matchAll(/:\s*"([^"]+)"/g);
|
|
270
|
+
for (const slugMatch of slugValues) {
|
|
271
|
+
if (slugMatch?.[1]) {
|
|
272
|
+
usedSlugs.add(toSlug(slugMatch[1]));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const orderMatch = source.match(/"caseOrder"\s*:\s*(\d+)/);
|
|
278
|
+
if (orderMatch?.[1]) {
|
|
279
|
+
const parsedOrder = Number.parseInt(orderMatch[1], 10);
|
|
280
|
+
if (Number.isFinite(parsedOrder)) {
|
|
281
|
+
caseOrders.push(parsedOrder);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return { usedSlugs, caseOrders };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function resolveUniqueSlug(baseSlug, usedSlugs) {
|
|
290
|
+
if (!usedSlugs.has(baseSlug)) {
|
|
291
|
+
return baseSlug;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let index = 2;
|
|
295
|
+
while (usedSlugs.has(`${baseSlug}-${index}`)) {
|
|
296
|
+
index += 1;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return `${baseSlug}-${index}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveCaseSlugFromOutput(outFile, name) {
|
|
303
|
+
const basename = path.basename(outFile || '');
|
|
304
|
+
if (basename.toLowerCase() === 'case.md') {
|
|
305
|
+
return toSlug(path.basename(path.dirname(outFile || '')) || name);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return toSlug(path.basename(outFile || '', '.md') || name);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolveDefaultScaffoldValues(cwd, name) {
|
|
312
|
+
const caseRoot = resolvePrimaryCasesRoot(cwd);
|
|
313
|
+
const { usedSlugs, caseOrders } = collectExistingCaseMetadata(caseRoot);
|
|
314
|
+
const baseSlug = toSlug(name);
|
|
315
|
+
const slug = resolveUniqueSlug(baseSlug, usedSlugs);
|
|
316
|
+
const maxOrder = caseOrders.length > 0 ? Math.max(...caseOrders) : DEFAULT_CASE_TEMPLATE.caseOrder - CASE_ORDER_STEP;
|
|
317
|
+
const caseOrder = maxOrder + CASE_ORDER_STEP;
|
|
318
|
+
const outFile = path.relative(cwd, path.join(caseRoot, slug, `${slug}.md`));
|
|
319
|
+
return { outFile, slug, caseOrder };
|
|
320
|
+
}
|
|
321
|
+
|
|
197
322
|
// === TEMPLATE CONSTRUCTION ===
|
|
198
323
|
// Produces localized starter markdown content for a new case study.
|
|
199
|
-
function buildTemplate({ name, slug }) {
|
|
324
|
+
function buildTemplate({ name, slug, caseOrder }) {
|
|
200
325
|
// Derives a normalized visible title from provided input.
|
|
201
326
|
const title = toTitle(name);
|
|
202
327
|
// Derives a safe slug fallback from either explicit slug or case name.
|
|
@@ -212,7 +337,7 @@ function buildTemplate({ name, slug }) {
|
|
|
212
337
|
|
|
213
338
|
const caseConfig = {
|
|
214
339
|
id: cleanSlug,
|
|
215
|
-
caseOrder
|
|
340
|
+
caseOrder,
|
|
216
341
|
slugByLocale: localizedValue({ default: cleanSlug }),
|
|
217
342
|
socialImage: localizedValue({ default: DEFAULT_CASE_TEMPLATE.socialImage }),
|
|
218
343
|
title: {
|
|
@@ -397,25 +522,29 @@ function formatCaseConfigBlock(caseConfig) {
|
|
|
397
522
|
return lines.join('\n');
|
|
398
523
|
}
|
|
399
524
|
|
|
400
|
-
function resolveCaseSlugFromOutput(outFile, name) {
|
|
401
|
-
const basename = path.basename(outFile || '');
|
|
402
|
-
if (basename.toLowerCase() === 'case.md') {
|
|
403
|
-
return toSlug(path.basename(path.dirname(outFile || '')) || name);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
return toSlug(path.basename(outFile || '', '.md') || name);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
525
|
// === SCAFFOLD EXECUTION ===
|
|
410
526
|
// Creates a new case markdown file, respecting overwrite safety rules.
|
|
411
527
|
export function runCaseScaffold(options = {}) {
|
|
412
528
|
// Resolves working directory so script can be invoked from any location.
|
|
413
529
|
const cwd = options.cwd || process.cwd();
|
|
414
|
-
// Reads desired output file path from options or uses default path.
|
|
415
|
-
const outFile = options.outFile || DEFAULT_OUTPUT;
|
|
416
530
|
// Reads desired case display name from options or uses default title.
|
|
417
531
|
const name = options.name || DEFAULT_NAME;
|
|
418
|
-
const
|
|
532
|
+
const hasExplicitOutFile = typeof options.outFile === 'string' && options.outFile.trim().length > 0;
|
|
533
|
+
|
|
534
|
+
const defaultScaffold = hasExplicitOutFile
|
|
535
|
+
? null
|
|
536
|
+
: resolveDefaultScaffoldValues(cwd, name);
|
|
537
|
+
|
|
538
|
+
// Reads desired output file path from options or uses resolved default path.
|
|
539
|
+
const outFile = hasExplicitOutFile
|
|
540
|
+
? options.outFile
|
|
541
|
+
: (defaultScaffold?.outFile || DEFAULT_OUTPUT);
|
|
542
|
+
|
|
543
|
+
const caseSlug = hasExplicitOutFile
|
|
544
|
+
? resolveCaseSlugFromOutput(outFile, name)
|
|
545
|
+
: defaultScaffold.slug;
|
|
546
|
+
|
|
547
|
+
const caseOrder = defaultScaffold?.caseOrder ?? DEFAULT_CASE_TEMPLATE.caseOrder;
|
|
419
548
|
// Converts force option to explicit boolean for overwrite checks.
|
|
420
549
|
const force = Boolean(options.force);
|
|
421
550
|
// Resolves absolute output path to avoid relative path ambiguity.
|
|
@@ -428,7 +557,7 @@ export function runCaseScaffold(options = {}) {
|
|
|
428
557
|
}
|
|
429
558
|
|
|
430
559
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
431
|
-
fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug }), 'utf8');
|
|
560
|
+
fs.writeFileSync(outputPath, buildTemplate({ name, slug: caseSlug, caseOrder }), 'utf8');
|
|
432
561
|
console.log(`[scaffold-case] Created starter case file: ${outputPath}`);
|
|
433
562
|
return 0;
|
|
434
563
|
}
|
|
@@ -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:
|
|
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) {
|