feature/markdown-syntax-check-linux-presenter #8
14 changed files with 1671 additions and 157 deletions
|
|
@ -19,7 +19,7 @@ Built with Flutter for macOS, Windows, and Linux.
|
||||||
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
||||||
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file).
|
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata. The library can filter images without tags and clean up md5-identical duplicates (merging their tags/captions and repointing every deck — open or on disk — to the kept file).
|
||||||
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
|
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
|
||||||
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
|
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. Paste a spreadsheet selection (or CSV / a markdown table) into a table cell to fill the whole grid. **Markdown mode** edits the whole deck as raw Marp text, with a structural syntax check (line highlights, optional **Apply anyway**) before switching back. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
|
||||||
- **Accessibility** — WCAG 2.1-oriented: interface text scaling up to 200%, keyboard-operable panel divider and dialogs, screen-reader labels for slides and charts (charts read out their data), and slide-change announcements while presenting.
|
- **Accessibility** — WCAG 2.1-oriented: interface text scaling up to 200%, keyboard-operable panel divider and dialogs, screen-reader labels for slides and charts (charts read out their data), and slide-change announcements while presenting.
|
||||||
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
|
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
|
||||||
- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
|
- **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
||||||
services/ # markdown, file, export, classification_policy, image, caption,
|
services/ # markdown, markdown_validator, file, export, classification_policy,
|
||||||
|
# image, caption,
|
||||||
# description, image_dedup (md5 duplicates),
|
# description, image_dedup (md5 duplicates),
|
||||||
# image_reference (.md rewrites), recovery, rasterizer,
|
# image_reference (.md rewrites), recovery, rasterizer,
|
||||||
# marp_html, annotation_codec, rehearsal_controller
|
# marp_html, annotation_codec, rehearsal_controller
|
||||||
|
|
@ -48,6 +49,12 @@ lib/
|
||||||
by their `_class` and parsed separately (their fenced block would otherwise
|
by their `_class` and parsed separately (their fenced block would otherwise
|
||||||
confuse the generic line parser).
|
confuse the generic line parser).
|
||||||
|
|
||||||
|
`MarkdownValidator` (`lib/services/markdown_validator.dart`) performs a
|
||||||
|
structural pre-flight before applying raw markdown in the editor: it reports line-
|
||||||
|
anchored errors/warnings for the same shapes the parser expects (front matter,
|
||||||
|
slide separators, `_class`, fences, OciDeck HTML fragments, chart JSON, etc.).
|
||||||
|
See `docs/FILE_FORMAT.md` §10 and `docs/USER_GUIDE.md` (Markdown mode).
|
||||||
|
|
||||||
This service is heavily covered by the round-trip tests — treat it as the
|
This service is heavily covered by the round-trip tests — treat it as the
|
||||||
source-of-truth for the file format and keep `FILE_FORMAT.md` in sync.
|
source-of-truth for the file format and keep `FILE_FORMAT.md` in sync.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -497,3 +497,58 @@ genegeerd, op presenter-notities na):
|
||||||
- **Voorwaartse migratie:** ontbrekende front-matter-velden en
|
- **Voorwaartse migratie:** ontbrekende front-matter-velden en
|
||||||
stijlprofiel-velden vallen terug op standaardwaarden, en het ontbreken van het
|
stijlprofiel-velden vallen terug op standaardwaarden, en het ontbreken van het
|
||||||
`no-footer`-token betekent (voor oudere bestanden) "footer zichtbaar".
|
`no-footer`-token betekent (voor oudere bestanden) "footer zichtbaar".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Markdown-modus en syntaxcontrole
|
||||||
|
|
||||||
|
In de editor schakelt het code-icoon in de werkbalk naar **Markdown-modus**: de
|
||||||
|
hele presentatie wordt als één Marp-markdownbestand getoond (dezelfde structuur
|
||||||
|
als op schijf). **Toepassen** parsed de tekst terug naar de getype slides;
|
||||||
|
**Annuleren** keert terug zonder wijzigingen door te voeren.
|
||||||
|
|
||||||
|
### Wanneer controleren?
|
||||||
|
|
||||||
|
- **Controleren** — op elk moment tijdens het bewerken; resultaten in een
|
||||||
|
samenvattingsbalk, met uitklapbare lijst. Regelnummers links worden rood
|
||||||
|
(fout) of geel (waarschuwing) gemarkeerd; klik op een melding om naar die regel
|
||||||
|
te springen.
|
||||||
|
- **Toepassen** — voert altijd eerst de controle uit. Bij bevindingen verschijnt
|
||||||
|
een dialoog met **Terug naar editor**, **Annuleren** of **Toch toepassen**.
|
||||||
|
|
||||||
|
De controle is **structureel**: hij volgt dezelfde regels als `MarkdownService`
|
||||||
|
(front matter, `\n---\n` als scheiding, `_class`-commentaar, fenced blocks en de
|
||||||
|
HTML-fragmenten die OciDeck zelf genereert). Geldige Marp-syntax die OciDeck
|
||||||
|
niet modelleert wordt niet gerapporteerd.
|
||||||
|
|
||||||
|
### Uitgevoerde controles
|
||||||
|
|
||||||
|
| Onderdeel | Ernst | Controle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Document** | waarschuwing | Presentatie is leeg. |
|
||||||
|
| **Document** | fout | Geen slide-inhoud na front matter. |
|
||||||
|
| **Document** | fout | `parseDeck` faalt volledig (`null`). |
|
||||||
|
| **Front matter** | fout | Openings-`---` zonder afsluitende `---`-regel. |
|
||||||
|
| **Front matter** | waarschuwing | Regel zonder `sleutel: waarde`-vorm. |
|
||||||
|
| **Front matter** | fout | Onbekende `tlp:`-waarde. |
|
||||||
|
| **Commentaar** | fout | `<!--` zonder `-->` op dezelfde regel. |
|
||||||
|
| **Commentaar** | waarschuwing | Commentaar zonder `_class:`, `_style:`, `ocideck_…`, `skip`, `tlp:` of `advance:`. |
|
||||||
|
| **Codeblokken** | fout | Oneven aantal ` ``` `-regels (niet afgesloten). |
|
||||||
|
| **`_class`** | fout | Malformed `<!-- _class: … -->`. |
|
||||||
|
| **`_class`** | waarschuwing | Onbekend token in `_class` (bekend: `title`, `section`, `two-bullets`, `split`, `quote`, `video`, `table`, `code`, `chart`, `logo-safe`, `no-logo`, `no-footer`). |
|
||||||
|
| **Slide-metadata** | fout | Onbekende `<!-- tlp: … -->`, niet-numerieke `<!-- advance: … -->`, of ongeldige `<!-- ocideck_list_style: … -->` (`bullets`, `numbered`, `checklist`). |
|
||||||
|
| **Twee kolommen** | fout | Ongeldige base64/JSON in `ocideck_two_bullets_*`-commentaren. |
|
||||||
|
| **Afbeeldingen** | fout | ``. |
|
||||||
|
| **Video/audio** | fout | Onvolledige `<video>`/`<audio>`-tag, of `<video>` zonder `src="…"`. |
|
||||||
|
| **`code`-slide** | fout | Geen afgesloten fenced ```-blok. |
|
||||||
|
| **`chart`-slide** | fout | Geen ` ```chart `-blok, niet afgesloten, of ongeldige JSON (geen `{…}`-object). |
|
||||||
|
| **`chart`-slide** | waarschuwing | Lege JSON in een afgesloten ` ```chart `-blok. |
|
||||||
|
| **`split`-slide** | fout | Ontbrekende of niet-afgesloten `<div class="split-text">` / `split-image`. |
|
||||||
|
| **`two-bullets`-slide** | fout | Ontbrekende of niet-afgesloten `<div class="ocideck-two-bullets">`. |
|
||||||
|
| **`table`-slide** | waarschuwing | Geen tabelregels. |
|
||||||
|
| **`table`-slide** | fout | Geen scheidingsrij (`\| --- \|`) of tweede rij is geen geldige GFM-scheiding. |
|
||||||
|
| **HTML** | fout | Ongebalanceerde `<div>`/ `</div>` binnen een slide. |
|
||||||
|
|
||||||
|
Implementatie: `lib/services/markdown_validator.dart`; tests:
|
||||||
|
`test/markdown_validator_test.dart`. Zie ook [`USER_GUIDE.md`](USER_GUIDE.md) (§
|
||||||
|
Markdown mode).
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,70 @@ OciDeck aims for WCAG 2.1 in the editor:
|
||||||
title", including the skipped state), charts read out their data as a text
|
title", including the skipped state), charts read out their data as a text
|
||||||
alternative, and the fullscreen presenter announces every slide change.
|
alternative, and the fullscreen presenter announces every slide change.
|
||||||
|
|
||||||
|
## Markdown mode
|
||||||
|
|
||||||
|
The toolbar code icon switches the editor to **Markdown mode**: the whole deck is
|
||||||
|
shown as one Marp Markdown document (the same structure OciDeck writes to disk).
|
||||||
|
Use this for bulk edits, copy-paste from another tool, or tweaks that are faster
|
||||||
|
in raw text. Switch back with **Apply** (to parse the text back into typed slides)
|
||||||
|
or **Cancel** (discard your edits and return to the visual editor).
|
||||||
|
|
||||||
|
### Syntax check
|
||||||
|
|
||||||
|
Markdown mode includes a **syntax check** that validates your text against what
|
||||||
|
OciDeck's parser (`MarkdownService`) can read reliably. Broken structure often
|
||||||
|
does not fail loudly — the deck may load with the wrong slide types or missing
|
||||||
|
content — so the check catches problems before you apply.
|
||||||
|
|
||||||
|
- **Check** — run validation at any time while editing. Results appear in a
|
||||||
|
summary bar; expand it for a list of issues. Line numbers in the gutter are
|
||||||
|
highlighted (red = error, amber = warning). Click an issue or a line number to
|
||||||
|
jump to that line.
|
||||||
|
- **Apply** — always runs the check first. If anything is found, a dialog lists
|
||||||
|
the problems and offers **Back to editor**, **Cancel**, or **Apply anyway**.
|
||||||
|
Choosing **Apply anyway** proceeds despite the warnings (you may still see the
|
||||||
|
existing "Markdown could not be processed" banner if parsing returns `null`).
|
||||||
|
|
||||||
|
The check is **structural**, not a full Marp linter: it mirrors OciDeck's own
|
||||||
|
splitting rules (front matter, `\n---\n` slide separators, `_class` comments,
|
||||||
|
fenced blocks, and the HTML fragments OciDeck generates). Valid Marp that OciDeck
|
||||||
|
does not model (e.g. arbitrary directives) is not reported.
|
||||||
|
|
||||||
|
#### Checks performed
|
||||||
|
|
||||||
|
Issues are reported with a **line number**, a **severity**, and a short message.
|
||||||
|
|
||||||
|
| Area | Severity | What is checked |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Document** | warning | The file is empty. |
|
||||||
|
| **Document** | error | No slide content after the front matter. |
|
||||||
|
| **Document** | error | `MarkdownService.parseDeck` returns `null` (unrecoverable parse failure). |
|
||||||
|
| **Front matter** | error | Opening `---` without a closing `---` line. |
|
||||||
|
| **Front matter** | warning | A line inside the block is not `key: value`. |
|
||||||
|
| **Front matter** | error | `tlp:` value is not a known key (`clear`, `green`, `amber`, `amber+strict`, `red`, or empty/`none`). |
|
||||||
|
| **Comments** | error | `<!--` without a matching `-->` on the same line. |
|
||||||
|
| **Comments** | warning | A comment looks like metadata but lacks `_class:`, `_style:`, `ocideck_…`, `skip`, `tlp:`, or `advance:`. |
|
||||||
|
| **Fenced code** | error | An odd number of ` ``` ` lines in the file (unclosed fence). |
|
||||||
|
| **Slide class** | error | A malformed `<!-- _class: … -->` (present but not parseable). |
|
||||||
|
| **Slide class** | warning | An unknown token in `_class` (only `title`, `section`, `two-bullets`, `split`, `quote`, `video`, `table`, `code`, `chart`, `logo-safe`, `no-logo`, `no-footer` are recognised; other tokens are kept as custom CSS classes but may change auto-detection). |
|
||||||
|
| **Per-slide metadata** | error | `<!-- tlp: … -->` with an unknown level. |
|
||||||
|
| **Per-slide metadata** | error | `<!-- advance: … -->` where the value is not a number. |
|
||||||
|
| **Per-slide metadata** | error | `<!-- ocideck_list_style: … -->` not `bullets`, `numbered`, or `checklist`. |
|
||||||
|
| **Two-column bullets** | error | `ocideck_two_bullets_left/right` or `*_title` comments with invalid base64/JSON. |
|
||||||
|
| **Images** | error | ``. |
|
||||||
|
| **Video / audio** | error | `<video>` / `<audio>` tag incomplete, or `<video>` without `src="…"`. |
|
||||||
|
| **`code` slides** | error | `_class: code` but fewer than two fence lines (no closed fenced block). |
|
||||||
|
| **`chart` slides** | error | Missing ` ```chart ` block, unclosed fence, or JSON that is not a valid `{…}` object. |
|
||||||
|
| **`chart` slides** | warning | Empty JSON inside a closed ` ```chart ` block. |
|
||||||
|
| **`split` slides** | error | Missing or unclosed `<div class="split-text">` or `<div class="split-image">`. |
|
||||||
|
| **`two-bullets` slides** | error | Missing or unclosed `<div class="ocideck-two-bullets">`. |
|
||||||
|
| **`table` slides** | warning | `_class: table` but no `\| … \|` rows. |
|
||||||
|
| **`table` slides** | error | Table present but no separator row (`\| --- \|`), or the second row is not a valid GFM separator. |
|
||||||
|
| **HTML layout** | error | Unbalanced `<div>` / `</div>` within a slide (extra closing tag, or an opening tag left open). |
|
||||||
|
|
||||||
|
Implementation: `lib/services/markdown_validator.dart` (unit tests in
|
||||||
|
`test/markdown_validator_test.dart`).
|
||||||
|
|
||||||
## Theming and language
|
## Theming and language
|
||||||
|
|
||||||
- **Style profiles** control deck colours (including the source-code background,
|
- **Style profiles** control deck colours (including the source-code background,
|
||||||
|
|
|
||||||
|
|
@ -1056,6 +1056,17 @@ const _dutchSourceStrings = {
|
||||||
'Toepassen': 'Apply',
|
'Toepassen': 'Apply',
|
||||||
'Markdown kon niet worden verwerkt. Controleer de syntax.':
|
'Markdown kon niet worden verwerkt. Controleer de syntax.':
|
||||||
'Markdown could not be processed. Check the syntax.',
|
'Markdown could not be processed. Check the syntax.',
|
||||||
|
'Controleren': 'Check syntax',
|
||||||
|
'Syntaxproblemen gevonden': 'Syntax issues found',
|
||||||
|
'De markdown bevat': 'The markdown contains',
|
||||||
|
'fout(en) en': 'error(s) and',
|
||||||
|
'waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.':
|
||||||
|
'warning(s). Slides may not be parsed correctly.',
|
||||||
|
'Terug naar editor': 'Back to editor',
|
||||||
|
'Toch toepassen': 'Apply anyway',
|
||||||
|
'Geen syntaxproblemen gevonden': 'No syntax issues found',
|
||||||
|
'fout(en),': 'error(s),',
|
||||||
|
'waarschuwing(en)': 'warning(s)',
|
||||||
'Afbeelding kiezen': 'Choose image',
|
'Afbeelding kiezen': 'Choose image',
|
||||||
'Afbeeldingen laden…': 'Loading images…',
|
'Afbeeldingen laden…': 'Loading images…',
|
||||||
'Sluiten (Esc)': 'Close (Esc)',
|
'Sluiten (Esc)': 'Close (Esc)',
|
||||||
|
|
|
||||||
36
lib/models/markdown_validation.dart
Normal file
36
lib/models/markdown_validation.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
enum MarkdownValidationSeverity { error, warning }
|
||||||
|
|
||||||
|
class MarkdownValidationIssue {
|
||||||
|
final int line;
|
||||||
|
final MarkdownValidationSeverity severity;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const MarkdownValidationIssue({
|
||||||
|
required this.line,
|
||||||
|
required this.severity,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'L$line: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkdownValidationResult {
|
||||||
|
final List<MarkdownValidationIssue> issues;
|
||||||
|
|
||||||
|
const MarkdownValidationResult(this.issues);
|
||||||
|
|
||||||
|
bool get isValid => !issues.any(
|
||||||
|
(issue) => issue.severity == MarkdownValidationSeverity.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get hasIssues => issues.isNotEmpty;
|
||||||
|
|
||||||
|
int get errorCount => issues
|
||||||
|
.where((i) => i.severity == MarkdownValidationSeverity.error)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
int get warningCount => issues
|
||||||
|
.where((i) => i.severity == MarkdownValidationSeverity.warning)
|
||||||
|
.length;
|
||||||
|
}
|
||||||
704
lib/services/markdown_validator.dart
Normal file
704
lib/services/markdown_validator.dart
Normal file
|
|
@ -0,0 +1,704 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../models/deck.dart';
|
||||||
|
import '../models/markdown_validation.dart';
|
||||||
|
import 'markdown_service.dart';
|
||||||
|
|
||||||
|
/// Validates deck markdown against what [MarkdownService] can parse reliably.
|
||||||
|
class MarkdownValidator {
|
||||||
|
static const _knownClassTokens = {
|
||||||
|
'title',
|
||||||
|
'section',
|
||||||
|
'two-bullets',
|
||||||
|
'split',
|
||||||
|
'quote',
|
||||||
|
'video',
|
||||||
|
'table',
|
||||||
|
'code',
|
||||||
|
'chart',
|
||||||
|
'logo-safe',
|
||||||
|
'no-logo',
|
||||||
|
'no-footer',
|
||||||
|
};
|
||||||
|
|
||||||
|
static const _validListStyles = {'bullets', 'numbered', 'checklist'};
|
||||||
|
|
||||||
|
MarkdownValidationResult validate(String markdown) {
|
||||||
|
final issues = <MarkdownValidationIssue>[];
|
||||||
|
if (markdown.trim().isEmpty) {
|
||||||
|
issues.add(
|
||||||
|
const MarkdownValidationIssue(
|
||||||
|
line: 1,
|
||||||
|
severity: MarkdownValidationSeverity.warning,
|
||||||
|
message: 'De presentatie is leeg.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return MarkdownValidationResult(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lines = markdown.split('\n');
|
||||||
|
_validateFrontMatter(lines, issues);
|
||||||
|
_validateHtmlComments(lines, issues);
|
||||||
|
_validateFenceBalance(lines, issues);
|
||||||
|
|
||||||
|
final body = _stripFrontMatter(markdown);
|
||||||
|
final bodyStartLine = markdown.length - body.length > 0
|
||||||
|
? markdown.substring(0, markdown.length - body.length).split('\n').length
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
final blocks = body.split(RegExp(r'\n---\n'));
|
||||||
|
if (blocks.every((block) => block.trim().isEmpty)) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: bodyStartLine,
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Geen slides gevonden.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockStartLine = bodyStartLine;
|
||||||
|
for (var i = 0; i < blocks.length; i++) {
|
||||||
|
final block = blocks[i].trim();
|
||||||
|
if (block.isEmpty) {
|
||||||
|
blockStartLine += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_validateSlideBlock(
|
||||||
|
block: block,
|
||||||
|
slideNumber: i + 1,
|
||||||
|
startLine: blockStartLine,
|
||||||
|
issues: issues,
|
||||||
|
);
|
||||||
|
blockStartLine += block.split('\n').length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MarkdownService().parseDeck(markdown) == null) {
|
||||||
|
issues.add(
|
||||||
|
const MarkdownValidationIssue(
|
||||||
|
line: 1,
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'De markdown kon niet worden ingelezen. Controleer de structuur.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.sort((a, b) => a.line.compareTo(b.line));
|
||||||
|
return MarkdownValidationResult(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateFrontMatter(
|
||||||
|
List<String> lines,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
if (lines.isEmpty || lines.first != '---') return;
|
||||||
|
|
||||||
|
final closingIndex = _indexOfFrontMatterClose(lines);
|
||||||
|
if (closingIndex == -1) {
|
||||||
|
issues.add(
|
||||||
|
const MarkdownValidationIssue(
|
||||||
|
line: 1,
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Front matter is niet afgesloten. Sluit af met een regel `---`.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 1; i < closingIndex; i++) {
|
||||||
|
final line = lines[i].trim();
|
||||||
|
if (line.isEmpty || line.startsWith('#')) continue;
|
||||||
|
if (!line.contains(':')) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: i + 1,
|
||||||
|
severity: MarkdownValidationSeverity.warning,
|
||||||
|
message: 'Front matter-regel heeft geen sleutel:waarde-vorm.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final key = line.substring(0, line.indexOf(':')).trim();
|
||||||
|
if (key == 'tlp') {
|
||||||
|
final value = line.substring(line.indexOf(':') + 1).trim();
|
||||||
|
if (!_isValidTlpKey(value)) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: i + 1,
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Onbekend TLP-niveau "$value". Gebruik clear, green, amber, amber+strict of red.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _indexOfFrontMatterClose(List<String> lines) {
|
||||||
|
for (var i = 1; i < lines.length; i++) {
|
||||||
|
if (lines[i] == '---') return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stripFrontMatter(String markdown) {
|
||||||
|
if (!markdown.startsWith('---\n')) return markdown;
|
||||||
|
final end = markdown.indexOf('\n---\n', 4);
|
||||||
|
if (end == -1) return markdown;
|
||||||
|
return markdown.substring(end + 5).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateHtmlComments(
|
||||||
|
List<String> lines,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
final line = lines[i];
|
||||||
|
final openCount = '<!--'.allMatches(line).length;
|
||||||
|
final closeCount = '-->'.allMatches(line).length;
|
||||||
|
if (openCount > closeCount) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: i + 1,
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'HTML-commentaar is niet afgesloten met `-->`.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final classLike = RegExp(r'<!--\s*([^_][^>]*?)-->').firstMatch(line);
|
||||||
|
if (classLike != null &&
|
||||||
|
!line.contains('_class:') &&
|
||||||
|
!line.contains('ocideck_') &&
|
||||||
|
!line.contains('_style:') &&
|
||||||
|
classLike.group(1)?.trim() != 'skip' &&
|
||||||
|
!RegExp(r'^tlp:\s*\S').hasMatch(classLike.group(1)!.trim()) &&
|
||||||
|
!RegExp(r'^advance:\s*[\d.]+').hasMatch(classLike.group(1)!.trim())) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: i + 1,
|
||||||
|
severity: MarkdownValidationSeverity.warning,
|
||||||
|
message:
|
||||||
|
'Commentaar mist `_class:` of een bekende ocideck-sleutel.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateFenceBalance(
|
||||||
|
List<String> lines,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
var fenceCount = 0;
|
||||||
|
int? firstFenceLine;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
if (RegExp(r'^\s*```').hasMatch(lines[i])) {
|
||||||
|
fenceCount++;
|
||||||
|
firstFenceLine ??= i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fenceCount.isOdd && firstFenceLine != null) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: firstFenceLine,
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Codeblok is niet afgesloten met ```.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateSlideBlock({
|
||||||
|
required String block,
|
||||||
|
required int slideNumber,
|
||||||
|
required int startLine,
|
||||||
|
required List<MarkdownValidationIssue> issues,
|
||||||
|
}) {
|
||||||
|
final blockLines = block.split('\n');
|
||||||
|
int lineNo(int index) => startLine + index;
|
||||||
|
|
||||||
|
final classMatch = RegExp(
|
||||||
|
r'<!--\s*_class:\s*([^>]+?)\s*-->',
|
||||||
|
).firstMatch(block);
|
||||||
|
if (classMatch == null &&
|
||||||
|
RegExp(r'<!--\s*_class:').hasMatch(block) &&
|
||||||
|
classMatch == null) {
|
||||||
|
final badLine = blockLines.indexWhere(
|
||||||
|
(line) => line.contains('<!--') && line.contains('_class:'),
|
||||||
|
);
|
||||||
|
if (badLine >= 0) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(badLine),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: `_class`-commentaar is ongeldig. Gebruik `<!-- _class: … -->`.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final cssClass = classMatch?.group(1)?.trim() ?? '';
|
||||||
|
final classTokens = cssClass
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final token in classTokens) {
|
||||||
|
if (!_knownClassTokens.contains(token)) {
|
||||||
|
final classLine = blockLines.indexWhere(
|
||||||
|
(line) => line.contains('<!-- _class:'),
|
||||||
|
);
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(classLine >= 0 ? classLine : 0),
|
||||||
|
severity: MarkdownValidationSeverity.warning,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: onbekende class "$token". Dit kan het slidetype beïnvloeden.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < blockLines.length; i++) {
|
||||||
|
final line = blockLines[i];
|
||||||
|
final trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith('<!-- tlp:')) {
|
||||||
|
final value = trimmed.substring('<!-- tlp:'.length).replaceAll('-->', '').trim();
|
||||||
|
if (!_isValidTlpKey(value)) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: onbekend TLP-niveau "$value".',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('<!-- advance:')) {
|
||||||
|
final value = trimmed
|
||||||
|
.substring('<!-- advance:'.length)
|
||||||
|
.replaceAll('-->', '')
|
||||||
|
.trim();
|
||||||
|
if (double.tryParse(value) == null) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: advance-waarde "$value" is geen getal.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('<!-- ocideck_list_style:')) {
|
||||||
|
final value = trimmed
|
||||||
|
.substring('<!-- ocideck_list_style:'.length)
|
||||||
|
.replaceAll('-->', '')
|
||||||
|
.trim();
|
||||||
|
if (!_validListStyles.contains(value)) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: onbekende lijststijl "$value". Gebruik bullets, numbered of checklist.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final prefix in const [
|
||||||
|
'ocideck_two_bullets_left:',
|
||||||
|
'ocideck_two_bullets_right:',
|
||||||
|
'ocideck_two_bullets_left_title:',
|
||||||
|
'ocideck_two_bullets_right_title:',
|
||||||
|
]) {
|
||||||
|
if (trimmed.startsWith('<!-- $prefix')) {
|
||||||
|
final encoded = trimmed
|
||||||
|
.substring('<!-- $prefix'.length)
|
||||||
|
.replaceAll('-->', '')
|
||||||
|
.trim();
|
||||||
|
if (!_isValidEncodedPayload(prefix, encoded)) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: `$prefix`-commentaar bevat ongeldige base64/JSON.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RegExp(r'!\[[^\]]*\]\([^)]*$').hasMatch(trimmed)) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: afbeeldings-markdown is niet afgesloten met `)`.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('<video') && !trimmed.endsWith('>')) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: `<video>`-tag is onvolledig of niet afgesloten.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (trimmed.startsWith('<video') &&
|
||||||
|
RegExp(r'src="([^"]+)"').firstMatch(trimmed) == null) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Slide $slideNumber: `<video>` mist een `src="…"`-attribuut.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('<audio') && !trimmed.endsWith('>')) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: `<audio>`-tag is onvolledig of niet afgesloten.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classTokens.contains('code')) {
|
||||||
|
_validateCodeSlide(blockLines, slideNumber, lineNo, issues);
|
||||||
|
}
|
||||||
|
if (classTokens.contains('chart')) {
|
||||||
|
_validateChartSlide(blockLines, slideNumber, lineNo, issues);
|
||||||
|
}
|
||||||
|
if (classTokens.contains('split')) {
|
||||||
|
_validateSplitSlide(blockLines, slideNumber, lineNo, issues);
|
||||||
|
}
|
||||||
|
if (classTokens.contains('two-bullets')) {
|
||||||
|
_validateTwoBulletsSlide(blockLines, slideNumber, lineNo, issues);
|
||||||
|
}
|
||||||
|
if (classTokens.contains('table')) {
|
||||||
|
_validateTableSlide(blockLines, slideNumber, lineNo, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateDivBalance(blockLines, slideNumber, lineNo, issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateCodeSlide(
|
||||||
|
List<String> blockLines,
|
||||||
|
int slideNumber,
|
||||||
|
int Function(int) lineNo,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
final fences = blockLines
|
||||||
|
.where((line) => RegExp(r'^\s*```').hasMatch(line))
|
||||||
|
.toList();
|
||||||
|
if (fences.length < 2) {
|
||||||
|
final firstFence = blockLines.indexWhere(
|
||||||
|
(line) => RegExp(r'^\s*```').hasMatch(line),
|
||||||
|
);
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(firstFence >= 0 ? firstFence : 0),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: broncode-slide vereist een fenced ```-blok.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateChartSlide(
|
||||||
|
List<String> blockLines,
|
||||||
|
int slideNumber,
|
||||||
|
int Function(int) lineNo,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
final openingIndex = blockLines.indexWhere(
|
||||||
|
(line) => RegExp(r'^\s*```chart\s*$').hasMatch(line.trim()),
|
||||||
|
);
|
||||||
|
if (openingIndex < 0) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(0),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: grafiek-slide vereist een ```chart-blok.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonLines = <String>[];
|
||||||
|
var inFence = false;
|
||||||
|
var closingIndex = -1;
|
||||||
|
for (var i = 0; i < blockLines.length; i++) {
|
||||||
|
final trimmed = blockLines[i].trim();
|
||||||
|
if (RegExp(r'^```chart\s*$').hasMatch(trimmed)) {
|
||||||
|
inFence = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence && RegExp(r'^```\s*$').hasMatch(trimmed)) {
|
||||||
|
closingIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (inFence) jsonLines.add(blockLines[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closingIndex < 0) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(openingIndex),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Slide $slideNumber: ```chart-blok is niet afgesloten.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = jsonLines.join('\n').trim();
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(openingIndex + 1),
|
||||||
|
severity: MarkdownValidationSeverity.warning,
|
||||||
|
message: 'Slide $slideNumber: grafiek-specificatie is leeg.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(openingIndex + 1),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: grafiek-JSON moet een object `{…}` zijn.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(openingIndex + 1),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Slide $slideNumber: grafiek-JSON is ongeldig.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateSplitSlide(
|
||||||
|
List<String> blockLines,
|
||||||
|
int slideNumber,
|
||||||
|
int Function(int) lineNo,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
_requireDiv(
|
||||||
|
blockLines: blockLines,
|
||||||
|
className: 'split-text',
|
||||||
|
slideNumber: slideNumber,
|
||||||
|
lineNo: lineNo,
|
||||||
|
issues: issues,
|
||||||
|
);
|
||||||
|
_requireDiv(
|
||||||
|
blockLines: blockLines,
|
||||||
|
className: 'split-image',
|
||||||
|
slideNumber: slideNumber,
|
||||||
|
lineNo: lineNo,
|
||||||
|
issues: issues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateTwoBulletsSlide(
|
||||||
|
List<String> blockLines,
|
||||||
|
int slideNumber,
|
||||||
|
int Function(int) lineNo,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
_requireDiv(
|
||||||
|
blockLines: blockLines,
|
||||||
|
className: 'ocideck-two-bullets',
|
||||||
|
slideNumber: slideNumber,
|
||||||
|
lineNo: lineNo,
|
||||||
|
issues: issues,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requireDiv({
|
||||||
|
required List<String> blockLines,
|
||||||
|
required String className,
|
||||||
|
required int slideNumber,
|
||||||
|
required int Function(int) lineNo,
|
||||||
|
required List<MarkdownValidationIssue> issues,
|
||||||
|
}) {
|
||||||
|
final openIndex = blockLines.indexWhere(
|
||||||
|
(line) => line.contains('<div class="$className"'),
|
||||||
|
);
|
||||||
|
if (openIndex < 0) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(0),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: verwacht `<div class="$className">`.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var depth = 0;
|
||||||
|
var closed = false;
|
||||||
|
for (var i = openIndex; i < blockLines.length; i++) {
|
||||||
|
final line = blockLines[i];
|
||||||
|
if (line.contains('<div')) depth++;
|
||||||
|
if (line.contains('</div>')) {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!closed) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(openIndex),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: `<div class="$className">` is niet afgesloten.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateTableSlide(
|
||||||
|
List<String> blockLines,
|
||||||
|
int slideNumber,
|
||||||
|
int Function(int) lineNo,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
final tableLineIndexes = <int>[];
|
||||||
|
for (var i = 0; i < blockLines.length; i++) {
|
||||||
|
if (blockLines[i].trim().startsWith('|')) {
|
||||||
|
tableLineIndexes.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tableLineIndexes.isEmpty) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(0),
|
||||||
|
severity: MarkdownValidationSeverity.warning,
|
||||||
|
message: 'Slide $slideNumber: tabel-slide bevat geen tabel.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableLineIndexes.length == 1) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(tableLineIndexes.first),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: tabel mist een scheidingsrij (`| --- |`).',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = tableLineIndexes[1];
|
||||||
|
final cells = blockLines[separatorIndex]
|
||||||
|
.trim()
|
||||||
|
.replaceFirst(RegExp(r'^\|'), '')
|
||||||
|
.replaceFirst(RegExp(r'\|$'), '')
|
||||||
|
.split('|')
|
||||||
|
.map((cell) => cell.trim())
|
||||||
|
.toList();
|
||||||
|
if (!cells.every((cell) => RegExp(r'^:?-+:?$').hasMatch(cell))) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(separatorIndex),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message:
|
||||||
|
'Slide $slideNumber: tweede tabelrij moet een scheidingsrij zijn (`| --- |`).',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateDivBalance(
|
||||||
|
List<String> blockLines,
|
||||||
|
int slideNumber,
|
||||||
|
int Function(int) lineNo,
|
||||||
|
List<MarkdownValidationIssue> issues,
|
||||||
|
) {
|
||||||
|
var depth = 0;
|
||||||
|
int? firstOpenLine;
|
||||||
|
for (var i = 0; i < blockLines.length; i++) {
|
||||||
|
final line = blockLines[i];
|
||||||
|
final opens = RegExp(r'<div\b').allMatches(line).length;
|
||||||
|
final closes = RegExp(r'</div>').allMatches(line).length;
|
||||||
|
if (opens > 0 && firstOpenLine == null) firstOpenLine = i;
|
||||||
|
depth += opens - closes;
|
||||||
|
if (depth < 0) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(i),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Slide $slideNumber: overtollige `</div>`.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
depth = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth > 0 && firstOpenLine != null) {
|
||||||
|
issues.add(
|
||||||
|
MarkdownValidationIssue(
|
||||||
|
line: lineNo(firstOpenLine),
|
||||||
|
severity: MarkdownValidationSeverity.error,
|
||||||
|
message: 'Slide $slideNumber: niet alle `<div>`-tags zijn afgesloten.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValidTlpKey(String raw) {
|
||||||
|
final normalized = raw.trim().toLowerCase();
|
||||||
|
if (normalized.isEmpty || normalized == 'none') return true;
|
||||||
|
return TlpLevel.values.any((level) => level.key == normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValidEncodedPayload(String prefix, String encoded) {
|
||||||
|
if (encoded.isEmpty) return prefix.contains('_title');
|
||||||
|
try {
|
||||||
|
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
|
||||||
|
if (prefix.contains('_title')) return true;
|
||||||
|
final raw = jsonDecode(decoded);
|
||||||
|
return raw is List;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
500
lib/widgets/editors/markdown_deck_editor.dart
Normal file
500
lib/widgets/editors/markdown_deck_editor.dart
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/markdown_validation.dart';
|
||||||
|
import '../../services/markdown_validator.dart';
|
||||||
|
|
||||||
|
class MarkdownDeckEditor extends StatefulWidget {
|
||||||
|
final String initialContent;
|
||||||
|
final bool Function(String) onApply;
|
||||||
|
final bool parseError;
|
||||||
|
final VoidCallback onExitMarkdown;
|
||||||
|
|
||||||
|
const MarkdownDeckEditor({
|
||||||
|
super.key,
|
||||||
|
required this.initialContent,
|
||||||
|
required this.onApply,
|
||||||
|
required this.parseError,
|
||||||
|
required this.onExitMarkdown,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarkdownDeckEditor> createState() => _MarkdownDeckEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarkdownDeckEditorState extends State<MarkdownDeckEditor> {
|
||||||
|
static const _lineHeight = 19.5;
|
||||||
|
|
||||||
|
late final TextEditingController _ctrl;
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
final _validator = MarkdownValidator();
|
||||||
|
MarkdownValidationResult? _validation;
|
||||||
|
bool _showIssues = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = TextEditingController(text: widget.initialContent);
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkdownValidationResult _runValidation() {
|
||||||
|
final result = _validator.validate(_ctrl.text);
|
||||||
|
setState(() {
|
||||||
|
_validation = result;
|
||||||
|
_showIssues = result.hasIssues;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _jumpToLine(int line) {
|
||||||
|
final lines = _ctrl.text.split('\n');
|
||||||
|
final index = (line - 1).clamp(0, lines.length - 1);
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < index; i++) {
|
||||||
|
offset += lines[i].length + 1;
|
||||||
|
}
|
||||||
|
_ctrl.selection = TextSelection(
|
||||||
|
baseOffset: offset,
|
||||||
|
extentOffset: offset + lines[index].length,
|
||||||
|
);
|
||||||
|
final target = (line - 1) * _lineHeight;
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
target.clamp(0.0, _scrollController.position.maxScrollExtent),
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmApplyWithIssues(
|
||||||
|
BuildContext context,
|
||||||
|
MarkdownValidationResult result,
|
||||||
|
) async {
|
||||||
|
final choice = await showDialog<_ApplyChoiceResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
final l10n = ctx.l10n;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.d('Syntaxproblemen gevonden')),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 520,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${l10n.d('De markdown bevat')} ${result.errorCount} '
|
||||||
|
'${l10n.d('fout(en) en')} ${result.warningCount} '
|
||||||
|
'${l10n.d('waarschuwing(en). Slides kunnen daardoor verkeerd worden ingelezen.')}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 260),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final issue in result.issues)
|
||||||
|
_IssueTile(
|
||||||
|
issue: issue,
|
||||||
|
onTap: () => Navigator.pop(
|
||||||
|
ctx,
|
||||||
|
_ApplyChoiceResult(_ApplyChoice.edit, issue.line),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, const _ApplyChoiceResult(_ApplyChoice.edit)),
|
||||||
|
child: Text(l10n.d('Terug naar editor')),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, const _ApplyChoiceResult(_ApplyChoice.cancel)),
|
||||||
|
child: Text(l10n.t('cancel')),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(
|
||||||
|
ctx,
|
||||||
|
const _ApplyChoiceResult(_ApplyChoice.applyAnyway),
|
||||||
|
),
|
||||||
|
child: Text(l10n.d('Toch toepassen')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice?.choice == _ApplyChoice.edit && choice?.focusLine != null) {
|
||||||
|
_jumpToLine(choice!.focusLine!);
|
||||||
|
}
|
||||||
|
return choice?.choice == _ApplyChoice.applyAnyway;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyMarkdown() async {
|
||||||
|
final result = _runValidation();
|
||||||
|
if (result.hasIssues) {
|
||||||
|
final proceed = await _confirmApplyWithIssues(context, result);
|
||||||
|
if (!proceed) return;
|
||||||
|
}
|
||||||
|
final ok = widget.onApply(_ctrl.text);
|
||||||
|
if (ok) widget.onExitMarkdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final lineCount = '\n'.allMatches(_ctrl.text).length + 1;
|
||||||
|
final issueLines = <int, MarkdownValidationSeverity>{
|
||||||
|
for (final issue in _validation?.issues ?? const [])
|
||||||
|
issue.line: issue.severity,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: const Color(0xFFFFF9E6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.code, size: 14, color: Color(0xFF92400E)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.d(
|
||||||
|
'Markdown modus — bewerk de volledige presentatie als Marp Markdown',
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF92400E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _runValidation,
|
||||||
|
icon: const Icon(Icons.rule, size: 16),
|
||||||
|
label: Text(l10n.d('Controleren')),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _applyMarkdown,
|
||||||
|
child: Text(l10n.d('Toepassen')),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onExitMarkdown,
|
||||||
|
child: Text(l10n.t('cancel')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_validation != null)
|
||||||
|
_ValidationSummaryBar(
|
||||||
|
result: _validation!,
|
||||||
|
expanded: _showIssues,
|
||||||
|
onToggle: () => setState(() => _showIssues = !_showIssues),
|
||||||
|
onJumpToLine: _jumpToLine,
|
||||||
|
),
|
||||||
|
if (widget.parseError)
|
||||||
|
Container(
|
||||||
|
color: const Color(0xFFFEE2E2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.d(
|
||||||
|
'Markdown kon niet worden verwerkt. Controleer de syntax.',
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_LineNumberGutter(
|
||||||
|
scrollController: _scrollController,
|
||||||
|
lineCount: lineCount,
|
||||||
|
issueLines: issueLines,
|
||||||
|
onLineTap: _jumpToLine,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
scrollController: _scrollController,
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
contentPadding: EdgeInsets.fromLTRB(8, 16, 16, 16),
|
||||||
|
border: InputBorder.none,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Color(0xFFF8FAFC),
|
||||||
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
_validation = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ApplyChoice { edit, cancel, applyAnyway }
|
||||||
|
|
||||||
|
class _ApplyChoiceResult {
|
||||||
|
final _ApplyChoice choice;
|
||||||
|
final int? focusLine;
|
||||||
|
|
||||||
|
const _ApplyChoiceResult(this.choice, [this.focusLine]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationSummaryBar extends StatelessWidget {
|
||||||
|
final MarkdownValidationResult result;
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<int> onJumpToLine;
|
||||||
|
|
||||||
|
const _ValidationSummaryBar({
|
||||||
|
required this.result,
|
||||||
|
required this.expanded,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onJumpToLine,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final color = result.isValid
|
||||||
|
? const Color(0xFFFEF3C7)
|
||||||
|
: const Color(0xFFFEE2E2);
|
||||||
|
final iconColor = result.isValid
|
||||||
|
? const Color(0xFF92400E)
|
||||||
|
: Colors.red.shade700;
|
||||||
|
final summary = result.hasIssues
|
||||||
|
? '${result.errorCount} ${l10n.d('fout(en),')} '
|
||||||
|
'${result.warningCount} ${l10n.d('waarschuwing(en)')}'
|
||||||
|
: l10n.d('Geen syntaxproblemen gevonden');
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: color,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: result.hasIssues ? onToggle : null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
result.hasIssues
|
||||||
|
? Icons.warning_amber_outlined
|
||||||
|
: Icons.check_circle_outline,
|
||||||
|
size: 14,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
summary,
|
||||||
|
style: TextStyle(fontSize: 11, color: iconColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (result.hasIssues)
|
||||||
|
Icon(
|
||||||
|
expanded ? Icons.expand_less : Icons.expand_more,
|
||||||
|
size: 18,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (expanded && result.hasIssues)
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 160),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
itemCount: result.issues.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final issue = result.issues[index];
|
||||||
|
return _IssueTile(
|
||||||
|
issue: issue,
|
||||||
|
onTap: () => onJumpToLine(issue.line),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IssueTile extends StatelessWidget {
|
||||||
|
final MarkdownValidationIssue issue;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _IssueTile({required this.issue, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isError = issue.severity == MarkdownValidationSeverity.error;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isError ? Icons.error_outline : Icons.info_outline,
|
||||||
|
size: 14,
|
||||||
|
color: isError ? Colors.red.shade700 : const Color(0xFF92400E),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Regel ${issue.line}: ${issue.message}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isError ? Colors.red.shade700 : const Color(0xFF92400E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LineNumberGutter extends StatelessWidget {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final int lineCount;
|
||||||
|
final Map<int, MarkdownValidationSeverity> issueLines;
|
||||||
|
final ValueChanged<int> onLineTap;
|
||||||
|
|
||||||
|
const _LineNumberGutter({
|
||||||
|
required this.scrollController,
|
||||||
|
required this.lineCount,
|
||||||
|
required this.issueLines,
|
||||||
|
required this.onLineTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: const Color(0xFFEEF2F7),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
child: ClipRect(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: scrollController,
|
||||||
|
builder: (context, child) {
|
||||||
|
final offset =
|
||||||
|
scrollController.hasClients ? scrollController.offset : 0.0;
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, 16 - offset),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < lineCount; index++)
|
||||||
|
_LineNumberCell(
|
||||||
|
line: index + 1,
|
||||||
|
severity: issueLines[index + 1],
|
||||||
|
onTap: onLineTap,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LineNumberCell extends StatelessWidget {
|
||||||
|
final int line;
|
||||||
|
final MarkdownValidationSeverity? severity;
|
||||||
|
final ValueChanged<int> onTap;
|
||||||
|
|
||||||
|
const _LineNumberCell({
|
||||||
|
required this.line,
|
||||||
|
required this.severity,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bg = switch (severity) {
|
||||||
|
MarkdownValidationSeverity.error => const Color(0xFFFECACA),
|
||||||
|
MarkdownValidationSeverity.warning => const Color(0xFFFDE68A),
|
||||||
|
null => Colors.transparent,
|
||||||
|
};
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onTap(line),
|
||||||
|
child: Container(
|
||||||
|
height: _MarkdownDeckEditorState._lineHeight,
|
||||||
|
color: bg,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Text(
|
||||||
|
'$line',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.5,
|
||||||
|
color: severity == MarkdownValidationSeverity.error
|
||||||
|
? Colors.red.shade700
|
||||||
|
: severity == MarkdownValidationSeverity.warning
|
||||||
|
? const Color(0xFF92400E)
|
||||||
|
: const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import '../editors/title_editor.dart';
|
||||||
import '../editors/two_bullets_editor.dart';
|
import '../editors/two_bullets_editor.dart';
|
||||||
import '../editors/two_images_editor.dart';
|
import '../editors/two_images_editor.dart';
|
||||||
import '../editors/video_slide_editor.dart';
|
import '../editors/video_slide_editor.dart';
|
||||||
|
import '../editors/markdown_deck_editor.dart';
|
||||||
|
|
||||||
class EditorPanel extends ConsumerWidget {
|
class EditorPanel extends ConsumerWidget {
|
||||||
const EditorPanel({super.key});
|
const EditorPanel({super.key});
|
||||||
|
|
@ -52,7 +53,7 @@ class EditorPanel extends ConsumerWidget {
|
||||||
];
|
];
|
||||||
|
|
||||||
if (editor.mode == EditorMode.markdown) {
|
if (editor.mode == EditorMode.markdown) {
|
||||||
return _MarkdownModeEditor(
|
return MarkdownDeckEditor(
|
||||||
// Verse instantie na undo/redo zodat de markdown opnieuw wordt geladen.
|
// Verse instantie na undo/redo zodat de markdown opnieuw wordt geladen.
|
||||||
key: ValueKey('md-${deckState.revision}'),
|
key: ValueKey('md-${deckState.revision}'),
|
||||||
initialContent: deckNotifier.generateMarkdown(),
|
initialContent: deckNotifier.generateMarkdown(),
|
||||||
|
|
@ -810,126 +811,3 @@ class _NotesFieldState extends State<_NotesField> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Markdown mode editor ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _MarkdownModeEditor extends StatefulWidget {
|
|
||||||
final String initialContent;
|
|
||||||
final bool Function(String) onApply;
|
|
||||||
final bool parseError;
|
|
||||||
final VoidCallback onExitMarkdown;
|
|
||||||
|
|
||||||
const _MarkdownModeEditor({
|
|
||||||
super.key,
|
|
||||||
required this.initialContent,
|
|
||||||
required this.onApply,
|
|
||||||
required this.parseError,
|
|
||||||
required this.onExitMarkdown,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_MarkdownModeEditor> createState() => _MarkdownModeEditorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MarkdownModeEditorState extends State<_MarkdownModeEditor> {
|
|
||||||
late final TextEditingController _ctrl;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_ctrl = TextEditingController(text: widget.initialContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_ctrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Toolbar
|
|
||||||
Container(
|
|
||||||
color: const Color(0xFFFFF9E6),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.code, size: 14, color: Color(0xFF92400E)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.d(
|
|
||||||
'Markdown modus — bewerk de volledige presentatie als Marp Markdown',
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Color(0xFF92400E),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
final ok = widget.onApply(_ctrl.text);
|
|
||||||
if (ok) widget.onExitMarkdown();
|
|
||||||
},
|
|
||||||
child: Text(l10n.d('Toepassen')),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: widget.onExitMarkdown,
|
|
||||||
child: Text(l10n.t('cancel')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.parseError)
|
|
||||||
Container(
|
|
||||||
color: const Color(0xFFFEE2E2),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.warning_amber_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.d(
|
|
||||||
'Markdown kon niet worden verwerkt. Controleer de syntax.',
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
// Code editor
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _ctrl,
|
|
||||||
maxLines: null,
|
|
||||||
expands: true,
|
|
||||||
textAlignVertical: TextAlignVertical.top,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
contentPadding: EdgeInsets.all(16),
|
|
||||||
border: InputBorder.none,
|
|
||||||
filled: true,
|
|
||||||
fillColor: Color(0xFFF8FAFC),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../services/markdown_service.dart';
|
import '../../services/markdown_service.dart';
|
||||||
import '../../utils/log.dart';
|
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
import 'annotation_overlay.dart';
|
import 'annotation_overlay.dart';
|
||||||
|
|
@ -130,16 +129,6 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
bullets2: List<String>.from(m['bullets2'] as List? ?? const []),
|
bullets2: List<String>.from(m['bullets2'] as List? ?? const []),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
case 'close':
|
|
||||||
try {
|
|
||||||
final self = await WindowController.fromCurrentEngine();
|
|
||||||
await self.close();
|
|
||||||
} catch (e) {
|
|
||||||
logWarning(
|
|
||||||
'_AudienceWindowAppState._onPresenterCall: close window',
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,32 @@ import 'annotation_overlay.dart';
|
||||||
import 'audience_window.dart';
|
import 'audience_window.dart';
|
||||||
import 'rehearsal_summary.dart';
|
import 'rehearsal_summary.dart';
|
||||||
|
|
||||||
|
/// Guards teardown of the secondary audience window so native close is only
|
||||||
|
/// invoked once (double-close on Linux can crash the embedder).
|
||||||
|
@visibleForTesting
|
||||||
|
class AudienceWindowHandle {
|
||||||
|
AudienceWindowHandle(
|
||||||
|
this.controller, {
|
||||||
|
Future<void> Function(WindowController controller)? closeImpl,
|
||||||
|
}) : _closeImpl = closeImpl ?? ((c) => c.close());
|
||||||
|
|
||||||
|
final WindowController controller;
|
||||||
|
final Future<void> Function(WindowController controller) _closeImpl;
|
||||||
|
bool _closed = false;
|
||||||
|
|
||||||
|
bool get isClosed => _closed;
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
if (_closed) return;
|
||||||
|
_closed = true;
|
||||||
|
try {
|
||||||
|
await _closeImpl(controller);
|
||||||
|
} catch (e) {
|
||||||
|
logWarning('AudienceWindowHandle.close: audience window', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||||
enum _Blank { none, black, white }
|
enum _Blank { none, black, white }
|
||||||
|
|
||||||
|
|
@ -38,9 +64,9 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
final Duration? targetDuration;
|
final Duration? targetDuration;
|
||||||
|
|
||||||
/// When set, this presenter drives a separate audience (beamer) window: the
|
/// When set, this presenter drives a separate audience (beamer) window: the
|
||||||
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
|
/// laptop shows the presenter view, the slide goes to [audience]. Null
|
||||||
/// for the classic single-screen mode.
|
/// for the classic single-screen mode.
|
||||||
final WindowController? audienceWindow;
|
final AudienceWindowHandle? audience;
|
||||||
|
|
||||||
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
|
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
|
||||||
/// made while presenting back to the deck.
|
/// made while presenting back to the deck.
|
||||||
|
|
@ -56,7 +82,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required this.initialIndex,
|
required this.initialIndex,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
this.targetDuration,
|
this.targetDuration,
|
||||||
this.audienceWindow,
|
this.audience,
|
||||||
this.initialAnnotations = const {},
|
this.initialAnnotations = const {},
|
||||||
this.onAnnotationsChanged,
|
this.onAnnotationsChanged,
|
||||||
this.onSlideChanged,
|
this.onSlideChanged,
|
||||||
|
|
@ -213,20 +239,27 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
WindowController? audience;
|
WindowController? audience;
|
||||||
|
AudienceWindowHandle? audienceHandle;
|
||||||
try {
|
try {
|
||||||
audience = await WindowController.create(
|
audience = await WindowController.create(
|
||||||
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||||||
);
|
);
|
||||||
|
audienceHandle = AudienceWindowHandle(audience);
|
||||||
|
await audience.show();
|
||||||
await audience.coverScreen(external: true);
|
await audience.coverScreen(external: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(
|
logError(
|
||||||
'FullscreenPresenter.showDualScreen: audience window setup failed',
|
'FullscreenPresenter.showDualScreen: audience window setup failed',
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
|
if (audienceHandle != null) {
|
||||||
|
await audienceHandle.close();
|
||||||
|
}
|
||||||
audience = null;
|
audience = null;
|
||||||
|
audienceHandle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audience == null) {
|
if (audience == null || audienceHandle == null) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await show(
|
await show(
|
||||||
context,
|
context,
|
||||||
|
|
@ -257,7 +290,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
audienceWindow: audience,
|
audience: audienceHandle,
|
||||||
initialAnnotations: annotations,
|
initialAnnotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
onSlideChanged: onSlideChanged,
|
onSlideChanged: onSlideChanged,
|
||||||
|
|
@ -271,7 +304,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
} finally {
|
} finally {
|
||||||
await _restoreWakeLock(hadWakeLock);
|
await _restoreWakeLock(hadWakeLock);
|
||||||
// Make sure the audience window is gone even if exit didn't close it.
|
// Make sure the audience window is gone even if exit didn't close it.
|
||||||
audience.close().catchError((_) => null);
|
await audienceHandle.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,7 +422,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
int _displayIndex = 0;
|
int _displayIndex = 0;
|
||||||
|
|
||||||
/// True when this presenter drives a separate audience (beamer) window.
|
/// True when this presenter drives a separate audience (beamer) window.
|
||||||
bool get _dual => widget.audienceWindow != null;
|
bool get _dual => widget.audience != null;
|
||||||
|
|
||||||
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
|
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
|
||||||
int? _lastSentIndex;
|
int? _lastSentIndex;
|
||||||
|
|
@ -484,7 +517,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
|
|
||||||
/// Mirror the current index/blank state to the audience window when it changed.
|
/// Mirror the current index/blank state to the audience window when it changed.
|
||||||
void _syncAudience() {
|
void _syncAudience() {
|
||||||
final aw = widget.audienceWindow;
|
final aw = widget.audience?.controller;
|
||||||
if (aw == null) return;
|
if (aw == null) return;
|
||||||
final blank = _blankCode;
|
final blank = _blankCode;
|
||||||
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
||||||
|
|
@ -534,7 +567,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
|
|
||||||
/// Send the current slide's strokes to the beamer (keyed by index there).
|
/// Send the current slide's strokes to the beamer (keyed by index there).
|
||||||
void _pushInk() {
|
void _pushInk() {
|
||||||
if (widget.audienceWindow == null) return;
|
if (widget.audience == null) return;
|
||||||
audienceChannel
|
audienceChannel
|
||||||
.invokeMethod('ink', {
|
.invokeMethod('ink', {
|
||||||
'index': _index,
|
'index': _index,
|
||||||
|
|
@ -557,7 +590,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLaserMove(Offset? point) {
|
void _onLaserMove(Offset? point) {
|
||||||
if (widget.audienceWindow == null) return;
|
if (widget.audience == null) return;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
// Throttle to keep the channel calm; always send the "gone" (null) event.
|
// Throttle to keep the channel calm; always send the "gone" (null) event.
|
||||||
if (point != null &&
|
if (point != null &&
|
||||||
|
|
@ -578,7 +611,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
|
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
|
||||||
/// just keeps the in-progress preview in sync for the same slide.
|
/// just keeps the in-progress preview in sync for the same slide.
|
||||||
void _onActiveStroke(InkStroke? stroke) {
|
void _onActiveStroke(InkStroke? stroke) {
|
||||||
if (widget.audienceWindow == null) return;
|
if (widget.audience == null) return;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
// Throttle growth events; always send the "done" (null) event so the
|
// Throttle growth events; always send the "done" (null) event so the
|
||||||
// beamer drops its live preview the moment the stroke commits.
|
// beamer drops its live preview the moment the stroke commits.
|
||||||
|
|
@ -777,12 +810,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
Future<void> _exit() async {
|
Future<void> _exit() async {
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
await _maybeShowRehearsalSummary();
|
await _maybeShowRehearsalSummary();
|
||||||
final aw = widget.audienceWindow;
|
final aw = widget.audience;
|
||||||
if (aw != null) {
|
if (aw != null) {
|
||||||
// Dual mode: the main window was never put in full screen; just tear down
|
// Dual mode: the main window was never put in full screen; just tear down
|
||||||
// the audience window.
|
// the audience window (once — double-close crashes the Linux embedder).
|
||||||
audienceChannel.invokeMethod('close').catchError((_) => null);
|
await aw.close();
|
||||||
aw.close().catchError((_) => null);
|
|
||||||
} else {
|
} else {
|
||||||
await windowManager.setFullScreen(false);
|
await windowManager.setFullScreen(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/models/settings.dart';
|
import 'package:ocideck/models/settings.dart';
|
||||||
import 'package:ocideck/models/slide.dart';
|
import 'package:ocideck/models/slide.dart';
|
||||||
|
|
@ -54,6 +55,22 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AudienceWindowHandle closes only once', () async {
|
||||||
|
var closeCount = 0;
|
||||||
|
final handle = AudienceWindowHandle(
|
||||||
|
WindowController.fromWindowId('test'),
|
||||||
|
closeImpl: (_) async {
|
||||||
|
closeCount++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await handle.close();
|
||||||
|
await handle.close();
|
||||||
|
|
||||||
|
expect(closeCount, 1);
|
||||||
|
expect(handle.isClosed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('dual-screen mode requires a desktop platform and two displays', () {
|
test('dual-screen mode requires a desktop platform and two displays', () {
|
||||||
expect(
|
expect(
|
||||||
shouldUseDualScreen(
|
shouldUseDualScreen(
|
||||||
|
|
|
||||||
224
test/markdown_validator_test.dart
Normal file
224
test/markdown_validator_test.dart
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/models/slide.dart';
|
||||||
|
import 'package:ocideck/services/markdown_service.dart';
|
||||||
|
import 'package:ocideck/services/markdown_validator.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final service = MarkdownService();
|
||||||
|
final validator = MarkdownValidator();
|
||||||
|
|
||||||
|
test('valid generated deck has no errors', () {
|
||||||
|
final markdown = service.generateDeck(
|
||||||
|
Deck(
|
||||||
|
title: 'Demo',
|
||||||
|
slides: [
|
||||||
|
Slide.create(SlideType.bullets).copyWith(
|
||||||
|
title: 'Kop',
|
||||||
|
bullets: ['Eerste punt'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isTrue);
|
||||||
|
expect(result.hasIssues, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects unclosed front matter', () {
|
||||||
|
const markdown = '---\nmarp: true\ntheme: ocideck\n# Titel\n';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('Front matter is niet afgesloten'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects unclosed code fence', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void main() {}
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('Codeblok is niet afgesloten'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects code slide without fenced block', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: code -->
|
||||||
|
|
||||||
|
# Titel
|
||||||
|
print('hi');
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('broncode-slide vereist'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects invalid chart JSON', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: chart -->
|
||||||
|
|
||||||
|
```chart
|
||||||
|
{ broken json
|
||||||
|
```
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('grafiek-JSON is ongeldig'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects split slide with missing divs', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: split -->
|
||||||
|
|
||||||
|
<div class="split-text">
|
||||||
|
|
||||||
|
# Kop
|
||||||
|
|
||||||
|
</div>
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('split-image'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects invalid two-bullets encoded comment', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: two-bullets -->
|
||||||
|
<!-- ocideck_two_bullets_left: !!! -->
|
||||||
|
<div class="ocideck-two-bullets">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('ocideck_two_bullets_left:'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects table without separator row', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: table -->
|
||||||
|
|
||||||
|
| Kop 1 | Kop 2 |
|
||||||
|
| a | b |
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('scheidingsrij'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects malformed image markdown', () {
|
||||||
|
const markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
;
|
||||||
|
expect(result.isValid, isFalse);
|
||||||
|
expect(
|
||||||
|
result.issues.any(
|
||||||
|
(issue) => issue.message.contains('afbeeldings-markdown'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts valid encoded two-bullets payload', () {
|
||||||
|
final encoded = base64Url.encode(utf8.encode(jsonEncode(['A', 'B'])));
|
||||||
|
final markdown = '''
|
||||||
|
---
|
||||||
|
marp: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- _class: two-bullets -->
|
||||||
|
<!-- ocideck_two_bullets_left: $encoded -->
|
||||||
|
<div class="ocideck-two-bullets">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
''';
|
||||||
|
|
||||||
|
final result = validator.validate(markdown);
|
||||||
|
expect(
|
||||||
|
result.issues.where(
|
||||||
|
(issue) => issue.message.contains('ocideck_two_bullets_left:'),
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -165,11 +165,8 @@ void MultiWindowManager::ObserveWindowClose(const std::string& window_id,
|
||||||
G_CALLBACK(+[](GtkWidget* widget, gpointer arg) {
|
G_CALLBACK(+[](GtkWidget* widget, gpointer arg) {
|
||||||
auto* window_id_ptr = static_cast<std::string*>(arg);
|
auto* window_id_ptr = static_cast<std::string*>(arg);
|
||||||
|
|
||||||
GtkWidget* child = gtk_bin_get_child(GTK_BIN(widget));
|
// Let GTK tear down the FlView with the window; manually removing the
|
||||||
if (child && FL_IS_VIEW(child)) {
|
// view first triggers FlutterEngineRemoveView errors on Linux.
|
||||||
gtk_container_remove(GTK_CONTAINER(widget), child);
|
|
||||||
}
|
|
||||||
|
|
||||||
MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr);
|
MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr);
|
||||||
delete window_id_ptr;
|
delete window_id_ptr;
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue