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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 veged
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -19,8 +19,23 @@
19
19
  ```bash
20
20
  npm install
21
21
  npm run dev
22
+ npm run validate-deck example.md
22
23
  ```
23
24
 
25
+ Проверка markdown-контрактов своей колоды (после `npm install slidev-theme-practicum`):
26
+
27
+ ```bash
28
+ npm exec -- slidev-practicum-validate path/to/slides.md
29
+ ```
30
+
31
+ В `package.json` колоды:
32
+
33
+ ```json
34
+ "validate:slides": "npm exec -- slidev-practicum-validate slides.md"
35
+ ```
36
+
37
+ В `example.md` у канонических слайдов в заметках автора (`<!-- Контракт … -->`) описано, сколько пунктов и какой вложенности ждёт каждый variant.
38
+
24
39
  Для использования в другой Slidev-презентации:
25
40
 
26
41
  ```bash
@@ -85,6 +100,14 @@ layout: cover | message | explainer | collection | none
85
100
 
86
101
  Для канонических макетов внутри `layout: cover|message|explainer|collection` используйте `Slot role="..."`; внутри layout-слайда не задавайте `area`, `col` и `row`. Для ручного режима используйте `Slot area`, `Slot col` или `Slot row`.
87
102
 
103
+ ### Markdown shorthand
104
+
105
+ Для `layout: message`, `explainer` и `collection` часть `variant` принимает markdown в default slot слайда (`#` заголовок, списки, blockquote, frontmatter) — тема разворачивает его в `Slot` / `Text`. Реестр: `composables/layout-shorthands.ts`.
106
+
107
+ Для `message:closing` обязателен один заголовок `# …`; под ним можно добавить один необязательный абзац. Дополнительные абзацы, списки и изображения не поддерживаются.
108
+
109
+ `layout: cover` markdown shorthand **не** имеет: для всех `cover:*` нужны явные `<Slot role="...">` и `<Text>` (см. обложки в [example.md](example.md)).
110
+
88
111
  | Слой | Для чего |
89
112
  | ------------- | ------------------------------------------------ |
90
113
  | `layout` | семантика слайда на уровне Slidev |
@@ -120,11 +143,12 @@ layout: cover | message | explainer | collection | none
120
143
  | `margin` | `1..8` | нет | общий внутренний отступ |
121
144
  | `margin-top`, `margin-right`, `margin-bottom`, `margin-left` | `1..8` | `margin` | отступ отдельной стороны |
122
145
  | `gap` | `0..7` | `0` | расстояние между прямыми дочерними элементами |
146
+ | `fit-group` | строка | нет | общий подбор размеров для нескольких слотов |
123
147
  | `centered` | boolean | `false` | центрирование содержимого |
124
148
  | `decor` | объект | нет | семантический декоративный слой |
125
149
  | `background` | объект или строка изображения | нет | явно заданное фоновое изображение |
126
150
 
127
- `Slot` согласует размер до трёх прямых `Text`-детей с диапазонами `size` и `priority`. Атрибуты потока принадлежат дочерним элементам: `Text align="end"` прибивает строку внутри потока. Отдельный flow-атрибут есть у `Person`, когда блок автора используется как прямой ребёнок `Slot`.
151
+ `Slot` согласует размер до трёх прямых `Text`-детей с диапазонами `size` и `priority`. Несколько `Slot` с одинаковым `fit-group` получают общий размерный вектор: берётся максимальный вариант, который помещается во все ячейки группы. Если диапазоны одноимённых позиций не пересекаются, слоты сохраняют независимую локальную подгонку вместо ошибки рендера. Атрибуты потока принадлежат дочерним элементам: `Text align="end"` прибивает строку внутри потока. Отдельный flow-атрибут есть у `Person`, когда блок автора используется как прямой ребёнок `Slot`.
128
152
 
129
153
  ## Text API
130
154
 
@@ -140,6 +164,24 @@ layout: cover | message | explainer | collection | none
140
164
 
141
165
  `max-size` выбирает самый крупный помещающийся токен; `muted` является булевым сокращением для `color="text-muted"` и учитывает текущую поверхность.
142
166
 
167
+ ### Markdown в default slot
168
+
169
+ В `Text` можно писать блочный Markdown темы (списки, абзацы, цитаты) — не полный GFM Slidev. Это не layout shorthand: на `cover:*` по-прежнему нужны явные `Slot` / `Text`.
170
+
171
+ Inline-разметка `**жирный**` сохраняется как `<strong>` и отображается начертанием YS Text Bold с весом `600`.
172
+
173
+ Slidev парсит markdown внутри компонента только если контент **отделён пустой строкой** от открывающего и закрывающего тега (как в [доке Slidev](https://sli.dev/builtin/components)). Без пустых строк список схлопнется в одну строку и сломается. Не добавляйте лишний отступ в 4 пробела у пунктов — Slidev превратит блок в code.
174
+
175
+ ```md
176
+ <Text size="4-5">
177
+
178
+ 1. где мы сейчас
179
+ 2. какой сценарий делегирования хотим
180
+ 3. как безопасно перейти
181
+
182
+ </Text>
183
+ ```
184
+
143
185
  | Размер | Шрифт | Интерлиньяж |
144
186
  | ------ | ------- | ----------- |
145
187
  | `0` | `16px` | `1.2` |
@@ -173,7 +215,7 @@ layout: cover | message | explainer | collection | none
173
215
 
174
216
  ```md
175
217
  <Slot area="6 / 7 / -1 / 10">
176
- <Image src="/photos/photo-4.png" fit="cover" position="50% 42%" zoom="1.12" />
218
+ <Image src="/theme/photos/photo-4.webp" fit="cover" position="50% 42%" zoom="1.12" />
177
219
  </Slot>
178
220
  ```
179
221
 
@@ -182,10 +224,12 @@ layout: cover | message | explainer | collection | none
182
224
  ```md
183
225
  <Slot
184
226
  area="1 / 7 / -1 / -1"
185
- :background="{ src: '/photos/photo-6.png', fit: 'cover', position: '50% 40%', zoom: 1.04 }"
227
+ :background="{ src: '/theme/photos/photo-6.webp', fit: 'cover', position: '50% 40%', zoom: 1.04 }"
186
228
  />
187
229
  ```
188
230
 
231
+ Встроенные файлы темы доступны по префиксу `/theme`: например, `/theme/photos/photo-6.webp` и `/theme/decor/decor-10.svg`. Файлы из `public` самой колоды остаются пользовательскими и задаются от корня, например `/decor/custom-data.png`; префикс `/theme` к ним добавлять не нужно.
232
+
189
233
  Даже когда используется `Image` или `Slot.background`, фотография остаётся декоративной. Ключевые факты, различия, инструкции и выводы должны быть записаны текстом или числом.
190
234
 
191
235
  ### Decor
@@ -239,11 +283,21 @@ themeConfig:
239
283
 
240
284
  В записи каталога `cols` и `rows` описывают допустимый размер слота в сетке 12x12, а `ratio` ограничивает соотношение `cols / rows` для ориентации. Вместо `ratio` можно использовать более явный алиас `aspectRatio`.
241
285
 
242
- | Поле | Тип | По умолчанию | Смысл |
243
- | ----------- | --------------- | ------------------ | -------------------------------------------------------- |
244
- | `deckTitle` | строка | заголовок слайда | заголовок в шапке |
245
- | `debugGrid` | boolean | `false` | отладочная сетка 12x12 |
246
- | `decors` | записи каталога | встроенный каталог | добавляет или переопределяет записи декора по `decor.id` |
286
+ | Поле | Тип | По умолчанию | Смысл |
287
+ | ----------------- | --------------- | ------------------ | -------------------------------------------------------- |
288
+ | `deckTitle` | строка | заголовок слайда | заголовок в шапке |
289
+ | `debugGrid` | boolean | `false` | отладочная сетка 12x12 |
290
+ | `decors` | записи каталога | встроенный каталог | добавляет или переопределяет записи декора по `decor.id` |
291
+ | `decorSaveOrigin` | origin URL | origin dev-сервера | разрешённый origin для сохранения настроек декора |
292
+
293
+ Если dev-сервер работает за reverse proxy с завершением TLS, задайте внешний origin явно:
294
+
295
+ ```yaml
296
+ themeConfig:
297
+ decorSaveOrigin: https://slides.example
298
+ ```
299
+
300
+ Endpoint сохранения принимает только точное значение `Origin`. Без явного `decorSaveOrigin` он выводит origin из TLS-соединения и заголовка `Host`; заголовкам `X-Forwarded-*` endpoint не доверяет.
247
301
 
248
302
  ## Цветовые токены
249
303
 
@@ -263,8 +317,16 @@ themeConfig:
263
317
  | `red-0` | `#e84033` |
264
318
  | `yellow-0` | `#ffd20a` |
265
319
 
266
- ## Проверка
320
+ ## Проверка перед выпуском
267
321
 
268
322
  ```bash
269
- npm run test
323
+ npm test
270
324
  ```
325
+
326
+ Единая команда последовательно проверяет:
327
+
328
+ - lint и типы;
329
+ - модульные, контрактные и архитектурные тесты;
330
+ - продукционную сборку и состав её артефактов;
331
+ - репрезентативные слайды в Chromium, переполнение холста и загрузку медиа по `/theme/...`;
332
+ - состав и размер npm-пакета.
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, defineComponent, h, isVNode, onMounted, onUpdated, shallowRef, useSlots, type PropType, type VNode } from 'vue'
2
+ import { computed, defineComponent, h, isVNode, onBeforeUnmount, onMounted, onUpdated, shallowRef, unref, useSlots, type PropType, type VNode } from 'vue'
3
3
  import { useSlideContext } from '@slidev/client'
4
4
  import DebugGrid from './DebugGrid.vue'
5
5
  import Header from './Header.vue'
@@ -9,10 +9,12 @@ import Slot from './Slot.vue'
9
9
  import Text from './Text.vue'
10
10
  import Timeline from './Timeline.vue'
11
11
  import { createSlideLayout } from '../composables/slide-layout'
12
- import type { ThemeLayout, ThemeLayoutHeader } from '../composables/layout-registry'
12
+ import type { SlideMarkdownContractError } from '../composables/layout-shorthands'
13
+ import type { ThemeLayout, ThemeLayoutHeader } from '../composables/layout-recipes'
13
14
  import { typographElement } from '../composables/text-typography.mjs'
14
15
  import { themeVars, type ThemeSlideMode, type ThemeTone } from '../composables/theme-foundation'
15
16
  import { provideSlotPlacementSession } from '../composables/slot-placement'
17
+ import { provideTextFitScope } from '../composables/text-fit-scope'
16
18
  import { useThemeConfig } from '../composables/use-theme-config'
17
19
 
18
20
  const props = withDefaults(defineProps<{
@@ -33,8 +35,12 @@ const props = withDefaults(defineProps<{
33
35
 
34
36
  const slots = useSlots()
35
37
  const slideElement = shallowRef<HTMLElement | null>(null)
38
+ const browserLifecycleToken = typeof window !== 'undefined'
39
+ && '__practicumBrowserLifecycle' in window
40
+ ? `${Date.now()}-${Math.random()}`
41
+ : ''
36
42
 
37
- const { $frontmatter } = useSlideContext()
43
+ const { $frontmatter, $page } = useSlideContext()
38
44
  const { debugGrid, defaultTone } = useThemeConfig()
39
45
  const slideLayout = createSlideLayout({
40
46
  components: {
@@ -46,14 +52,38 @@ const slideLayout = createSlideLayout({
46
52
  },
47
53
  })
48
54
 
55
+ const layoutContractLabel = computed(() => {
56
+ const layout = props.layout || String($frontmatter.layout ?? '')
57
+ const variant = props.variant || String($frontmatter.variant ?? '')
58
+ return variant ? `${layout}:${variant}` : layout
59
+ })
60
+
61
+ function reportLayoutContractError(error: SlideMarkdownContractError) {
62
+ const lines = [
63
+ `[Slidev] Слайд ${$page ?? '?'}: ${layoutContractLabel.value}`,
64
+ error.message,
65
+ ]
66
+ if (error.hint)
67
+ lines.push(` Подсказка: ${error.hint}`)
68
+ lines.push(' См. example.md в slidev-theme-practicum (блоки «Контракт»).')
69
+ console.error(lines.join('\n'))
70
+ }
71
+
49
72
  function compileCurrentAuthoring(children?: readonly VNode[]) {
50
- return slideLayout.compile({
51
- props,
52
- frontmatter: $frontmatter as Record<string, unknown>,
53
- children,
54
- defaultTone: defaultTone.value,
55
- debugGrid: debugGrid.value,
56
- })
73
+ try {
74
+ return slideLayout.compile({
75
+ props,
76
+ frontmatter: $frontmatter as Record<string, unknown>,
77
+ children,
78
+ defaultTone: defaultTone.value,
79
+ debugGrid: debugGrid.value,
80
+ })
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && error.name === 'SlideMarkdownContractError')
84
+ reportLayoutContractError(error as SlideMarkdownContractError)
85
+ throw error
86
+ }
57
87
  }
58
88
 
59
89
  const authored = computed(() => compileCurrentAuthoring())
@@ -65,19 +95,51 @@ const resolvedTheme = computed(() => authored.value.theme)
65
95
  const isContrast = computed(() => authored.value.contrast)
66
96
  const showDebugGrid = computed(() => authored.value.showDebugGrid)
67
97
  provideSlotPlacementSession(slideLayout)
98
+ provideTextFitScope()
68
99
 
69
100
  function applySlideTypography() {
70
101
  typographElement(slideElement.value)
71
102
  }
72
103
 
104
+ function reportBrowserLifecycle(
105
+ phase: 'mounted' | 'unmounted',
106
+ page: number | string,
107
+ token: string,
108
+ ) {
109
+ if (typeof window === 'undefined' || !('__practicumBrowserLifecycle' in window))
110
+ return
111
+
112
+ window.dispatchEvent(new CustomEvent('practicum:slide-lifecycle', {
113
+ detail: {
114
+ phase,
115
+ slide: page,
116
+ token,
117
+ },
118
+ }))
119
+ }
120
+
73
121
  onMounted(() => {
74
122
  applySlideTypography()
123
+ reportBrowserLifecycle(
124
+ 'mounted',
125
+ unref($page) ?? 0,
126
+ browserLifecycleToken,
127
+ )
75
128
  })
76
129
 
77
130
  onUpdated(() => {
78
131
  applySlideTypography()
79
132
  })
80
133
 
134
+ onBeforeUnmount(() => {
135
+ reportBrowserLifecycle(
136
+ 'unmounted',
137
+ unref($page) ?? 0,
138
+ browserLifecycleToken,
139
+ )
140
+ })
141
+
142
+ // eslint-disable-next-line vue/one-component-per-file -- Рендер-адаптер намеренно локален: он не является самостоятельным публичным компонентом.
81
143
  const RenderVNode = defineComponent({
82
144
  name: 'RenderVNode',
83
145
  props: {
@@ -93,6 +155,7 @@ const RenderVNode = defineComponent({
93
155
 
94
156
  const slideStyle = computed(() => themeVars('slide', resolvedTheme.value))
95
157
 
158
+ // eslint-disable-next-line vue/one-component-per-file -- Компонент замыкает compileCurrentAuthoring текущего слайда и не имеет самостоятельного API.
96
159
  const LayoutBody = defineComponent({
97
160
  name: 'LayoutBody',
98
161
  setup(_, { slots: layoutSlots }) {
@@ -219,22 +282,6 @@ const slideClass = computed(() => ({
219
282
  margin-bottom: var(--theme-slot-margin-4);
220
283
  }
221
284
 
222
- :deep(.Slide-TitleSupportsList) {
223
- max-width: var(--theme-grid-span-6-width);
224
- padding-left: 0;
225
- list-style: none;
226
- color: var(--theme-text-muted);
227
- }
228
-
229
- :deep(.Slide-TitleSupportsList li) {
230
- margin-bottom: calc(var(--theme-grid-module) * 2);
231
- }
232
-
233
- :deep(.Slide-TitleSupportsList_size_large li) {
234
- font-size: var(--theme-text-size-4);
235
- line-height: var(--theme-text-line-4);
236
- }
237
-
238
285
  :deep(.Slide-TitleBody) {
239
286
  max-width: var(--theme-grid-span-6-width);
240
287
  color: var(--theme-text);
@@ -242,6 +289,10 @@ const slideClass = computed(() => ({
242
289
  line-height: var(--theme-text-line-4);
243
290
  }
244
291
 
292
+ :deep(.Slide-TitleBody_muted) {
293
+ color: var(--theme-text-muted);
294
+ }
295
+
245
296
  :deep(.Slide-TitleBody :where(p, ul, ol, blockquote)) {
246
297
  margin: 0;
247
298
  color: inherit;
@@ -272,6 +323,10 @@ const slideClass = computed(() => ({
272
323
  content: '●';
273
324
  }
274
325
 
326
+ :deep(.Slide-TitleBody ul ul li)::marker {
327
+ content: '▪︎';
328
+ }
329
+
275
330
  :deep(.Slide-TitleBody :where(p, ul, ol, blockquote) + :where(p, ul, ol, blockquote)) {
276
331
  margin-top: calc(var(--theme-grid-module) * 3);
277
332
  }
@@ -2,30 +2,26 @@
2
2
  import { useSlideContext } from '@slidev/client'
3
3
  import {
4
4
  computed,
5
- defineComponent,
6
5
  getCurrentInstance,
7
- h,
8
- isVNode,
9
6
  onBeforeUnmount,
10
7
  shallowRef,
11
8
  watchEffect,
12
- type CSSProperties,
13
- type PropType,
14
- type VNode,
15
9
  } from 'vue'
16
10
  import ImageRenderer from './ImageRenderer.vue'
11
+ import TextFitGroup from './TextFitGroup.vue'
17
12
  import { createThemeMedia } from '../composables/theme-media.mjs'
18
- import type { ThemeLayoutSlotSpec } from '../composables/layout-registry'
13
+ import type { ThemeLayoutSlotSpec } from '../composables/layout-recipes'
19
14
  import { useSlotPlacementSession, type ResolvedSlotPlacement } from '../composables/slot-placement'
20
- import { createTextFitRuntime } from '../composables/text-fit-runtime'
15
+ import {
16
+ resolveSlotGapCssValue,
17
+ resolveSlotSpacingStyle,
18
+ type SlotGapInput,
19
+ type SlotMarginInput,
20
+ } from '../composables/slot-spacing'
21
21
  import { useThemeConfig } from '../composables/use-theme-config'
22
22
  import { resolveSlotTheme, themeVars, type ThemeSlotSurface } from '../composables/theme-foundation'
23
23
  import { camelToKebab } from '../composables/layout-vnode'
24
24
 
25
- type SlotMarginLevel = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
26
- type SlotMarginInput = SlotMarginLevel | `${SlotMarginLevel}`
27
- type SlotGap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
28
- type SlotGapInput = SlotGap | `${SlotGap}`
29
25
  type SlotRole = 'primary' | 'secondary' | 'support' | 'media'
30
26
  type SlotSurfaceInput = ThemeSlotSurface
31
27
  type ImageDataInput = {
@@ -51,8 +47,6 @@ type SlotDecorInput = Omit<Partial<ImageDataInput>, 'src'> & {
51
47
  tone?: string
52
48
  }
53
49
 
54
- const SLOT_MARGIN_LEVELS = new Set<`${SlotMarginLevel}`>(['1', '2', '3', '4', '5', '6', '7', '8'])
55
- const SLOT_GAP_LEVELS = new Set<`${SlotGap}`>(['0', '1', '2', '3', '4', '5', '6', '7'])
56
50
  const layoutOverrideKeys = [
57
51
  'surface',
58
52
  'tone',
@@ -64,36 +58,6 @@ const layoutOverrideKeys = [
64
58
  'centered',
65
59
  ] as const satisfies readonly (keyof ThemeLayoutSlotSpec)[]
66
60
 
67
- const SlotContent = defineComponent({
68
- name: 'SlotContent',
69
- props: {
70
- contentStyle: {
71
- type: Object as PropType<CSSProperties>,
72
- required: true,
73
- },
74
- },
75
- setup(contentProps, { slots }) {
76
- const contentElement = shallowRef<HTMLElement | null>(null)
77
- let currentChildren: VNode[] = []
78
- const textFit = createTextFitRuntime()
79
- const { apply } = textFit.useGroup({
80
- target: contentElement,
81
- nodes: () => currentChildren,
82
- })
83
-
84
- return () => {
85
- const children = (slots.default?.() ?? []).filter(isVNode)
86
- currentChildren = children
87
-
88
- return h('div', {
89
- ref: contentElement,
90
- class: 'Slot-Content',
91
- style: contentProps.contentStyle,
92
- }, apply(children))
93
- }
94
- },
95
- })
96
-
97
61
  const props = withDefaults(defineProps<{
98
62
  as?: string
99
63
  role?: SlotRole
@@ -111,6 +75,7 @@ const props = withDefaults(defineProps<{
111
75
  marginBottom?: SlotMarginInput
112
76
  marginLeft?: SlotMarginInput
113
77
  gap?: SlotGapInput
78
+ fitGroup?: string
114
79
  centered?: boolean
115
80
  }>(), {
116
81
  as: 'section',
@@ -124,6 +89,7 @@ const props = withDefaults(defineProps<{
124
89
  background: undefined,
125
90
  decor: undefined,
126
91
  gap: '0',
92
+ fitGroup: '',
127
93
  centered: false,
128
94
  })
129
95
 
@@ -259,52 +225,21 @@ const backgroundLayers = computed(() =>
259
225
  }),
260
226
  )
261
227
 
262
- function resolveMarginCssValue(value: SlotMarginInput | undefined) {
263
- if (value == null)
264
- return undefined
265
-
266
- const normalizedValue = String(value) as `${SlotMarginLevel}`
267
- if (!SLOT_MARGIN_LEVELS.has(normalizedValue)) {
268
- throw new Error(`[${props.label || slotId}] margin должен быть уровнем 1..8. Получено: "${String(value)}".`)
269
- }
270
-
271
- return `var(--theme-slot-margin-${normalizedValue})`
272
- }
273
-
274
- function resolveGapCssValue(value: SlotGapInput | undefined) {
275
- if (value == null)
276
- return '0'
277
-
278
- const normalizedValue = String(value) as `${SlotGap}`
279
- if (normalizedValue === '0')
280
- return '0'
281
-
282
- if (!SLOT_GAP_LEVELS.has(normalizedValue)) {
283
- throw new Error(`[${props.label || slotId}] gap должен быть уровнем 0..7. Получено: "${String(value)}".`)
284
- }
285
-
286
- return `var(--theme-slot-margin-${normalizedValue})`
287
- }
288
-
289
228
  const marginStyle = computed(() => {
290
229
  const layoutSlot = resolvedLayoutSlot.value
291
- const margin = layoutSlot?.margin ?? props.margin
292
- const style = {
293
- paddingTop: resolveMarginCssValue(layoutSlot?.marginTop ?? props.marginTop ?? margin),
294
- paddingRight: resolveMarginCssValue(layoutSlot?.marginRight ?? props.marginRight ?? margin),
295
- paddingBottom: resolveMarginCssValue(layoutSlot?.marginBottom ?? props.marginBottom ?? margin),
296
- paddingLeft: resolveMarginCssValue(layoutSlot?.marginLeft ?? props.marginLeft ?? margin),
297
- }
298
-
299
- if (Object.values(style).every(value => value == null))
300
- return null
301
230
 
302
- return style
231
+ return resolveSlotSpacingStyle({
232
+ margin: layoutSlot?.margin ?? props.margin,
233
+ marginTop: layoutSlot?.marginTop ?? props.marginTop,
234
+ marginRight: layoutSlot?.marginRight ?? props.marginRight,
235
+ marginBottom: layoutSlot?.marginBottom ?? props.marginBottom,
236
+ marginLeft: layoutSlot?.marginLeft ?? props.marginLeft,
237
+ }, props.label || slotId)
303
238
  })
304
239
 
305
240
  const contentStyle = computed(() => ({
306
241
  ...(marginStyle.value ?? {}),
307
- '--theme-slot-gap': resolveGapCssValue(props.gap),
242
+ '--theme-slot-gap': resolveSlotGapCssValue(props.gap, props.label || slotId),
308
243
  }))
309
244
 
310
245
  const alignmentClass = computed(() => ({
@@ -333,9 +268,9 @@ const surfaceClass = computed(() => {
333
268
  class="Slot-BackgroundLayer"
334
269
  :layer="backgroundLayer" />
335
270
  </div>
336
- <SlotContent :content-style="contentStyle">
271
+ <TextFitGroup class="Slot-Content" :style="contentStyle" :fit-group="props.fitGroup">
337
272
  <slot />
338
- </SlotContent>
273
+ </TextFitGroup>
339
274
  </component>
340
275
  </template>
341
276
 
@@ -1,11 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef } from 'vue'
2
+ import { computed, shallowRef, useSlots } from 'vue'
3
3
  import {
4
4
  createTextFitRuntime,
5
5
  parseThemeTextSizeRange,
6
6
  type ThemeTextSize,
7
7
  type ThemeTextSizeInput,
8
8
  } from '../composables/text-fit-runtime'
9
+ import { resolveTextSlotMarkdown } from '../composables/text-slot-markdown.mjs'
9
10
 
10
11
  type ThemeTextPriority = 1 | 2 | 3
11
12
  type ThemeTextPriorityInput = ThemeTextPriority | `${ThemeTextPriority}`
@@ -71,6 +72,15 @@ const props = withDefaults(defineProps<{
71
72
  color: 'current',
72
73
  })
73
74
 
75
+ const slots = useSlots()
76
+ const markdownNodes = computed(() => {
77
+ const tag = props.as.toLowerCase()
78
+
79
+ if (/^h[1-6]$/.test(tag))
80
+ return null
81
+
82
+ return resolveTextSlotMarkdown(slots.default?.() ?? [])
83
+ })
74
84
  const textElement = shallowRef<HTMLElement | null>(null)
75
85
  const normalizedSizeRange = computed(() => parseThemeTextSizeRange(props.size, 2))
76
86
  const fitMax = computed(() => props.maxSize || normalizedSizeRange.value.isRange)
@@ -120,7 +130,10 @@ const textStyle = computed(() => ({
120
130
  },
121
131
  ]"
122
132
  :style="textStyle">
123
- <slot />
133
+ <slot v-if="!markdownNodes" />
134
+ <component v-for="(node, index) in markdownNodes"
135
+ :is="node"
136
+ :key="index" />
124
137
  </component>
125
138
  </template>
126
139
 
@@ -129,6 +142,7 @@ const textStyle = computed(() => ({
129
142
  min-width: 0;
130
143
  margin: 0;
131
144
  font-weight: 400;
145
+ --theme-text-list-marker-width: 2ch;
132
146
  }
133
147
 
134
148
  .Text_fit_max {
@@ -213,4 +227,56 @@ const textStyle = computed(() => ({
213
227
  font-size: var(--theme-text-size-12);
214
228
  line-height: var(--theme-text-line-12);
215
229
  }
230
+
231
+ .Text :deep(:where(p, li)) {
232
+ font-size: inherit;
233
+ line-height: inherit;
234
+ }
235
+
236
+ .Text :deep(p) {
237
+ margin: 0;
238
+ }
239
+
240
+ .Text :deep(:where(ul, ol)) {
241
+ margin: 0;
242
+ padding-left: 0;
243
+ list-style-position: outside;
244
+ }
245
+
246
+ .Text :deep(ul) {
247
+ list-style-type: disc;
248
+ padding-inline-start: 1.25ch;
249
+ }
250
+
251
+ .Text :deep(ol) {
252
+ list-style-type: decimal;
253
+ padding-inline-start: var(--theme-text-list-marker-width);
254
+ font-variant-numeric: tabular-nums;
255
+ }
256
+
257
+ .Text :deep(li > :is(ul, ol)) {
258
+ padding-inline-start: calc(var(--theme-grid-module) * 2);
259
+ }
260
+
261
+ .Text :deep(li) {
262
+ display: list-item;
263
+ margin-bottom: var(--theme-space-flow);
264
+ color: inherit;
265
+ font-size: inherit;
266
+ }
267
+
268
+ /* Slidev pass-through: <li><p>…</p></li> ломает outside-маркеры */
269
+ .Text :deep(li > p) {
270
+ display: inline;
271
+ margin: 0;
272
+ }
273
+
274
+ .Text :deep(:where(p, li) + :where(p, ul, ol)) {
275
+ margin-top: var(--theme-space-flow);
276
+ }
277
+
278
+ .Text :deep(li)::marker {
279
+ color: currentcolor;
280
+ font-size: inherit;
281
+ }
216
282
  </style>