slidev-theme-practicum 0.1.0 → 0.1.3

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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -10
  3. package/components/Slide.vue +81 -26
  4. package/components/Slot.vue +20 -85
  5. package/components/Text.vue +68 -2
  6. package/components/TextFitGroup.vue +71 -0
  7. package/components/Timeline.vue +40 -37
  8. package/composables/deck-slot-markup.cjs +249 -0
  9. package/composables/decor-catalog.mjs +169 -47
  10. package/composables/layout-authoring.ts +6 -2
  11. package/composables/layout-markdown.mjs +33 -0
  12. package/composables/layout-recipes.ts +51 -5
  13. package/composables/layout-shorthands.ts +251 -81
  14. package/composables/markdown-to-vnodes.cjs +1 -0
  15. package/composables/markdown-to-vnodes.mjs +237 -0
  16. package/composables/slot-placement.ts +1 -1
  17. package/composables/slot-spacing.ts +66 -0
  18. package/composables/text-fit-runtime.ts +458 -76
  19. package/composables/text-fit-scope.ts +13 -0
  20. package/composables/text-slot-markdown.mjs +193 -0
  21. package/composables/text-typography.mjs +2 -0
  22. package/composables/theme-assets.mjs +60 -0
  23. package/composables/timeline-grid.ts +31 -0
  24. package/composables/typescript-require.cjs +29 -0
  25. package/composables/validate-deck-layouts.cjs +277 -0
  26. package/decor-library.md +2 -0
  27. package/example.md +132 -10
  28. package/package.json +25 -11
  29. package/public/photos/photo-1.webp +0 -0
  30. package/public/photos/photo-10.webp +0 -0
  31. package/public/photos/photo-11.webp +0 -0
  32. package/public/photos/photo-12.webp +0 -0
  33. package/public/photos/photo-13.webp +0 -0
  34. package/public/photos/photo-14.webp +0 -0
  35. package/public/photos/photo-15.webp +0 -0
  36. package/public/photos/photo-16.webp +0 -0
  37. package/public/photos/photo-17.webp +0 -0
  38. package/public/photos/photo-18.webp +0 -0
  39. package/public/photos/photo-19.webp +0 -0
  40. package/public/photos/photo-2.webp +0 -0
  41. package/public/photos/photo-20.webp +0 -0
  42. package/public/photos/photo-21.webp +0 -0
  43. package/public/photos/photo-22.webp +0 -0
  44. package/public/photos/photo-23.webp +0 -0
  45. package/public/photos/photo-24.webp +0 -0
  46. package/public/photos/photo-25.webp +0 -0
  47. package/public/photos/photo-26.webp +0 -0
  48. package/public/photos/photo-27.webp +0 -0
  49. package/public/photos/photo-28.webp +0 -0
  50. package/public/photos/photo-29.webp +0 -0
  51. package/public/photos/photo-3.webp +0 -0
  52. package/public/photos/photo-30.webp +0 -0
  53. package/public/photos/photo-4.webp +0 -0
  54. package/public/photos/photo-5.webp +0 -0
  55. package/public/photos/photo-6.webp +0 -0
  56. package/public/photos/photo-7.webp +0 -0
  57. package/public/photos/photo-8.webp +0 -0
  58. package/public/photos/photo-9.webp +0 -0
  59. package/scripts/browser-smoke-runtime.mjs +428 -0
  60. package/scripts/browser-smoke.mjs +436 -0
  61. package/scripts/build-artifact-assets.mjs +55 -0
  62. package/scripts/check-build-artifact.mjs +243 -0
  63. package/scripts/check-package.mjs +132 -0
  64. package/scripts/check-test-architecture.mjs +176 -0
  65. package/scripts/npm-spawn.mjs +31 -0
  66. package/scripts/test-architecture-policy.mjs +245 -0
  67. package/scripts/theme-asset-inventory.mjs +83 -0
  68. package/scripts/validate-deck.cjs +18 -0
  69. package/setup/decor-save-middleware.ts +244 -0
  70. package/setup/vite-plugins.ts +14 -84
  71. package/skills/slidev-practicum/SKILL.md +25 -0
  72. package/skills/slidev-practicum/agents/openai.yaml +4 -0
  73. package/styles/index.css +10 -1
  74. package/composables/layout-registry.ts +0 -47
  75. package/env.d.ts +0 -6
  76. package/public/photos/photo-1.png +0 -0
  77. package/public/photos/photo-10.png +0 -0
  78. package/public/photos/photo-2.png +0 -0
  79. package/public/photos/photo-3.png +0 -0
  80. package/public/photos/photo-4.png +0 -0
  81. package/public/photos/photo-5.png +0 -0
  82. package/public/photos/photo-6.png +0 -0
  83. package/public/photos/photo-7.png +0 -0
  84. package/public/photos/photo-8.png +0 -0
  85. package/public/photos/photo-9.png +0 -0
  86. package/slidev-theme-practicum.png +0 -0
  87. package/slidev-theme-practicum.svg +0 -102
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ computed,
4
+ defineComponent,
5
+ h,
6
+ isVNode,
7
+ shallowRef,
8
+ type PropType,
9
+ type VNode,
10
+ } from 'vue'
11
+ import { createTextFitRuntime } from '../composables/text-fit-runtime'
12
+ import { useTextFitScope } from '../composables/text-fit-scope'
13
+
14
+ const textFit = createTextFitRuntime()
15
+
16
+ const props = defineProps<{
17
+ fitGroup: string
18
+ }>()
19
+
20
+ const TextFitGroupContent = defineComponent({
21
+ name: 'TextFitGroupContent',
22
+ props: {
23
+ fitGroup: {
24
+ type: String as PropType<string>,
25
+ required: true,
26
+ },
27
+ },
28
+ setup(props, { slots }) {
29
+ const groupElement = shallowRef<HTMLElement | null>(null)
30
+ const textFitScope = useTextFitScope()
31
+ const sharedKey = computed(() => {
32
+ if (!props.fitGroup)
33
+ return ''
34
+
35
+ return `${textFitScope}:${props.fitGroup}`
36
+ })
37
+ let currentChildren: VNode[] = []
38
+ const { apply } = textFit.useGroup({
39
+ target: groupElement,
40
+ nodes: () => currentChildren,
41
+ sharedKey,
42
+ })
43
+
44
+ return () => {
45
+ const children = (slots.default?.() ?? []).filter(isVNode)
46
+ currentChildren = children
47
+
48
+ return h('div', {
49
+ ref: groupElement,
50
+ class: 'TextFitGroup',
51
+ }, apply(children))
52
+ }
53
+ },
54
+ })
55
+ </script>
56
+
57
+ <template>
58
+ <TextFitGroupContent :fit-group="props.fitGroup">
59
+ <slot />
60
+ </TextFitGroupContent>
61
+ </template>
62
+
63
+ <style scoped>
64
+ .TextFitGroup {
65
+ min-width: 0;
66
+ width: 100%;
67
+ height: 100%;
68
+ display: flex;
69
+ align-items: inherit;
70
+ }
71
+ </style>
@@ -1,8 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef, type ComponentPublicInstance } from 'vue'
2
+ import { computed, getCurrentInstance, shallowRef, type ComponentPublicInstance } from 'vue'
3
+ import { resolveTimelineItemGridStyle, resolveTimelineLabelSpan } from '../composables/timeline-grid'
3
4
  import { createTextFitRuntime, type ThemeTextSize } from '../composables/text-fit-runtime'
5
+ import Text from './Text.vue'
6
+ import TextFitGroup from './TextFitGroup.vue'
4
7
 
5
8
  const TIMELINE_ACTIVE_YEAR_FALLBACK_SIZE: ThemeTextSize = 9
9
+ const timelineFitGroup = `timeline-${getCurrentInstance()?.uid ?? 'default'}`
6
10
 
7
11
  const props = defineProps({
8
12
  items: {
@@ -15,6 +19,7 @@ const timelineElement = shallowRef<HTMLElement | null>(null)
15
19
  const activeYearElement = shallowRef<HTMLElement | null>(null)
16
20
  const hasActiveItem = computed(() => props.items.some(item => item.active))
17
21
  const activeIndices = computed(() => props.items.flatMap((item, itemIndex) => item.active ? [itemIndex] : []))
22
+ const labelSpan = computed(() => resolveTimelineLabelSpan(props.items))
18
23
  const TIMELINE_ACTIVE_YEAR_MAX_SIZE = computed<ThemeTextSize>(() => 12)
19
24
  const textFit = createTextFitRuntime()
20
25
 
@@ -63,38 +68,19 @@ const { size: fittedActiveYearSize } = textFit.useElement({
63
68
  ],
64
69
  })
65
70
 
66
- const timelineStyle = computed(() => hasActiveItem.value
67
- ? {
68
- '--theme-timeline-active-year-size': `var(--theme-text-size-${fittedActiveYearSize.value})`,
69
- '--theme-timeline-active-year-line': `var(--theme-text-line-${fittedActiveYearSize.value})`,
70
- }
71
- : {})
71
+ const timelineStyle = computed(() => ({
72
+ '--theme-timeline-label-width': `var(--theme-grid-span-${labelSpan.value}-width)`,
73
+ ...(hasActiveItem.value
74
+ ? {
75
+ '--theme-timeline-active-year-size': `var(--theme-text-size-${fittedActiveYearSize.value})`,
76
+ '--theme-timeline-active-year-line': `var(--theme-text-line-${fittedActiveYearSize.value})`,
77
+ }
78
+ : {}),
79
+ }))
72
80
 
73
81
  function getItemGridStyle(index: number) {
74
- const count = props.items.length || 1
75
- const activeItemIndices = activeIndices.value
76
-
77
82
  assertSingleActiveItem()
78
-
79
- if (count === 3 && activeItemIndices.length === 1) {
80
- const activeIndex = activeItemIndices[0]
81
- const spans = props.items.map((_, itemIndex) => itemIndex === activeIndex ? 6 : 3)
82
- const start = spans.slice(0, index).reduce((sum, span) => sum + span, 1)
83
-
84
- return {
85
- gridColumn: `${start} / ${start + spans[index]}`,
86
- }
87
- }
88
-
89
- if (count > 12)
90
- throw new Error('[Timeline] items поддерживает не больше 12 событий.')
91
-
92
- const start = Math.floor((index * 12) / count) + 1
93
- const end = Math.floor(((index + 1) * 12) / count) + 1
94
-
95
- return {
96
- gridColumn: `${start} / ${end}`,
97
- }
83
+ return resolveTimelineItemGridStyle(props.items, index)
98
84
  }
99
85
  </script>
100
86
 
@@ -110,9 +96,21 @@ function getItemGridStyle(index: number) {
110
96
  class="Timeline-Item"
111
97
  :style="getItemGridStyle(index)"
112
98
  :class="{ 'Timeline-Item_active': item.active }">
113
- <div :ref="element => setYearElement(element, item.active)" class="Timeline-Year">{{ item.year }}</div>
99
+ <div :ref="element => setYearElement(element, item.active)" class="Timeline-Year">
100
+ <span v-if="item.active">{{ item.year }}</span>
101
+ <TextFitGroup v-else
102
+ class="Timeline-YearFitGroup"
103
+ :fit-group="`${timelineFitGroup}-years`">
104
+ <Text size="4-7">{{ item.year }}</Text>
105
+ </TextFitGroup>
106
+ </div>
114
107
  <div class="Timeline-Dot" />
115
- <div class="Timeline-Label">{{ item.label }}</div>
108
+ <div class="Timeline-Label">
109
+ <Text v-if="item.active" size="2-3">{{ item.label }}</Text>
110
+ <TextFitGroup v-else :fit-group="`${timelineFitGroup}-labels`">
111
+ <Text size="2-3">{{ item.label }}</Text>
112
+ </TextFitGroup>
113
+ </div>
116
114
  </div>
117
115
  </div>
118
116
  </template>
@@ -123,6 +121,7 @@ function getItemGridStyle(index: number) {
123
121
  --timeline-dot-size: calc(var(--theme-grid-module) * 4);
124
122
  --theme-timeline-active-year-size: var(--theme-text-size-9);
125
123
  --theme-timeline-active-year-line: var(--theme-text-line-9);
124
+ --theme-timeline-label-width: var(--theme-grid-span-3-width);
126
125
  position: relative;
127
126
  display: grid;
128
127
  grid-template-columns: repeat(var(--theme-grid-columns), minmax(0, 1fr));
@@ -143,6 +142,7 @@ function getItemGridStyle(index: number) {
143
142
 
144
143
  .Timeline-Item {
145
144
  position: relative;
145
+ min-width: 0;
146
146
  display: grid;
147
147
  grid-template-rows:
148
148
  var(--timeline-block-height)
@@ -152,14 +152,18 @@ function getItemGridStyle(index: number) {
152
152
  }
153
153
 
154
154
  .Timeline-Year {
155
+ min-width: 0;
155
156
  min-height: var(--timeline-block-height);
157
+ padding-right: var(--theme-grid-gap);
156
158
  display: flex;
157
159
  align-items: flex-end;
158
- font-size: var(--theme-text-size-7);
159
- line-height: var(--theme-text-line-7);
160
160
  color: var(--theme-text);
161
161
  }
162
162
 
163
+ .Timeline-YearFitGroup {
164
+ padding-bottom: var(--theme-grid-module);
165
+ }
166
+
163
167
  .Timeline-Dot {
164
168
  width: var(--timeline-dot-size);
165
169
  height: var(--timeline-dot-size);
@@ -171,11 +175,10 @@ function getItemGridStyle(index: number) {
171
175
 
172
176
  .Timeline-Label {
173
177
  min-height: var(--timeline-block-height);
174
- max-width: var(--theme-grid-span-2-width);
178
+ width: min(100%, var(--theme-timeline-label-width));
179
+ padding-right: var(--theme-grid-gap);
175
180
  padding-top: var(--theme-slot-margin-3);
176
181
  color: var(--theme-text-muted);
177
- font-size: var(--theme-text-size-2);
178
- line-height: var(--theme-text-line-2);
179
182
  }
180
183
 
181
184
  .Timeline_has_active .Timeline-Year {
@@ -0,0 +1,249 @@
1
+ const MarkdownIt = require('markdown-it')
2
+ const { parse } = require('vue/compiler-sfc')
3
+
4
+ /** @typedef {import('@vue/compiler-core').TemplateChildNode} TemplateChildNode */
5
+ /** @typedef {{ role: string, hasCoordinates: boolean, label: string }} ExplicitSlotIntent */
6
+ /**
7
+ * @typedef {(
8
+ * | { kind: 'comment' }
9
+ * | { kind: 'text', content: string }
10
+ * | { kind: 'element', tag: string, props: Record<string, unknown>, children: DeckLiveNode[] }
11
+ * )} DeckLiveNode
12
+ */
13
+ /** @typedef {{ children: DeckLiveNode[], intents: ExplicitSlotIntent[] }} DeckLiveStructure */
14
+
15
+ const SLOT_CONTRACT_PROPS = new Set(['role', 'area', 'col', 'row'])
16
+ const SLOT_CONTROL_FLOW_DIRECTIVES = new Set(['if', 'else-if', 'else', 'for'])
17
+ const markdown = new MarkdownIt({ html: true })
18
+
19
+ /** @param {unknown} error */
20
+ function formatCompilerError(error) {
21
+ return error instanceof Error ? error.message : String(error)
22
+ }
23
+
24
+ /**
25
+ * @param {import('@vue/compiler-core').DirectiveNode} prop
26
+ * @returns {unknown}
27
+ */
28
+ function readLiteralBinding(prop) {
29
+ const ast = prop.exp?.ast
30
+ if (!ast)
31
+ return undefined
32
+
33
+ if (ast.type === 'StringLiteral'
34
+ || ast.type === 'NumericLiteral'
35
+ || ast.type === 'BooleanLiteral') {
36
+ return ast.value
37
+ }
38
+
39
+ if (ast.type === 'NullLiteral')
40
+ return null
41
+
42
+ if (ast.type === 'TemplateLiteral' && ast.expressions.length === 0)
43
+ return ast.quasis[0]?.value?.cooked ?? ''
44
+
45
+ return undefined
46
+ }
47
+
48
+ /** @param {string} directive */
49
+ function failSlotControlFlow(directive) {
50
+ const error = /** @type {Error & { hint: string }} */ (
51
+ new Error(
52
+ `[DeckValidator] Slot не поддерживает v-${directive} `
53
+ + 'на самом компоненте или его предке.',
54
+ )
55
+ )
56
+ error.hint = 'Разверните конечное количество Slot явно, чтобы validator мог проверить role и capacity.'
57
+ throw error
58
+ }
59
+
60
+ /** @param {import('@vue/compiler-core').ElementNode} node */
61
+ function controlFlowDirective(node) {
62
+ return node.props.find(prop =>
63
+ prop.type === 7
64
+ && SLOT_CONTROL_FLOW_DIRECTIVES.has(prop.name),
65
+ )?.name ?? ''
66
+ }
67
+
68
+ /** @param {TemplateChildNode} node */
69
+ function containsSlot(node) {
70
+ if (node.type !== 1)
71
+ return false
72
+
73
+ return node.tag === 'Slot' || node.children.some(containsSlot)
74
+ }
75
+
76
+ /**
77
+ * @param {import('@vue/compiler-core').ElementNode} node
78
+ * @returns {Record<string, unknown>}
79
+ */
80
+ function elementProps(node) {
81
+ /** @type {Record<string, unknown>} */
82
+ const props = {}
83
+
84
+ for (const prop of node.props) {
85
+ if (prop.type === 6) {
86
+ props[prop.name] = prop.value?.content ?? ''
87
+ continue
88
+ }
89
+
90
+ if (prop.name !== 'bind')
91
+ continue
92
+
93
+ const key = prop.arg?.type === 4 && prop.arg.isStatic
94
+ ? prop.arg.content
95
+ : ''
96
+
97
+ if (node.tag !== 'Slot'
98
+ || (!SLOT_CONTRACT_PROPS.has(key) && key !== 'label' && key)) {
99
+ continue
100
+ }
101
+
102
+ if (!key) {
103
+ throw new Error(
104
+ '[DeckValidator] Slot не поддерживает динамический v-bind без статического имени prop.',
105
+ )
106
+ }
107
+
108
+ const value = readLiteralBinding(prop)
109
+ if (key === 'label' && value === undefined)
110
+ continue
111
+
112
+ if (value === undefined) {
113
+ throw new Error(
114
+ `[DeckValidator] Slot не поддерживает динамический v-bind:${key}; укажите литеральное значение.`,
115
+ )
116
+ }
117
+
118
+ props[key] = value
119
+ }
120
+
121
+ return props
122
+ }
123
+
124
+ /**
125
+ * @param {TemplateChildNode} node
126
+ * @returns {DeckLiveNode | null}
127
+ */
128
+ function liveNode(node) {
129
+ if (node.type === 1) {
130
+ const directive = controlFlowDirective(node)
131
+ if (directive && containsSlot(node))
132
+ failSlotControlFlow(directive)
133
+
134
+ return {
135
+ kind: 'element',
136
+ tag: node.tag,
137
+ props: elementProps(node),
138
+ children: node.children
139
+ .map(liveNode)
140
+ .filter(isLiveNode),
141
+ }
142
+ }
143
+
144
+ if (node.type === 2)
145
+ return { kind: 'text', content: node.content }
146
+
147
+ if (node.type === 3)
148
+ return { kind: 'comment' }
149
+
150
+ if (node.type === 5)
151
+ return { kind: 'text', content: `{{ ${node.content.loc.source} }}` }
152
+
153
+ return null
154
+ }
155
+
156
+ /**
157
+ * @param {DeckLiveNode | null} node
158
+ * @returns {node is DeckLiveNode}
159
+ */
160
+ function isLiveNode(node) {
161
+ return node !== null
162
+ }
163
+
164
+ /**
165
+ * @param {DeckLiveNode} node
166
+ * @returns {ExplicitSlotIntent[]}
167
+ */
168
+ function collectSlotIntents(node) {
169
+ const nested = node.kind === 'element'
170
+ ? node.children.flatMap(collectSlotIntents)
171
+ : []
172
+
173
+ if (node.kind !== 'element' || node.tag !== 'Slot')
174
+ return nested
175
+
176
+ const role = String(node.props.role ?? '').trim()
177
+ const label = String(node.props.label ?? '').trim()
178
+ const hasCoordinates = ['area', 'col', 'row']
179
+ .some(key => Boolean(String(node.props[key] ?? '').trim()))
180
+
181
+ return [{ role, hasCoordinates, label }, ...nested]
182
+ }
183
+
184
+ /**
185
+ * MarkdownIt оборачивает одиночные inline HTML components в `<p>`. Slidev
186
+ * поднимает component-only параграф обратно в default slot, поэтому validator
187
+ * нормализует ту же границу.
188
+ *
189
+ * @param {DeckLiveNode} node
190
+ * @returns {DeckLiveNode[]}
191
+ */
192
+ function normalizeTopLevelNode(node) {
193
+ if (node.kind !== 'element' || node.tag !== 'p')
194
+ return [node]
195
+
196
+ const meaningful = node.children.filter(child =>
197
+ child.kind !== 'comment'
198
+ && !(child.kind === 'text' && !child.content.trim()),
199
+ )
200
+
201
+ if (meaningful.length > 0
202
+ && meaningful.every(child => child.kind === 'element' && child.tag === 'Slot')) {
203
+ return meaningful
204
+ }
205
+
206
+ return [node]
207
+ }
208
+
209
+ /**
210
+ * Компилирует ровно ту top-level структуру, которую Slidev передаст layout-компоненту:
211
+ * Markdown уже превращён в HTML, а live Vue-компоненты сохранены как элементы.
212
+ *
213
+ * @param {string} content
214
+ * @returns {DeckLiveStructure}
215
+ */
216
+ function extractDeckLiveStructure(content) {
217
+ const rendered = markdown.render(String(content))
218
+ const source = `<template>${rendered}</template>`
219
+ const { descriptor, errors } = parse(source, { filename: 'deck-slide.vue' })
220
+
221
+ if (errors.length) {
222
+ throw new Error(
223
+ `[DeckValidator] Не удалось разобрать live markup слайда: ${errors.map(formatCompilerError).join('; ')}`,
224
+ )
225
+ }
226
+
227
+ const children = (descriptor.template?.ast?.children ?? [])
228
+ .map(liveNode)
229
+ .filter(isLiveNode)
230
+ .flatMap(normalizeTopLevelNode)
231
+
232
+ return {
233
+ children,
234
+ intents: children.flatMap(collectSlotIntents),
235
+ }
236
+ }
237
+
238
+ /**
239
+ * @param {string} content
240
+ * @returns {ExplicitSlotIntent[]}
241
+ */
242
+ function extractExplicitSlotIntents(content) {
243
+ return extractDeckLiveStructure(content).intents
244
+ }
245
+
246
+ module.exports = {
247
+ extractDeckLiveStructure,
248
+ extractExplicitSlotIntents,
249
+ }