@commonpub/layer 0.81.0 → 0.83.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.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +11 -3
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +54 -20
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useAuth.ts +13 -0
- package/composables/useCan.ts +23 -0
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +43 -18
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +8 -8
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/roles.vue +286 -0
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +81 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -764
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +97 -8
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/admin/permissions.get.ts +14 -0
- package/server/api/admin/roles/[id]/index.delete.ts +25 -0
- package/server/api/admin/roles/[id]/index.put.ts +24 -0
- package/server/api/admin/roles/index.get.ts +10 -0
- package/server/api/admin/roles/index.post.ts +27 -0
- package/server/api/admin/users/[id]/role.put.ts +20 -1
- package/server/api/admin/users/[id]/roles.get.ts +10 -0
- package/server/api/admin/users/[id]/roles.put.ts +17 -0
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/advance.post.ts +10 -5
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/index.get.ts +10 -2
- package/server/api/contests/[slug]/index.put.ts +11 -2
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
- package/server/api/contests/[slug]/transition.post.ts +8 -3
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/me.get.ts +7 -0
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contest-specific block content shapes (BlockTuple content objects).
|
|
3
|
+
*
|
|
4
|
+
* These blocks live ENTIRELY in the layer: the edit component is provided to
|
|
5
|
+
* BlockCanvas via `BLOCK_COMPONENTS_KEY` (no @commonpub/editor change — the
|
|
6
|
+
* editor registry is unused; component resolution is `override ?? builtin ??
|
|
7
|
+
* TextBlock`), and the view component is registered in BlockContentRenderer's
|
|
8
|
+
* map. Persistence rides the contest `descriptionBlocks`/`rulesBlocks` jsonb
|
|
9
|
+
* (validated loosely as `BlockTuple[]`), so no registry/schema wiring is needed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single judge/mentor card in the `judgesShowcase` block. */
|
|
13
|
+
export interface JudgeShowcaseEntry {
|
|
14
|
+
name: string;
|
|
15
|
+
/** Avatar image URL (falls back to the name initial). */
|
|
16
|
+
avatarUrl?: string;
|
|
17
|
+
/** Role / affiliation line, e.g. "Lead Judge, ACME Labs". */
|
|
18
|
+
title?: string;
|
|
19
|
+
/** Short bio / description. */
|
|
20
|
+
bio?: string;
|
|
21
|
+
/** Optional profile or external link (http(s) only when rendered). */
|
|
22
|
+
link?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `judgesShowcase` block — an editorial judges/mentors showcase for the contest
|
|
27
|
+
* overview (avatar + name + bio), curated independently of the `contest_judges`
|
|
28
|
+
* table / Judges tab.
|
|
29
|
+
*/
|
|
30
|
+
export interface JudgesShowcaseContent {
|
|
31
|
+
/** Optional section heading, e.g. "Meet the Judges". */
|
|
32
|
+
heading?: string;
|
|
33
|
+
judges: JudgeShowcaseEntry[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Block type key shared by the edit component (provide map) + the view (renderer map). */
|
|
37
|
+
export const JUDGES_SHOWCASE_TYPE = 'judgesShowcase';
|
|
38
|
+
|
|
39
|
+
/** A single logo in the `sponsors` block. */
|
|
40
|
+
export interface SponsorLogo {
|
|
41
|
+
/** Logo image URL (uploaded or pasted). */
|
|
42
|
+
src: string;
|
|
43
|
+
/** Accessible name — the organization, used as the img alt. */
|
|
44
|
+
alt: string;
|
|
45
|
+
/** Optional outbound link (http(s) only when rendered). */
|
|
46
|
+
url?: string;
|
|
47
|
+
/** Optional tier label, e.g. "Gold". Logos sharing a tier render in one group. */
|
|
48
|
+
tier?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `sponsors` block — a logo wall for partners/sponsors. Flat list of logos with an
|
|
53
|
+
* optional eyebrow heading; logos that share a `tier` group together (the view
|
|
54
|
+
* shows the tier labels only when at least one logo is tiered).
|
|
55
|
+
*/
|
|
56
|
+
export interface SponsorsContent {
|
|
57
|
+
/** Eyebrow heading above the wall, e.g. "Sponsors". */
|
|
58
|
+
heading?: string;
|
|
59
|
+
logos: SponsorLogo[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const SPONSORS_TYPE = 'sponsors';
|
|
63
|
+
|
|
64
|
+
/** Tone of a `compareColumns` column — drives its color + per-item icon. */
|
|
65
|
+
export type CompareTone = 'positive' | 'negative' | 'neutral';
|
|
66
|
+
|
|
67
|
+
/** One column in the `compareColumns` block. */
|
|
68
|
+
export interface CompareColumn {
|
|
69
|
+
tone: CompareTone;
|
|
70
|
+
/** Column title, e.g. "Encouraged" / "Out of scope". */
|
|
71
|
+
title: string;
|
|
72
|
+
/** Bullet items (plain text). */
|
|
73
|
+
items: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `compareColumns` block — side-by-side guidance columns (the classic
|
|
78
|
+
* "Encouraged / Out of scope" or do-vs-don't pattern), with an optional eyebrow,
|
|
79
|
+
* heading, and a footer note.
|
|
80
|
+
*/
|
|
81
|
+
export interface CompareColumnsContent {
|
|
82
|
+
/** Eyebrow label above the heading, e.g. "What is in scope". */
|
|
83
|
+
eyebrow?: string;
|
|
84
|
+
/** Heading line. */
|
|
85
|
+
heading?: string;
|
|
86
|
+
columns: CompareColumn[];
|
|
87
|
+
/** Optional footer note shown under the columns. */
|
|
88
|
+
note?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const COMPARE_COLUMNS_TYPE = 'compareColumns';
|
|
92
|
+
|
|
93
|
+
/** Visual emphasis of a roadmap node — default (hollow), accent (filled), highlight (finale). */
|
|
94
|
+
export type RoadmapTone = 'default' | 'accent' | 'highlight';
|
|
95
|
+
|
|
96
|
+
/** One milestone on the `roadmap` timeline. */
|
|
97
|
+
export interface RoadmapItem {
|
|
98
|
+
/** Free-text date label, e.g. "Jun 30" (organizer-editable, not a real date). */
|
|
99
|
+
date?: string;
|
|
100
|
+
title: string;
|
|
101
|
+
/** Plain-text blurb under the title. */
|
|
102
|
+
description?: string;
|
|
103
|
+
/** Optional pill next to the date, e.g. "Mid-term". */
|
|
104
|
+
badge?: string;
|
|
105
|
+
tone?: RoadmapTone;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `roadmap` block — a vertical schedule timeline. The edit block can seed its
|
|
110
|
+
* items from the contest's stages/schedule (one click), then the organizer edits,
|
|
111
|
+
* reorders, and styles them freely; the saved items are independent of the live
|
|
112
|
+
* stages (present-how-you-like).
|
|
113
|
+
*/
|
|
114
|
+
export interface RoadmapContent {
|
|
115
|
+
/** Eyebrow label, e.g. "Key dates, 2026". */
|
|
116
|
+
eyebrow?: string;
|
|
117
|
+
/** Heading line, e.g. "The 18-week roadmap". */
|
|
118
|
+
heading?: string;
|
|
119
|
+
items: RoadmapItem[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const ROADMAP_TYPE = 'roadmap';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for contest content blocks (criteria-bar segment math, the shared
|
|
3
|
+
* theme-color palette) + the inject key the contest editor uses to feed the
|
|
4
|
+
* criteria-bar block its rubric. The math is pure/unit-testable in isolation.
|
|
5
|
+
*/
|
|
6
|
+
import type { InjectionKey, Ref } from 'vue';
|
|
7
|
+
import type { RoadmapItem } from '../types/contestBlocks';
|
|
8
|
+
|
|
9
|
+
/** A contest judging-rubric criterion (mirrors useContestEditor's criteria row). */
|
|
10
|
+
export interface ContestRubricCriterion { label: string; weight?: number; description?: string }
|
|
11
|
+
|
|
12
|
+
/** ContestEditor `provide`s its live judging criteria under this key so the
|
|
13
|
+
* criteria-bar edit block can offer a "use this contest's rubric" auto-fill.
|
|
14
|
+
* Absent (null) when the block is used outside the contest editor. */
|
|
15
|
+
export const CONTEST_RUBRIC_KEY: InjectionKey<Ref<ContestRubricCriterion[]>> = Symbol('contestRubric');
|
|
16
|
+
|
|
17
|
+
/** ContestEditor `provide`s a ready-to-use roadmap derived from the contest's
|
|
18
|
+
* effective schedule under this key, so the roadmap block can offer a
|
|
19
|
+
* "pull from schedule" seed. Absent (null) outside the contest editor. */
|
|
20
|
+
export const CONTEST_SCHEDULE_KEY: InjectionKey<Ref<RoadmapItem[]>> = Symbol('contestSchedule');
|
|
21
|
+
|
|
22
|
+
/** A stage as the roadmap cares about it (structural subset of ContestStage). */
|
|
23
|
+
export interface RoadmapStageSource { name: string; kind?: string; startsAt?: string; endsAt?: string; description?: string }
|
|
24
|
+
/** The three core schedule dates, when there are no custom stages. */
|
|
25
|
+
export interface RoadmapScheduleDates { startDate?: string; endDate?: string; judgingEndDate?: string }
|
|
26
|
+
|
|
27
|
+
/** Format an ISO date as a short label ("Jun 30"); '' for empty/invalid input. */
|
|
28
|
+
export function fmtRoadmapDate(iso?: string): string {
|
|
29
|
+
if (!iso) return '';
|
|
30
|
+
const d = new Date(iso);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
32
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Derive roadmap items from the contest's effective timeline: the custom stages
|
|
37
|
+
* when present, else the core Submissions → Judging → Results flow from the
|
|
38
|
+
* schedule dates. The `results` kind (and the synthetic finale) get the
|
|
39
|
+
* `highlight` tone. Pure — the seed the roadmap edit block copies in.
|
|
40
|
+
*/
|
|
41
|
+
export function roadmapFromSchedule(stages: RoadmapStageSource[] | undefined, schedule: RoadmapScheduleDates): RoadmapItem[] {
|
|
42
|
+
const named = (stages ?? []).filter((s) => (s?.name ?? '').trim());
|
|
43
|
+
if (named.length) {
|
|
44
|
+
return named.map((s) => ({
|
|
45
|
+
date: fmtRoadmapDate(s.startsAt ?? s.endsAt),
|
|
46
|
+
title: s.name.trim(),
|
|
47
|
+
description: (s.description ?? '').trim() || undefined,
|
|
48
|
+
tone: s.kind === 'results' ? 'highlight' : 'default',
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
const items: RoadmapItem[] = [];
|
|
52
|
+
if (schedule.startDate) items.push({ date: fmtRoadmapDate(schedule.startDate), title: 'Submissions open', tone: 'default' });
|
|
53
|
+
if (schedule.endDate) items.push({ date: fmtRoadmapDate(schedule.endDate), title: 'Submissions close', tone: 'default' });
|
|
54
|
+
const judge = schedule.judgingEndDate || schedule.endDate;
|
|
55
|
+
if (judge) items.push({ date: fmtRoadmapDate(judge), title: 'Judging ends', tone: 'default' });
|
|
56
|
+
items.push({ title: 'Results announced', tone: 'highlight' });
|
|
57
|
+
return items;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Theme color tokens segments cycle through (all dark/light-safe `var(--*)`).
|
|
62
|
+
* Ordered to spread hue across the rotation so adjacent segments stay
|
|
63
|
+
* distinguishable in a seamless (gap-free) bar — incl. green-accent themes where
|
|
64
|
+
* accent/teal/green collapse, so those three are kept apart in the order.
|
|
65
|
+
*/
|
|
66
|
+
export const CRITERIA_BAR_PALETTE = ['accent', 'yellow', 'purple', 'teal', 'pink', 'green', 'red'] as const;
|
|
67
|
+
export type CriteriaColorKey = (typeof CRITERIA_BAR_PALETTE)[number];
|
|
68
|
+
|
|
69
|
+
/** Resolve a segment color: the author's palette key, else a rotation by index.
|
|
70
|
+
* Always a theme `var(--*)` so it adapts to light/dark + custom themes. */
|
|
71
|
+
export function criteriaColorVar(key: string | undefined, index = 0): string {
|
|
72
|
+
const k = key && (CRITERIA_BAR_PALETTE as readonly string[]).includes(key)
|
|
73
|
+
? key
|
|
74
|
+
: CRITERIA_BAR_PALETTE[index % CRITERIA_BAR_PALETTE.length];
|
|
75
|
+
return `var(--${k})`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CriteriaBarItem { label: string; weight?: number; color?: string; description?: string }
|
|
79
|
+
export interface CriteriaRow { label: string; weight: number; description?: string; pct: number; colorVar: string; colorKey: string }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve criteria items into legend rows + bar geometry. EVERY labeled item
|
|
83
|
+
* becomes a row (so the legend lists them all, even 0-weight/holistic ones);
|
|
84
|
+
* `pct` is each weight's share of the total (the bar fills 100% regardless of
|
|
85
|
+
* whether weights sum to 100). Colors are assigned by the item's index in the
|
|
86
|
+
* labeled list so a row's legend swatch always matches its bar segment. The bar
|
|
87
|
+
* renders `rows.filter(r => r.pct > 0)`.
|
|
88
|
+
*/
|
|
89
|
+
export function criteriaBar(items: CriteriaBarItem[] | undefined): { rows: CriteriaRow[]; total: number } {
|
|
90
|
+
const labeled = (items ?? []).filter((i) => (i?.label ?? '').trim());
|
|
91
|
+
const total = labeled.reduce((s, i) => s + Math.max(0, Number(i?.weight) || 0), 0);
|
|
92
|
+
const rows = labeled.map((i, idx) => {
|
|
93
|
+
const key = i.color && (CRITERIA_BAR_PALETTE as readonly string[]).includes(i.color)
|
|
94
|
+
? i.color
|
|
95
|
+
: CRITERIA_BAR_PALETTE[idx % CRITERIA_BAR_PALETTE.length]!;
|
|
96
|
+
const w = Math.max(0, Number(i.weight) || 0);
|
|
97
|
+
return {
|
|
98
|
+
label: i.label.trim(),
|
|
99
|
+
weight: w,
|
|
100
|
+
description: (i.description ?? '').trim() || undefined,
|
|
101
|
+
pct: total > 0 ? Math.round((w / total) * 1000) / 10 : 0,
|
|
102
|
+
colorVar: `var(--${key})`,
|
|
103
|
+
colorKey: key,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
return { rows, total };
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { markdownToBlockTuples, type BlockTuple } from '@commonpub/editor';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Seed the contest body block editor (overview/rules): prefer existing
|
|
5
|
+
* `BlockTuple[]`; otherwise convert the legacy markdown/html body — the
|
|
6
|
+
* convert-on-edit pattern (CLAUDE rule #4), so editing a legacy contest doesn't
|
|
7
|
+
* lose its content. Legacy HTML is preserved VERBATIM in a single markdown block
|
|
8
|
+
* (lossless) rather than lossily re-parsed; markdown is parsed into real blocks.
|
|
9
|
+
*/
|
|
10
|
+
export function seedBodyBlocks(
|
|
11
|
+
blocks: unknown[] | null | undefined,
|
|
12
|
+
legacy?: string | null,
|
|
13
|
+
legacyFormat?: 'markdown' | 'html' | null,
|
|
14
|
+
): BlockTuple[] {
|
|
15
|
+
if (Array.isArray(blocks) && blocks.length) return blocks as BlockTuple[];
|
|
16
|
+
const text = (legacy ?? '').trim();
|
|
17
|
+
if (!text) return [];
|
|
18
|
+
if (legacyFormat === 'html') return [['markdown', { content: text }]];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = markdownToBlockTuples(text);
|
|
21
|
+
return parsed.length ? parsed : [['markdown', { content: text }]];
|
|
22
|
+
} catch {
|
|
23
|
+
return [['markdown', { content: text }]];
|
|
24
|
+
}
|
|
25
|
+
}
|
package/utils/contestStages.ts
CHANGED
|
@@ -154,11 +154,73 @@ export function withTemplateFieldRemoved(stages: ContestStage[], i: number, fi:
|
|
|
154
154
|
return withTemplate(stages, i, cur);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
type FieldType = ContestSubmissionTemplateField['type'];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Change a template field's type AND normalize the type-specific ancillary props
|
|
161
|
+
* so the stored field stays coherent (Phase 4): `address` forces `pii`; leaving
|
|
162
|
+
* `select` drops `options`; leaving `agreement` drops `terms`/`termsFormat`/
|
|
163
|
+
* `mustAccept`; entering `select` seeds one blank option; entering `agreement`
|
|
164
|
+
* defaults `mustAccept` true.
|
|
165
|
+
*/
|
|
166
|
+
export function withTemplateFieldTypeChanged(
|
|
167
|
+
stages: ContestStage[],
|
|
168
|
+
i: number,
|
|
169
|
+
fi: number,
|
|
170
|
+
type: FieldType,
|
|
171
|
+
): ContestStage[] {
|
|
172
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
173
|
+
if (!field) return stages;
|
|
174
|
+
const patch: Partial<ContestSubmissionTemplateField> = { type };
|
|
175
|
+
patch.options = type === 'select' ? (field.options?.length ? field.options : [{ value: '', label: '' }]) : undefined;
|
|
176
|
+
if (type === 'agreement') {
|
|
177
|
+
patch.mustAccept = field.mustAccept ?? true;
|
|
178
|
+
} else {
|
|
179
|
+
patch.terms = undefined;
|
|
180
|
+
patch.termsFormat = undefined;
|
|
181
|
+
patch.mustAccept = undefined;
|
|
182
|
+
}
|
|
183
|
+
if (type === 'address') patch.pii = true;
|
|
184
|
+
return withTemplateFieldSet(stages, i, fi, patch);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function withTemplateOptionAdded(stages: ContestStage[], i: number, fi: number): ContestStage[] {
|
|
188
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
189
|
+
if (!field) return stages;
|
|
190
|
+
return withTemplateFieldSet(stages, i, fi, { options: [...(field.options ?? []), { value: '', label: '' }] });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function withTemplateOptionSet(
|
|
194
|
+
stages: ContestStage[],
|
|
195
|
+
i: number,
|
|
196
|
+
fi: number,
|
|
197
|
+
oi: number,
|
|
198
|
+
patch: Partial<{ value: string; label: string }>,
|
|
199
|
+
): ContestStage[] {
|
|
200
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
201
|
+
if (!field) return stages;
|
|
202
|
+
const options = (field.options ?? []).map((o, idx) => (idx === oi ? { ...o, ...patch } : o));
|
|
203
|
+
return withTemplateFieldSet(stages, i, fi, { options });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function withTemplateOptionRemoved(stages: ContestStage[], i: number, fi: number, oi: number): ContestStage[] {
|
|
207
|
+
const field = stages[i]?.submissionTemplate?.[fi];
|
|
208
|
+
if (!field) return stages;
|
|
209
|
+
return withTemplateFieldSet(stages, i, fi, { options: (field.options ?? []).filter((_, idx) => idx !== oi) });
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
/** Human label for each template field type (for the editor dropdown). */
|
|
158
213
|
export const TEMPLATE_FIELD_TYPE_LABEL: Record<ContestSubmissionTemplateField['type'], string> = {
|
|
159
214
|
text: 'Short text',
|
|
160
215
|
textarea: 'Long text',
|
|
161
216
|
url: 'Link (URL)',
|
|
217
|
+
email: 'Email address',
|
|
218
|
+
number: 'Number',
|
|
219
|
+
select: 'Dropdown (select)',
|
|
220
|
+
checkbox: 'Checkbox',
|
|
221
|
+
date: 'Date',
|
|
222
|
+
agreement: 'Agreement (terms to accept)',
|
|
223
|
+
address: 'Mailing address',
|
|
162
224
|
};
|
|
163
225
|
|
|
164
226
|
/** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
2
|
+
|
|
3
|
+
// Client-side helpers for the entrant submission form (the per-stage artifact
|
|
4
|
+
// form + the proposal form share these). The SERVER (validateSubmissionFields)
|
|
5
|
+
// is the authoritative validator; these only drive UX (required gating, the
|
|
6
|
+
// payload shape) so the two surfaces behave identically.
|
|
7
|
+
|
|
8
|
+
/** Markers a checkbox/agreement value counts as accepted/checked. */
|
|
9
|
+
const TRUTHY = new Set(['true', 'on', '1', 'yes', 'accepted', 'checked']);
|
|
10
|
+
|
|
11
|
+
export function isChecked(value: string | undefined): boolean {
|
|
12
|
+
return TRUTHY.has((value ?? '').trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Structured mailing-address subfields (stored JSON-encoded in the value). */
|
|
16
|
+
export const ADDRESS_SUBFIELDS = [
|
|
17
|
+
{ key: 'line1', label: 'Address line 1' },
|
|
18
|
+
{ key: 'line2', label: 'Address line 2' },
|
|
19
|
+
{ key: 'city', label: 'City' },
|
|
20
|
+
{ key: 'region', label: 'State / region' },
|
|
21
|
+
{ key: 'postal', label: 'Postal code' },
|
|
22
|
+
{ key: 'country', label: 'Country' },
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type AddressValue = Partial<Record<(typeof ADDRESS_SUBFIELDS)[number]['key'], string>>;
|
|
26
|
+
|
|
27
|
+
export function parseAddress(value: string | undefined): AddressValue {
|
|
28
|
+
if (!value) return {};
|
|
29
|
+
try {
|
|
30
|
+
const o = JSON.parse(value);
|
|
31
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? (o as AddressValue) : {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Serialize an address to a compact JSON string, or '' when entirely empty. */
|
|
38
|
+
export function serializeAddress(addr: AddressValue): string {
|
|
39
|
+
const cleaned: AddressValue = {};
|
|
40
|
+
for (const { key } of ADDRESS_SUBFIELDS) {
|
|
41
|
+
const v = (addr[key] ?? '').trim();
|
|
42
|
+
if (v) cleaned[key] = v;
|
|
43
|
+
}
|
|
44
|
+
return Object.keys(cleaned).length ? JSON.stringify(cleaned) : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when a field's value is "filled" for required purposes (type-aware). */
|
|
48
|
+
export function isFieldFilled(field: ContestSubmissionTemplateField, value: string | undefined): boolean {
|
|
49
|
+
const v = (value ?? '').trim();
|
|
50
|
+
if (field.type === 'checkbox' || field.type === 'agreement') return isChecked(v);
|
|
51
|
+
if (field.type === 'address') return Object.keys(parseAddress(v)).length > 0;
|
|
52
|
+
return v.length > 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The labels of fields that block submission: required-but-empty fields, plus
|
|
57
|
+
* any must-accept agreement that isn't accepted. Mirrors the server's gate so
|
|
58
|
+
* the entrant sees the problem before the round-trip.
|
|
59
|
+
*/
|
|
60
|
+
export function blockingFields(
|
|
61
|
+
template: ContestSubmissionTemplateField[],
|
|
62
|
+
values: Record<string, string>,
|
|
63
|
+
): string[] {
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
for (const f of template) {
|
|
66
|
+
const filled = isFieldFilled(f, values[f.key]);
|
|
67
|
+
if (f.type === 'agreement') {
|
|
68
|
+
if ((f.required || f.mustAccept !== false) && !filled) out.push(f.label);
|
|
69
|
+
} else if (f.required && !filled) {
|
|
70
|
+
out.push(f.label);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the submit payload: trimmed values, omitting blanks. Checkbox/agreement
|
|
78
|
+
* normalize to 'true'/'false'; address passes its JSON through; everything else
|
|
79
|
+
* is the trimmed string. Empty optional fields are dropped.
|
|
80
|
+
*/
|
|
81
|
+
export function buildSubmissionPayload(
|
|
82
|
+
template: ContestSubmissionTemplateField[],
|
|
83
|
+
values: Record<string, string>,
|
|
84
|
+
): Record<string, string> {
|
|
85
|
+
const out: Record<string, string> = {};
|
|
86
|
+
for (const f of template) {
|
|
87
|
+
const raw = (values[f.key] ?? '').trim();
|
|
88
|
+
if (f.type === 'checkbox' || f.type === 'agreement') {
|
|
89
|
+
// Only send a positive marker (the server treats absent as not-accepted).
|
|
90
|
+
if (isChecked(raw)) out[f.key] = 'true';
|
|
91
|
+
else if (f.type === 'checkbox' && raw) out[f.key] = 'false';
|
|
92
|
+
} else if (raw) {
|
|
93
|
+
out[f.key] = raw;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-time conversion for `<input type="datetime-local">`.
|
|
3
|
+
*
|
|
4
|
+
* A datetime-local control speaks the operator's LOCAL wall-clock with no zone.
|
|
5
|
+
* The common idiom `new Date(iso).toISOString().slice(0, 16)` is WRONG: toISOString
|
|
6
|
+
* is UTC, so the value shown is shifted by the local offset and the time the
|
|
7
|
+
* operator picks isn't the time that gets stored. These helpers build the input
|
|
8
|
+
* value from the date's LOCAL components and parse it back as local, so the
|
|
9
|
+
* round-trip is offset-correct in every timezone.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
13
|
+
|
|
14
|
+
/** An ISO instant rendered as `YYYY-MM-DDTHH:mm` in the viewer's local zone (the input value). */
|
|
15
|
+
export function toLocalInput(iso?: string | null): string {
|
|
16
|
+
if (!iso) return '';
|
|
17
|
+
const d = new Date(iso);
|
|
18
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
19
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A `YYYY-MM-DDTHH:mm` local wall-clock value parsed to an ISO instant. Empty/invalid -> undefined. */
|
|
23
|
+
export function fromLocalInput(local?: string | null): string | undefined {
|
|
24
|
+
if (!local) return undefined;
|
|
25
|
+
// A datetime-local string carries no offset, so the runtime parses it as LOCAL.
|
|
26
|
+
const d = new Date(local);
|
|
27
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An ISO instant as a short human date in the VIEWER's local zone, e.g.
|
|
32
|
+
* "Aug 1, 2026" (or "Aug 1" with `{ year: false }`). One formatter for every
|
|
33
|
+
* contest date surface (hero, sidebar timeline, entry rows).
|
|
34
|
+
*
|
|
35
|
+
* It is timezone-dependent (the local calendar day differs by zone), so any caller
|
|
36
|
+
* that renders server-side MUST gate it behind a client `mounted` flag — otherwise
|
|
37
|
+
* the server's TZ and the viewer's disagree on hydration and Vue won't rectify it.
|
|
38
|
+
* Empty / invalid input -> ''.
|
|
39
|
+
*/
|
|
40
|
+
export function formatLocalDate(iso?: string | null, opts?: { year?: boolean }): string {
|
|
41
|
+
if (!iso) return '';
|
|
42
|
+
const d = new Date(iso);
|
|
43
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
44
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(opts?.year === false ? {} : { year: 'numeric' }) });
|
|
45
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectBlocks — pure parsers that turn a project's BlockTuple content into
|
|
3
|
+
* the structured shapes the ProjectView tabs render (BOM parts, build steps,
|
|
4
|
+
* code snippets, download files, table-of-contents headings).
|
|
5
|
+
*
|
|
6
|
+
* Extracted verbatim from ProjectView.vue's inline computeds (session 206) so
|
|
7
|
+
* the parsing is unit-tested independently of the component. Each function
|
|
8
|
+
* accepts the raw `content` value (which may be undefined, a legacy markdown
|
|
9
|
+
* string, or a BlockTuple[]) and returns [] for anything that is not an array
|
|
10
|
+
* of blocks — matching the component's original guards exactly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PartItem {
|
|
14
|
+
name: string;
|
|
15
|
+
quantity: number;
|
|
16
|
+
productId?: string;
|
|
17
|
+
notes?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BuildStep {
|
|
21
|
+
number: number;
|
|
22
|
+
title: string;
|
|
23
|
+
children: Array<[string, Record<string, unknown>]>;
|
|
24
|
+
time?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CodeSnippet {
|
|
28
|
+
language: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
code: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FileItem {
|
|
34
|
+
name: string;
|
|
35
|
+
url: string;
|
|
36
|
+
size?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TocEntry {
|
|
40
|
+
id: string;
|
|
41
|
+
text: string;
|
|
42
|
+
level: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Block = [string, Record<string, unknown>];
|
|
46
|
+
|
|
47
|
+
/** Narrow raw content to a block array, or [] when it is not one. */
|
|
48
|
+
function asBlocks(blocks: unknown): Block[] {
|
|
49
|
+
return Array.isArray(blocks) ? (blocks as Block[]) : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Slugify heading text into a stable anchor id (lowercase, dash-separated). */
|
|
53
|
+
export function headingSlug(text: string): string {
|
|
54
|
+
return text
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
57
|
+
.replace(/(^-|-$)/g, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Flatten every `partsList` block into a BOM parts list. */
|
|
61
|
+
export function extractParts(blocks: unknown): PartItem[] {
|
|
62
|
+
const items: PartItem[] = [];
|
|
63
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
64
|
+
if (type === 'partsList' && Array.isArray(data.parts)) {
|
|
65
|
+
for (const part of data.parts as Array<Record<string, unknown>>) {
|
|
66
|
+
items.push({
|
|
67
|
+
name: (part.name as string) || 'Unknown',
|
|
68
|
+
quantity: (part.qty as number) ?? (part.quantity as number) ?? 1,
|
|
69
|
+
productId: part.productId as string | undefined,
|
|
70
|
+
notes: (part.notes as string) || '',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return items;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Collect `buildStep` blocks, migrating the legacy instructions+image shape into children. */
|
|
79
|
+
export function extractBuildSteps(blocks: unknown): BuildStep[] {
|
|
80
|
+
const steps: BuildStep[] = [];
|
|
81
|
+
let stepNum = 0;
|
|
82
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
83
|
+
if (type !== 'buildStep') continue;
|
|
84
|
+
stepNum++;
|
|
85
|
+
// Migrate old format (instructions + image) to children
|
|
86
|
+
let children: Array<[string, Record<string, unknown>]> = [];
|
|
87
|
+
if (data.children && Array.isArray(data.children) && data.children.length > 0) {
|
|
88
|
+
children = data.children as Array<[string, Record<string, unknown>]>;
|
|
89
|
+
} else {
|
|
90
|
+
const instructions = data.instructions as string | undefined;
|
|
91
|
+
if (instructions && instructions.trim()) {
|
|
92
|
+
const html = instructions.startsWith('<') ? instructions : `<p>${instructions}</p>`;
|
|
93
|
+
children.push(['paragraph', { html }]);
|
|
94
|
+
}
|
|
95
|
+
const image = data.image as string | undefined;
|
|
96
|
+
if (image && image.trim()) {
|
|
97
|
+
children.push(['image', { src: image, alt: `Step ${stepNum}`, caption: '' }]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
steps.push({
|
|
101
|
+
number: (data.stepNumber as number) || stepNum,
|
|
102
|
+
title: (data.title as string) || `Step ${stepNum}`,
|
|
103
|
+
children,
|
|
104
|
+
time: data.time as string | undefined,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return steps;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Collect `code_block`/`codeBlock` snippets for the Code tab. */
|
|
111
|
+
export function extractCodeBlocks(blocks: unknown): CodeSnippet[] {
|
|
112
|
+
const snippets: CodeSnippet[] = [];
|
|
113
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
114
|
+
if (type === 'code_block' || type === 'codeBlock') {
|
|
115
|
+
snippets.push({
|
|
116
|
+
language: (data.language as string) || '',
|
|
117
|
+
filename: (data.filename as string) || '',
|
|
118
|
+
code: (data.code as string) || '',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return snippets;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Flatten every `downloads` block into a flat file list for the Files tab. */
|
|
126
|
+
export function extractDownloadFiles(blocks: unknown): FileItem[] {
|
|
127
|
+
const files: FileItem[] = [];
|
|
128
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
129
|
+
if (type === 'downloads' && Array.isArray(data.files)) {
|
|
130
|
+
for (const file of data.files as Array<Record<string, unknown>>) {
|
|
131
|
+
files.push({
|
|
132
|
+
name: (file.name as string) || 'Unknown',
|
|
133
|
+
url: (file.url as string) || '',
|
|
134
|
+
size: (file.size as string) || '',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return files;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Build a table of contents from `heading` blocks (HTML stripped, slugified ids). */
|
|
143
|
+
export function extractTocEntries(blocks: unknown): TocEntry[] {
|
|
144
|
+
const entries: TocEntry[] = [];
|
|
145
|
+
for (const [type, data] of asBlocks(blocks)) {
|
|
146
|
+
if (type === 'heading' && data.text) {
|
|
147
|
+
const raw = String(data.text);
|
|
148
|
+
const label = raw.replace(/<[^>]+>/g, '');
|
|
149
|
+
if (label.trim()) {
|
|
150
|
+
entries.push({
|
|
151
|
+
// Slug the RAW text: BlockHeadingView.vue (and ArticleView.vue) render
|
|
152
|
+
// the anchor id by slugging the unstripped text, so the TOC id must match
|
|
153
|
+
// or getElementById()/scroll-spy can't find the heading.
|
|
154
|
+
id: headingSlug(raw),
|
|
155
|
+
text: label.trim(),
|
|
156
|
+
level: (data.level as number) ?? 2,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return entries;
|
|
162
|
+
}
|