create-portfoliable 1.2.13 → 1.2.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-portfoliable",
3
- "version": "1.2.13",
3
+ "version": "1.2.15",
4
4
  "description": "Unified Portfoliable package with initializer and runtime CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,6 +27,14 @@ export function resolveConsumerRuntimeAliases(projectRoot = process.cwd()) {
27
27
  find: templateSpecifier,
28
28
  replacement: consumerAbsolutePath
29
29
  });
30
+
31
+ // Keep raw markdown imports in sync with consumer content overrides.
32
+ if (templateSpecifier.endsWith('.md')) {
33
+ aliases.push({
34
+ find: `${templateSpecifier}?raw`,
35
+ replacement: `${consumerAbsolutePath}?raw`
36
+ });
37
+ }
30
38
  }
31
39
 
32
40
  return aliases;
@@ -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 resolveDefaultOutFile(cwd, name) {
182
- const caseSlug = toSlug(name);
183
+ function resolvePrimaryCasesRoot(cwd) {
183
184
  const roots = resolveCasesRoots(cwd);
184
185
  if (roots.length > 0) {
185
- const target = path.join(roots[0], caseSlug, 'case.md');
186
- return path.relative(cwd, target);
186
+ return roots[0];
187
187
  }
188
188
 
189
- return DEFAULT_OUTPUT;
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: DEFAULT_CASE_TEMPLATE.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 caseSlug = resolveCaseSlugFromOutput(outFile, name);
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
  }
@@ -127,10 +127,14 @@ function readSupportedLocales(cwd = process.cwd()) {
127
127
  }
128
128
 
129
129
  // === DEFAULTS ===
130
- // Defines the default output path used when callers do not provide --out.
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: DEFAULT_OUTPUT,
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: DEFAULT_CASE_TEMPLATE.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 caseSlug = resolveCaseSlugFromOutput(outFile, name);
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
  }