slidev-theme-practicum 0.2.0 → 0.3.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/README.md +147 -18
- package/components/Slide.vue +11 -57
- package/components/Slot.vue +2 -1
- package/components/StepsGrid.vue +112 -0
- package/composables/deck-decors.ts +74 -0
- package/composables/decor-sources.ts +43 -0
- package/composables/layout-authoring.ts +34 -9
- package/composables/layout-recipes.ts +16 -2
- package/composables/layout-shorthands.ts +6 -17
- package/composables/local-layout-variant-files.ts +106 -0
- package/composables/local-layout-variants.ts +73 -0
- package/composables/slide-layout.ts +3 -0
- package/composables/use-theme-config.ts +17 -10
- package/composables/validate-deck-layouts.cjs +77 -6
- package/env.d.ts +18 -0
- package/package.json +6 -3
- package/scripts/browser-smoke.mjs +65 -0
- package/scripts/check-local-layout-variant-build.mjs +189 -0
- package/scripts/check-package.mjs +18 -2
- package/scripts/requirements-illustrations.txt +1 -0
- package/scripts/trace-line-art.py +422 -0
- package/scripts/validate-deck.cjs +1 -1
- package/setup/vite-plugins.ts +109 -2
- package/skills/slidev-practicum/SKILL.md +18 -4
- package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
- package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
- package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
1. Откройте [example.md](example.md) и найдите слайд, похожий по задаче.
|
|
8
8
|
2. Скопируйте весь блок слайда от `---` до следующего `---`.
|
|
9
|
-
3. Замените
|
|
10
|
-
4. Проверьте, что
|
|
9
|
+
3. Замените видимый заголовок и остальной текст в теле слайда, затем пункты или числа. Технические поля лучше не трогать, пока не станет понятно, за что они отвечают.
|
|
10
|
+
4. Проверьте, что тезис, факт, инструкция и вывод читаются без фотографии и декоративных элементов.
|
|
11
11
|
5. Если подходящего примера нет, сначала выберите задачу слайда по таблице ниже.
|
|
12
12
|
|
|
13
13
|
## Агентный скилл
|
|
@@ -46,6 +46,34 @@ npm install -D slidev-theme-practicum
|
|
|
46
46
|
theme: practicum
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
## Структура проекта колоды
|
|
50
|
+
|
|
51
|
+
Для презентации, которая использует установленную тему, каноническая точка входа — `slides.md`, а локальные медиа лежат в `public/` и подключаются абсолютным путём от корня сайта:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
<deck>/
|
|
55
|
+
├── slides.md
|
|
56
|
+
├── decors.yaml # необязательный внешний каталог декора
|
|
57
|
+
├── package.json
|
|
58
|
+
├── package-lock.json
|
|
59
|
+
├── pages/ # разделы большой колоды
|
|
60
|
+
├── components/ # только компоненты этой колоды
|
|
61
|
+
│ └── layout-variants/ # локальные варианты тематических layout
|
|
62
|
+
├── layouts/ # новые нативные layout самой презентации
|
|
63
|
+
├── snippets/ # импортируемые примеры кода
|
|
64
|
+
├── styles/index.css # локальные глобальные переопределения
|
|
65
|
+
├── public/
|
|
66
|
+
│ ├── decor/ # графический декор
|
|
67
|
+
│ ├── photos/ # фотографии
|
|
68
|
+
│ ├── illustrations/ # контурные предметы, парные PNG/SVG
|
|
69
|
+
│ └── figures/ # схемы, графики и снимки интерфейса
|
|
70
|
+
└── reference/ # исходники и источники, не входящие в сборку
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Создавайте необязательные каталоги только при появлении содержимого. Не заводите параллельные `assets/`, `images/` или `img/`. Фотография остаётся в `photos`, даже если служит фоном; контурный предмет остаётся в `illustrations`, даже если используется как декор. Для файла колоды пишите `/photos/team-workshop.webp`, а не `public/photos/...` и не `/theme/photos/...`. Префикс `/theme` принадлежит только встроенным файлам темы.
|
|
74
|
+
|
|
75
|
+
Полный контракт структуры, классификации, именования и проверки закреплён в агентном справочнике [deck-project-structure.md](skills/slidev-practicum/references/deck-project-structure.md). Производственный контур сюжетных фотографий описан в [photographic-illustrations.md](skills/slidev-practicum/references/photographic-illustrations.md), а протокол создания парных PNG/SVG для контурных предметов — в [contour-illustrations.md](skills/slidev-practicum/references/contour-illustrations.md).
|
|
76
|
+
|
|
49
77
|
## Как выбрать слайд
|
|
50
78
|
|
|
51
79
|
Выбирайте не по теме презентации, а по задаче кадра.
|
|
@@ -69,6 +97,8 @@ theme: practicum
|
|
|
69
97
|
|
|
70
98
|
Заголовок должен отвечать на вопрос «что зритель должен понять сейчас?». Не называйте слайд технически вроде «Метрики» или «Композиция», если можно сразу написать вывод.
|
|
71
99
|
|
|
100
|
+
В первом headmatter верхнеуровневое поле `title` разрешено: это служебное название всей колоды для HTML-документа и метаданных Slidev, а не содержимое первого слайда. Видимый заголовок всё равно пишите в теле: как `# …` для сокращённой записи или как `<Text as="h1">…</Text>` для явной композиции, включая `cover`. Со второго слайда верхнеуровневое поле `title` запрещено, чтобы метаданные не принимали за отображаемый текст. Вложенные поля `items[].title`, `comparison.from/to.title` и `person.title` остаются частью видимых моделей компонентов и не относятся к этому запрету.
|
|
101
|
+
|
|
72
102
|
Поясняющий текст нужен для контекста, ограничения или критерия выбора. Если текст превращается в два разных вывода, разнесите его по двум слайдам.
|
|
73
103
|
|
|
74
104
|
Список работает, когда пункты однотипны: шаги, правила, критерии, темы. Пишите пункты в одинаковой грамматической форме, чтобы их можно было быстро просканировать.
|
|
@@ -77,15 +107,19 @@ theme: practicum
|
|
|
77
107
|
|
|
78
108
|
Цитата должна быть короткой. Авторство добавляет источник, но не должно конкурировать с самой фразой.
|
|
79
109
|
|
|
80
|
-
##
|
|
110
|
+
## Семантические роли изображений
|
|
81
111
|
|
|
82
|
-
|
|
112
|
+
Роль изображения определяется не форматом файла и не компонентом, а тем, что зритель должен из него понять.
|
|
83
113
|
|
|
84
|
-
|
|
114
|
+
| Роль | Назначение | Требование к смыслу |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| Декоративная фотография | Настроение, ритм, плотность, брендовый характер или пауза | Взаимозаменяема: если её убрать или заменить, вывод слайда не изменится |
|
|
117
|
+
| Сюжетная фотографическая иллюстрация | Конкретная метафора, действие или сквозные персонажи | Не взаимозаменяема внутри сюжета, но не является единственным носителем факта, инструкции или вывода |
|
|
118
|
+
| Информационная фигура | Схема, график, интерфейс, сравнение или доказательство, которое нужно рассмотреть | Существенные данные и вывод продублированы доступной подписью или текстом слайда |
|
|
85
119
|
|
|
86
|
-
|
|
120
|
+
Встроенные фотографии темы и выбор через `decor` всегда декоративные. Колода может добавлять собственные сюжетные фотографии в `public/photos/` и размещать их через `Image` или `Slot.background`. В таком случае конкретный сюжетный смысл должен быть назван заголовком, текстом, подписью или доступным описанием: фотография поддерживает рассказ, но не заменяет его.
|
|
87
121
|
|
|
88
|
-
Не пишите
|
|
122
|
+
Не пишите о декоративной фотографии: «фото доказывает» или «фотография объясняет число». Если зритель действительно должен рассмотреть данные, интерфейс или причинно-следственную схему, это информационная фигура из `public/figures/`, а не декоративный слой.
|
|
89
123
|
|
|
90
124
|
## Живая галерея
|
|
91
125
|
|
|
@@ -107,10 +141,88 @@ layout: cover | message | explainer | collection | none
|
|
|
107
141
|
|
|
108
142
|
Для `layout: message`, `explainer` и `collection` часть `variant` принимает markdown в default slot слайда (`#` заголовок, списки, blockquote, frontmatter) — тема разворачивает его в `Slot` / `Text`. Реестр: `composables/layout-shorthands.ts`.
|
|
109
143
|
|
|
144
|
+
Для `message:centered` тема выбирает самый крупный помещающийся размер заголовка в диапазоне крупных токенов `7-12`.
|
|
145
|
+
|
|
110
146
|
Для `message:closing` обязателен один заголовок `# …`; под ним можно добавить один необязательный абзац. Дополнительные абзацы, списки и изображения не поддерживаются.
|
|
111
147
|
|
|
112
148
|
`layout: cover` markdown shorthand **не** имеет: для всех `cover:*` нужны явные `<Slot role="...">` и `<Text>` (см. обложки в [example.md](example.md)).
|
|
113
149
|
|
|
150
|
+
Сокращённая Markdown-запись и явные `<Slot role="...">` — два взаимоисключающих режима авторинга одного встроенного варианта. Если в основном слоте есть явный ролевой `Slot`, тема использует ролевые компоненты как готовое содержимое и не разворачивает `items`, `comparison` и другие поля сокращённой записи. Исключение — `collection:agenda`, где разрешён один явный `Slot role="media"` как переопределение иллюстрации. Чтобы `items[].title` или `comparison.from/to.title` попали в визуальный результат, оставьте во входе только поля front matter и канонический Markdown-заголовок; не дублируйте те же данные в `<Text>`.
|
|
151
|
+
|
|
152
|
+
### Локальные варианты презентации
|
|
153
|
+
|
|
154
|
+
Slidev уже автоматически подключает Vue-компоненты из `components/` конечной презентации. Их можно использовать тегами прямо в `slides.md`. Локальный вариант нужен для другого случая: повторяющаяся композиция, или архетип, выбирается привычной парой `layout` + `variant`, а содержимое слайда остаётся обычным Markdown без Vue-тегов.
|
|
155
|
+
|
|
156
|
+
Файл лежит внутри каталога тематического `layout`. Например, `components/layout-variants/explainer/lesson-summary.vue` соответствует `layout: explainer` и `variant: lesson-summary`:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
<deck>/
|
|
160
|
+
├── slides.md
|
|
161
|
+
├── components/
|
|
162
|
+
│ ├── CourseBadge.vue
|
|
163
|
+
│ └── layout-variants/
|
|
164
|
+
│ └── explainer/
|
|
165
|
+
│ └── lesson-summary.vue
|
|
166
|
+
└── layouts/
|
|
167
|
+
└── workshop.vue
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```md
|
|
171
|
+
---
|
|
172
|
+
layout: explainer
|
|
173
|
+
variant: lesson-summary
|
|
174
|
+
badge:
|
|
175
|
+
text: Практика
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
# Что запомнить
|
|
179
|
+
|
|
180
|
+
- Компоненты принадлежат презентации
|
|
181
|
+
- Локальный вариант выбирается через `layout` и `variant`
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Vue-файл получает отдельную копию `frontmatter`, защищённую от записи на верхнем уровне, значения `layout` и `variant`, а также разобранный Markdown через основной слот:
|
|
185
|
+
|
|
186
|
+
```vue
|
|
187
|
+
<script setup lang="ts">
|
|
188
|
+
import type {
|
|
189
|
+
DeckLayoutVariantProps,
|
|
190
|
+
DeckLayoutVariantSlots,
|
|
191
|
+
} from 'slidev-theme-practicum/composables/local-layout-variants'
|
|
192
|
+
|
|
193
|
+
type LessonSummaryFrontmatter = {
|
|
194
|
+
badge?: {
|
|
195
|
+
text?: string
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
defineProps<DeckLayoutVariantProps<LessonSummaryFrontmatter>>()
|
|
200
|
+
defineSlots<DeckLayoutVariantSlots>()
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<Slot area="1 / 1 / 9 / 9" surface="light" margin="4" gap="3">
|
|
205
|
+
<slot />
|
|
206
|
+
</Slot>
|
|
207
|
+
|
|
208
|
+
<Slot area="9 / 9 / -1 / -1" surface="color" margin="3">
|
|
209
|
+
<CourseBadge :text="frontmatter.badge?.text ?? variant" />
|
|
210
|
+
</Slot>
|
|
211
|
+
</template>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Компоненты темы (`Slot`, `Text`, `Image`, `Person` и другие) и остальные компоненты презентации внутри такого файла доступны по обычным правилам автоматического подключения Slidev. Корневые `Slot` локального варианта используют ручные `area`, `col` или `row`: поле `role` принадлежит только встроенным рецептам темы.
|
|
215
|
+
|
|
216
|
+
Правила выбора намеренно строгие:
|
|
217
|
+
|
|
218
|
+
- первый каталог — один из тематических `cover`, `message`, `explainer` или `collection`, а имя Vue-файла и значение `variant` пишутся в `kebab-case`;
|
|
219
|
+
- встроенный вариант темы нельзя затереть локальным файлом: для локального архетипа выбирайте новое имя `variant`;
|
|
220
|
+
- `arrangement` нельзя добавлять к локальному варианту — дополнительные параметры получают собственные поля front matter;
|
|
221
|
+
- неизвестный `variant` останавливает просмотр, сборку и экспорт с подсказкой до ожидаемого файла;
|
|
222
|
+
- для принципиально нового типа слайда используйте нативный механизм Slidev: `layouts/workshop.vue` и `layout: workshop`. Такой layout не проходит через варианты темы.
|
|
223
|
+
|
|
224
|
+
`slidev-practicum-validate` проверяет имя, наличие локального варианта и допустимое сочетание полей front matter. Типы и шаблон конкретного Vue-файла дополнительно проверяются обычной типизацией и сборкой презентации.
|
|
225
|
+
|
|
114
226
|
### Варианты collection P0
|
|
115
227
|
|
|
116
228
|
| `variant` | `arrangement` | Контракт |
|
|
@@ -206,12 +318,14 @@ arrangement: numbered-quartet
|
|
|
206
318
|
| `arrangement` | компактная перестановка одной модели данных |
|
|
207
319
|
| `Slot` | область сетки, поверхность, отступы и медиа-слой |
|
|
208
320
|
| `Text` | типографика и согласованный подбор размера |
|
|
209
|
-
| `Image` |
|
|
321
|
+
| `Image` | изображение внутри ручной композиции; роль задаёт автор |
|
|
210
322
|
| `decor` | семантический выбор декоративного изображения |
|
|
211
|
-
| `background` | низкоуровневое размещение
|
|
323
|
+
| `background` | низкоуровневое размещение изображения |
|
|
212
324
|
|
|
213
325
|
## Метаданные слайда
|
|
214
326
|
|
|
327
|
+
Первый headmatter может содержать верхнеуровневый `title` как служебное название всей колоды. На остальных слайдах валидатор отклоняет этот ключ; основной текст каждого кадра должен находиться в теле слайда.
|
|
328
|
+
|
|
215
329
|
| Поле | Значения | По умолчанию | Смысл |
|
|
216
330
|
| ------------- | ----------------------------------------------------- | --------------- | ---------------------------------------- |
|
|
217
331
|
| `layout` | `cover`, `message`, `explainer`, `collection`, `none` | значение Slidev | роль слайда или ручная сетка |
|
|
@@ -293,7 +407,8 @@ Slidev парсит markdown внутри компонента только ес
|
|
|
293
407
|
|
|
294
408
|
| Свойство или поле | Тип | По умолчанию | Смысл |
|
|
295
409
|
| ------------------ | ------------------------------------------------ | ------------ | --------------------------------- |
|
|
296
|
-
| `src` | путь | обязательно | исходное
|
|
410
|
+
| `src` | путь | обязательно | исходное изображение |
|
|
411
|
+
| `alt` | строка | пусто | доступное описание недекоративного изображения |
|
|
297
412
|
| `fit` | `cover`, `contain`, `fill`, `none`, `scale-down` | `cover` | режим заполнения |
|
|
298
413
|
| `position` | позиция CSS | `center` | позиция изображения |
|
|
299
414
|
| `zoom` | число | `1` | масштаб |
|
|
@@ -321,7 +436,7 @@ Slidev парсит markdown внутри компонента только ес
|
|
|
321
436
|
|
|
322
437
|
Встроенные файлы темы доступны по префиксу `/theme`: например, `/theme/photos/photo-6.webp` и `/theme/decor/decor-10.svg`. Файлы из `public` самой колоды остаются пользовательскими и задаются от корня, например `/decor/custom-data.png`; префикс `/theme` к ним добавлять не нужно.
|
|
323
438
|
|
|
324
|
-
|
|
439
|
+
`Image` и `Slot.background` не определяют семантическую роль. Встроенные `/theme/photos/...` и выбор через `decor` остаются декоративными; собственная фотография колоды из `/photos/...` может быть сюжетной. Для сюжетного `Image` заполняйте `alt`. Фон `Slot.background` скрыт от вспомогательных технологий, поэтому его смысл обязательно дублируется видимым заголовком, текстом или подписью. Ключевые факты, различия, инструкции и выводы в любом случае должны быть записаны текстом, числом или доступной подписью.
|
|
325
440
|
|
|
326
441
|
### Decor
|
|
327
442
|
|
|
@@ -358,11 +473,18 @@ themeConfig:
|
|
|
358
473
|
tone: blue
|
|
359
474
|
```
|
|
360
475
|
|
|
476
|
+
```yaml
|
|
477
|
+
themeConfig:
|
|
478
|
+
decors: ./decors.yaml
|
|
479
|
+
```
|
|
480
|
+
|
|
361
481
|
```yaml
|
|
362
482
|
themeConfig:
|
|
363
483
|
deckTitle: 'Название колоды'
|
|
364
484
|
debugGrid: false
|
|
365
485
|
decors:
|
|
486
|
+
- ./decors/clocks.yaml
|
|
487
|
+
- ./decors/photos.yaml
|
|
366
488
|
- id: decor-custom-data
|
|
367
489
|
src: /decor/custom-data.png
|
|
368
490
|
meaning: data
|
|
@@ -372,14 +494,21 @@ themeConfig:
|
|
|
372
494
|
ratio: [1.2, 2.7]
|
|
373
495
|
```
|
|
374
496
|
|
|
497
|
+
`decors` принимает список записей, путь к файлу или смешанный список путей и записей. Файл может быть YAML, JSON или ESM (`decors.yaml`, `.yml`, `.json`, `.mjs`) и содержать массив записей, объект `{ decors: [...] }` или одну запись. Если `decors` не задан, тема подхватывает `decors.yaml` / `.yml` / `.json` / `.mjs` в корне колоды, если такой файл есть. Позже идущие записи с тем же `id` переопределяют более ранние.
|
|
498
|
+
|
|
499
|
+
Файл каталога по умолчанию **заменяет** встроенный каталог темы: слоты видят только записи колоды. Короткие inline-записи по умолчанию по-прежнему добавляются к встроенному каталогу. Явный `replaceDecors` перекрывает оба случая.
|
|
500
|
+
|
|
375
501
|
В записи каталога `cols` и `rows` описывают допустимый размер слота в сетке 12x12, а `ratio` ограничивает соотношение `cols / rows` для ориентации. Вместо `ratio` можно использовать более явный алиас `aspectRatio`.
|
|
376
502
|
|
|
377
|
-
| Поле | Тип
|
|
378
|
-
| ----------------- |
|
|
379
|
-
| `deckTitle` | строка
|
|
380
|
-
| `debugGrid` | boolean
|
|
381
|
-
| `decors` |
|
|
382
|
-
| `
|
|
503
|
+
| Поле | Тип | По умолчанию | Смысл |
|
|
504
|
+
| ----------------- | --------------------------------- | ------------------ | -------------------------------------------------------- |
|
|
505
|
+
| `deckTitle` | строка | пустая строка | служебное название колоды в шапке |
|
|
506
|
+
| `debugGrid` | boolean | `false` | отладочная сетка 12x12 |
|
|
507
|
+
| `decors` | записи, путь или список путей | встроенный каталог | файл заменяет встроенный каталог; inline-записи добавляют или переопределяют по `decor.id` |
|
|
508
|
+
| `replaceDecors` | boolean | `true` для файла каталога | не подмешивать встроенные картинки темы |
|
|
509
|
+
| `decorSaveOrigin` | origin URL | origin dev-сервера | разрешённый origin для сохранения настроек декора |
|
|
510
|
+
|
|
511
|
+
`themeConfig.deckTitle` задаётся явно и отвечает только за повторяющееся служебное название в шапке. Он не заменяет видимый заголовок конкретного слайда и не берётся из верхнеуровневого `title`.
|
|
383
512
|
|
|
384
513
|
Если dev-сервер работает за reverse proxy с завершением TLS, задайте внешний origin явно:
|
|
385
514
|
|
|
@@ -418,6 +547,6 @@ npm test
|
|
|
418
547
|
|
|
419
548
|
- lint и типы;
|
|
420
549
|
- модульные, контрактные и архитектурные тесты;
|
|
421
|
-
- продукционную
|
|
550
|
+
- продукционную сборку, локальный вариант внешней презентации и состав артефактов;
|
|
422
551
|
- репрезентативные слайды в Chromium, переполнение холста и загрузку медиа по `/theme/...`;
|
|
423
552
|
- состав и размер npm-пакета.
|
package/components/Slide.vue
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
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
|
+
import { DECK_LAYOUT_VARIANTS } from 'virtual:practicum-deck-layout-variants'
|
|
4
5
|
import DebugGrid from './DebugGrid.vue'
|
|
5
6
|
import Header from './Header.vue'
|
|
6
7
|
import Image from './Image.vue'
|
|
7
8
|
import Person from './Person.vue'
|
|
8
9
|
import Slot from './Slot.vue'
|
|
10
|
+
import StepsGrid from './StepsGrid.vue'
|
|
9
11
|
import Text from './Text.vue'
|
|
10
12
|
import Timeline from './Timeline.vue'
|
|
11
13
|
import { createSlideLayout } from '../composables/slide-layout'
|
|
@@ -47,9 +49,11 @@ const slideLayout = createSlideLayout({
|
|
|
47
49
|
Image,
|
|
48
50
|
Person,
|
|
49
51
|
Slot,
|
|
52
|
+
StepsGrid,
|
|
50
53
|
Text,
|
|
51
54
|
Timeline,
|
|
52
55
|
},
|
|
56
|
+
layoutVariants: DECK_LAYOUT_VARIANTS,
|
|
53
57
|
})
|
|
54
58
|
|
|
55
59
|
const layoutContractLabel = computed(() => {
|
|
@@ -60,12 +64,14 @@ const layoutContractLabel = computed(() => {
|
|
|
60
64
|
|
|
61
65
|
function reportLayoutContractError(error: SlideMarkdownContractError) {
|
|
62
66
|
const lines = [
|
|
63
|
-
`[Slidev] Слайд ${$page ?? '?'}: ${layoutContractLabel.value}`,
|
|
67
|
+
`[Slidev] Слайд ${unref($page) ?? '?'}: ${layoutContractLabel.value}`,
|
|
64
68
|
error.message,
|
|
65
69
|
]
|
|
66
70
|
if (error.hint)
|
|
67
71
|
lines.push(` Подсказка: ${error.hint}`)
|
|
68
|
-
lines.push(
|
|
72
|
+
lines.push(error.hint?.includes('components/layout-variants/')
|
|
73
|
+
? ' См. README.md в slidev-theme-practicum, раздел «Локальные варианты презентации».'
|
|
74
|
+
: ' См. example.md в slidev-theme-practicum (блоки «Контракт»).')
|
|
69
75
|
console.error(lines.join('\n'))
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -89,6 +95,7 @@ function compileCurrentAuthoring(children?: readonly VNode[]) {
|
|
|
89
95
|
const authored = computed(() => compileCurrentAuthoring())
|
|
90
96
|
const resolvedLayout = computed(() => authored.value.layout)
|
|
91
97
|
const isLayoutMode = computed(() => authored.value.mode === 'layout')
|
|
98
|
+
const isCompiledMode = computed(() => authored.value.mode !== 'manual')
|
|
92
99
|
const resolvedVariant = computed(() => authored.value.variant)
|
|
93
100
|
const resolvedHeader = computed(() => authored.value.header)
|
|
94
101
|
const resolvedTheme = computed(() => authored.value.theme)
|
|
@@ -187,14 +194,14 @@ const slideClass = computed(() => ({
|
|
|
187
194
|
:class="slideClass"
|
|
188
195
|
:style="slideStyle">
|
|
189
196
|
<div class="Slide-Header">
|
|
190
|
-
<slot v-if="!
|
|
197
|
+
<slot v-if="!isCompiledMode && slots.header" name="header" />
|
|
191
198
|
<Header v-else-if="resolvedHeader !== 'none'"
|
|
192
199
|
:variant="resolvedHeader === 'cover' ? 'cover' : 'default'"
|
|
193
200
|
:inverted="isContrast" />
|
|
194
201
|
</div>
|
|
195
202
|
<DebugGrid v-if="showDebugGrid" />
|
|
196
203
|
<div class="Slide-Grid">
|
|
197
|
-
<slot v-if="!
|
|
204
|
+
<slot v-if="!isCompiledMode" />
|
|
198
205
|
<LayoutBody v-else>
|
|
199
206
|
<slot />
|
|
200
207
|
</LayoutBody>
|
|
@@ -382,59 +389,6 @@ const slideClass = computed(() => ({
|
|
|
382
389
|
line-height: var(--theme-text-line-5);
|
|
383
390
|
}
|
|
384
391
|
|
|
385
|
-
:deep(.Slide-StepsGrid) {
|
|
386
|
-
display: grid;
|
|
387
|
-
width: 100%;
|
|
388
|
-
height: 100%;
|
|
389
|
-
min-width: 0;
|
|
390
|
-
min-height: 0;
|
|
391
|
-
grid-template-columns: repeat(var(--slide-steps-count), minmax(0, 1fr));
|
|
392
|
-
gap: var(--theme-grid-gap);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
:deep(.Slide-Step) {
|
|
396
|
-
display: flex;
|
|
397
|
-
min-width: 0;
|
|
398
|
-
min-height: 0;
|
|
399
|
-
flex-direction: column;
|
|
400
|
-
gap: calc(var(--theme-grid-module) * 2);
|
|
401
|
-
padding: var(--theme-slot-margin-3);
|
|
402
|
-
border-radius: var(--theme-panel-radius);
|
|
403
|
-
background: var(--theme-surface-light);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
:deep(.Slide-StepsGrid_staggered) {
|
|
407
|
-
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
408
|
-
grid-template-rows: repeat(2, minmax(0, 1fr));
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
:deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(1)) {
|
|
412
|
-
grid-area: 1 / 1 / 2 / 3;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
:deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(2)) {
|
|
416
|
-
grid-area: 1 / 3 / 2 / 5;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
:deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(3)) {
|
|
420
|
-
grid-area: 1 / 5 / 2 / 7;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
:deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(4)) {
|
|
424
|
-
grid-area: 2 / 2 / 3 / 4;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
:deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(5)) {
|
|
428
|
-
grid-area: 2 / 4 / 3 / 6;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
:deep(.Slide-Step_active) {
|
|
432
|
-
background: var(--theme-current-color);
|
|
433
|
-
color: var(--theme-text-on-dark);
|
|
434
|
-
--theme-text: var(--theme-text-on-dark);
|
|
435
|
-
--theme-text-muted: var(--theme-text-muted-on-contrast);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
392
|
:deep(.Slide-Quote) {
|
|
439
393
|
max-width: var(--theme-grid-span-11-width);
|
|
440
394
|
font-size: var(--theme-text-size-7);
|
package/components/Slot.vue
CHANGED
|
@@ -96,7 +96,7 @@ const props = withDefaults(defineProps<{
|
|
|
96
96
|
const slotId = `theme-slot-${Math.random().toString(36).slice(2, 10)}`
|
|
97
97
|
const instance = getCurrentInstance()
|
|
98
98
|
const placementSession = useSlotPlacementSession()
|
|
99
|
-
const { defaultTone, deckTitle, decors } = useThemeConfig()
|
|
99
|
+
const { defaultTone, deckTitle, decors, replaceDecors } = useThemeConfig()
|
|
100
100
|
const { $slidev } = useSlideContext()
|
|
101
101
|
const resolvedRect = shallowRef<ResolvedSlotPlacement['rect'] | null>(null)
|
|
102
102
|
const resolvedFootprint = shallowRef<ResolvedSlotPlacement['footprint'] | null>(null)
|
|
@@ -208,6 +208,7 @@ const decorSeed = computed(() => [
|
|
|
208
208
|
].filter(Boolean).join('|'))
|
|
209
209
|
|
|
210
210
|
const media = computed(() => createThemeMedia({
|
|
211
|
+
...(replaceDecors.value ? { baseCatalog: [] } : {}),
|
|
211
212
|
themeCatalog: decors.value,
|
|
212
213
|
warn: (message: string) => console.warn(message),
|
|
213
214
|
}))
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { getCurrentInstance } from 'vue'
|
|
3
|
+
import Text from './Text.vue'
|
|
4
|
+
import TextFitGroup from './TextFitGroup.vue'
|
|
5
|
+
|
|
6
|
+
type StepItem = {
|
|
7
|
+
title: string
|
|
8
|
+
body: string
|
|
9
|
+
label: string
|
|
10
|
+
active: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const stepsFitGroup = `steps-${getCurrentInstance()?.uid ?? 'default'}`
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
items: StepItem[]
|
|
17
|
+
arrangement: 'linear' | 'staggered'
|
|
18
|
+
}>()
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="Slide-StepsGrid"
|
|
23
|
+
:class="`Slide-StepsGrid_${props.arrangement}`"
|
|
24
|
+
:data-count="props.items.length"
|
|
25
|
+
:style="{ '--slide-steps-count': props.items.length }">
|
|
26
|
+
<article v-for="(item, index) in props.items"
|
|
27
|
+
:key="index"
|
|
28
|
+
class="Slide-Step"
|
|
29
|
+
:class="{ 'Slide-Step_active': item.active }"
|
|
30
|
+
:data-index="index + 1">
|
|
31
|
+
<Text size="4">{{ item.label }}</Text>
|
|
32
|
+
<TextFitGroup class="Slide-Step-FitGroup"
|
|
33
|
+
:fit-group="`${stepsFitGroup}-titles`">
|
|
34
|
+
<Text size="3-5">{{ item.title }}</Text>
|
|
35
|
+
</TextFitGroup>
|
|
36
|
+
<TextFitGroup v-if="item.body"
|
|
37
|
+
class="Slide-Step-FitGroup"
|
|
38
|
+
:fit-group="`${stepsFitGroup}-bodies`">
|
|
39
|
+
<Text size="2-3" priority="2" muted>{{ item.body }}</Text>
|
|
40
|
+
</TextFitGroup>
|
|
41
|
+
</article>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.Slide-StepsGrid {
|
|
47
|
+
display: grid;
|
|
48
|
+
width: 100%;
|
|
49
|
+
max-width: 100%;
|
|
50
|
+
height: 100%;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
min-height: 0;
|
|
53
|
+
grid-template-columns: repeat(var(--slide-steps-count), minmax(0, 1fr));
|
|
54
|
+
gap: var(--theme-grid-gap);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.Slide-Step {
|
|
58
|
+
display: flex;
|
|
59
|
+
min-width: 0;
|
|
60
|
+
min-height: 0;
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
gap: calc(var(--theme-grid-module) * 2);
|
|
64
|
+
padding: var(--theme-slot-margin-3);
|
|
65
|
+
border-radius: var(--theme-panel-radius);
|
|
66
|
+
background: var(--theme-surface-light);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.Slide-Step-FitGroup {
|
|
70
|
+
width: 100%;
|
|
71
|
+
height: auto;
|
|
72
|
+
min-width: 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.Slide-Step :deep(.TextFitGroup) {
|
|
76
|
+
width: 100%;
|
|
77
|
+
height: auto;
|
|
78
|
+
min-width: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.Slide-StepsGrid_staggered {
|
|
82
|
+
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
83
|
+
grid-template-rows: repeat(2, minmax(0, 1fr));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.Slide-StepsGrid_staggered .Slide-Step:nth-child(1) {
|
|
87
|
+
grid-area: 1 / 1 / 2 / 3;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.Slide-StepsGrid_staggered .Slide-Step:nth-child(2) {
|
|
91
|
+
grid-area: 1 / 3 / 2 / 5;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.Slide-StepsGrid_staggered .Slide-Step:nth-child(3) {
|
|
95
|
+
grid-area: 1 / 5 / 2 / 7;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.Slide-StepsGrid_staggered .Slide-Step:nth-child(4) {
|
|
99
|
+
grid-area: 2 / 2 / 3 / 4;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.Slide-StepsGrid_staggered .Slide-Step:nth-child(5) {
|
|
103
|
+
grid-area: 2 / 4 / 3 / 6;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.Slide-Step_active {
|
|
107
|
+
background: var(--theme-current-color);
|
|
108
|
+
color: var(--theme-text-on-dark);
|
|
109
|
+
--theme-text: var(--theme-text-on-dark);
|
|
110
|
+
--theme-text-muted: var(--theme-text-muted-on-contrast);
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { extname, isAbsolute, relative, resolve } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { parse as parseYaml } from 'yaml'
|
|
6
|
+
import { collectDecorSources, unwrapDecorFile } from './decor-sources'
|
|
7
|
+
|
|
8
|
+
export const DECK_DECORS_VIRTUAL_ID = 'virtual:practicum-deck-decors'
|
|
9
|
+
export const RESOLVED_DECK_DECORS_VIRTUAL_ID = `\0${DECK_DECORS_VIRTUAL_ID}`
|
|
10
|
+
export const CONVENTION_DECOR_FILES = Object.freeze([
|
|
11
|
+
'decors.yaml',
|
|
12
|
+
'decors.yml',
|
|
13
|
+
'decors.json',
|
|
14
|
+
'decors.mjs',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const SUPPORTED_EXTENSIONS = new Set(['.yaml', '.yml', '.json', '.mjs', '.js'])
|
|
18
|
+
|
|
19
|
+
export function resolveDecorFilePath(root: string, spec: string) {
|
|
20
|
+
const base = resolve(root)
|
|
21
|
+
const resolved = resolve(base, spec)
|
|
22
|
+
const rel = relative(base, resolved)
|
|
23
|
+
|
|
24
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel))
|
|
25
|
+
throw new Error(`Decor catalog path escapes the deck root: ${spec}`)
|
|
26
|
+
|
|
27
|
+
return resolved
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function findConventionDecorFile(root: string) {
|
|
31
|
+
return CONVENTION_DECOR_FILES
|
|
32
|
+
.map(name => resolve(root, name))
|
|
33
|
+
.find(path => existsSync(path))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function collectDeckDecorFiles(input: { decors: unknown, root: string }) {
|
|
37
|
+
const { files } = collectDecorSources(input.decors)
|
|
38
|
+
|
|
39
|
+
if (files.length)
|
|
40
|
+
return files.map(file => resolveDecorFilePath(input.root, file))
|
|
41
|
+
|
|
42
|
+
if (input.decors != null)
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
const convention = findConventionDecorFile(input.root)
|
|
46
|
+
return convention ? [convention] : []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function loadDecorFile(path: string) {
|
|
50
|
+
const ext = extname(path).toLowerCase()
|
|
51
|
+
if (!SUPPORTED_EXTENSIONS.has(ext))
|
|
52
|
+
throw new Error(`Unsupported decor catalog file: ${path}`)
|
|
53
|
+
|
|
54
|
+
if (!existsSync(path))
|
|
55
|
+
throw new Error(`Decor catalog file not found: ${path}`)
|
|
56
|
+
|
|
57
|
+
if (ext === '.mjs' || ext === '.js') {
|
|
58
|
+
const loadModule = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<Record<string, unknown>>
|
|
59
|
+
const mod = await loadModule(`${pathToFileURL(path).href}?t=${Date.now()}`)
|
|
60
|
+
return unwrapDecorFile(mod.decors ?? mod.default)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const source = await readFile(path, 'utf8')
|
|
64
|
+
return unwrapDecorFile(ext === '.json' ? JSON.parse(source) : parseYaml(source))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolveDeckFileDecors(input: { decors: unknown, root: string }) {
|
|
68
|
+
const records: Record<string, unknown>[] = []
|
|
69
|
+
|
|
70
|
+
for (const path of collectDeckDecorFiles(input))
|
|
71
|
+
records.push(...await loadDecorFile(path))
|
|
72
|
+
|
|
73
|
+
return records
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isDecorFileRef(value: unknown): value is string {
|
|
6
|
+
return typeof value === 'string' && Boolean(value.trim())
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function collectDecorSources(value: unknown): {
|
|
10
|
+
files: string[]
|
|
11
|
+
inline: Record<string, unknown>[]
|
|
12
|
+
} {
|
|
13
|
+
if (isDecorFileRef(value))
|
|
14
|
+
return { files: [value.trim()], inline: [] }
|
|
15
|
+
|
|
16
|
+
if (!Array.isArray(value))
|
|
17
|
+
return { files: [], inline: [] }
|
|
18
|
+
|
|
19
|
+
const files: string[] = []
|
|
20
|
+
const inline: Record<string, unknown>[] = []
|
|
21
|
+
|
|
22
|
+
for (const item of value) {
|
|
23
|
+
if (isDecorFileRef(item))
|
|
24
|
+
files.push(item.trim())
|
|
25
|
+
else if (isRecord(item))
|
|
26
|
+
inline.push(item)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { files, inline }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function unwrapDecorFile(value: unknown): Record<string, unknown>[] {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.filter(isRecord)
|
|
35
|
+
|
|
36
|
+
if (isRecord(value) && Array.isArray(value.decors))
|
|
37
|
+
return value.decors.filter(isRecord)
|
|
38
|
+
|
|
39
|
+
if (isRecord(value) && typeof value.id === 'string')
|
|
40
|
+
return [value]
|
|
41
|
+
|
|
42
|
+
return []
|
|
43
|
+
}
|