mdeck 3.10.1 → 3.12.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/CHANGELOG.md +44 -0
- package/README.md +15 -4
- package/assets/base.css +6 -1
- package/docs/reference/authoring.md +111 -9
- package/docs/site/assets/site.css +2 -0
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +6 -3
- package/docs/site/content/drawing.md +5 -5
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +49 -0
- package/docs/site/content/media.md +20 -1
- package/docs/site/content/presenting.md +67 -9
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/welcome.md +21 -1
- package/docs/site/content/writing.md +27 -0
- package/docs/site/images/features/hotspots.webp +0 -0
- package/docs/site/images/features/languages.webp +0 -0
- package/docs/site/pages.js +1 -0
- package/examples/custom-layouts/slides.md +21 -9
- package/examples/showcase/refs.bib +21 -0
- package/examples/showcase/slides.ar.drawings.json +11 -0
- package/examples/showcase/slides.ar.md +567 -0
- package/examples/showcase/slides.de.drawings.json +11 -0
- package/examples/showcase/slides.de.md +567 -0
- package/examples/showcase/slides.de.results.json +57 -0
- package/examples/showcase/slides.en.drawings.json +11 -0
- package/examples/showcase/{slides.md → slides.en.md} +112 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +14 -2
- package/src/build/check.js +34 -7
- package/src/build/components.js +5 -3
- package/src/build/config.js +2 -0
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +70 -22
- package/src/build/svgCheck.js +65 -0
- package/src/build/timingPlugin.js +96 -0
- package/src/cli/args.js +6 -5
- package/src/cli/index.js +77 -21
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/VideoPlayer.jsx +6 -1
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/editDeck.js +44 -7
- package/src/core/icons.js +9 -1
- package/src/core/labels.js +78 -0
- package/src/core/parseSlides.js +16 -6
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/timing.js +83 -0
- package/src/core/validateDeck.js +11 -2
- package/src/editor/components/DeckSettings.jsx +1 -0
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/home/Home.jsx +39 -93
- package/src/home/home.css +5 -17
- package/src/layouts/layoutApi.jsx +51 -8
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +9 -0
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +18 -6
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/RehearsalSummary.jsx +129 -0
- package/src/runtime/component.js +27 -0
- package/src/runtime/deck-stage.js +371 -37
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/bus.js +13 -5
- package/src/runtime/ink/outbox.js +27 -0
- package/src/runtime/ink/room.js +37 -12
- package/src/runtime/ink/segments.js +21 -0
- package/src/runtime/main.jsx +508 -286
- package/src/runtime/shared.js +77 -0
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/steps.js +36 -0
- package/src/runtime/useTalkTimer.js +67 -0
- package/examples/citations/refs.bib +0 -30
- package/examples/citations/refs.yml +0 -21
- package/examples/citations/slides.md +0 -43
- package/examples/citations/slides.pdf +0 -0
- package/examples/poll/slides.md +0 -127
- package/examples/showcase/slides.drawings.json +0 -11
package/src/core/icons.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// markup of the Lucide SVG.
|
|
6
6
|
|
|
7
7
|
export const ICONS = {
|
|
8
|
+
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>', // house
|
|
8
9
|
presenter: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/>', // presentation
|
|
9
10
|
projector: '<path d="M5 7 3 5"/><path d="M9 6V3"/><path d="m13 7 2-2"/><circle cx="9" cy="13" r="3"/><path d="M11.83 12H20a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h2.17"/><path d="M16 16h2"/>', // projector
|
|
10
11
|
reader: '<path d="M12 5v16"/><path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z"/>', // book-open
|
|
@@ -26,6 +27,7 @@ export const ICONS = {
|
|
|
26
27
|
up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', // arrow-up
|
|
27
28
|
down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', // arrow-down
|
|
28
29
|
add: '<path d="M5 12h14"/><path d="M12 5v14"/>', // plus
|
|
30
|
+
remove: '<path d="M5 12h14"/>', // minus
|
|
29
31
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', // x
|
|
30
32
|
grid: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>', // layout-grid
|
|
31
33
|
menu: '<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/>', // menu
|
|
@@ -48,11 +50,14 @@ export const ICONS = {
|
|
|
48
50
|
highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
|
|
49
51
|
marker: '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>', // sparkles
|
|
50
52
|
select: '<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>', // square-dashed
|
|
51
|
-
laser: '<
|
|
53
|
+
laser: '<rect width="5" height="13" x="3.5" y="9.5" rx="2.5" transform="rotate(45 6 16)"/><path d="m11.5 12.5 3-3" stroke-dasharray="0.1 2.6"/><circle cx="17.5" cy="6.5" r="3"/><path d="M17.5 1v0.5"/><path d="M23 6.5h-.5"/><path d="m21.4 2.6-.35.35"/>', // drawn for mdeck on Lucide's grid: a laser pointer (Lucide has none; its "locate" read as a map pin)
|
|
52
54
|
fold: '<path d="m7 7 10 10"/><path d="M17 7v10H7"/>', // arrow-down-right
|
|
53
55
|
eraser: '<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/>', // eraser
|
|
56
|
+
join: '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>', // qr-code
|
|
57
|
+
settings: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>', // sliders-horizontal
|
|
54
58
|
people: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>', // users
|
|
55
59
|
tablet: '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>', // tablet
|
|
60
|
+
phone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>', // smartphone
|
|
56
61
|
speaker: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>', // panel-right
|
|
57
62
|
fullscreen: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>', // maximize
|
|
58
63
|
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', // sun
|
|
@@ -60,6 +65,9 @@ export const ICONS = {
|
|
|
60
65
|
'fullscreen-exit': '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>', // minimize
|
|
61
66
|
play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', // play
|
|
62
67
|
hotspots: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/>', // scan-search
|
|
68
|
+
rehearse: '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>', // timer
|
|
69
|
+
blank: '<path d="M17 17H4a2 2 0 0 1-2-2V5c0-1.5 1-2 1-2"/><path d="M22 15V5a2 2 0 0 0-2-2H9"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m2 2 20 20"/>', // monitor-off
|
|
70
|
+
overview: '<rect width="18" height="14" x="3" y="3" rx="2"/><path d="M4 21h1"/><path d="M9 21h1"/><path d="M14 21h1"/><path d="M19 21h1"/>', // gallery-horizontal-end
|
|
63
71
|
pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
|
|
64
72
|
loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
|
|
65
73
|
}
|
package/src/core/labels.js
CHANGED
|
@@ -51,6 +51,7 @@ export const LABELS = {
|
|
|
51
51
|
'respond.waiting': 'The next question will appear here.',
|
|
52
52
|
'respond.send': 'Send',
|
|
53
53
|
'respond.sent': 'Sent. Thank you!',
|
|
54
|
+
'respond.react': 'React',
|
|
54
55
|
'join.scan': 'Scan to join',
|
|
55
56
|
'follow.scan': 'Scan to follow the slides',
|
|
56
57
|
'follow.open': 'Follow the slides',
|
|
@@ -124,6 +125,7 @@ export const LABELS = {
|
|
|
124
125
|
'respond.waiting': 'Die nächste Frage erscheint hier.',
|
|
125
126
|
'respond.send': 'Senden',
|
|
126
127
|
'respond.sent': 'Gesendet. Danke!',
|
|
128
|
+
'respond.react': 'Reagieren',
|
|
127
129
|
'join.scan': 'Scannen und mitmachen',
|
|
128
130
|
'follow.scan': 'Scannen und Folien mitverfolgen',
|
|
129
131
|
'follow.open': 'Folien mitverfolgen',
|
|
@@ -151,6 +153,80 @@ export const LABELS = {
|
|
|
151
153
|
'question.empty': 'Hier erscheinen die Antworten.',
|
|
152
154
|
'scale.average': 'Durchschnitt {n}',
|
|
153
155
|
},
|
|
156
|
+
ar: {
|
|
157
|
+
'reader.outline': 'المخطط',
|
|
158
|
+
'reader.untitled': 'الشرائح',
|
|
159
|
+
'reader.slides': 'الشرائح',
|
|
160
|
+
'reader.read': 'قراءة',
|
|
161
|
+
'reader.appearance': 'فاتح أو داكن',
|
|
162
|
+
'reader.light': 'فاتح',
|
|
163
|
+
'reader.dark': 'داكن',
|
|
164
|
+
'reader.drawings': 'الرسوم',
|
|
165
|
+
'reader.hideDrawings': 'إخفاء الرسوم',
|
|
166
|
+
'reader.showDrawings': 'إظهار الرسوم',
|
|
167
|
+
'reader.downloadPdf': 'تنزيل PDF',
|
|
168
|
+
'reader.savePdf': 'حفظ بصيغة PDF…',
|
|
169
|
+
'reader.savePdfHint': 'يفتح نافذة الطباعة في المتصفح؛ اختر الحفظ بصيغة PDF',
|
|
170
|
+
'reader.present': 'عرض',
|
|
171
|
+
'reader.previous': 'السابق',
|
|
172
|
+
'reader.next': 'التالي',
|
|
173
|
+
'reader.copyLink': 'نسخ رابط هذه الشريحة',
|
|
174
|
+
'reader.linkCopied': 'نُسخ الرابط',
|
|
175
|
+
'reader.slide': 'الشريحة {n}',
|
|
176
|
+
'deck.overview': 'نظرة عامة',
|
|
177
|
+
'slide.chapter': 'الفصل',
|
|
178
|
+
'deck.controls': 'أدوات التحكم في العرض',
|
|
179
|
+
'deck.previous': 'الشريحة السابقة',
|
|
180
|
+
'deck.next': 'الشريحة التالية',
|
|
181
|
+
'deck.reset': 'من البداية',
|
|
182
|
+
'deck.resetHint': 'العودة إلى الشريحة الأولى',
|
|
183
|
+
'poll.scan': 'امسح الرمز للتصويت',
|
|
184
|
+
'video.sound': 'انقر لتشغيل الصوت',
|
|
185
|
+
'poll.answer': 'إجابة واحدة',
|
|
186
|
+
'poll.answers': '{n} إجابات',
|
|
187
|
+
'poll.reset': 'من البداية',
|
|
188
|
+
'poll.showAnswer': 'إظهار الإجابة الصحيحة',
|
|
189
|
+
'poll.hideAnswer': 'إخفاء الإجابة الصحيحة',
|
|
190
|
+
'poll.showResults': 'إظهار النتائج',
|
|
191
|
+
'poll.hideResults': 'إخفاء النتائج',
|
|
192
|
+
'poll.live': 'مباشر',
|
|
193
|
+
'poll.offline': 'غير متصل بالخادم',
|
|
194
|
+
'poll.unreachable': 'لا تستطيع الهواتف الوصول إلى هذا الحاسوب. ابدأ بالأمر {command}، أو اضبط {setting}.',
|
|
195
|
+
'poll.tryHere': 'جرّب صفحة الإجابة هنا',
|
|
196
|
+
'poll.static': 'أُجيب عنه مباشرة أثناء المحاضرة.',
|
|
197
|
+
'poll.pick': 'اختر إجابة واحدة.',
|
|
198
|
+
'poll.thanks': 'شكرًا! اخترت «{choice}». اختر غيرها لتغيير إجابتك.',
|
|
199
|
+
'respond.waiting': 'سيظهر السؤال التالي هنا.',
|
|
200
|
+
'respond.send': 'إرسال',
|
|
201
|
+
'respond.sent': 'أُرسلت. شكرًا لك!',
|
|
202
|
+
'respond.react': 'تفاعل',
|
|
203
|
+
'join.scan': 'امسح الرمز للانضمام',
|
|
204
|
+
'follow.scan': 'امسح الرمز لمتابعة الشرائح',
|
|
205
|
+
'follow.open': 'متابعة الشرائح',
|
|
206
|
+
'follow.live': 'مباشر',
|
|
207
|
+
'follow.back': 'العودة إلى البث المباشر',
|
|
208
|
+
'follow.waiting': 'لم تبدأ المحاضرة بعد.',
|
|
209
|
+
'follow.tryHere': 'جرّب المتابعة هنا',
|
|
210
|
+
'join.static': 'أثناء المحاضرة انضمت الهواتف من هنا للإجابة.',
|
|
211
|
+
'poll.answeredOf': 'أجاب {n} من {of}',
|
|
212
|
+
'poll.close': 'إغلاق: لا تُحتسب الإجابات اللاحقة',
|
|
213
|
+
'poll.open': 'فتح من جديد',
|
|
214
|
+
'poll.closed': 'مغلق',
|
|
215
|
+
'poll.several': 'يمكن اختيار أكثر من إجابة.',
|
|
216
|
+
'poll.pickSeveral': 'اختر كل ما ينطبق.',
|
|
217
|
+
'poll.thanksSeveral': 'شكرًا! اخترت {choice}. اختر مجددًا للتغيير.',
|
|
218
|
+
'respond.closed': 'أُغلق: لا مزيد من الإجابات.',
|
|
219
|
+
'respond.right': 'صحيح!',
|
|
220
|
+
'respond.wrong': 'ليس تمامًا. الإجابة الصحيحة: {answer}',
|
|
221
|
+
'respond.solution': 'الإجابة الصحيحة: {answer}',
|
|
222
|
+
'numeric.placeholder': 'رقم',
|
|
223
|
+
'numeric.invalid': 'أدخل رقمًا، مثل 0.5 أو 1/2.',
|
|
224
|
+
'numeric.sent': 'أُرسل: {value}. أرسل رقمًا آخر للتغيير.',
|
|
225
|
+
'numeric.other': 'أخرى',
|
|
226
|
+
'numeric.right': '{n} صحيحة',
|
|
227
|
+
'question.empty': 'تظهر الإجابات هنا.',
|
|
228
|
+
'scale.average': 'المتوسط {n}',
|
|
229
|
+
},
|
|
154
230
|
}
|
|
155
231
|
|
|
156
232
|
export const LABEL_KEYS = Object.keys(LABELS.en)
|
|
@@ -162,6 +238,8 @@ export const CALLOUT_LABELS = {
|
|
|
162
238
|
definition: 'Definition', theorem: 'Theorem', lemma: 'Lemma', corollary: 'Corollary', proof: 'Proof', example: 'Example', remark: 'Remark' },
|
|
163
239
|
de: { note: 'Hinweis', tip: 'Tipp', important: 'Wichtig', warning: 'Achtung', caution: 'Vorsicht',
|
|
164
240
|
definition: 'Definition', theorem: 'Satz', lemma: 'Lemma', corollary: 'Korollar', proof: 'Beweis', example: 'Beispiel', remark: 'Bemerkung' },
|
|
241
|
+
ar: { note: 'ملاحظة', tip: 'نصيحة', important: 'مهم', warning: 'تحذير', caution: 'انتبه',
|
|
242
|
+
definition: 'تعريف', theorem: 'مبرهنة', lemma: 'تمهيدية', corollary: 'نتيجة', proof: 'برهان', example: 'مثال', remark: 'ملحوظة' },
|
|
165
243
|
}
|
|
166
244
|
|
|
167
245
|
export const CALLOUT_TYPES = Object.keys(CALLOUT_LABELS.en)
|
package/src/core/parseSlides.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import yaml from 'js-yaml'
|
|
2
|
-
import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
|
|
2
|
+
import { sourceLines, fenceState, scanDirectives, diagnostic, isIncludeLine } from './source.js'
|
|
3
3
|
export { sourceLines, fenceState } from './source.js'
|
|
4
4
|
|
|
5
|
-
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots'.split(' '))
|
|
6
|
-
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots'.split(' '))
|
|
5
|
+
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots time reactions'.split(' '))
|
|
6
|
+
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots time'.split(' '))
|
|
7
7
|
|
|
8
8
|
// Names from before 2.0. They are still recognised, so that an old deck gets a
|
|
9
9
|
// message naming the new setting (see validateDeck) instead of being read as
|
|
@@ -32,7 +32,7 @@ function segmentsOf(source) {
|
|
|
32
32
|
const before = fence
|
|
33
33
|
fence = fenceState(line.text, fence)
|
|
34
34
|
if (before || fence) continue
|
|
35
|
-
if (/^:::\s*[\w-]+/.test(line.text)) depth++
|
|
35
|
+
if (/^:::\s*[\w-]+/.test(line.text) && !isIncludeLine(line.text)) depth++
|
|
36
36
|
else if (/^:::\s*$/.test(line.text)) depth = Math.max(0, depth - 1)
|
|
37
37
|
if (!depth && /^---[ \t]*$/.test(line.text)) {
|
|
38
38
|
segments.push({ start, end: line.start, delimiterEnd: line.end })
|
|
@@ -44,7 +44,7 @@ function segmentsOf(source) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// The first block is the deck's settings when its first key is only a deck
|
|
47
|
-
// key. A key both may have (hotspots) leaves it to the others: a slide's own
|
|
47
|
+
// key. A key both may have (hotspots, time) leaves it to the others: a slide's own
|
|
48
48
|
// key makes it the first slide's, otherwise it is the deck's.
|
|
49
49
|
function isDeckBlock(text) {
|
|
50
50
|
const keys = sourceLines(text).map(line => line.text.match(/^([\w-]+):/)?.[1]).filter(Boolean)
|
|
@@ -103,6 +103,15 @@ function extractBody(source, body, diagnostics) {
|
|
|
103
103
|
return { content, notes: notes.join('\n\n'), regions, blocks }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// What a slide that is nothing but `:::include <file> [slides=3-7]` includes:
|
|
107
|
+
// { file, slides } (slides as written, or null), or null for any other slide.
|
|
108
|
+
export function includeOf(content) {
|
|
109
|
+
const match = /^:::\s*include\s+(?:"([^"]+)"|(\S+))((?:\s+[\w-]+=\S+)*)\s*$/.exec(String(content ?? '').trim())
|
|
110
|
+
if (!match) return null
|
|
111
|
+
const options = Object.fromEntries([...match[3].matchAll(/([\w-]+)=(\S+)/g)].map(([, key, value]) => [key, value]))
|
|
112
|
+
return { file: match[1] ?? match[2], slides: options.slides ?? null, options }
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
export function parseSlides(source) {
|
|
107
116
|
const segments = segmentsOf(source)
|
|
108
117
|
const diagnostics = []
|
|
@@ -150,9 +159,10 @@ export function parseSlides(source) {
|
|
|
150
159
|
// source editing and validation, separate from the resolved block notes.
|
|
151
160
|
delete meta.notes
|
|
152
161
|
if (notes) meta.notes = notes
|
|
162
|
+
const include = Object.keys(authoredMeta).length ? null : includeOf(content)
|
|
153
163
|
slides.push({ id: meta.id ?? `slide-${slides.length + 1}`, meta, authoredMeta, content, regions, blocks,
|
|
154
164
|
source: { start: segment.start, end: body?.end ?? segment.end },
|
|
155
|
-
metaSource, bodySource: body ? { start: body.start, end: body.end } : null })
|
|
165
|
+
metaSource, bodySource: body ? { start: body.start, end: body.end } : null, ...(include ? { include } : {}) })
|
|
156
166
|
}
|
|
157
167
|
let currentSection = null
|
|
158
168
|
for (const slide of slides) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Reactions: people tap an emoji on the answer page, and it floats up on the
|
|
2
|
+
// presenter's screen for a moment, never on the projector. A deck turns them
|
|
3
|
+
// on with `reactions: true` (the set below) or its own list of up to eight,
|
|
4
|
+
// such as `reactions: [👍, 🤔, 🐢]`. They go to the session's `reactions`
|
|
5
|
+
// room, which the server passes on without keeping (src/live/rooms.js).
|
|
6
|
+
|
|
7
|
+
export const REACTIONS_ROOM = 'reactions'
|
|
8
|
+
// 🐢: "slower, please".
|
|
9
|
+
export const DEFAULT_REACTIONS = ['👍', '👏', '❤️', '😮', '🤔', '🐢']
|
|
10
|
+
const MAX = 8
|
|
11
|
+
const MAX_LENGTH = 16
|
|
12
|
+
|
|
13
|
+
/** Whether `value` is a usable `reactions` setting; a problem as text, or null. */
|
|
14
|
+
export function reactionsProblem(value) {
|
|
15
|
+
if (value == null || typeof value === 'boolean') return null
|
|
16
|
+
if (!Array.isArray(value) || !value.length || value.length > MAX) return `reactions must be true, false, or a list of 1 to ${MAX} emoji or short words`
|
|
17
|
+
const bad = value.find(item => typeof item !== 'string' && typeof item !== 'number' || !String(item).trim() || String(item).trim().length > MAX_LENGTH)
|
|
18
|
+
return bad !== undefined ? `reactions: each one is an emoji or a word of up to ${MAX_LENGTH} characters, not ${JSON.stringify(bad)}` : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The reactions a deck offers, or null when it offers none. */
|
|
22
|
+
export function reactionsOf(config) {
|
|
23
|
+
const value = config?.reactions
|
|
24
|
+
if (value === true) return DEFAULT_REACTIONS
|
|
25
|
+
if (reactionsProblem(value) || !Array.isArray(value)) return null
|
|
26
|
+
return [...new Set(value.map(item => String(item).trim()))]
|
|
27
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The answers kept in a talk (<deck>.results.json, results.js) read against
|
|
2
|
+
// the deck they were given in: which slide asked, what, and how it came out.
|
|
3
|
+
// `mdeck results` and the launch page export them from here, as the answers
|
|
4
|
+
// one by one (CSV), one row per option, word or number (summary CSV), or
|
|
5
|
+
// all of it (JSON).
|
|
6
|
+
//
|
|
7
|
+
// The summary counts as the slide does: for a poll, a scale, a number and a
|
|
8
|
+
// word cloud, each phone's latest answer; for open questions, every answer. Phones stay numbered per question; no device ids are kept.
|
|
9
|
+
import { tagsIn } from './tags.js'
|
|
10
|
+
import { expandPollLists, pollChoices } from './pollList.js'
|
|
11
|
+
import { choicesOf } from '../components/inlineText.js'
|
|
12
|
+
import { parseNumber, groupNumbers } from '../components/numbers.js'
|
|
13
|
+
|
|
14
|
+
const KINDS = { poll: 'poll', scale: 'scale', numeric: 'number', wordcloud: 'words', question: 'question' }
|
|
15
|
+
|
|
16
|
+
const plain = text => String(text ?? '').replace(/\$([^$]+)\$/g, '$1').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
|
|
17
|
+
|
|
18
|
+
function headingOf(slide) {
|
|
19
|
+
const heading = String(slide?.content ?? '').replace(/^(```|~~~)[\s\S]*?^\1/gm, '').match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]
|
|
20
|
+
return heading ? plain(heading) : plain(slide?.meta?.title ?? '')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const scaleBounds = ({ min, max }) => {
|
|
24
|
+
const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
|
|
25
|
+
const high = Number.isFinite(Number(max)) && max !== '' && max != null ? Math.round(Number(max)) : 5
|
|
26
|
+
return high > low && high - low <= 10 ? [low, high] : [1, 5]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The activities of a parsed deck, in order: [{ room, slide, kind, tag,
|
|
31
|
+
* question, options?, multiple?, min?, max? }]. `slide` counts from 1; a
|
|
32
|
+
* room asked on several slides is listed where it is first asked.
|
|
33
|
+
*/
|
|
34
|
+
export function activitiesIn(deck) {
|
|
35
|
+
const found = new Map()
|
|
36
|
+
;(deck?.slides ?? []).forEach((slide, i) => {
|
|
37
|
+
const texts = [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
|
|
38
|
+
for (const text of texts) {
|
|
39
|
+
for (const tag of tagsIn(expandPollLists(text))) {
|
|
40
|
+
const room = tag.attrs.room
|
|
41
|
+
if (tag.closing || !room || found.has(room)) continue
|
|
42
|
+
const kind = KINDS[tag.name] ?? tag.name
|
|
43
|
+
const entry = { room, slide: i + 1, kind, tag: tag.name, question: plain(tag.attrs.question) || headingOf(slide) }
|
|
44
|
+
if (tag.name === 'poll') {
|
|
45
|
+
const listed = tag.attrs['data-choices'] != null && pollChoices(tag.attrs['data-choices'])
|
|
46
|
+
entry.options = listed ? listed.map(choice => choice.text) : choicesOf(tag.attrs.options)
|
|
47
|
+
if (tag.attrs.multiple != null && tag.attrs.multiple !== 'false') entry.multiple = true
|
|
48
|
+
}
|
|
49
|
+
if (tag.name === 'scale') [entry.min, entry.max] = scaleBounds(tag.attrs)
|
|
50
|
+
found.set(room, entry)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
return [...found.values()]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Each phone's last answer, in the order the phones first answered.
|
|
58
|
+
function latestPerPhone(answers) {
|
|
59
|
+
const latest = new Map()
|
|
60
|
+
for (const answer of answers) latest.set(answer.phone, answer)
|
|
61
|
+
return [...latest.values()]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const round = value => Math.round(value * 100) / 100
|
|
65
|
+
function stats(values) {
|
|
66
|
+
if (!values.length) return {}
|
|
67
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
68
|
+
const middle = sorted.length >> 1
|
|
69
|
+
const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
|
|
70
|
+
return { average: round(values.reduce((sum, value) => sum + value, 0) / values.length), median: round(median), lowest: sorted[0], highest: sorted.at(-1) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** How one activity came out: { phones, answers, counts: [{ answer, count }], average?, median?, lowest?, highest? }. */
|
|
74
|
+
export function summarize(activity, answers = []) {
|
|
75
|
+
const phones = new Set(answers.map(answer => answer.phone)).size
|
|
76
|
+
const base = { phones, answers: answers.length }
|
|
77
|
+
const tag = activity?.tag
|
|
78
|
+
if (tag === 'poll') {
|
|
79
|
+
const options = activity.options ?? []
|
|
80
|
+
const votes = latestPerPhone(answers)
|
|
81
|
+
.map(answer => activity.multiple ? (Array.isArray(answer.value) ? answer.value : [answer.value]) : (Array.isArray(answer.value) ? [] : [answer.value]))
|
|
82
|
+
.map(picks => picks.filter(pick => options.includes(pick)))
|
|
83
|
+
.filter(picks => picks.length)
|
|
84
|
+
return { ...base, voted: votes.length, counts: options.map(option => ({ answer: option, count: votes.filter(picks => picks.includes(option)).length })) }
|
|
85
|
+
}
|
|
86
|
+
if (tag === 'scale') {
|
|
87
|
+
const steps = Array.from({ length: activity.max - activity.min + 1 }, (_, i) => activity.min + i)
|
|
88
|
+
const values = latestPerPhone(answers).map(answer => Number(answer.value)).filter(value => steps.includes(value))
|
|
89
|
+
return { ...base, voted: values.length, counts: steps.map(step => ({ answer: String(step), count: values.filter(value => value === step).length })), ...stats(values) }
|
|
90
|
+
}
|
|
91
|
+
if (tag === 'numeric') {
|
|
92
|
+
const texts = latestPerPhone(answers).map(answer => answer.value).filter(value => parseNumber(value) != null)
|
|
93
|
+
return { ...base, voted: texts.length, counts: groupNumbers(texts).map(group => ({ answer: group.text, count: group.n })), ...stats(texts.map(parseNumber)) }
|
|
94
|
+
}
|
|
95
|
+
// Words (each phone's latest) and open answers (every one), the same words together.
|
|
96
|
+
const counts = new Map()
|
|
97
|
+
for (const answer of tag === 'wordcloud' ? latestPerPhone(answers) : answers) {
|
|
98
|
+
const text = String(answer.value ?? '').trim().replace(/\s+/g, ' ')
|
|
99
|
+
if (!text) continue
|
|
100
|
+
const key = text.toLocaleLowerCase()
|
|
101
|
+
const entry = counts.get(key) ?? { answer: text, count: 0 }
|
|
102
|
+
entry.count++
|
|
103
|
+
counts.set(key, entry)
|
|
104
|
+
}
|
|
105
|
+
return { ...base, counts: [...counts.values()].sort((a, b) => b.count - a.count || a.answer.localeCompare(b.answer)) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The kept answers against the deck: [{ room, slide, kind, question,
|
|
110
|
+
* closed, savedAt, summary, answers }], in the order of the slides; rooms
|
|
111
|
+
* the deck no longer asks come last, with what is known of them.
|
|
112
|
+
*/
|
|
113
|
+
export function resultsReport(results, deck) {
|
|
114
|
+
const rooms = results?.rooms ?? {}
|
|
115
|
+
const activities = activitiesIn(deck)
|
|
116
|
+
const asked = activities.filter(activity => rooms[activity.room])
|
|
117
|
+
const gone = Object.keys(rooms).filter(room => !activities.some(activity => activity.room === room)).map(room => ({ room, slide: null, kind: null, tag: null, question: '' }))
|
|
118
|
+
return [...asked, ...gone].map(activity => {
|
|
119
|
+
const entry = rooms[activity.room]
|
|
120
|
+
const { tag, options, multiple, min, max, ...shown } = activity
|
|
121
|
+
return { ...shown, closed: entry.closed, savedAt: entry.savedAt, summary: summarize(activity, entry.answers), answers: entry.answers }
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const csvCell = value => {
|
|
126
|
+
const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
|
|
127
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
128
|
+
}
|
|
129
|
+
const csv = rows => rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
|
|
130
|
+
|
|
131
|
+
/** Every answer with its question: slide, room, kind, question, phone, time, answer (several picks joined by |). */
|
|
132
|
+
export function reportAnswersCsv(report) {
|
|
133
|
+
const rows = [['slide', 'room', 'kind', 'question', 'phone', 'at', 'answer']]
|
|
134
|
+
for (const entry of report) for (const answer of entry.answers) rows.push([entry.slide, entry.room, entry.kind, entry.question, answer.phone, answer.at, answer.value])
|
|
135
|
+
return csv(rows)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One row per option, step, number or word: slide, room, kind, question, answer, count, and its share of the phones that answered. */
|
|
139
|
+
export function reportSummaryCsv(report) {
|
|
140
|
+
const rows = [['slide', 'room', 'kind', 'question', 'answer', 'count', 'share']]
|
|
141
|
+
for (const entry of report) {
|
|
142
|
+
const { counts = [], voted } = entry.summary
|
|
143
|
+
const total = voted ?? counts.reduce((sum, row) => sum + row.count, 0)
|
|
144
|
+
for (const row of counts) rows.push([entry.slide, entry.room, entry.kind, entry.question, row.answer, row.count, total ? `${Math.round((row.count / total) * 100)}%` : ''])
|
|
145
|
+
for (const key of ['average', 'median']) if (entry.summary[key] != null) rows.push([entry.slide, entry.room, entry.kind, entry.question, key, entry.summary[key], ''])
|
|
146
|
+
}
|
|
147
|
+
return csv(rows)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** All of it, for other programs: the deck's title, when, and each question with its summary and answers. */
|
|
151
|
+
export function reportJson(report, { title = '', exportedAt = new Date().toISOString() } = {}) {
|
|
152
|
+
return JSON.stringify({ version: 1, title, exportedAt, questions: report }, null, 2) + '\n'
|
|
153
|
+
}
|
package/src/core/source.js
CHANGED
|
@@ -24,6 +24,11 @@ export function diagnostic(source, code, message, offset = 0, severity = 'error'
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Balanced directive scanner, shared by the document parser and Markdown lexer.
|
|
27
|
+
// `:::include ./part.md`, alone on a slide, stands for the slides of another
|
|
28
|
+
// deck file (build/include.js puts them in its place). Without a path, `:::include`
|
|
29
|
+
// opens a block as any other name does (a callout of the deck's own).
|
|
30
|
+
export const isIncludeLine = text => /^:::\s*include\s+\S/.test(text)
|
|
31
|
+
|
|
27
32
|
export function scanDirectives(source) {
|
|
28
33
|
const nodes = []
|
|
29
34
|
const stack = []
|
|
@@ -38,7 +43,9 @@ export function scanDirectives(source) {
|
|
|
38
43
|
const node = { name: open[1], argument: open[2].trim(), start: line.start, bodyStart: line.end, children: [] }
|
|
39
44
|
if (stack.length) stack.at(-1).children.push(node)
|
|
40
45
|
else nodes.push(node)
|
|
41
|
-
|
|
46
|
+
// :::include is one line: it has no body and no closing fence.
|
|
47
|
+
if (isIncludeLine(line.text)) Object.assign(node, { bodyEnd: line.end, end: line.end })
|
|
48
|
+
else stack.push(node)
|
|
42
49
|
} else if (/^:::[ \t]*$/.test(line.text)) {
|
|
43
50
|
const node = stack.pop()
|
|
44
51
|
if (!node) diagnostics.push(diagnostic(source, 'unexpected-close', 'Directive closing fence has no opening fence', line.start))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Timing a talk: how long each slide may take (`time: 2m` in its settings,
|
|
2
|
+
// `time: 45m` for the whole deck), and where the talk stands against that
|
|
3
|
+
// plan while it is given. A rehearsal (runtime/RehearsalSummary.jsx) writes
|
|
4
|
+
// the times it measured back with formatTime.
|
|
5
|
+
//
|
|
6
|
+
// Times are written 90s, 2m, 1m30s or 1.5m; a bare number is minutes.
|
|
7
|
+
|
|
8
|
+
export const TIME_STEP = 15
|
|
9
|
+
|
|
10
|
+
const TIME_RE = /^(?:(\d+(?:\.\d+)?)\s*m(?:in)?)?\s*(?:(\d+(?:\.\d+)?)\s*s(?:ec)?)?$/i
|
|
11
|
+
|
|
12
|
+
// Seconds, or null for something that is not a time.
|
|
13
|
+
export function parseTime(value) {
|
|
14
|
+
if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : null
|
|
15
|
+
if (typeof value !== 'string') return null
|
|
16
|
+
const text = value.trim()
|
|
17
|
+
if (/^\d+(\.\d+)?$/.test(text)) return parseTime(Number(text))
|
|
18
|
+
const match = text.match(TIME_RE)
|
|
19
|
+
if (!text || !match || (match[1] == null && match[2] == null)) return null
|
|
20
|
+
const seconds = Math.round(Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0))
|
|
21
|
+
return seconds > 0 ? seconds : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The way mdeck writes a time: 45s, 2m, 1m30s.
|
|
25
|
+
export function formatTime(seconds) {
|
|
26
|
+
const s = Math.max(0, Math.round(seconds))
|
|
27
|
+
const m = Math.floor(s / 60), rest = s % 60
|
|
28
|
+
if (!m) return `${rest}s`
|
|
29
|
+
return rest ? `${m}m${rest}s` : `${m}m`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A clock reading: 4:05, or 1:02:03 past an hour; negative values get a minus.
|
|
33
|
+
export function formatClock(seconds) {
|
|
34
|
+
const sign = seconds < 0 ? '-' : ''
|
|
35
|
+
const s = Math.abs(Math.round(seconds))
|
|
36
|
+
const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, rest = s % 60
|
|
37
|
+
const pad = n => String(n).padStart(2, '0')
|
|
38
|
+
return h ? `${sign}${h}:${pad(m)}:${pad(rest)}` : `${sign}${m}:${pad(rest)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A measured time to the nearest step, never less than one step.
|
|
42
|
+
export function roundTime(seconds, step = TIME_STEP) {
|
|
43
|
+
return Math.max(step, Math.round(seconds / step) * step)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Each slide's share of the talk. Slides without a time share what the
|
|
47
|
+
// deck's `time` leaves; without one they count as nothing. `times` replaces
|
|
48
|
+
// slides' own times by id (what a rehearsal just saved).
|
|
49
|
+
export function planFor(slides, deckConfig = {}, times = {}) {
|
|
50
|
+
const own = slides.map(slide => parseTime(Object.hasOwn(times, slide.id) ? times[slide.id] : slide.meta?.time))
|
|
51
|
+
const budget = parseTime(deckConfig?.time)
|
|
52
|
+
const set = own.reduce((sum, t) => sum + (t ?? 0), 0)
|
|
53
|
+
const untimed = own.filter(t => t == null).length
|
|
54
|
+
const share = budget && untimed ? Math.max(0, budget - set) / untimed : 0
|
|
55
|
+
const perSlide = own.map(t => t ?? share)
|
|
56
|
+
const start = []
|
|
57
|
+
let total = 0
|
|
58
|
+
for (const t of perSlide) { start.push(total); total += t }
|
|
59
|
+
return { perSlide, start, total, own, budget }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Where the talk stands on slide `index`: the time left on it (negative when
|
|
63
|
+
// over), and how far behind the plan the talk is (negative: ahead).
|
|
64
|
+
export function scheduleAt(plan, index, { totalElapsed, slideElapsed }) {
|
|
65
|
+
const planned = plan.perSlide[index] ?? 0
|
|
66
|
+
const slideLeft = planned - slideElapsed
|
|
67
|
+
const due = (plan.start[index] ?? plan.total) + Math.min(slideElapsed, planned)
|
|
68
|
+
return { planned, slideLeft, delta: totalElapsed - due }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A rehearsal's new time for each slide, in seconds or null for none: the
|
|
72
|
+
// time it got, rounded, or for a slide it did not reach, the one it has.
|
|
73
|
+
export function initialTimes(slides, spent, own = slides.map(slide => parseTime(slide.meta?.time))) {
|
|
74
|
+
return slides.map((slide, i) => spent[slide.id] >= 1 ? roundTime(spent[slide.id]) : own[i])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// What saving a rehearsal changes: { slideId: '1m30s' | null } for the slides
|
|
78
|
+
// whose new time is not the one they have.
|
|
79
|
+
export function changedTimes(slides, own, next) {
|
|
80
|
+
const times = {}
|
|
81
|
+
slides.forEach((slide, i) => { if (next[i] !== own[i]) times[slide.id] = next[i] == null ? null : formatTime(next[i]) })
|
|
82
|
+
return times
|
|
83
|
+
}
|
package/src/core/validateDeck.js
CHANGED
|
@@ -5,9 +5,13 @@ import { LABEL_KEYS, CALLOUT_TYPES } from './labels.js'
|
|
|
5
5
|
import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
|
|
6
6
|
import { movedHint } from '../extensions/moved.js'
|
|
7
7
|
import { paramValue } from '../extensions/params.js'
|
|
8
|
+
import { parseTime } from './timing.js'
|
|
9
|
+
import { reactionsProblem } from './reactions.js'
|
|
8
10
|
|
|
9
11
|
// Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
|
|
10
12
|
const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
|
|
13
|
+
// Timing (core/timing.js): how long a slide, or the whole talk, may take.
|
|
14
|
+
const TIME_HINT = value => `time: ${JSON.stringify(value)} is not a time; write it as 90s, 2m, 1m30s or 1.5m`
|
|
11
15
|
|
|
12
16
|
// `layouts`, `themes` and `palettes` are manifest maps from the extension
|
|
13
17
|
// registry (see src/extensions/discover.js). Checks for a kind are skipped when
|
|
@@ -52,6 +56,8 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
52
56
|
if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
|
|
53
57
|
}
|
|
54
58
|
if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
|
|
59
|
+
if (config.time != null && parseTime(config.time) == null) add('invalid-config', TIME_HINT(config.time), deck.configSource?.start)
|
|
60
|
+
if (reactionsProblem(config.reactions)) add('invalid-config', reactionsProblem(config.reactions), deck.configSource?.start)
|
|
55
61
|
const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
|
|
56
62
|
const switches = ['citations']
|
|
57
63
|
if (isPlainObject(config.show)) {
|
|
@@ -96,7 +102,9 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
96
102
|
if (slide.meta.props != null && !isPlainObject(slide.meta.props)) add('invalid-props', 'props must be a mapping', offset)
|
|
97
103
|
if (slide.meta.overlay != null && typeof slide.meta.overlay !== 'boolean') add('invalid-metadata', 'overlay must be true or false', offset)
|
|
98
104
|
if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
|
|
105
|
+
if (slide.meta.time != null && parseTime(slide.meta.time) == null) add('invalid-metadata', TIME_HINT(slide.meta.time), offset)
|
|
99
106
|
for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
|
|
107
|
+
if (node.name === 'include' && !slide.include) add('misplaced-include', ':::include stands alone on its slide, with no settings or other content: :::include ./part.md, or :::include ./part.md slides=2-5', slide.source.start + node.start, 'warning')
|
|
100
108
|
if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
|
|
101
109
|
const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
|
|
102
110
|
add('unknown-callout', `Unknown block "${node.name}"${near ? `; did you mean "${near}"?` : ''} It shows as a plain callout. Known: ${CALLOUT_TYPES.join(', ')}; name your own under callouts: in the settings`, slide.source.start + node.start, 'warning')
|
|
@@ -129,7 +137,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
// The `:::` blocks mdeck reads itself; any other name is a callout type.
|
|
132
|
-
const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns']
|
|
140
|
+
const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns', 'include']
|
|
133
141
|
|
|
134
142
|
function directivesIn(source) {
|
|
135
143
|
const all = []
|
|
@@ -154,6 +162,7 @@ function closest(name, names) {
|
|
|
154
162
|
return best && best[1] <= 2 ? best[0] : null
|
|
155
163
|
}
|
|
156
164
|
|
|
165
|
+
// A diagnostic with a `file` of its own (one in an included deck) names it.
|
|
157
166
|
export function formatDiagnostics(diagnostics, filename = 'slides.md') {
|
|
158
|
-
return diagnostics.map(d => `${filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
|
|
167
|
+
return diagnostics.map(d => `${d.file ?? filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
|
|
159
168
|
}
|
|
@@ -86,6 +86,7 @@ export function DeckSettings({ state, edit }) {
|
|
|
86
86
|
</>}
|
|
87
87
|
<p class="section-title">About the talk</p>
|
|
88
88
|
{META.map(([key, label]) => <Field key={key} label={label}><input type="text" value={config.meta?.[key] ?? ''} onInput={e => meta(key, e.currentTarget.value)} /></Field>)}
|
|
89
|
+
<Field label="Length" hint="how long the talk takes, e.g. 20m; slides without a time of their own share what is left"><input type="text" value={config.time ?? ''} placeholder="20m" onInput={e => text('time', e.currentTarget.value.trim())} /></Field>
|
|
89
90
|
<p class="section-title">Slide frame</p>
|
|
90
91
|
<div class="grid-2">
|
|
91
92
|
<Field label="Width"><input type="number" value={config.width ?? 1920} onInput={e => patch({ width: Number(e.currentTarget.value) || undefined }, 'deck:width')} /></Field>
|
|
@@ -20,6 +20,7 @@ export function MetaFields({ slide, slideEdit }) {
|
|
|
20
20
|
slideEdit((deck, id) => setSlideMeta(deck, id, { hotspots: value === '' ? undefined : value === 'off' ? false : value }), { group: 'meta:hotspots' })
|
|
21
21
|
}}><option value="">the deck's</option>{['auto', 'marked', 'off'].map(o => <option key={o} value={o}>{o}</option>)}</select>
|
|
22
22
|
</Field>
|
|
23
|
+
<Field label="Time" hint="how long to spend on this slide: 90s, 2m or 1m30s; mdeck rehearse measures it">{text('time')}</Field>
|
|
23
24
|
<Field label="Speaker notes"><TextArea rows={4} value={slide.meta.notes ?? ''} onInput={value => slideEdit((deck, id) => setSlideNotes(deck, id, value), { group: 'notes' })} /></Field>
|
|
24
25
|
<details class="more" open={MORE.some(([key]) => meta[key] != null)}>
|
|
25
26
|
<summary>More metadata</summary>
|