Skip to content

Commit 6cd849d

Browse files
Add markdown and composed deck support (#10)
Allow presentations to resolve from standalone Markdown files or ordered directories of Blade and Markdown parts while keeping the existing Slide DTO runtime contract intact. Also add scaffolding for Markdown and multi-file decks so authors can start with the format that matches the size of their presentation.
1 parent cbb4582 commit 6cd849d

37 files changed

Lines changed: 983 additions & 55 deletions

src/Commands/MakeSlideCommand.php

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,54 @@ class MakeSlideCommand extends Command
1414
{name? : The presentation path, e.g. team/q1-kickoff}
1515
{--presentation= : The presentation path override}
1616
{--title= : The first slide title}
17+
{--md : Create a standalone Markdown deck}
18+
{--multi : Create a multi-file deck}
1719
{--force : Overwrite existing files}';
1820

1921
protected $description = 'Create a SlideWire presentation scaffold';
2022

2123
public function handle(PresentationPathResolver $resolver): int
2224
{
25+
if ((bool) $this->option('md') && (bool) $this->option('multi')) {
26+
$this->error('The --md and --multi options cannot be combined.');
27+
28+
return self::FAILURE;
29+
}
30+
2331
$presentation = $this->resolvePresentationName();
2432
$title = $this->option('title') ?: $this->ask('Presentation title', 'SlideWire Presentation');
2533

26-
$presentationPath = $resolver->absolutePresentationPath($presentation);
34+
$existingPath = $resolver->presentationPath($presentation);
2735

28-
if (File::exists($presentationPath) && ! (bool) $this->option('force')) {
29-
$this->error("Slide scaffold already exists at [{$presentationPath}]. Use --force to overwrite.");
36+
if ($existingPath !== null && ! (bool) $this->option('force')) {
37+
$this->error("Slide scaffold already exists at [{$existingPath}]. Use --force to overwrite.");
3038

3139
return self::FAILURE;
3240
}
3341

42+
if (is_string($existingPath) && (bool) $this->option('force')) {
43+
$this->deletePath($existingPath);
44+
}
45+
46+
if ((bool) $this->option('multi')) {
47+
$presentationDirectory = $resolver->firstRoot() . DIRECTORY_SEPARATOR . $presentation;
48+
49+
$this->createMultiFilePresentation($presentationDirectory, $presentation, $title);
50+
51+
$this->info("Created SlideWire presentation [{$presentation}] at [{$presentationDirectory}].");
52+
53+
return self::SUCCESS;
54+
}
55+
56+
$presentationPath = $this->presentationPath($resolver, $presentation, (bool) $this->option('md'));
57+
3458
File::ensureDirectoryExists(dirname($presentationPath));
3559

36-
$stub = File::get(__DIR__ . '/../../stubs/presentation.stub');
37-
$contents = str_replace(['{{ title }}', '{{ presentation }}'], [$title, $presentation], $stub);
60+
$stub = (bool) $this->option('md')
61+
? File::get(__DIR__ . '/../../stubs/presentation-markdown.stub')
62+
: File::get(__DIR__ . '/../../stubs/presentation.stub');
63+
64+
$contents = $this->populateStub($stub, $presentation, $title);
3865

3966
File::put($presentationPath, $contents);
4067

@@ -53,4 +80,56 @@ protected function resolvePresentationName(): string
5380

5481
return trim((string) $this->ask('Presentation path', 'index'), '/');
5582
}
83+
84+
protected function presentationPath(PresentationPathResolver $resolver, string $presentation, bool $markdown): string
85+
{
86+
$root = $resolver->firstRoot();
87+
$suffix = $markdown ? '.md' : '.blade.php';
88+
89+
return $root . DIRECTORY_SEPARATOR . $presentation . $suffix;
90+
}
91+
92+
protected function createMultiFilePresentation(string $presentationDirectory, string $presentation, string $title): void
93+
{
94+
File::ensureDirectoryExists($presentationDirectory);
95+
96+
File::put(
97+
$presentationDirectory . DIRECTORY_SEPARATOR . 'deck.blade.php',
98+
File::get(__DIR__ . '/../../stubs/presentation-multi-deck.stub'),
99+
);
100+
101+
File::put(
102+
$presentationDirectory . DIRECTORY_SEPARATOR . '01-intro.blade.php',
103+
$this->populateStub(
104+
File::get(__DIR__ . '/../../stubs/presentation-multi-slide.stub'),
105+
$presentation,
106+
$title,
107+
),
108+
);
109+
110+
File::put(
111+
$presentationDirectory . DIRECTORY_SEPARATOR . '02-markdown.md',
112+
$this->populateStub(
113+
File::get(__DIR__ . '/../../stubs/presentation-multi-markdown.stub'),
114+
$presentation,
115+
$title,
116+
),
117+
);
118+
}
119+
120+
protected function populateStub(string $stub, string $presentation, string $title): string
121+
{
122+
return str_replace(['{{ title }}', '{{ presentation }}'], [$title, $presentation], $stub);
123+
}
124+
125+
protected function deletePath(string $path): void
126+
{
127+
if (File::isDirectory($path)) {
128+
File::deleteDirectory($path);
129+
130+
return;
131+
}
132+
133+
File::delete($path);
134+
}
56135
}

src/SlideWireServiceProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
use WendellAdriel\SlideWire\Support\CodeBlockPrecompiler;
1313
use WendellAdriel\SlideWire\Support\CodeHighlighter;
1414
use WendellAdriel\SlideWire\Support\EffectiveSettingsResolver;
15+
use WendellAdriel\SlideWire\Support\MarkdownPresentationParser;
16+
use WendellAdriel\SlideWire\Support\MarkdownRenderer;
1517
use WendellAdriel\SlideWire\Support\PresentationCompiler;
1618
use WendellAdriel\SlideWire\Support\PresentationPathResolver;
1719
use WendellAdriel\SlideWire\Support\SlideContext;
20+
use WendellAdriel\SlideWire\Support\SlideIdGenerator;
1821
use WendellAdriel\SlideWire\Support\ThemeResolver;
1922
use WendellAdriel\SlideWire\Support\UiThemeResolver;
2023

@@ -26,6 +29,9 @@ public function register(): void
2629

2730
$this->app->singleton(PresentationPathResolver::class);
2831
$this->app->singleton(CodeHighlighter::class);
32+
$this->app->singleton(MarkdownRenderer::class);
33+
$this->app->singleton(MarkdownPresentationParser::class);
34+
$this->app->singleton(SlideIdGenerator::class);
2935
$this->app->singleton(PresentationCompiler::class);
3036
$this->app->singleton(EffectiveSettingsResolver::class);
3137
$this->app->singleton(ThemeResolver::class);
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WendellAdriel\SlideWire\Support;
6+
7+
use RuntimeException;
8+
use WendellAdriel\SlideWire\DTOs\Slide;
9+
10+
class MarkdownPresentationParser
11+
{
12+
public function __construct(
13+
protected MarkdownRenderer $renderer,
14+
protected SlideIdGenerator $slideIdGenerator,
15+
) {}
16+
17+
/**
18+
* @return array{deck_meta: array<string, string>, slides: array<int, array<int, Slide>>}
19+
*/
20+
public function parse(string $path, string $source): array
21+
{
22+
$source = str_replace(["\r\n", "\r"], "\n", $source);
23+
24+
[$deckMeta, $body] = $this->extractFrontmatter($source, $path, 'deck');
25+
$segments = $this->splitSlides($body);
26+
$slides = [];
27+
28+
foreach ($segments as $hIndex => $segment) {
29+
$slide = $this->parseSlide($path, $segment, $deckMeta, $hIndex);
30+
31+
if (! $slide instanceof Slide) {
32+
continue;
33+
}
34+
35+
$slides[] = [$slide];
36+
}
37+
38+
return ['deck_meta' => $deckMeta, 'slides' => $slides];
39+
}
40+
41+
protected function parseSlide(string $path, string $segment, array $deckMeta, int $hIndex): ?Slide
42+
{
43+
[$slideMeta, $body] = $this->extractFrontmatter($segment, $path, 'slide');
44+
$body = trim($body);
45+
46+
if ($body === '' && $slideMeta === []) {
47+
return null;
48+
}
49+
50+
$presentationTheme = $slideMeta['theme'] ?? $deckMeta['theme'] ?? null;
51+
$highlightTheme = $slideMeta['highlight_theme'] ?? $deckMeta['highlight_theme'] ?? null;
52+
$html = trim($this->renderer->toHtml($body, $presentationTheme, $highlightTheme));
53+
54+
return new Slide(
55+
id: $this->slideIdGenerator->fromPath($path, $hIndex, 0),
56+
html: $html,
57+
meta: $slideMeta,
58+
fragments: $this->fragmentCount($html),
59+
h: $hIndex,
60+
v: 0,
61+
);
62+
}
63+
64+
/**
65+
* @return array{0: array<string, string>, 1: string}
66+
*/
67+
protected function extractFrontmatter(string $source, string $path, string $context): array
68+
{
69+
$trimmedSource = ltrim($source, "\n");
70+
71+
if (! str_starts_with($trimmedSource, "---\n") && trim((string) strtok($trimmedSource, "\n")) !== '---') {
72+
return [[], $source];
73+
}
74+
75+
$lines = explode("\n", $trimmedSource);
76+
77+
if (($lines[0] ?? null) !== '---') {
78+
throw new RuntimeException("Malformed {$context} frontmatter in markdown presentation [{$path}].");
79+
}
80+
81+
$frontmatterLines = [];
82+
$closingIndex = null;
83+
84+
foreach ($lines as $index => $line) {
85+
if ($index === 0) {
86+
continue;
87+
}
88+
89+
if ($line === '---') {
90+
$closingIndex = $index;
91+
92+
break;
93+
}
94+
95+
$frontmatterLines[] = $line;
96+
}
97+
98+
if ($closingIndex === null) {
99+
throw new RuntimeException("Malformed {$context} frontmatter in markdown presentation [{$path}].");
100+
}
101+
102+
$body = implode("\n", array_slice($lines, $closingIndex + 1));
103+
104+
return [$this->parseFrontmatter($frontmatterLines, $path, $context), ltrim($body, "\n")];
105+
}
106+
107+
/**
108+
* @param array<int, string> $lines
109+
* @return array<string, string>
110+
*/
111+
protected function parseFrontmatter(array $lines, string $path, string $context): array
112+
{
113+
$meta = [];
114+
115+
foreach ($lines as $line) {
116+
if (trim($line) === '') {
117+
continue;
118+
}
119+
120+
if (preg_match('/^\s+/', $line) === 1 || preg_match('/^\s*-\s*/', $line) === 1) {
121+
throw new RuntimeException("Unsupported non-scalar {$context} frontmatter value in markdown presentation [{$path}].");
122+
}
123+
124+
if (preg_match('/^([A-Za-z0-9_-]+):\s*(.*)$/', $line, $matches) !== 1) {
125+
throw new RuntimeException("Malformed {$context} frontmatter in markdown presentation [{$path}].");
126+
}
127+
128+
$value = trim($matches[2]);
129+
130+
if ($value !== '' && preg_match('/^[\[{]/', $value) === 1) {
131+
throw new RuntimeException("Unsupported non-scalar {$context} frontmatter value in markdown presentation [{$path}].");
132+
}
133+
134+
if (
135+
strlen($value) >= 2
136+
&& (($value[0] === '"' && $value[strlen($value) - 1] === '"') || ($value[0] === '\'' && $value[strlen($value) - 1] === '\''))
137+
) {
138+
$value = substr($value, 1, -1);
139+
}
140+
141+
$meta[$this->normalizeMetaKey($matches[1])] = $value;
142+
}
143+
144+
return $meta;
145+
}
146+
147+
/**
148+
* @return array<int, string>
149+
*/
150+
protected function splitSlides(string $markdown): array
151+
{
152+
if (trim($markdown) === '') {
153+
return [];
154+
}
155+
156+
$segments = [];
157+
$buffer = [];
158+
$inFence = false;
159+
160+
foreach (explode("\n", $markdown) as $line) {
161+
if ($this->isFenceDelimiter($line)) {
162+
$inFence = ! $inFence;
163+
}
164+
165+
if (! $inFence && trim($line) === '<!-- slide -->') {
166+
$segments[] = implode("\n", $buffer);
167+
$buffer = [];
168+
169+
continue;
170+
}
171+
172+
$buffer[] = $line;
173+
}
174+
175+
$segments[] = implode("\n", $buffer);
176+
177+
return $segments;
178+
}
179+
180+
protected function isFenceDelimiter(string $line): bool
181+
{
182+
return preg_match('/^\s*```/', $line) === 1;
183+
}
184+
185+
protected function normalizeMetaKey(string $key): string
186+
{
187+
return str_replace('-', '_', strtolower(trim($key)));
188+
}
189+
190+
protected function fragmentCount(string $html): int
191+
{
192+
preg_match_all('/data-fragment(?:-index)?="?(\d+)?"?/', $html, $matches);
193+
194+
if ($matches[0] === []) {
195+
return 0;
196+
}
197+
198+
$indices = array_filter($matches[1], static fn (string $value): bool => $value !== '');
199+
200+
if ($indices === []) {
201+
return count($matches[0]);
202+
}
203+
204+
return max(array_map(intval(...), $indices)) + 1;
205+
}
206+
}

src/Support/MarkdownRenderer.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WendellAdriel\SlideWire\Support;
6+
7+
use Illuminate\Support\Str;
8+
use Phiki\Theme\Theme;
9+
10+
class MarkdownRenderer
11+
{
12+
public function __construct(protected CodeHighlighter $highlighter) {}
13+
14+
public function toHtml(string $markdown, ?string $presentationTheme = null, Theme|string|null $highlightTheme = null, ?string $size = null): string
15+
{
16+
$markdown = CodeBlockPrecompiler::decode($markdown);
17+
18+
$withHighlightedCode = $this->highlighter->replaceCodeBlocks(
19+
$markdown,
20+
$highlightTheme,
21+
$presentationTheme,
22+
null,
23+
$size,
24+
);
25+
26+
return Str::markdown($withHighlightedCode);
27+
}
28+
}

0 commit comments

Comments
 (0)