@miragon/slidev-toolkit 1.7.1 → 1.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.
@@ -8,7 +8,14 @@
8
8
  * Past six the rail WRAPS into balanced rows and drops the previews, becoming a
9
9
  * static, top-aligned overview.
10
10
  *
11
- * Props: eyebrow (kicker, default "Agenda"), title (h2), accent (blue|green|mixed).
11
+ * `layout: subsection` slides do NOT open a chapter (they divide a chapter
12
+ * internally); they are collected onto the enclosing chapter's `subsections`.
13
+ * With `preview="subsections"` the previews show just those sub-chapter dividers
14
+ * instead of every slide — a sparse overview for chapters with many slides.
15
+ * Chapters without any subsection fall back to their full slide list.
16
+ *
17
+ * Props: eyebrow (kicker, default "Agenda"), title (h2), accent (blue|green|mixed),
18
+ * preview ("slides" | "subsections", default "slides").
12
19
  */
13
20
  import { computed, ref, watch } from 'vue'
14
21
  import { useElementSize } from '@vueuse/core'
@@ -27,8 +34,9 @@ const props = withDefaults(
27
34
  eyebrow?: string
28
35
  title?: string
29
36
  accent?: 'blue' | 'green' | 'mixed'
37
+ preview?: 'slides' | 'subsections'
30
38
  }>(),
31
- { eyebrow: 'Agenda', accent: 'mixed' },
39
+ { eyebrow: 'Agenda', accent: 'mixed', preview: 'slides' },
32
40
  )
33
41
 
34
42
  const { slides, go, currentPage } = useNav()
@@ -48,6 +56,7 @@ interface Chapter {
48
56
  eyebrow: string
49
57
  title: string
50
58
  routes: any[]
59
+ subsections: any[]
51
60
  }
52
61
 
53
62
  const chapters = computed<Chapter[]>(() => {
@@ -61,9 +70,13 @@ const chapters = computed<Chapter[]>(() => {
61
70
  eyebrow: clean(fm.eyebrow) || `Chapter ${n}`,
62
71
  title: clean(route.meta?.slide?.title) || clean(fm.eyebrow) || `Chapter ${n}`,
63
72
  routes: [route],
73
+ subsections: [],
64
74
  })
65
75
  } else if (out.length) {
66
- out[out.length - 1].routes.push(route)
76
+ const chapter = out[out.length - 1]
77
+ chapter.routes.push(route)
78
+ // Sub-chapter dividers structure a chapter without opening a new one.
79
+ if (fm.layout === 'subsection') chapter.subsections.push(route)
67
80
  }
68
81
  }
69
82
  return out
@@ -86,6 +99,17 @@ watch(
86
99
 
87
100
  const activeChapter = computed(() => chapters.value[selected.value])
88
101
 
102
+ // The slides shown as previews for the selected chapter. In "subsections" mode
103
+ // that is just the chapter's sub-chapter dividers (a sparse overview); chapters
104
+ // without any subsection fall back to their full slide list so nothing vanishes.
105
+ const previewRoutes = computed(() => {
106
+ const ch = activeChapter.value
107
+ if (!ch) return []
108
+ return props.preview === 'subsections' && ch.subsections.length
109
+ ? ch.subsections
110
+ : ch.routes
111
+ })
112
+
89
113
  // Mini size is computed as if a chapter held at most this many slides, so the
90
114
  // frame never shrinks below the clean size; extra slides scroll below the fold.
91
115
  const MAX_MINIS = 21
@@ -146,7 +170,7 @@ const ASPECT = 16 / 9
146
170
  const MAX_W = 320
147
171
 
148
172
  const miniWidth = computed(() => {
149
- const n = Math.min(activeChapter.value?.routes.length ?? 0, MAX_MINIS)
173
+ const n = Math.min(previewRoutes.value.length, MAX_MINIS)
150
174
  const W = stageW.value
151
175
  const H = stageH.value
152
176
  if (!n || W < 1 || H < 1) return 200
@@ -230,7 +254,7 @@ function openSlide(no: number, ev: MouseEvent) {
230
254
  <transition name="fade-preview" mode="out-in">
231
255
  <div :key="selected" class="preview-row">
232
256
  <button
233
- v-for="route in activeChapter?.routes"
257
+ v-for="route in previewRoutes"
234
258
  :key="route.no"
235
259
  type="button"
236
260
  class="mini"
@@ -31,6 +31,7 @@
31
31
  margin: 0;
32
32
  }
33
33
  .step-list.step-list :deep(p + p) { margin-top: 0.4em; }
34
+ .step-list.step-list :deep(.step__body > p) { display: inline; margin: 0; }
34
35
  .step-list.step-list :deep(strong) { font-weight: 700; color: var(--miragon-text-primary); }
35
36
  .step-list.step-list :deep(a) {
36
37
  color: var(--miragon-blue);
@@ -0,0 +1,129 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * subsection — Unterkapitel-Trenner (STATISCHE Welt, KEIN Mesh-Shader).
4
+ *
5
+ * Der kleine Bruder von `section`: gliedert ein Kapitel INTERN, ohne im Agenda-
6
+ * Rail ein eigenes Kapitel zu eröffnen (die Agenda zählt nur `layout: section`).
7
+ * Optisch untergeordnet — kleinerer Titel, dezentere Ghost-Ziffer, eine
8
+ * optionale Kapitel-Rückreferenz als Überzeile — damit klar bleibt: dies ist
9
+ * eine Zwischenüberschrift, kein neues Kapitel.
10
+ *
11
+ * Die Agenda kann diese Trenner mit `<Agenda preview="subsections">` statt der
12
+ * Einzelfolien als Kapitel-Vorschau rendern.
13
+ *
14
+ * Slots: default = Unterkapiteltitel (h1) + optionaler Einzeiler (p) aus Markdown.
15
+ * Frontmatter-Props:
16
+ * index — Nummer als String (z. B. "2.1"); rendert als Ghost-Ziffer
17
+ * eyebrow — kleine Überzeile (z. B. das übergeordnete Kapitel)
18
+ * accent — "blue" | "green" | "mixed" — Gradient-Akzent (default blue)
19
+ */
20
+ import { computed } from 'vue'
21
+
22
+ const props = withDefaults(
23
+ defineProps<{
24
+ index?: string
25
+ eyebrow?: string
26
+ accent?: 'blue' | 'green' | 'mixed'
27
+ }>(),
28
+ { accent: 'blue' },
29
+ )
30
+
31
+ // Token reaktiv (siehe section.vue) — alle Werte aus theme.css, keine Hex.
32
+ const gradientVar = computed(() => `var(--miragon-gradient-${props.accent})`)
33
+ const accentVar = computed(() =>
34
+ props.accent === 'green' ? 'var(--miragon-green-deep)' : 'var(--miragon-blue)',
35
+ )
36
+ </script>
37
+
38
+ <template>
39
+ <div
40
+ class="subsection-layout"
41
+ :style="{ '--s-grad': gradientVar, '--s-accent': accentVar }"
42
+ >
43
+ <span v-if="index" class="subsection-ghost" aria-hidden="true">{{ index }}</span>
44
+
45
+ <div class="subsection-content">
46
+ <span class="subsection-bar" aria-hidden="true"></span>
47
+ <div v-if="eyebrow" class="subsection-eyebrow">{{ eyebrow }}</div>
48
+ <div class="subsection-body">
49
+ <slot />
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </template>
54
+
55
+ <style scoped>
56
+ .subsection-layout {
57
+ position: relative;
58
+ width: 100%;
59
+ height: 100%;
60
+ overflow: hidden;
61
+ background: var(--miragon-gray-bg);
62
+ color: var(--miragon-text-primary);
63
+ display: flex;
64
+ align-items: center;
65
+ }
66
+
67
+ /* Ghost-Ziffer wie section, aber kleiner und dezenter — Unterkapitel ordnet sich
68
+ dem Kapitel unter. Solide Akzentfarbe (exportsicher, vgl. section.vue). */
69
+ .subsection-ghost {
70
+ position: absolute;
71
+ z-index: 0;
72
+ right: 4.5rem;
73
+ bottom: 2rem;
74
+ font-size: 20rem;
75
+ line-height: 1;
76
+ font-weight: 900;
77
+ letter-spacing: -0.04em;
78
+ color: var(--s-accent);
79
+ opacity: 0.06;
80
+ user-select: none;
81
+ pointer-events: none;
82
+ }
83
+
84
+ .subsection-content {
85
+ position: relative;
86
+ z-index: 1;
87
+ width: 100%;
88
+ max-width: 60rem;
89
+ padding: 0 5rem;
90
+ display: flex;
91
+ flex-direction: column;
92
+ }
93
+
94
+ .subsection-bar {
95
+ width: 3rem;
96
+ height: 0.3rem;
97
+ border-radius: 999px;
98
+ background: var(--s-grad);
99
+ margin-bottom: 1.5rem;
100
+ }
101
+ .subsection-eyebrow {
102
+ font-size: 0.85rem;
103
+ font-weight: 600;
104
+ letter-spacing: 0.18em;
105
+ text-transform: uppercase;
106
+ color: var(--s-accent);
107
+ margin-bottom: 1rem;
108
+ }
109
+
110
+ .subsection-body :deep(h1) {
111
+ font-size: clamp(2rem, 3.6vw, 3rem);
112
+ line-height: 1.1;
113
+ font-weight: 800;
114
+ letter-spacing: -0.02em;
115
+ color: var(--miragon-text-primary);
116
+ margin: 0;
117
+ }
118
+ .subsection-body :deep(h1 strong) {
119
+ font-weight: 800;
120
+ color: var(--s-accent);
121
+ }
122
+ .subsection-body :deep(p) {
123
+ font-size: 1.1rem;
124
+ font-weight: 500;
125
+ color: var(--miragon-text-muted);
126
+ margin: 1.5rem 0 0;
127
+ max-width: 38rem;
128
+ }
129
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miragon/slidev-toolkit",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Miragon Slidev toolkit: the brand theme, the layout archetypes, the reusable components (Card, CardGrid, StepList, Figure, SplitView) and the BrandMeshBackground animation. Single source of design truth, consumed by the reference deck in this repo.",
5
5
  "keywords": [
6
6
  "slidev-theme",
@@ -0,0 +1,67 @@
1
+ import { defineTransformersSetup, defineMarkdownTransformer } from '@slidev/types'
2
+
3
+ /**
4
+ * Make inline Markdown render inside a component body, whatever the author wrote.
5
+ *
6
+ * Slidev only runs inline Markdown (`` `code` ``, **bold**, links) inside a
7
+ * component body when that body is its OWN Markdown block, i.e. separated from
8
+ * the opening/closing tags by blank lines. A body written on the tag line
9
+ * (`<Card title="X">`code`</Card>`) is handed through as raw HTML and the
10
+ * backticks/asterisks render literally. That is a silent footgun: the natural,
11
+ * one-line authoring form the deck otherwise mandates is exactly the form that
12
+ * breaks.
13
+ *
14
+ * This pre-transformer rewrites the single-line form into the blank-line form
15
+ * before the Markdown is parsed, so authors keep writing components on one line
16
+ * and inline Markdown just works:
17
+ *
18
+ * <Step label="Handle">`@Autowired ProcessEngine`.</Step>
19
+ *
20
+ * becomes, only for the parser:
21
+ *
22
+ * <Step label="Handle">
23
+ *
24
+ * `@Autowired ProcessEngine`.
25
+ *
26
+ * </Step>
27
+ *
28
+ * Paired with the `.step__body > p` rule in StepList.vue (which renders the
29
+ * resulting <p> inline) the step still reads as "Label: body" on one line.
30
+ *
31
+ * Scope is deliberately narrow: only the components whose default slot is a
32
+ * prose body, only when the whole `<Tag …>body</Tag>` sits on a single line
33
+ * (multi-line bodies are already blank-line blocks and are left untouched), and
34
+ * never inside fenced code blocks (so slides that *show* component source stay
35
+ * verbatim). Extend BODY_TAGS if another prose-body component is added.
36
+ */
37
+ const BODY_TAGS = ['Card', 'Step']
38
+
39
+ // <indent><Tag attrs>body</Tag> with body and close tag on the same line.
40
+ const SINGLE_LINE = new RegExp(
41
+ String.raw`^([ \t]*)<(${BODY_TAGS.join('|')})\b([^>\n]*)>(.+?)</\2>[ \t]*$`,
42
+ )
43
+ const FENCE = /^\s*(```|~~~)/
44
+
45
+ const wrapComponentBody = defineMarkdownTransformer((ctx) => {
46
+ const src = ctx.s.original
47
+ if (!src) return
48
+ const lines = src.split('\n')
49
+ let inFence = false
50
+ let changed = false
51
+ for (let i = 0; i < lines.length; i++) {
52
+ if (FENCE.test(lines[i])) {
53
+ inFence = !inFence
54
+ continue
55
+ }
56
+ if (inFence) continue
57
+ const m = lines[i].match(SINGLE_LINE)
58
+ if (!m) continue
59
+ const [, indent, tag, attrs, body] = m
60
+ if (!body.trim()) continue
61
+ lines[i] = `${indent}<${tag}${attrs}>\n\n${body.trim()}\n\n${indent}</${tag}>`
62
+ changed = true
63
+ }
64
+ if (changed) ctx.s.overwrite(0, src.length, lines.join('\n'))
65
+ })
66
+
67
+ export default defineTransformersSetup(() => ({ pre: [wrapComponentBody] }))