@commonpub/layer 0.7.26 → 0.8.0

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.
@@ -5,9 +5,8 @@ defineProps<{
5
5
 
6
6
  const iconMap: Record<string, string> = {
7
7
  project: 'fa-solid fa-microchip',
8
- article: 'fa-solid fa-file-lines',
9
- guide: 'fa-solid fa-book',
10
8
  blog: 'fa-solid fa-pen-nib',
9
+ article: 'fa-solid fa-file-lines',
11
10
  explainer: 'fa-solid fa-lightbulb',
12
11
  };
13
12
  </script>
@@ -28,7 +28,7 @@ const actorName = computed(() =>
28
28
 
29
29
  const typeLabel = computed(() => {
30
30
  if (props.content.cpubType) return props.content.cpubType;
31
- return props.content.apType === 'Note' ? 'post' : 'article';
31
+ return props.content.apType === 'Note' ? 'post' : 'blog';
32
32
  });
33
33
 
34
34
  const timeAgo = computed(() => {
@@ -154,6 +154,9 @@ const seoDomain = computed(() => {
154
154
  });
155
155
  const seoPreviewDesc = computed(() => (props.metadata.seoDescription as string) || (props.metadata.description as string) || '');
156
156
 
157
+ // --- Schedule ---
158
+ const scheduleEnabled = ref(false);
159
+
157
160
  // --- Right panel ---
158
161
  const openSections = ref<Record<string, boolean>>({
159
162
  content: true, seo: false, publishing: true, cover: false, banner: false,
@@ -174,7 +177,17 @@ const wordCount = computed(() => {
174
177
  const html = (block.content.html as string) || '';
175
178
  const text = (block.content.text as string) || '';
176
179
  const code = (block.content.code as string) || '';
177
- const combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code;
180
+ const instructions = (block.content.instructions as string) || '';
181
+ let combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code + ' ' + instructions;
182
+ const children = block.content.children;
183
+ if (Array.isArray(children)) {
184
+ for (const child of children) {
185
+ const [, childData] = child as [string, Record<string, unknown>];
186
+ const childHtml = (childData?.html as string) || '';
187
+ const childCode = (childData?.code as string) || '';
188
+ combined += ' ' + childHtml.replace(/<[^>]*>/g, ' ') + ' ' + childCode;
189
+ }
190
+ }
178
191
  count += combined.split(/\s+/).filter((w) => w.length > 0).length;
179
192
  }
180
193
  return count;
@@ -336,9 +349,14 @@ const canvasMaxWidth = computed(() => {
336
349
  <label class="cpub-ep-flabel">Slug</label>
337
350
  <input class="cpub-ep-input" type="text" :value="metadata.slug" placeholder="auto-generated" @input="updateMeta('slug', ($event.target as HTMLInputElement).value)">
338
351
  </div>
352
+ <div class="cpub-ep-field">
353
+ <label class="cpub-ep-flabel">Subtitle <span class="cpub-ep-optional">(optional)</span></label>
354
+ <input class="cpub-ep-input" type="text" :value="metadata.subtitle as string" placeholder="Add a subtitle..." @input="updateMeta('subtitle', ($event.target as HTMLInputElement).value)">
355
+ </div>
339
356
  <div class="cpub-ep-field">
340
357
  <label class="cpub-ep-flabel">Description</label>
341
- <textarea class="cpub-ep-textarea" rows="3" :value="metadata.description as string" placeholder="Brief description..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
358
+ <textarea class="cpub-ep-textarea" rows="3" :value="metadata.description as string" placeholder="Brief description shown in feed previews..." @input="updateMeta('description', ($event.target as HTMLTextAreaElement).value)" />
359
+ <span class="cpub-ep-hint cpub-ep-hint-right">{{ ((metadata.description as string) || '').length }} / 300</span>
342
360
  </div>
343
361
  <div class="cpub-ae-cover" :class="{ 'has-image': !!coverImageUrl }">
344
362
  <template v-if="coverImageUrl">
@@ -393,9 +411,9 @@ const canvasMaxWidth = computed(() => {
393
411
  <div class="cpub-seo-card">
394
412
  <div class="cpub-seo-url">
395
413
  <span class="cpub-seo-favicon">C</span>
396
- {{ seoDomain }} &rsaquo; article
414
+ {{ seoDomain }} &rsaquo; blog
397
415
  </div>
398
- <div class="cpub-seo-title">{{ (metadata.title as string) || 'Article title' }}</div>
416
+ <div class="cpub-seo-title">{{ (metadata.title as string) || 'Post title' }}</div>
399
417
  <div class="cpub-seo-desc">{{ seoPreviewDesc || 'Post description will appear here...' }}</div>
400
418
  </div>
401
419
  <div class="cpub-ep-field" style="margin-top: 10px;">
@@ -415,18 +433,38 @@ const canvasMaxWidth = computed(() => {
415
433
  <label class="cpub-ep-flabel">Category</label>
416
434
  <select class="cpub-ep-select" :value="metadata.category || ''" @change="updateMeta('category', ($event.target as HTMLSelectElement).value)">
417
435
  <option value="">Select category</option>
418
- <option value="technology">Technology</option>
419
- <option value="hardware">Hardware</option>
420
- <option value="ai-ml">AI &amp; Machine Learning</option>
436
+ <option value="article">Article</option>
437
+ <option value="blog">Blog Post</option>
421
438
  <option value="tutorial">Tutorial</option>
422
439
  <option value="deep-dive">Deep Dive</option>
423
440
  <option value="opinion">Opinion</option>
441
+ <option value="hardware">Hardware &amp; Makers</option>
442
+ <option value="software">Software</option>
443
+ <option value="ai-ml">AI &amp; Machine Learning</option>
444
+ <option value="homelab">Home Lab</option>
424
445
  </select>
425
446
  </div>
426
447
  <div class="cpub-ep-field">
427
448
  <label class="cpub-ep-flabel">Tags</label>
428
449
  <EditorTagInput :tags="tags" @update:tags="onTagsUpdate" />
429
450
  </div>
451
+ <div class="cpub-ep-field">
452
+ <label class="cpub-ep-flabel">Series <span class="cpub-ep-optional">(optional)</span></label>
453
+ <input class="cpub-ep-input" type="text" :value="metadata.series as string" placeholder="e.g. Home Lab Chronicles" @input="updateMeta('series', ($event.target as HTMLInputElement).value)">
454
+ </div>
455
+ <div class="cpub-ep-field">
456
+ <label class="cpub-ae-schedule-row">
457
+ <span class="cpub-ae-toggle-switch">
458
+ <input v-model="scheduleEnabled" type="checkbox" />
459
+ <span class="cpub-ae-toggle-track" />
460
+ </span>
461
+ <span class="cpub-ae-toggle-label">Schedule for later</span>
462
+ </label>
463
+ </div>
464
+ <div v-if="scheduleEnabled" class="cpub-ep-field">
465
+ <label class="cpub-ep-flabel">Publish Date</label>
466
+ <input class="cpub-ep-input cpub-ep-input-mono" type="datetime-local" :value="metadata.scheduledAt as string" @input="updateMeta('scheduledAt', ($event.target as HTMLInputElement).value)">
467
+ </div>
430
468
  </EditorSection>
431
469
  </div>
432
470
  </aside>
@@ -629,4 +667,27 @@ const canvasMaxWidth = computed(() => {
629
667
  .cpub-ae-banner-preview { position: relative; margin-bottom: 8px; }
630
668
  .cpub-ae-banner-img { width: 100%; height: 80px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); }
631
669
  .cpub-ae-banner-actions { display: flex; gap: 6px; margin-top: 6px; }
670
+
671
+ /* Schedule toggle */
672
+ .cpub-ae-schedule-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
673
+ .cpub-ae-toggle-switch {
674
+ position: relative; width: 30px; height: 16px; flex-shrink: 0;
675
+ }
676
+ .cpub-ae-toggle-switch input { display: none; }
677
+ .cpub-ae-toggle-track {
678
+ display: block; width: 100%; height: 100%; background: var(--surface3);
679
+ border: var(--border-width-default) solid var(--border); cursor: pointer; transition: background 0.15s; position: relative;
680
+ }
681
+ .cpub-ae-toggle-track::after {
682
+ content: ''; position: absolute; width: 8px; height: 8px;
683
+ background: var(--text-faint); top: 2px; left: 2px; transition: transform 0.15s, background 0.15s;
684
+ }
685
+ .cpub-ae-toggle-switch input:checked + .cpub-ae-toggle-track { background: var(--accent-bg); border-color: var(--accent); }
686
+ .cpub-ae-toggle-switch input:checked + .cpub-ae-toggle-track::after { transform: translateX(14px); background: var(--accent); }
687
+ .cpub-ae-toggle-label { font-size: 11px; color: var(--text-dim); }
688
+
689
+ /* Optional hint / mono input */
690
+ .cpub-ep-optional { font-size: 9px; font-weight: 400; color: var(--text-faint); }
691
+ .cpub-ep-input-mono { font-family: var(--font-mono); font-size: 11px; }
692
+ .cpub-ep-hint-right { text-align: right; display: block; }
632
693
  </style>
@@ -108,7 +108,16 @@ const wordCount = computed(() => {
108
108
  const text = (block.content.text as string) || '';
109
109
  const code = (block.content.code as string) || '';
110
110
  const instructions = (block.content.instructions as string) || '';
111
- const combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code + ' ' + instructions;
111
+ let combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code + ' ' + instructions;
112
+ const children = block.content.children;
113
+ if (Array.isArray(children)) {
114
+ for (const child of children) {
115
+ const [, childData] = child as [string, Record<string, unknown>];
116
+ const childHtml = (childData?.html as string) || '';
117
+ const childCode = (childData?.code as string) || '';
118
+ combined += ' ' + childHtml.replace(/<[^>]*>/g, ' ') + ' ' + childCode;
119
+ }
120
+ }
112
121
  count += combined.split(/\s+/).filter((w) => w.length > 0).length;
113
122
  }
114
123
  return count;
@@ -158,7 +158,17 @@ const wordCount = computed(() => {
158
158
  const text = (block.content.text as string) || '';
159
159
  const code = (block.content.code as string) || '';
160
160
  const instructions = (block.content.instructions as string) || '';
161
- const combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code + ' ' + instructions;
161
+ let combined = html.replace(/<[^>]*>/g, ' ') + ' ' + text + ' ' + code + ' ' + instructions;
162
+ // Count words in nested children (build steps)
163
+ const children = block.content.children;
164
+ if (Array.isArray(children)) {
165
+ for (const child of children) {
166
+ const [, childData] = child as [string, Record<string, unknown>];
167
+ const childHtml = (childData?.html as string) || '';
168
+ const childCode = (childData?.code as string) || '';
169
+ combined += ' ' + childHtml.replace(/<[^>]*>/g, ' ') + ' ' + childCode;
170
+ }
171
+ }
162
172
  count += combined.split(/\s+/).filter((w) => w.length > 0).length;
163
173
  }
164
174
  return count;
@@ -7,7 +7,7 @@ const props = defineProps<{
7
7
  }>();
8
8
 
9
9
  const contentId = computed(() => props.content?.id);
10
- const contentType = computed(() => props.content?.type ?? 'article');
10
+ const contentType = computed(() => props.content?.type ?? 'blog');
11
11
  const fedId = computed(() => props.federatedId);
12
12
  const { liked, bookmarked, likeCount, isFederated, toggleLike, toggleBookmark, share, fetchInitialState } = useEngagement({ contentId, contentType, federatedContentId: fedId });
13
13
 
@@ -65,6 +65,12 @@ async function handleFollowAuthor(): Promise<void> {
65
65
  }
66
66
  }
67
67
 
68
+ // Series data — only available when content has series metadata
69
+ const seriesPart = computed(() => props.content?.seriesPart as number | undefined);
70
+ const seriesTitle = computed(() => props.content?.seriesTitle as string | undefined);
71
+ const seriesTotalParts = computed(() => (props.content?.seriesTotalParts as number) || 0);
72
+ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value > 0);
73
+
68
74
  const config = useRuntimeConfig();
69
75
  useJsonLd({
70
76
  type: 'article',
@@ -97,7 +103,7 @@ useJsonLd({
97
103
  <div class="cpub-article-wrap">
98
104
 
99
105
  <!-- TYPE BADGE -->
100
- <div class="cpub-content-type-badge"><i class="fa-solid fa-newspaper"></i> Article</div>
106
+ <div class="cpub-content-type-badge"><i class="fa-solid fa-pen-nib"></i> {{ content.category || 'Blog Post' }}</div>
101
107
 
102
108
  <!-- TITLE -->
103
109
  <h1 class="cpub-article-title">{{ content.title }}</h1>
@@ -122,6 +128,10 @@ useJsonLd({
122
128
  <span>{{ new Date(content.publishedAt || content.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}</span>
123
129
  <span class="cpub-sep">·</span>
124
130
  <span><i class="fa-regular fa-clock"></i> {{ content.readTime || '5 min read' }}</span>
131
+ <template v-if="hasSeries">
132
+ <span class="cpub-sep">·</span>
133
+ <span class="cpub-tag cpub-tag-accent">{{ seriesTitle }} · Part {{ seriesPart || 1 }} of {{ seriesTotalParts }}</span>
134
+ </template>
125
135
  <template v-if="content.tags?.length">
126
136
  <span class="cpub-sep">·</span>
127
137
  <NuxtLink :to="`/tags/${content.tags[0]?.slug || (content.tags[0]?.name || String(content.tags[0])).toLowerCase().replace(/\s+/g, '-')}`" class="cpub-tag cpub-tag-teal">{{ content.tags[0]?.name || content.tags[0] }}</NuxtLink>
@@ -167,6 +177,49 @@ useJsonLd({
167
177
  </template>
168
178
  </div>
169
179
 
180
+ <!-- SERIES NAVIGATION -->
181
+ <div v-if="hasSeries" class="cpub-series-nav">
182
+ <div class="cpub-series-header">
183
+ <div class="cpub-series-icon"><i class="fa-solid fa-layer-group"></i></div>
184
+ <div>
185
+ <div class="cpub-series-label">Series</div>
186
+ <div class="cpub-series-title">{{ seriesTitle }}</div>
187
+ </div>
188
+ <div style="margin-left:auto;">
189
+ <span class="cpub-tag cpub-tag-accent">Part {{ seriesPart || 1 }} of {{ seriesTotalParts }}</span>
190
+ </div>
191
+ </div>
192
+ <div class="cpub-series-progress">
193
+ <div class="cpub-series-progress-label">
194
+ <span>Progress</span>
195
+ <span>{{ seriesPart || 1 }} / {{ seriesTotalParts }} published</span>
196
+ </div>
197
+ <div class="cpub-series-progress-track">
198
+ <div class="cpub-series-progress-fill" :style="{ width: ((seriesPart || 1) / seriesTotalParts * 100) + '%' }"></div>
199
+ </div>
200
+ </div>
201
+ <div class="cpub-series-nav-btns">
202
+ <NuxtLink v-if="content.seriesPrev" :to="content.seriesPrev.url || '#'" class="cpub-series-nav-btn cpub-prev">
203
+ <div class="cpub-series-nav-dir"><i class="fa-solid fa-chevron-left"></i> Previous</div>
204
+ <div class="cpub-series-nav-ep">Part {{ (seriesPart || 2) - 1 }}</div>
205
+ <div class="cpub-series-nav-post-title">{{ content.seriesPrev.title }}</div>
206
+ </NuxtLink>
207
+ <div v-else class="cpub-series-nav-btn cpub-prev cpub-disabled">
208
+ <div class="cpub-series-nav-dir"><i class="fa-solid fa-chevron-left"></i> Previous</div>
209
+ <div class="cpub-series-nav-ep">—</div>
210
+ </div>
211
+ <NuxtLink v-if="content.seriesNext" :to="content.seriesNext.url || '#'" class="cpub-series-nav-btn cpub-next">
212
+ <div class="cpub-series-nav-dir">Next <i class="fa-solid fa-chevron-right"></i></div>
213
+ <div class="cpub-series-nav-ep">Part {{ (seriesPart || 1) + 1 }}</div>
214
+ <div class="cpub-series-nav-post-title">{{ content.seriesNext.title }}</div>
215
+ </NuxtLink>
216
+ <div v-else class="cpub-series-nav-btn cpub-next cpub-disabled">
217
+ <div class="cpub-series-nav-dir">Next <i class="fa-solid fa-chevron-right"></i></div>
218
+ <div class="cpub-series-nav-ep">Coming soon</div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+
170
223
  <!-- TAGS -->
171
224
  <div v-if="content.tags?.length" class="cpub-tags-row">
172
225
  <div class="cpub-tags-label">Filed under</div>
@@ -183,7 +236,8 @@ useJsonLd({
183
236
 
184
237
  <!-- AUTHOR CARD -->
185
238
  <div v-if="content.author" class="cpub-author-card">
186
- <div class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
239
+ <img v-if="content.author.avatarUrl" :src="content.author.avatarUrl" :alt="content.author.displayName ?? content.author.username ?? ''" class="cpub-av cpub-av-xl" style="object-fit:cover;border:2px solid var(--border);" />
240
+ <div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
187
241
  <div class="cpub-author-card-info">
188
242
  <div class="cpub-author-card-label">Written by</div>
189
243
  <div class="cpub-author-card-name">
@@ -194,7 +248,7 @@ useJsonLd({
194
248
  <div v-if="content.author.bio" class="cpub-author-card-bio">{{ content.author.bio }}</div>
195
249
  <div class="cpub-author-card-footer">
196
250
  <div class="cpub-author-card-stats">
197
- <div class="cpub-author-card-stat"><span class="n">{{ content.author.articleCount ?? 0 }}</span><span class="l">articles</span></div>
251
+ <div class="cpub-author-card-stat"><span class="n">{{ content.author.articleCount ?? 0 }}</span><span class="l">posts</span></div>
198
252
  <div class="cpub-author-card-stat"><span class="n">{{ content.author.followerCount ?? 0 }}</span><span class="l">followers</span></div>
199
253
  <div class="cpub-author-card-stat"><span class="n">{{ content.author.totalViews ?? 0 }}</span><span class="l">total views</span></div>
200
254
  </div>
@@ -209,7 +263,7 @@ useJsonLd({
209
263
  </div>
210
264
 
211
265
  <!-- RELATED ARTICLES -->
212
- <div v-if="content.related?.length" class="cpub-section-head">Related Articles</div>
266
+ <div v-if="content.related?.length" class="cpub-section-head">Related Posts</div>
213
267
  <div v-if="content.related?.length" class="cpub-related-grid">
214
268
  <NuxtLink
215
269
  v-for="item in content.related.slice(0, 3)"
@@ -576,6 +630,123 @@ useJsonLd({
576
630
  margin: 36px 0;
577
631
  }
578
632
 
633
+ /* ── SERIES NAV ── */
634
+ .cpub-series-nav {
635
+ background: var(--surface);
636
+ border: var(--border-width-default) solid var(--border);
637
+ padding: 20px;
638
+ margin: 40px 0;
639
+ box-shadow: var(--shadow-sm);
640
+ }
641
+
642
+ .cpub-series-header {
643
+ display: flex;
644
+ align-items: center;
645
+ gap: 8px;
646
+ margin-bottom: 14px;
647
+ }
648
+
649
+ .cpub-series-icon {
650
+ width: 28px;
651
+ height: 28px;
652
+ background: var(--accent-bg);
653
+ border: var(--border-width-default) solid var(--accent);
654
+ display: flex;
655
+ align-items: center;
656
+ justify-content: center;
657
+ font-size: 12px;
658
+ color: var(--accent);
659
+ flex-shrink: 0;
660
+ }
661
+
662
+ .cpub-series-label {
663
+ font-size: 10px;
664
+ font-family: var(--font-mono);
665
+ color: var(--text-faint);
666
+ letter-spacing: 0.1em;
667
+ text-transform: uppercase;
668
+ }
669
+
670
+ .cpub-series-title {
671
+ font-size: 13px;
672
+ font-weight: 600;
673
+ color: var(--text);
674
+ }
675
+
676
+ .cpub-series-progress {
677
+ margin-bottom: 16px;
678
+ }
679
+
680
+ .cpub-series-progress-label {
681
+ font-size: 11px;
682
+ font-family: var(--font-mono);
683
+ color: var(--text-faint);
684
+ margin-bottom: 6px;
685
+ display: flex;
686
+ justify-content: space-between;
687
+ }
688
+
689
+ .cpub-series-progress-track {
690
+ height: 4px;
691
+ background: var(--surface3);
692
+ overflow: hidden;
693
+ border: var(--border-width-default) solid var(--border2);
694
+ }
695
+
696
+ .cpub-series-progress-fill {
697
+ height: 100%;
698
+ background: var(--accent);
699
+ }
700
+
701
+ .cpub-series-nav-btns {
702
+ display: grid;
703
+ grid-template-columns: 1fr 1fr;
704
+ gap: 8px;
705
+ }
706
+
707
+ .cpub-series-nav-btn {
708
+ background: var(--surface);
709
+ border: var(--border-width-default) solid var(--border);
710
+ padding: 12px 14px;
711
+ cursor: pointer;
712
+ text-decoration: none;
713
+ display: flex;
714
+ flex-direction: column;
715
+ gap: 4px;
716
+ color: inherit;
717
+ transition: background var(--transition-fast);
718
+ }
719
+
720
+ .cpub-series-nav-btn:hover { background: var(--surface2); }
721
+ .cpub-series-nav-btn.cpub-next { text-align: right; }
722
+ .cpub-series-nav-btn.cpub-disabled { opacity: 0.5; pointer-events: none; }
723
+
724
+ .cpub-series-nav-dir {
725
+ font-size: 10px;
726
+ font-family: var(--font-mono);
727
+ color: var(--text-faint);
728
+ letter-spacing: 0.08em;
729
+ text-transform: uppercase;
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 4px;
733
+ }
734
+
735
+ .cpub-series-nav-btn.cpub-next .cpub-series-nav-dir { justify-content: flex-end; }
736
+
737
+ .cpub-series-nav-ep {
738
+ font-size: 10px;
739
+ font-family: var(--font-mono);
740
+ color: var(--accent);
741
+ }
742
+
743
+ .cpub-series-nav-post-title {
744
+ font-size: 12px;
745
+ font-weight: 600;
746
+ color: var(--text);
747
+ line-height: 1.35;
748
+ }
749
+
579
750
  /* ── TAGS ROW ── */
580
751
  .cpub-tags-row {
581
752
  display: flex;
@@ -803,6 +974,8 @@ useJsonLd({
803
974
  .cpub-engage-btn { padding: 8px 12px; min-height: 36px; }
804
975
  .cpub-engage-sep { display: none; }
805
976
  .cpub-tag-link { padding: 4px 10px; font-size: 11px; min-height: 28px; display: inline-flex; align-items: center; }
977
+ .cpub-series-nav-btns { grid-template-columns: 1fr; }
978
+ .cpub-series-nav-btn { padding: 12px; min-height: 44px; }
806
979
  }
807
980
 
808
981
  @media (max-width: 480px) {
@@ -111,8 +111,7 @@ const partsFromBlocks = computed<PartItem[]>(() => {
111
111
  interface BuildStep {
112
112
  number: number;
113
113
  title: string;
114
- instructions: string;
115
- image?: string;
114
+ children: Array<[string, Record<string, unknown>]>;
116
115
  time?: string;
117
116
  }
118
117
 
@@ -125,11 +124,25 @@ const buildStepsFromBlocks = computed<BuildStep[]>(() => {
125
124
  const [type, data] = block as [string, Record<string, unknown>];
126
125
  if (type === 'buildStep') {
127
126
  stepNum++;
127
+ // Migrate old format (instructions + image) to children
128
+ let children: Array<[string, Record<string, unknown>]> = [];
129
+ if (data.children && Array.isArray(data.children) && data.children.length > 0) {
130
+ children = data.children as Array<[string, Record<string, unknown>]>;
131
+ } else {
132
+ const instructions = data.instructions as string | undefined;
133
+ if (instructions && instructions.trim()) {
134
+ const html = instructions.startsWith('<') ? instructions : `<p>${instructions}</p>`;
135
+ children.push(['paragraph', { html }]);
136
+ }
137
+ const image = data.image as string | undefined;
138
+ if (image && image.trim()) {
139
+ children.push(['image', { src: image, alt: `Step ${stepNum}`, caption: '' }]);
140
+ }
141
+ }
128
142
  steps.push({
129
143
  number: (data.stepNumber as number) || stepNum,
130
144
  title: (data.title as string) || `Step ${stepNum}`,
131
- instructions: (data.instructions as string) || '',
132
- image: data.image as string | undefined,
145
+ children,
133
146
  time: data.time as string | undefined,
134
147
  });
135
148
  }
@@ -495,9 +508,8 @@ async function handleBuild(): Promise<void> {
495
508
  <h3 class="cpub-build-step-title">{{ step.title }}</h3>
496
509
  <span v-if="step.time" class="cpub-build-step-time"><i class="fa-regular fa-clock"></i> {{ step.time }}</span>
497
510
  </div>
498
- <div class="cpub-build-step-body">
499
- <p>{{ step.instructions }}</p>
500
- <img v-if="step.image" :src="step.image" :alt="`Step ${step.number}`" class="cpub-build-step-img" />
511
+ <div v-if="step.children.length > 0" class="cpub-build-step-body">
512
+ <BlockContentRenderer :blocks="step.children" />
501
513
  </div>
502
514
  </div>
503
515
  </div>
@@ -2,10 +2,9 @@
2
2
 
3
3
  export type ContentType = 'project' | 'article' | 'blog' | 'explainer';
4
4
 
5
- const CONTENT_TYPE_META: Record<ContentType, { label: string; plural: string; icon: string; route: string }> = {
5
+ const CONTENT_TYPE_META: Record<string, { label: string; plural: string; icon: string; route: string }> = {
6
6
  project: { label: 'Project', plural: 'Projects', icon: 'fa-solid fa-microchip', route: '/project' },
7
- article: { label: 'Article', plural: 'Articles', icon: 'fa-solid fa-file-lines', route: '/article' },
8
- blog: { label: 'Blog', plural: 'Blogs', icon: 'fa-solid fa-pen-nib', route: '/blog' },
7
+ blog: { label: 'Blog', plural: 'Blog', icon: 'fa-solid fa-pen-nib', route: '/blog' },
9
8
  explainer: { label: 'Explainer', plural: 'Explainers', icon: 'fa-solid fa-lightbulb', route: '/explainer' },
10
9
  };
11
10
 
@@ -14,8 +13,8 @@ export function useContentTypes() {
14
13
 
15
14
  const enabledTypes = computed<ContentType[]>(() => {
16
15
  const raw = config.public.contentTypes as string;
17
- if (!raw) return ['project', 'article', 'blog', 'explainer'];
18
- return raw.split(',').map(s => s.trim()).filter(Boolean) as ContentType[];
16
+ if (!raw) return ['project', 'blog', 'explainer'];
17
+ return raw.split(',').map(s => s.trim()).filter(s => s !== 'article').filter(Boolean) as ContentType[];
19
18
  });
20
19
 
21
20
  const isTypeEnabled = (type: ContentType): boolean => {
@@ -6,7 +6,7 @@ import type { ContentViewData } from './useEngagement';
6
6
  */
7
7
  export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>) {
8
8
  const contentType = computed(() => {
9
- const t = (fedContent.value?.cpubType as string) || (fedContent.value?.apType as string)?.toLowerCase() || 'article';
9
+ const t = (fedContent.value?.cpubType as string) || (fedContent.value?.apType as string)?.toLowerCase() || 'blog';
10
10
  return t;
11
11
  });
12
12
 
@@ -112,7 +112,6 @@ const userUsername = computed(() => user.value?.username ?? '');
112
112
  <i class="fa-solid fa-newspaper"></i> Read <i class="fa-solid fa-chevron-down cpub-nav-caret" />
113
113
  </button>
114
114
  <div v-if="openDropdown === 'read'" class="cpub-nav-panel">
115
- <NuxtLink to="/article" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-file-lines"></i> Articles</NuxtLink>
116
115
  <NuxtLink to="/blog" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
117
116
  </div>
118
117
  </div>
@@ -202,7 +201,6 @@ const userUsername = computed(() => user.value?.username ?? '');
202
201
 
203
202
  <!-- Read -->
204
203
  <div class="cpub-mobile-section-label">Read</div>
205
- <NuxtLink to="/article" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-file-lines"></i> Articles</NuxtLink>
206
204
  <NuxtLink to="/blog" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
207
205
 
208
206
  <!-- Watch -->
package/nuxt.config.ts CHANGED
@@ -90,7 +90,7 @@ export default defineNuxtConfig({
90
90
  admin: false,
91
91
  emailNotifications: false,
92
92
  },
93
- contentTypes: 'project,article,blog,explainer',
93
+ contentTypes: 'project,blog,explainer',
94
94
  contestCreation: 'admin',
95
95
  instanceCookies: [] as Array<{ name: string; category: string; description: string; duration: string; provider?: string }>,
96
96
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.7.26",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,15 +51,15 @@
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
53
  "@commonpub/auth": "0.5.0",
54
- "@commonpub/docs": "0.6.2",
55
54
  "@commonpub/editor": "0.7.9",
56
- "@commonpub/explainer": "0.7.6",
57
- "@commonpub/config": "0.9.0",
58
- "@commonpub/protocol": "0.9.7",
59
55
  "@commonpub/learning": "0.5.0",
56
+ "@commonpub/config": "0.9.1",
57
+ "@commonpub/docs": "0.6.2",
58
+ "@commonpub/protocol": "0.9.8",
59
+ "@commonpub/schema": "0.9.6",
60
60
  "@commonpub/ui": "0.8.5",
61
- "@commonpub/server": "2.27.7",
62
- "@commonpub/schema": "0.9.5"
61
+ "@commonpub/server": "2.28.0",
62
+ "@commonpub/explainer": "0.7.10"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
package/pages/create.vue CHANGED
@@ -21,13 +21,13 @@ const allTypes = [
21
21
  badge: 'Popular',
22
22
  },
23
23
  {
24
- type: 'article',
25
- icon: 'fa-solid fa-file-lines',
26
- color: 'var(--teal)',
27
- bg: 'var(--teal-bg)',
28
- border: 'var(--teal-border)',
29
- name: 'Article',
30
- desc: 'Write a long-form technical article with code examples, diagrams, and rich formatting.',
24
+ type: 'blog',
25
+ icon: 'fa-solid fa-pen-nib',
26
+ color: 'var(--pink)',
27
+ bg: 'var(--pink-bg)',
28
+ border: 'var(--pink-border)',
29
+ name: 'Blog',
30
+ desc: 'Write long-form content articles, tutorials, deep dives, opinion pieces, or personal updates with rich formatting.',
31
31
  },
32
32
  {
33
33
  type: 'explainer',
@@ -38,15 +38,6 @@ const allTypes = [
38
38
  name: 'Explainer',
39
39
  desc: 'Create an interactive explorable explanation with sliders, quizzes, and section-by-section progression.',
40
40
  },
41
- {
42
- type: 'blog',
43
- icon: 'fa-solid fa-pen-nib',
44
- color: 'var(--pink)',
45
- bg: 'var(--pink-bg)',
46
- border: 'var(--pink-border)',
47
- name: 'Blog Post',
48
- desc: 'Share thoughts, tutorials, or updates with a clean writing experience and inline media.',
49
- },
50
41
  ];
51
42
 
52
43
  const types = computed(() => allTypes.filter(t => isTypeEnabled(t.type as ContentType)));
package/pages/explore.vue CHANGED
@@ -168,7 +168,7 @@ const sortOptions = [
168
168
  <div v-if="statsData" class="cpub-explore-stats">
169
169
  <div class="cpub-explore-stat">
170
170
  <span class="cpub-explore-stat-n">{{ statsData?.content?.total ?? 0 }}</span>
171
- <span class="cpub-explore-stat-l">Projects & Articles</span>
171
+ <span class="cpub-explore-stat-l">Projects & Posts</span>
172
172
  </div>
173
173
  <div v-if="hubsEnabled" class="cpub-explore-stat">
174
174
  <span class="cpub-explore-stat-n">{{ statsData?.hubs?.total ?? 0 }}</span>
package/pages/index.vue CHANGED
@@ -294,8 +294,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
294
294
  <span class="cpub-stat-lbl">Projects</span>
295
295
  </div>
296
296
  <div class="cpub-stat-block">
297
- <span class="cpub-stat-num">{{ stats?.content?.byType?.article ?? 0 }}</span>
298
- <span class="cpub-stat-lbl">Articles</span>
297
+ <span class="cpub-stat-num">{{ (stats?.content?.byType?.blog ?? 0) + (stats?.content?.byType?.article ?? 0) }}</span>
298
+ <span class="cpub-stat-lbl">Posts</span>
299
299
  </div>
300
300
  <div class="cpub-stat-block">
301
301
  <span class="cpub-stat-num">{{ stats?.users?.total ?? 0 }}</span>
@@ -343,7 +343,7 @@ async function handlePublish(): Promise<void> {
343
343
 
344
344
  <ContentPicker
345
345
  :open="showContentPicker"
346
- :types="['article', 'project', 'explainer', 'blog']"
346
+ :types="['blog', 'project', 'explainer']"
347
347
  @update:open="showContentPicker = $event"
348
348
  @select="linkContent"
349
349
  />
@@ -98,8 +98,7 @@ useSeoMeta({
98
98
 
99
99
  <!-- Reuse existing content view components by type -->
100
100
  <ViewsProjectView v-if="contentType === 'project'" :content="transformedContent" :federated-id="id" />
101
- <ViewsArticleView v-else-if="contentType === 'article'" :content="transformedContent" :federated-id="id" />
102
- <ViewsBlogView v-else-if="contentType === 'blog'" :content="transformedContent" :federated-id="id" />
101
+ <ViewsArticleView v-else-if="contentType === 'article' || contentType === 'blog'" :content="transformedContent" :federated-id="id" />
103
102
  <ViewsExplainerView v-else-if="contentType === 'explainer'" :content="transformedContent" :federated-id="id" />
104
103
 
105
104
  <!-- Fallback for non-CommonPub content (Mastodon notes, Lemmy posts, etc.) -->
package/pages/privacy.vue CHANGED
@@ -31,7 +31,7 @@ const { federation: federationEnabled } = useFeatures();
31
31
  <ul>
32
32
  <li><strong>Account data:</strong> email address, username, password (stored as a secure hash)</li>
33
33
  <li><strong>Profile data:</strong> display name, bio, headline, location, website, avatar, banner image, social links, skills, pronouns, timezone (all optional)</li>
34
- <li><strong>Content:</strong> projects, articles, blog posts, comments, and other content you create</li>
34
+ <li><strong>Content:</strong> projects, articles, comments, and other content you create</li>
35
35
  <li><strong>Activity data:</strong> likes, follows, bookmarks, hub memberships, learning path enrollments</li>
36
36
  <li><strong>Messages:</strong> direct messages you send to other users on this instance</li>
37
37
  </ul>
@@ -77,7 +77,7 @@ const { federation: federationEnabled } = useFeatures();
77
77
  <p>This instance participates in the <a href="https://activitypub.rocks" target="_blank" rel="noopener">ActivityPub</a> federation protocol. When you publish content or interact publicly, the following data may be shared with remote instances:</p>
78
78
  <ul>
79
79
  <li>Your username, display name, avatar, and bio</li>
80
- <li>Your published content (projects, articles, blog posts)</li>
80
+ <li>Your published content (projects, articles, explainers)</li>
81
81
  <li>Your public interactions (likes, follows, comments on federated content)</li>
82
82
  </ul>
83
83
  <p>Your email address, location, social links, timezone, and other private profile fields are <strong>never</strong> shared via federation.</p>
@@ -130,8 +130,8 @@ provide(SEARCH_PRODUCTS_KEY, async (query: string) => {
130
130
 
131
131
  // --- Specialized editor component map ---
132
132
  const editorMap: Record<string, Component> = {
133
- article: resolveComponent('EditorsArticleEditor') as Component,
134
- blog: resolveComponent('EditorsBlogEditor') as Component,
133
+ blog: resolveComponent('EditorsArticleEditor') as Component,
134
+ article: resolveComponent('EditorsArticleEditor') as Component, // article merged into blog
135
135
  explainer: resolveComponent('EditorsExplainerEditor') as Component,
136
136
  project: resolveComponent('EditorsProjectEditor') as Component,
137
137
  };
@@ -90,8 +90,7 @@ onMounted(() => {
90
90
 
91
91
  <!-- Specialized view by content type -->
92
92
  <ViewsProjectView v-if="contentType === 'project'" :content="(enrichedContent as any)" />
93
- <ViewsArticleView v-else-if="contentType === 'article'" :content="(enrichedContent as any)" />
94
- <ViewsBlogView v-else-if="contentType === 'blog'" :content="(enrichedContent as any)" />
93
+ <ViewsArticleView v-else-if="contentType === 'article' || contentType === 'blog'" :content="(enrichedContent as any)" />
95
94
  <ViewsExplainerView v-else-if="contentType === 'explainer'" :content="(enrichedContent as any)" />
96
95
 
97
96
  <!-- Fallback: generic view for unknown types -->
@@ -33,7 +33,7 @@ const activeTab = ref('projects');
33
33
  const tabDefs = computed(() => {
34
34
  const tabs = [
35
35
  { value: 'projects', label: 'Projects', icon: 'fa-solid fa-folder-open' },
36
- { value: 'articles', label: 'Articles', icon: 'fa-solid fa-newspaper' },
36
+ { value: 'articles', label: 'Blog', icon: 'fa-solid fa-pen-nib' },
37
37
  ];
38
38
  if (explainersEnabled.value) {
39
39
  tabs.push({ value: 'explainers', label: 'Explainers', icon: 'fa-solid fa-book-open' });
@@ -52,7 +52,7 @@ const profileStats = computed(() => {
52
52
  { value: p.stats?.projects ?? 0, label: 'Projects' },
53
53
  { value: p.followerCount ?? p.stats?.followers ?? 0, label: 'Followers' },
54
54
  { value: p.followingCount ?? p.stats?.following ?? 0, label: 'Following' },
55
- { value: p.stats?.articles ?? 0, label: 'Articles' },
55
+ { value: p.stats?.articles ?? 0, label: 'Posts' },
56
56
  { value: p.viewCount ?? 0, label: 'Total Views' },
57
57
  { value: p.likeCount ?? 0, label: 'Likes' },
58
58
  ];
@@ -49,7 +49,7 @@ export default defineEventHandler(async (event): Promise<HubPostItem> => {
49
49
  const sharePayload = JSON.stringify({
50
50
  federatedContentId: fedContent.id,
51
51
  title: fedContent.title,
52
- type: fedContent.cpubType ?? fedContent.apType ?? 'article',
52
+ type: fedContent.cpubType ?? fedContent.apType ?? 'blog',
53
53
  coverImageUrl: fedContent.coverImageUrl ?? null,
54
54
  description: fedContent.summary ?? null,
55
55
  originUrl: fedContent.url ?? fedContent.objectUri,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Server middleware: redirect article URLs to blog URLs.
3
+ * - /u/{username}/article/{slug} → /u/{username}/blog/{slug}
4
+ * - /u/{username}/article/{slug}/edit → /u/{username}/blog/{slug}/edit
5
+ * - /article → /blog (listing page)
6
+ *
7
+ * Article content type has been merged into blog. These 301 redirects
8
+ * ensure old URLs and bookmarks continue working.
9
+ */
10
+ export default defineEventHandler((event) => {
11
+ const path = getRequestURL(event).pathname;
12
+
13
+ // Redirect /article listing to /blog
14
+ if (path === '/article' || path === '/article/') {
15
+ const query = getRequestURL(event).search;
16
+ return sendRedirect(event, `/blog${query}`, 301);
17
+ }
18
+
19
+ // Redirect /u/{username}/article/{slug}[/edit] to /u/{username}/blog/{slug}[/edit]
20
+ const match = path.match(/^\/u\/([^/]+)\/article\/(.+)$/);
21
+ if (!match) return;
22
+
23
+ const newPath = `/u/${match[1]}/blog/${match[2]}`;
24
+ const query = getRequestURL(event).search;
25
+ return sendRedirect(event, `${newPath}${query}`, 301);
26
+ });
@@ -1,6 +1,6 @@
1
1
  import { contentToArticle } from '@commonpub/protocol';
2
2
  import { contentItems, users } from '@commonpub/schema';
3
- import { eq, and, isNull } from 'drizzle-orm';
3
+ import { eq, and, isNull, sql } from 'drizzle-orm';
4
4
 
5
5
  /**
6
6
  * Middleware: serve ActivityPub Article JSON-LD for content URIs.
@@ -26,12 +26,18 @@ export default defineEventHandler(async (event) => {
26
26
  const config = useConfig();
27
27
  if (!config.features.federation) return;
28
28
 
29
- const [, username, type, slug] = match;
30
- if (!username || !type || !slug) return;
29
+ const [, username, rawType, slug] = match;
30
+ if (!username || !rawType || !slug) return;
31
+ const type = rawType === 'article' ? 'blog' : rawType; // normalize article→blog
31
32
 
32
33
  const db = useDB();
33
34
  const domain = config.instance.domain;
34
35
 
36
+ // For blog type, also match 'article' in DB (transition: pre-migration rows still have type='article')
37
+ const typeFilter = type === 'blog'
38
+ ? sql`${contentItems.type} IN ('blog', 'article')`
39
+ : eq(contentItems.type, type as 'project' | 'explainer');
40
+
35
41
  const [row] = await db
36
42
  .select({
37
43
  content: contentItems,
@@ -44,7 +50,7 @@ export default defineEventHandler(async (event) => {
44
50
  .innerJoin(users, eq(contentItems.authorId, users.id))
45
51
  .where(and(
46
52
  eq(users.username, username),
47
- eq(contentItems.type, type as 'project' | 'article' | 'blog' | 'explainer'),
53
+ typeFilter,
48
54
  eq(contentItems.slug, slug),
49
55
  eq(contentItems.status, 'published'),
50
56
  isNull(contentItems.deletedAt),
@@ -0,0 +1,67 @@
1
+ /**
2
+ * One-time data migration: article → blog type merge.
3
+ *
4
+ * Converts content_items with type='article' to type='blog', preserving the
5
+ * original intent by setting category='article' (only when category is empty).
6
+ *
7
+ * Handles the edge case where the same author has both type='article' and
8
+ * type='blog' with the same slug — appends '-2' to the article's slug before
9
+ * converting to avoid unique constraint violation on (authorId, type, slug).
10
+ *
11
+ * Idempotent — safe to run on every startup. Once no article rows remain,
12
+ * this is a no-op SELECT that returns 0.
13
+ *
14
+ * Can be removed after confirming both instances have been migrated.
15
+ */
16
+ import { contentItems } from '@commonpub/schema';
17
+ import { eq, and, sql, isNull } from 'drizzle-orm';
18
+
19
+ export default defineNitroPlugin((nitro) => {
20
+ setTimeout(async () => {
21
+ try {
22
+ const db = useDB();
23
+
24
+ const [{ count: articleCount }] = await db
25
+ .select({ count: sql<number>`count(*)::int` })
26
+ .from(contentItems)
27
+ .where(eq(contentItems.type, 'article'));
28
+
29
+ if (articleCount === 0) return;
30
+
31
+ // Handle slug collisions: if author has both article/slug and blog/slug,
32
+ // rename the article's slug before converting type
33
+ await db.execute(sql`
34
+ UPDATE content_items a
35
+ SET slug = a.slug || '-2'
36
+ WHERE a.type = 'article'
37
+ AND EXISTS (
38
+ SELECT 1 FROM content_items b
39
+ WHERE b.type = 'blog'
40
+ AND b.author_id = a.author_id
41
+ AND b.slug = a.slug
42
+ )
43
+ `);
44
+
45
+ // Set category='article' for rows that don't already have a category,
46
+ // so the original "article" intent is preserved as metadata
47
+ await db
48
+ .update(contentItems)
49
+ .set({ category: 'article' })
50
+ .where(and(
51
+ eq(contentItems.type, 'article'),
52
+ isNull(contentItems.category),
53
+ ));
54
+
55
+ // Convert the type
56
+ await db
57
+ .update(contentItems)
58
+ .set({ type: 'blog' })
59
+ .where(eq(contentItems.type, 'article'));
60
+
61
+ console.log(`[migrate-article-to-blog] Converted ${articleCount} article(s) to blog type (category preserved)`);
62
+ } catch (err) {
63
+ // Log but don't crash the app — migration can retry on next restart
64
+ console.warn('[migrate-article-to-blog] Migration failed, will retry on next restart:', (err as Error).message);
65
+ }
66
+ }, 3000);
67
+ });
@@ -59,7 +59,7 @@ export default defineEventHandler(async (event) => {
59
59
  try {
60
60
  const shared = JSON.parse(post.content) as Record<string, unknown>;
61
61
  ext['cpub:sharedContent'] = {
62
- type: shared.type ?? 'article',
62
+ type: shared.type ?? 'blog',
63
63
  title: shared.title ?? '',
64
64
  summary: shared.description ?? null,
65
65
  coverImageUrl: shared.coverImageUrl ?? null,