From 78518ee0ab7075d4f73bb8e299ba41c8c384a148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Mon, 2 Nov 2026 15:42:09 +0100 Subject: [PATCH 1/7] The skill's links go somewhere, and WordCraft is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The links.** The bundle is distributed as an archive with no website beside it, and its pages carried the site's own paths: fifteen of the thirty-six links in it went nowhere. A link to a page the bundle carries is now a link to the file beside it, and a link to a page it does not carry is the address of that page on the site. The slides half's command line is carried too — `references/command-line-presentations.md` and `references/command-line-theme.md` — because the bundle claims to be about decks as well as about documents now, and a page that is not there is a page an agent cannot read. The checker holds it to that: every link in the bundle must go somewhere, and a link that is still a path of the site is a link that goes nowhere. **WordCraft is named.** It is in the table of renderers to believe and there was nowhere in the bundle that said what it is or where it comes from, so an agent reading it had a word and no meaning. It is a clean-room implementation of Word from [storytold/wordcraft](https://github.com/storytold/wordcraft), [storytold/deckcraft](https://github.com/storytold/deckcraft) is its half for decks, and a machine without them falls back to LibreOffice and says so. **The description covers decks.** The skill is called `mdword` and described itself in terms of Word only, while carrying eight decks beside its eight documents. An agent asked to build a presentation did not match it, and the deck examples were never found. **The spec** says what `bleed=true` does, on both halves, and what a `blank` slide does with the frontmatter's title. --- content/command-line/presentations.md | 21 +++++ content/presentation/directives.md | 10 +++ content/presentation/themes.md | 5 ++ content/word/directives.md | 7 ++ content/word/errors.md | 1 + skill/mdword/SKILL.md | 15 +++- skill/mdword/pages.json | 15 +++- tools/check-skill.php | 117 +++++++++++++++++++++++++- tools/write-skill.js | 66 +++++++++++++-- 9 files changed, 245 insertions(+), 12 deletions(-) diff --git a/content/command-line/presentations.md b/content/command-line/presentations.md index b5ab985..e478f67 100644 --- a/content/command-line/presentations.md +++ b/content/command-line/presentations.md @@ -33,6 +33,7 @@ There is no `--theme`: the renderer resolves the theme from the deck's own `them | `-o, --output ` | path | beside the input, `.md`; `-` for standard output | | `--media ` | path | no extraction | | `--line-ending ` | `lf`, `crlf` | `lf` | +| `--preserve-geometry` | flag | off | | `-h, --help` | flag | off | A line ending may also be written `unix`, `n`, `dos`, `windows` or `r`. @@ -40,6 +41,26 @@ A line ending may also be written `unix`, `n`, `dos`, `windows` or `r`. The Markdown that comes back is the Markdown this tool writes decks in, so a deck can be read out, edited as text and put in again through `to-pptx` with a different theme. +**`--preserve-geometry` keeps the shapes a deck placed itself.** A deck read back into Markdown +comes back as text, and every free shape in it is left out: the shape itself disappears, not +merely its design, so `deck.md` → `deck.pptx` → `deck.md` loses every position, colour and +size. With the flag on, a free shape comes back as the free shape it is, with the box it was +given and the colour it was painted: + +```markdown +::roundrect{left=10% top=40% width=30% height=20% fill=4472C4} + +:::content{left=50% top=40% width=30% height=20%} +Text in a free box. +::: +``` + +The box is a percentage of the slide, because a shape is placed relative to the slide rather +than in the pixels of one page size, and a shape that runs off the edge comes back with +`bleed=true`. What still does not travel is what a shape is besides its box and its colour: the +theme slot a colour came from, the shadow it casts, the corner radius of a rounded rectangle, +the layer it was drawn on and the anchor of its box. + ## Options for `diff` | Option | Value | Default | diff --git a/content/presentation/directives.md b/content/presentation/directives.md index d7ec9da..b9b380e 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -184,6 +184,16 @@ is refused with a message that lists all thirteen. attribute after another, not a list with commas in it — a comma is part of the value, so `{fill=red,stroke=blue}` is one attribute called `fill` whose value is `red,stroke=blue`. +**A shape may run off the edge of the slide, if it says so.** A free shape lies within the slide +unless it is written to bleed, and a negative `left` or `top` is refused with the two ways out +named: write it as `0`, or write `bleed=true` if the shape is meant to hang off the edge. The +grey circles behind a title that the page cuts off are a shape this can now write, and a +mistyped `left=-20` is still caught — because the permission is on the shape, and a deck-level +switch would silence the check for every shape in it. + +The Word half has the same rule for the same thing: a text box hangs off the edge of the page on +`bleed=true` as well. + `layout: blank` is what most of them want: a slide whose whole content is free shapes has no title to place them around. Every attribute each of them reads is in the package's own [README](https://github.com/markdown-office/markdown-presentation#free-shapes). diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 39d4c4f..59be80f 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -118,6 +118,11 @@ title content two-column section blank image-text image-title image-slide `blank` and `image-slide` have no title box. `layouts.yml` maps each role to a layout name in the master — the file may be a bare mapping or nested under a single `layouts:` key. +**A slide with no title box draws the frontmatter's title as a heading in its body.** A deck +that names no title has nothing to put one in, and a title that is not drawn is a silence — +so the title becomes the first thing in the body, where it is a heading like any other. Write +`title: ""` to leave it as metadata only, and the slide opens with no title at all. + Only the roles a deck actually uses are built, so a ten-slide deck does not carry eight masters. ## Templates diff --git a/content/word/directives.md b/content/word/directives.md index 7433e6f..bd56782 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -217,9 +217,16 @@ it is a paragraph. | `width`, `height` | the size in centimetres | | `left`, `top` | where it floats, in centimetres | | `border` | the colour of the line round it, six hexadecimal digits without a `#` | +| `bleed` | whether the box may hang off the edge of the page | A box with no size is written out as its own source text. +**A box hangs off the edge of the page only when it says so.** A negative `left` or `top` is +refused with the two ways out named: write it as `0`, or write `bleed=true` if the box is meant +to hang off the edge. The slides half has the same rule for the same thing — a free shape that +runs off the edge of the slide is written `bleed=true` as well — so a document and a deck are +written the same way. + ## `::shape` — a shape ```markdown diff --git a/content/word/errors.md b/content/word/errors.md index 1b6b766..e1b171a 100644 --- a/content/word/errors.md +++ b/content/word/errors.md @@ -27,6 +27,7 @@ about both libraries, is on [Errors in general](/errors/). | `FileNotWritable` | **not** `InvalidInput`; the target cannot be written | | `TemplateNotFound` | `template_file:` names nothing | | `UnreadableTemplate` | the template holds more parts, or a larger part, than are allowed | +| `BleedingBox` | a text box hangs off the edge of the page without saying that it means to | | `NoContentControl` | `--require-control`, and the template has none | | `MalformedTheme` | a theme file is not shaped like one | | `ThemeNotFound` | a theme name resolves to nothing — the message lists the ones that do | diff --git a/skill/mdword/SKILL.md b/skill/mdword/SKILL.md index d5cf857..69fe9f9 100644 --- a/skill/mdword/SKILL.md +++ b/skill/mdword/SKILL.md @@ -1,6 +1,6 @@ --- name: mdword -description: Turn Markdown into a Word document — .docx, .odt or .rtf — with the markdown-word library, and turn one back into Markdown. Use when asked to write or convert a Word document, to render Markdown into a company's own Word template or theme, to read a Word document back into Markdown, or to check that a document the library produced looks right. +description: Turn Markdown into a Word document — .docx, .odt or .rtf — or a PowerPoint deck, with the markdown-word and markdown-presentation libraries, and turn one back to Markdown. Use when asked to write or convert a Word document or a presentation, to render Markdown into a company's own Word template or theme, to read a Word document or a deck back to Markdown, or to check that a document the library produced looks right. --- # mdword @@ -26,7 +26,7 @@ documentation, not by recalling it. | What the two halves of the suite do with a name they know, and with one they do not | `references/directive-rules.md` | | The errors both halves throw | `references/errors-general.md` | | Where a document's files come from — the finder, the three roles, and the names the library composes | `references/general-files.md` | -| The command line, and the options each command takes | `references/command-line-word.md`, `references/command-line-basic.md` | +| The command line, and the options each command takes | `references/command-line-word.md`, `references/command-line-basic.md`, `references/command-line-presentations.md`, `references/command-line-theme.md` | | Installing the command line | `references/command-line-install.md` | | Installing this skill | `references/install.md` | @@ -71,6 +71,17 @@ writes out of its settings folder. The script finds the key itself. it had to fall back**, because a renderer that cannot be trusted is worse than no renderer: a caller who does not know is told a layout is right when it is not. +**WordCraft is not Microsoft's.** It is a clean-room implementation of Word — it draws a +document the way Word does without being Word — and it comes from +[storytold/wordcraft](https://github.com/storytold/wordcraft); +[storytold/deckcraft](https://github.com/storytold/deckcraft) is its half for decks. The +script looks for them on the path as `wordcraft-cli` and `deckcraft-cli`, and **a machine +without them falls back to LibreOffice** and says so. Microsoft Word, where it is +installed, is found on its own and used in preference to both. + +The two are separate programs: one draws a Word document and the other draws a deck, and a +machine may have one without the other. + ## What this half does differently **The Word half never refuses.** A directive it does not know is written into the document diff --git a/skill/mdword/pages.json b/skill/mdword/pages.json index 7ec5825..7b4bd46 100644 --- a/skill/mdword/pages.json +++ b/skill/mdword/pages.json @@ -8,9 +8,10 @@ "", "The 'sections' are the pages that must all be there. A page added to one of them and", "not carried is a page the bundle has silently fallen behind on, and the checker says", - "so rather than letting it pass. The pages outside them \u2014 the command line for the", - "slides half, and the suite itself \u2014 are carried or not as this file says, because a", - "skill about the Word half has no reason to carry the slides half's command line.", + "The pages outside them — the suite itself — are carried or not as this file says,", + "because a skill about the two halves has no reason to carry the suite's own page. The", + "slides half's command line is carried: the bundle claims to be about decks as well as", + "about documents, and a page that is not there is a page an agent cannot read.", "", "The 'file' is the name the page is carried under. It is not the page's URL: the pages", "are flattened into one directory, and two pages of different sections can want the", @@ -81,6 +82,14 @@ "file": "command-line-install.md", "page": "command-line/installation.md" }, + { + "file": "command-line-presentations.md", + "page": "command-line/presentations.md" + }, + { + "file": "command-line-theme.md", + "page": "command-line/theme.md" + }, { "file": "install.md", "page": "skills/index.md" diff --git a/tools/check-skill.php b/tools/check-skill.php index 20b3ba2..70b545b 100644 --- a/tools/check-skill.php +++ b/tools/check-skill.php @@ -235,6 +235,55 @@ function paths(string $reference): array return $found[1]; } +/** + * The text of a piece of prose without the targets of its links. + * + * A link to a page the bundle carries is rewritten to the file beside it, so the same + * prose is not the same string in the bundle as it is on the site. The words are the same + * and the words are what this compares. + */ +function withoutTargets(string $text): string +{ + return (string) \preg_replace('/\]\([^)]*\)/', ']', $text); +} + +/** + * Every link in a piece of the bundle, as the target it names. + * + * @return list + */ +function linksIn(string $text): array +{ + \preg_match_all('/\]\(([^)]*)\)/', $text, $found); + + return $found[1]; +} + +/** + * The anchors a file's headings give it. + * + * The names GitHub gives them: the heading in lower case, with everything that is not a + * letter, a digit, a space, a hyphen or an underscore taken out, and the spaces turned into + * hyphens. + * + * @return list + */ +function anchorsOf(string $text): array +{ + \preg_match_all('/^#{1,6}\s+(.*)$/m', $text, $found); + + $anchors = []; + + foreach ($found[1] as $heading) { + $slug = \strtolower(\trim($heading)); + $slug = (string) \preg_replace('/[^\p{L}\p{N} _-]/u', '', $slug); + + $anchors[] = \str_replace(' ', '-', $slug); + } + + return $anchors; +} + $manifest = manifest($root); $pages = pages($root . '/content'); @@ -265,7 +314,10 @@ function paths(string $reference): array continue; } - if (!\str_contains($written, $piece)) { + // The link targets are the bundle's own business — a link to a page the bundle + // carries is rewritten to the file beside it — so the text is compared without + // them. What a link points at is checked by check 9. + if (!\str_contains(withoutTargets($written), withoutTargets($piece))) { $report->fail(\sprintf( 'The reference file "%s" does not carry the text of "%s". It is a copy of the page, ' . 'not a paraphrase of it.', @@ -446,6 +498,69 @@ function paths(string $reference): array // The address is read out of the site's own page rather than written down, so a page that // moves does not leave this pointing at a page that is no longer there — the pattern // `check-readme-links.php` already uses for the command-line page. +// 9. Every link in the bundle goes somewhere. +// +// The bundle is distributed as an archive with no website beside it, so a link to a page of +// the site resolves to nothing in it. A link to a page the bundle carries is rewritten to +// the file beside it, and a link to a page it does not carry is the address of that page. +// +// Either is a link that goes somewhere. A link that is still a path of the site is a link +// that goes nowhere, and an agent that follows it is told the page does not exist. +$bundleFiles = []; + +foreach (\glob($bundle . '/references/*.md') ?: [] as $file) { + $bundleFiles['references/' . \basename($file)] = (string) \file_get_contents($file); +} + +$bundleFiles['SKILL.md'] = (string) \file_get_contents($bundle . '/SKILL.md'); + +foreach ($bundleFiles as $name => $text) { + $directory = \dirname($name); + + foreach (linksIn($text) as $target) { + if (\str_starts_with($target, 'http://') || \str_starts_with($target, 'https://') + || \str_starts_with($target, 'mailto:')) { + continue; + } + + if (\str_starts_with($target, '/')) { + $report->fail(\sprintf( + 'The link "%s" in "%s" is a path of the site, and the bundle is read beside no site.', + $target, + $name, + )); + + continue; + } + + // A link with only a fragment is a link into the file it is in. + if (\str_starts_with($target, '#')) { + $anchor = \substr($target, 1); + + if (!\in_array($anchor, anchorsOf($text), true)) { + $report->fail(\sprintf( + 'The link "%s" in "%s" goes to nothing: the file has no heading of that name.', + $target, + $name, + )); + } + + continue; + } + + $path = $directory . '/' . \explode('#', $target)[0]; + + if (!\is_file($bundle . '/' . $path)) { + $report->fail(\sprintf( + 'The link "%s" in "%s" goes to nothing: there is no "%s" in the bundle.', + $target, + $name, + $path, + )); + } + } +} + $readme = $root . '/../markdown-word/README.md'; if (\is_file($readme)) { diff --git a/tools/write-skill.js b/tools/write-skill.js index 2d793db..c04e5cd 100644 --- a/tools/write-skill.js +++ b/tools/write-skill.js @@ -9,6 +9,29 @@ import { expandExamples, slug } from "./markdown.js"; const here = dirname(fileURLToPath(import.meta.url)); +/** + * The address the site is served from, without a trailing slash. + * + * The same address the site itself is built with, so a link in the bundle that points at + * the site points at the site that was built. + */ +const CANONICAL = (process.env.MARKDOWN_OFFICE_CANONICAL ?? "https://markdown-office.github.io").replace( + /\/+$/, + "", +); + +/** + * The address a page is at, from its frontmatter. + * + * Null where the page states none, which is a page the bundle cannot place: a link to it + * cannot be rewritten into a link to a file. + */ +function permalinkOf(source) { + const found = /^permalink:\s*(\S+)\s*$/m.exec(source); + + return found === null ? null : found[1]; +} + /** * The tools the library ships, which the bundle carries unchanged. * @@ -94,19 +117,50 @@ export async function writeSkill({ dir }) { // The pages, one file each, with the frontmatter off. await mkdir(join(bundle, "references"), { recursive: true }); + const carried = manifest.references ?? []; + + // The address a page is at on the site, and the file the bundle carries it in. + // + // The bundle is distributed as an archive with no website beside it, so a page's link to + // another page of the site resolves to nothing. A link to a page the bundle carries + // becomes a link to the file beside it; a link to a page it does not carry becomes the + // address of the page on the site, which is where the page is. + const at = new Map(); + + for (const reference of carried) { + const page = join(here, "..", "content", reference.page); + + if (!existsSync(page)) { + throw new Error(`The page "${reference.page}" is not there, and the bundle says it carries it.`); + } + + const permalink = permalinkOf(readFileSync(page, "utf8")); + + if (permalink !== null) { + at.set(permalink, reference.file); + } + } + await Promise.all( - (manifest.references ?? []).map(async (reference) => { + carried.map(async (reference) => { const page = join(here, "..", "content", reference.page); - if (! existsSync(page)) { - throw new Error(`The page "${reference.page}" is not there, and the bundle says it carries it.`); - } - const source = readFileSync(page, "utf8"); await writeFile( join(bundle, "references", reference.file), - `${expandExamples(source, examples)}\n`, + `${expandExamples(source, examples)}\n`.replace( + /\]\((\/[^)\s#]*)(#[^)\s]*)?\)/g, + (whole, path, fragment) => { + const target = at.get(path); + + if (target !== undefined) { + return `](${target}${fragment ?? ""})`; + } + + return `](${CANONICAL}${path})`; + }, + ), ); }), ); From 19fce5004f141ee9efecfaf906d03fe207880498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 10:46:17 +0100 Subject: [PATCH 2/7] The lock carries the gradients as well The library's patch now writes a gradient of more than two stops and a background painted with a gradient, and the lock names the patch that does it. Without this the site installs the older patch, and the library asks for what the newer one has. --- composer.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index 00df4fd..aafd4ad 100644 --- a/composer.lock +++ b/composer.lock @@ -740,7 +740,7 @@ "dist": { "type": "path", "url": "../markdown-presentation", - "reference": "ea0926185c80eef6305eebb75ca2a762bbe3a00c" + "reference": "3903c2996d9df74c879691b96da13b657c89f7bc" }, "require": { "cweagans/composer-patches": "^2.0", @@ -765,9 +765,9 @@ "patches": { "phpoffice/phppresentation": [ { - "description": "PHPPresentation 1.2.0 brought up to upstream's master, with the fixes that give a picture the path, the bytes and the name it was read from, that find the folder of a part of a package with the slashes of the package, that write a chart series in the order the schema names it, that write the data points of a series and the fill of the series itself, and that give a category axis a tick label skip of its own", - "url": "https://raw.githubusercontent.com/markdown-office/PHPPresentation/605bc7c9724ac4a0781cc1692ff7ae279a33bb8a/patches/phpoffice-phppresentation-1.2.0-to-master.patch", - "sha256": "46862cef4470ae8e755a03f1f8afd94c7e772cc1ee4ab0a39441c2cb392d5146" + "description": "PHPPresentation 1.2.0 brought up to upstream's master, with the fixes that give a picture the path, the bytes and the name it was read from, that find the folder of a part of a package with the slashes of the package, that write a chart series in the order the schema names it, that write the data points of a series and the fill of the series itself, and that give a category axis a tick label skip of its own, and that write a gradient of more than two stops and a background painted with a gradient", + "url": "https://raw.githubusercontent.com/markdown-office/PHPPresentation/c6de3987c9019a1d60876b4aa1964f15e1abc132/patches/phpoffice-phppresentation-1.2.0-to-master.patch", + "sha256": "bb422f73ad2211948835cf9c5f1aed7ec993c3e4595d6e11bd9836e2f9a5f8d5" } ] } @@ -839,7 +839,7 @@ "dist": { "type": "path", "url": "../markdown-word", - "reference": "9a0c7ab08a66c89cc70ba4cf6788cff328027e71" + "reference": "9e244b4eb89e4898cbe2b56922743f8c33a5089d" }, "require": { "cweagans/composer-patches": "^2.0", From 52a2a79f529fed157f1fd54f65f4881a60b017cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 12:07:02 +0100 Subject: [PATCH 3/7] The page carries a background and a slide's own values The frontmatter table has five more keys, and the themes page says what `theme-variables:` is: the four keys a `theme.yml` holds a value in, read by the reader that reads the theme's own file. The three that hold the theme's identity are not a slide's, so naming one is refused. The errors page carries the new exception, and the index counts the keys as the code does. --- content/presentation/directives.md | 7 ++++++- content/presentation/errors.md | 1 + content/presentation/index.md | 4 ++-- content/presentation/themes.md | 11 +++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/content/presentation/directives.md b/content/presentation/directives.md index b9b380e..0d112d4 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -256,7 +256,7 @@ splits here the way it does there. ## Frontmatter directives -Every key of every slide's frontmatter must be one of these nine: +Every key of every slide's frontmatter must be one of these fourteen: @@ -271,6 +271,11 @@ Every key of every slide's frontmatter must be one of these nine: | `description` | string | `null` | **first only** | | `template` | string, or a list of them | none | any | | `templates-file` | string | none | **first only** | +| `background` | string | none | any | +| `background-gradient` | string | none | any | +| `background-angle` | integer | 180 | any | +| `background-radial` | boolean | `false` | any | +| `theme-variables` | mapping | the theme's | any | The layout roles are listed with the [themes](/presentation/themes/#layout-roles). Anything else — including Slidev's own `two-cols` — is refused with the list of what would have worked. diff --git a/content/presentation/errors.md b/content/presentation/errors.md index c40d808..2fa0dd5 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -22,6 +22,7 @@ is about both libraries, is on [Errors in general](/errors/). | `UnknownLayoutRole` | `layout:` is not one of the layout roles | | `UnknownTemplate` | a slide names a template the deck does not have — the message names the templates | | `MalformedChrome` | a template holds ordinary Markdown | +| `MalformedGradient` | a gradient is not one: too few stops, a stop that names no colour, a position that is not a number, an angle that is not a whole number of degrees | | `MalformedTemplatesFile` | the templates file a deck names holds a slide | | `TemplatesFileNotFound` | the templates file a deck names is not there, or is outside the roots | | `TemplatesFileTooLarge` | the templates file a deck names is over the ceiling | diff --git a/content/presentation/index.md b/content/presentation/index.md index 06b6d5c..58ddd64 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -62,8 +62,8 @@ slides and **cannot be a thematic break** — use `***`. ### Extended: frontmatter and themes The deck's own configuration goes in the frontmatter of slide 1. Every key is in a **closed set of -nine**, and anything outside it fails the build with a message naming what would have worked. The -nine are listed with the [directives](/presentation/directives/#frontmatter-directives). +fourteen**, and anything outside it fails the build with a message naming what would have worked. +The fourteen are listed with the [directives](/presentation/directives/#frontmatter-directives). {% example "The four keys that describe a deck" %} ````md presentation diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 59be80f..1dd14e1 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -39,6 +39,17 @@ and the table names the keys as they are — the colon is YAML's and not theirs. `dark: yes` being refused is not pedantry. YAML 1.1 truthy strings are what make a theme written by hand behave differently from one written by a machine, and this one is for machines. +## A slide's own values + +A slide may name the theme's values for itself, in its frontmatter, and what it names is a +`theme.yml`: the four keys above that hold a value — `dark`, `colors`, `fonts` and `style` — +read by the reader that reads the theme's own file. The three that hold the theme's identity +— `name`, `description` and `author` — are not a slide's, so naming one is refused. + +A slide's values are laid over the template's, and the template's over the theme's, and one +value laid over another leaves the rest alone: a slide that names one colour leaves the other +eleven as they were. + ## The twelve colour slots From d3274834b323b3d99543fa940e601520cb0cfc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 16:06:39 +0100 Subject: [PATCH 4/7] The page says what a gradient is and what it refuses A gradient on a shape was nowhere on the page, and the errors page described MalformedGradient as though a gradient could only be wrong in four ways, which it could not: the stops have to move forward, there is a ceiling on how many there are, and a shape cannot be filled with a colour and a gradient at once. --- content/presentation/directives.md | 11 +++++++++++ content/presentation/errors.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/content/presentation/directives.md b/content/presentation/directives.md index 0d112d4..51e557d 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -280,6 +280,17 @@ Every key of every slide's frontmatter must be one of these fourteen: The layout roles are listed with the [themes](/presentation/themes/#layout-roles). Anything else — including Slidev's own `two-cols` — is refused with the list of what would have worked. +**A background is one colour or one gradient.** `background-gradient` takes the stops of the +gradient, one after another with a comma between them, and a stop may carry the place it is at +as a percentage of the gradient — `accent1@50` is that stop at half of the way. +`background-angle` says which way the gradient runs, and `background-radial` says it runs from +the middle outwards, which is a way of running that has no angle. + +**A free shape may be painted with a gradient too**, with `gradient`, `gradient-angle` and +`gradient-radial` carrying the same three things. A shape is filled with a colour **or** with +a gradient, and the two together are refused. Every attribute each of them reads is in the +package's own [README](https://github.com/markdown-office/markdown-presentation#free-shapes). + A deck directive on a later slide is refused by name, because it is nearly always a slide that was meant to be the first one. The deck's own frontmatter **is the first slide's**, so `theme:`, `author:`, `description:` and `templates-file:` belong on the slide that opens the deck. diff --git a/content/presentation/errors.md b/content/presentation/errors.md index 2fa0dd5..1b10132 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -22,7 +22,7 @@ is about both libraries, is on [Errors in general](/errors/). | `UnknownLayoutRole` | `layout:` is not one of the layout roles | | `UnknownTemplate` | a slide names a template the deck does not have — the message names the templates | | `MalformedChrome` | a template holds ordinary Markdown | -| `MalformedGradient` | a gradient is not one: too few stops, a stop that names no colour, a position that is not a number, an angle that is not a whole number of degrees | +| `MalformedGradient` | a gradient is not one: too few stops, a stop that names no colour, a position that is not a number, an angle that is not a whole number of degrees, stops that do not move forward, more stops than the ceiling allows, or a colour and a gradient on one shape | | `MalformedTemplatesFile` | the templates file a deck names holds a slide | | `TemplatesFileNotFound` | the templates file a deck names is not there, or is outside the roots | | `TemplatesFileTooLarge` | the templates file a deck names is over the ceiling | From 9d31d62552d1f95e976fde68121ec7517226d6c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 17:21:08 +0100 Subject: [PATCH 5/7] The page says what a definition may carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A definition names a template and nothing but its own settings, and the page said it names a template and nothing else — which the key beside it refutes. --- content/presentation/directives.md | 23 +++++++++++++++++++++-- content/presentation/index.md | 4 ++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/content/presentation/directives.md b/content/presentation/directives.md index 51e557d..5fd0841 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -256,7 +256,7 @@ splits here the way it does there. ## Frontmatter directives -Every key of every slide's frontmatter must be one of these fourteen: +Every key of every slide's frontmatter must be one of these fifteen: @@ -271,6 +271,7 @@ Every key of every slide's frontmatter must be one of these fourteen: | `description` | string | `null` | **first only** | | `template` | string, or a list of them | none | any | | `templates-file` | string | none | **first only** | +| `template-settings` | mapping | none | a definition | | `background` | string | none | any | | `background-gradient` | string | none | any | | `background-angle` | integer | 180 | any | @@ -330,7 +331,7 @@ writing it out on every slide means changing it on every slide. A **template** i written once. A template is a slide of the deck's own Markdown whose frontmatter names a template and **nothing -else**: +but its own settings**: ```markdown --- @@ -352,6 +353,24 @@ template: brand --- ``` +**A template's own settings are named as the template's.** A template may carry a background and +the theme's values for itself, and every slide that draws it gets them. They go under +`template-settings:`, which holds the same keys a slide's own settings do: + +```markdown +--- +template: brand +template-settings: + background: accent1 + theme-variables: + dark: true +--- +``` + +The ordinary keys mean the **slide's** own everywhere, which is what tells a definition from a +slide: a slide that draws a template and names a `background:` of its own is a slide, and is +drawn with it. + {% example "A deck whose wordmark is written once" %} ````md presentation --- diff --git a/content/presentation/index.md b/content/presentation/index.md index 58ddd64..46b7de7 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -62,8 +62,8 @@ slides and **cannot be a thematic break** — use `***`. ### Extended: frontmatter and themes The deck's own configuration goes in the frontmatter of slide 1. Every key is in a **closed set of -fourteen**, and anything outside it fails the build with a message naming what would have worked. -The fourteen are listed with the [directives](/presentation/directives/#frontmatter-directives). +fifteen**, and anything outside it fails the build with a message naming what would have worked. +The fifteen are listed with the [directives](/presentation/directives/#frontmatter-directives). {% example "The four keys that describe a deck" %} ````md presentation From 3b549e9205e42f44b7b2ffdee9d435e6f2ba8af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 17:47:24 +0100 Subject: [PATCH 6/7] The two findings Sonar raised on the skill's tools The pattern that looks at the bundle's reference files was written out three times, and the one that reads a page's permalink could reach over a line, which gives the engine two ways to match the same text and sends it back over what it has already read. --- tools/check-skill.php | 14 +++++++++++--- tools/write-skill.js | 5 ++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tools/check-skill.php b/tools/check-skill.php index 70b545b..54a9dc1 100644 --- a/tools/check-skill.php +++ b/tools/check-skill.php @@ -73,6 +73,14 @@ */ const SKILL_PAGE = 'content/skills/index.md'; +/** + * The reference files the bundle carries, as a glob beside the bundle. + * + * The bundle's own path is in front of it, so the three places that look at the reference + * files look at the same files. + */ +const REFERENCE_FILES = '/references/*.md'; + /** * The manifest: the pages the bundle carries, and the files they are carried in. * @@ -392,7 +400,7 @@ function anchorsOf(string $text): array } // 4. Every path a reference file names exists. -foreach (\glob($bundle . '/references/*.md') ?: [] as $file) { +foreach (\glob($bundle . REFERENCE_FILES) ?: [] as $file) { foreach (paths((string) \file_get_contents($file)) as $path) { if (!\file_exists($bundle . '/' . $path)) { $report->fail(\sprintf( @@ -424,7 +432,7 @@ function anchorsOf(string $text): array // Every reference the bundle carries is named in the skill, because a file nothing // points at is a file nobody reads. - foreach (\glob($bundle . '/references/*.md') ?: [] as $file) { + foreach (\glob($bundle . REFERENCE_FILES) ?: [] as $file) { $name = \basename($file); if (!\str_contains($skill, 'references/' . $name)) { @@ -508,7 +516,7 @@ function anchorsOf(string $text): array // that goes nowhere, and an agent that follows it is told the page does not exist. $bundleFiles = []; -foreach (\glob($bundle . '/references/*.md') ?: [] as $file) { +foreach (\glob($bundle . REFERENCE_FILES) ?: [] as $file) { $bundleFiles['references/' . \basename($file)] = (string) \file_get_contents($file); } diff --git a/tools/write-skill.js b/tools/write-skill.js index c04e5cd..58e0951 100644 --- a/tools/write-skill.js +++ b/tools/write-skill.js @@ -27,7 +27,10 @@ const CANONICAL = (process.env.MARKDOWN_OFFICE_CANONICAL ?? "https://markdown-of * cannot be rewritten into a link to a file. */ function permalinkOf(source) { - const found = /^permalink:\s*(\S+)\s*$/m.exec(source); + // The spaces and tabs, and not the whitespace: a newline is a line of its own, and a + // pattern that can reach over one has two ways to match the same text, which is what + // makes the engine walk back over what it has already read. + const found = /^permalink:[ \t]*(\S+)[ \t]*$/m.exec(source); return found === null ? null : found[1]; } From 44502461948e8533a3df73febef9b7b996a00fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 17:50:49 +0100 Subject: [PATCH 7/7] The slashes at the end of an address are counted off, not matched The pattern that stripped them gave one of them back at a time, and it gave one back for every place it tried, so a run of two hundred thousand slashes that was not at the end took fourteen seconds. The address is the environment's to give, and the environment is not a person who would not do that. --- tools/write-skill.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tools/write-skill.js b/tools/write-skill.js index 58e0951..d412209 100644 --- a/tools/write-skill.js +++ b/tools/write-skill.js @@ -15,11 +15,28 @@ const here = dirname(fileURLToPath(import.meta.url)); * The same address the site itself is built with, so a link in the bundle that points at * the site points at the site that was built. */ -const CANONICAL = (process.env.MARKDOWN_OFFICE_CANONICAL ?? "https://markdown-office.github.io").replace( - /\/+$/, - "", +const CANONICAL = withoutTrailingSlashes( + process.env.MARKDOWN_OFFICE_CANONICAL ?? "https://markdown-office.github.io", ); +/** + * An address without the slashes at its end. + * + * The slashes are counted off rather than matched: a pattern that strips them gives one + * of them back at a time, and it gives one back for every place it tries, so a long run + * of them costs more the longer it is. The address is the environment's to give, and the + * environment is not a person who would not do that. + */ +function withoutTrailingSlashes(address) { + let end = address.length; + + while (end > 0 && address[end - 1] === "/") { + end -= 1; + } + + return address.slice(0, end); +} + /** * The address a page is at, from its frontmatter. * @@ -27,10 +44,7 @@ const CANONICAL = (process.env.MARKDOWN_OFFICE_CANONICAL ?? "https://markdown-of * cannot be rewritten into a link to a file. */ function permalinkOf(source) { - // The spaces and tabs, and not the whitespace: a newline is a line of its own, and a - // pattern that can reach over one has two ways to match the same text, which is what - // makes the engine walk back over what it has already read. - const found = /^permalink:[ \t]*(\S+)[ \t]*$/m.exec(source); + const found = /^permalink:\s*(\S+)\s*$/m.exec(source); return found === null ? null : found[1]; }