From 3962c276fe36ac5dcc514f87928b2276a5f91d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 21:26:39 +0100 Subject: [PATCH 01/10] A theme is one file, and the deck's settings are named as the deck's The two libraries read a theme out of one file now, and the slides half has a layer for the deck's own settings. The pages said what they did before, and that was five files, a `styles.yml` nobody read, a `layouts.yml` of its own and fifteen frontmatter keys. The reference tables are corrected against the code as it is: the slides half's theme keys gain the two sections, its exceptions lose `UnknownStyleSlot` and gain `RetiredThemeFile`, and its frontmatter gains `deck-settings`. The `StyleSlots` class the tables asked for is gone from the slides half, so the style slots are asked of the Word half alone. The site's own finder holds a theme for each half, because a theme file serves one of them and each half refuses the other's keys. --- content/command-line/theme.md | 71 ++++++++++++++++--- content/general/files.md | 28 +++++--- content/presentation/directives.md | 74 +++++++++++++++++--- content/presentation/errors.md | 2 +- content/presentation/index.md | 17 ++--- content/presentation/themes.md | 107 +++++++++++++++++++++-------- content/word/configuration.md | 18 +++-- content/word/directives.md | 3 +- content/word/templates.md | 12 ++-- src/Finder/MemoryFinder.php | 60 +++++++++------- src/Reference/UnreadableCode.php | 23 +++++++ src/Reference/Vocabularies.php | 29 +++++++- tests/Claims/deck-settings.php | 77 +++++++++++++++++++++ tests/Claims/finder.php | 19 +++-- tests/Claims/text-styles.php | 44 +++++++++--- tests/Support/Theme.php | 71 +++++++++++++------ tools/lib/Runner.php | 2 +- 17 files changed, 510 insertions(+), 147 deletions(-) create mode 100644 src/Reference/UnreadableCode.php create mode 100644 tests/Claims/deck-settings.php diff --git a/content/command-line/theme.md b/content/command-line/theme.md index e3c05af..3651900 100644 --- a/content/command-line/theme.md +++ b/content/command-line/theme.md @@ -37,14 +37,21 @@ mdoffice theme: Pass --force to replace it. ## What is written -Three files go into the directory. +Two files go into the directory. | File | Holds | | --- | --- | -| `theme.yml` | the colours, the fonts and the type scale | -| `layouts.yml` | the layout roles, each pointed at a layout of the deck | +| `theme.yml` | the colours, the fonts, the type scale and the layout roles | | `template.pptx` | the deck's own layouts, without its slides | +A deck with no master has no template to write, so the directory holds the theme's file alone. + +**Every role is mapped.** The roles the deck's own layouts cover are named the deck's way, and +the rest take the library's own names — the names the library draws the layouts it generates +under. A theme's `layouts:` section is the whole mapping, so a role it leaves out is a role no +slide can be drawn on, and the roles the deck has no layout for are reported rather than +pointed at a layout that is something else. + **The theme is read back before the command reports success.** The library's own reader reads it and the library's own geometry reads the boxes of the template, so a theme this command cannot read is a failure here rather than a theme nobody can use. @@ -61,8 +68,9 @@ largest type on the first slide, the body the size the prose is written at, and scale the sizes below the title. **The layouts** are mapped by the placeholders they hold, not by their names: a deck calls its -layouts whatever it likes, and the same layout is called different things in two offices. A role -the deck has no layout for is reported rather than pointed at a layout that is something else. +layouts whatever it likes, and the same layout is called different things in two offices. The +roles the deck has no layout for are reported rather than pointed at a layout that is something +else. **The template** is the deck's layouts without its slides, and without the pictures on them. A template places, and the slides of the deck it came from are not a placement. @@ -79,15 +87,56 @@ the file can be told apart from the numbers somebody chose. The command says what it read and where it read it from: ```text -mdoffice theme: wrote the theme for "deck.pptx" into "deck". +mdoffice theme: wrote the theme for "probe.pptx" into "/tmp/probe-theme". - theme.yml the colours, the fonts and the type scale - layouts.yml 1 of the 8 layout roles mapped - template.pptx the deck's 1 layouts, without its 3 slides + theme.yml the colours, the fonts, the type scale and the layout roles + template.pptx the deck's 3 layouts, without its 2 slides colours - dk1 #000000 the type - lt2 #000000 a filled block + dk1 #0D1B2A the background of the slides + lt1 #F8FAFC the type + accent1 #00B4D8 the colour the deck draws with + hlink #00B4D8 the accent, as a link is drawn + + type + heading Cambria the face of the type at the title size + body Calibri the face the deck writes its prose in + title 26pt the largest type on most of the slides + cover 26pt the largest type on the first slide + section 26pt the title on a slide that carries little else + body size 14pt the size the deck writes its prose at + headings 14pt the sizes below the title + leading 1.0 the line spacing the deck sets + heading weight regular the weight of the headings + heading ink ink the colour the headings are drawn in + + roles + title Titelfolie a title and a subtitle + content Titel und Inhalt a title and one column + two-column Two Content the library's own name + section Section Header the library's own name + blank Leer nothing in it at all + image-text Picture Left the library's own name + image-title Picture with Caption the library's own name + image-slide Full Bleed Picture the library's own name + + the deck's own layouts map 3 of the 8 roles; the library's own names map the other 5. + + left at the library's default + colours dk2 #44546A, lt2 #E7E6E6, accent2 #ED7D31, accent3 #A5A5A5, accent4 #FFC000, accent5 #5B9BD5, accent6 #70AD47, folHlink #954F72 + type margin, gutter, block, rule, mono + + the deck has no layout for + two-column, section, image-text, image-title, image-slide + + the template places 0 of the 3 roles the deck's own layouts map. +``` + +A deck with no master has no template, and the report says so where the template's line would +be: + +```text + the deck has no master, so the theme carries no template. ``` The report goes to standard output, and the theme it writes is a directory the slides part diff --git a/content/general/files.md b/content/general/files.md index 28d0c65..00bd9ad 100644 --- a/content/general/files.md +++ b/content/general/files.md @@ -62,7 +62,7 @@ as for a theme. | Role | What it is | | --- | --- | -| `theme` | a file of a theme — its manifest, its slots, its layouts, its master | +| `theme` | a file of a theme — its manifest and its master | | `picture` | an image a document names | | `document` | anything else a document names by path — a `.dotx`, a deck's templates file | @@ -77,16 +77,23 @@ not compose the same ones. | The library wants | It asks | Which half | | --- | --- | --- | | the theme `corporate`'s manifest | `read('corporate/theme.yml', 'theme')` | both | -| the theme `corporate`'s styles | `read('corporate/styles.yml', 'theme')` | both | -| the theme `corporate`'s layouts | `read('corporate/layouts.yml', 'theme')` | slides | -| the theme `corporate`'s text styles | `read('corporate/text-styles.yml', 'theme')` | slides | +| the theme `corporate`'s old styles | `read('corporate/styles.yml', 'theme')` | both | +| the theme `corporate`'s old layouts | `read('corporate/layouts.yml', 'theme')` | slides | +| the theme `corporate`'s old text styles | `read('corporate/text-styles.yml', 'theme')` | slides | | the theme `corporate`'s master | `read('corporate/template.pptx', 'theme')` | slides | | the picture `logo.png` | `read('logo.png', 'picture')` | both | | the document's `report.dotx` | `read('report.dotx', 'document')` | Word | | the deck's templates file | `read('slides.md', 'document')` | slides | -The Word half's `styles.yml` carries `textStyles:` inside it; the slides half keeps the named -styles in a `text-styles.yml` of their own. The finder is never told which is which — it is asked +**Three of those names are asked for only so that they can be refused.** A theme is one file +now, and `styles.yml`, `layouts.yml` and `text-styles.yml` are the format it used to be made +of: the library asks the finder whether one of them is there, and refuses the theme when it +is. The other five are read. + +The two halves keep separate vocabularies, and a theme file serves one of them: the Word +half's `theme.yml` may carry `styles:` and `textStyles:`, and the slides half's may carry +`dark:`, `colors:`, `fonts:`, `style:`, `layouts:` and `textStyles:`. Each half refuses the +other's keys, so one file cannot be both. The finder is never told which is which — it is asked for a name. ## A finder answers; it does not refuse @@ -299,17 +306,18 @@ theme: house A document whose theme came from a finder rather than from a directory. -The theme's own `styles.yml` names the face and the colour of the heading, and the library -asked the finder for both of them. +The theme's own `theme.yml` names the face and the colour of the heading, and the library +asked the finder for it. ```` {% endexample %} -And a deck, from the same source: +And a deck, from the same source. The finder holds a theme for each half, because a theme file +serves one of them: {% example "A deck whose theme came from a finder" %} ````md presentation finder --- -theme: house +theme: deck --- # The house style diff --git a/content/presentation/directives.md b/content/presentation/directives.md index 5fd0841..f519d17 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -128,7 +128,7 @@ it. The three forms and the eight attributes are the suite's, and are on [How directives work](/directives/#a-word-in-a-style). The named styles come from the theme's -`text-styles.yml` — see [Text styles](/presentation/themes/#text-styles). +`textStyles:` section — see [Text styles](/presentation/themes/#text-styles). **What this half does with a name it does not know is the opposite of the Word half's: it refuses it**, and the message names the styles the theme does have. Every inline directive in a deck is a @@ -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 fifteen: +Every key of every slide's frontmatter must be one of these sixteen: @@ -272,6 +272,7 @@ Every key of every slide's frontmatter must be one of these fifteen: | `template` | string, or a list of them | none | any | | `templates-file` | string | none | **first only** | | `template-settings` | mapping | none | a definition | +| `deck-settings` | mapping | none | **first only** | | `background` | string | none | any | | `background-gradient` | string | none | any | | `background-angle` | integer | 180 | any | @@ -281,6 +282,11 @@ Every key of every slide's frontmatter must be one of these fifteen: 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. +**The ordinary keys are the slide's own, wherever they are written.** A key that holds somebody +else's settings says whose: `template-settings:` says the template's, and `deck-settings:` says +the deck's. That is what tells a definition from a slide and a slide from a deck — see +[The deck's own settings](#the-decks-own-settings). + **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. @@ -294,7 +300,8 @@ package's own [README](https://github.com/markdown-office/markdown-presentation# 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. +`author:`, `description:`, `templates-file:` and `deck-settings:` belong on the slide that opens +the deck. {% example %} ````md presentation "The four keys that describe a deck" @@ -323,6 +330,57 @@ Lead with the renewal number. It is the one people remember. ```` {% endexample %} +## The deck's own settings + +**A deck's settings are the deck's, and they are named as the deck's.** Every slide is drawn +with them, and they go under `deck-settings:`, which holds the same keys a slide's own settings +do: + +```markdown +--- +theme: corporate-dark +deck-settings: + background: "#101014" + theme-variables: + colors: + accent1: "#123456" +--- +``` + +`deck-settings:` is the deck's alone, so it is refused on any slide but the first — the way +`theme:` is. It holds the five settings — `background`, `background-gradient`, +`background-angle`, `background-radial` and `theme-variables` — and a key that is not one of +them is refused by name rather than left out, so that a typo in a deck's settings is a failure +and not a setting that does nothing. + +**The precedence is slide → template → deck → theme.** A slide's own settings are laid over the +template's, and the template's over the deck's, and the deck'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. + +That is the whole of the rule, and it is one rule: **the ordinary keys are the slide's own, +wherever they are written; a key that holds somebody else's settings says whose.** A slide that +draws a template and names a `background:` of its own is therefore a slide, and is drawn with +it. + +{% example "A deck whose accent every slide is drawn with" %} +````md presentation +--- +theme: corporate-dark +deck-settings: + theme-variables: + colors: + accent1: "#123456" +--- + +# The quarter + +## What changed + +- Two products shipped +```` +{% endexample %} + ## Slide templates The furniture of a deck — the wordmark (a company's name on a slide, with its mark), the rule, the @@ -368,8 +426,8 @@ template-settings: ``` 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. +slide. A template's settings are laid over the slide's, and the deck's over the template's — +see [The deck's own settings](#the-decks-own-settings). {% example "A deck whose wordmark is written once" %} ````md presentation @@ -412,9 +470,9 @@ template: [brand, page] ``` **`layout:` and `template:` are independent.** `layout:` chooses the geometry — the role the -theme's `layouts.yml` maps onto a box on the page — and `template:` adds furniture on top of it. -`layout: blank` with `template: brand` is a slide with no furniture of its own and the wordmark -drawn where the template puts it. +theme's `layouts:` section maps onto a box on the page — and `template:` adds furniture on top +of it. `layout: blank` with `template: brand` is a slide with no furniture of its own and the +wordmark drawn where the template puts it. The chrome is drawn **over** what the slide placed itself, so a full-bleed picture is behind the wordmark and not over it. diff --git a/content/presentation/errors.md b/content/presentation/errors.md index 1b10132..8a33d0c 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -36,7 +36,7 @@ is about both libraries, is on [Errors in general](/errors/). | `FencedFrontmatter` | a ```` ```yaml ```` block at the top of a slide | | `MalformedTheme` | a theme file is not shaped like one | | `ThemeNotFound` | a theme name resolves to nothing — the message lists the ones that do | -| `UnknownStyleSlot` | `styles.yml` names a slot that does not exist | +| `RetiredThemeFile` | a file of the old theme format is beside the theme — the message says what to do with it | | `MalformedTemplate` | a `template.pptx` is not one | | `MalformedShape` | a free shape has an attribute it does not read, or a value it cannot be | | `UnknownTextStyle` | an inline style names a style the theme does not have — the message names the styles | diff --git a/content/presentation/index.md b/content/presentation/index.md index 46b7de7..91f5ddf 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 -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). +sixteen**, and anything outside it fails the build with a message naming what would have worked. +The sixteen are listed with the [directives](/presentation/directives/#frontmatter-directives). {% example "The four keys that describe a deck" %} ````md presentation @@ -86,10 +86,10 @@ description: What changed, what it cost, and what happens next. #### A theme A theme is a directory, and `theme.yml` is what it has to hold: the name, an optional description -and author, the colours, the fonts and the geometry. A `theme:` may also name the `theme.yml` -itself, in which case the theme's other files are the ones beside it. The keys it takes, the -colour slots, the geometry and the themes that ship are all on -[Themes and layout](/presentation/themes/). +and author, the colours, the fonts and the geometry — and, when it says anything, the layout +roles and the named text styles. A `theme:` may also name the `theme.yml` itself, in which case +the theme's master beside it is the theme's. The keys it takes, the colour slots, the geometry +and the themes that ship are all on [Themes and layout](/presentation/themes/). Resolution is a path, then `themes/` beside the deck, then the package's own. **No silent fallback to `default`**: a name that does not resolve raises `ThemeNotFound` listing the ones that @@ -161,8 +161,9 @@ More, including the refusal table and the three things that look like they work ## 2. Command line -**The package declares no `bin`**, and `examples/build.php` in the repository is -the closest thing — it converts a directory of Markdown files and renders page images of each. +**The package declares no `bin`**, and the build script in the library's own `examples` +directory is the closest thing — it converts a directory of Markdown files and renders page +images of each. A shell loop is the whole substitute: diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 1dd14e1..074b244 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -7,16 +7,15 @@ section: Markdown Presentation order: 2 --- -A theme is a directory. `theme.yml` is required; `styles.yml`, `layouts.yml` and -`template.pptx` are optional. +A theme is **one file**, plus a master if it has one. `theme.yml` holds the look, the roles the +theme maps and the named styles a deck may write in, and `template.pptx` beside it is the +master: the PowerPoint file that says where the theme's layouts put their boxes. A theme +without one is drawn with the library's own geometry. ```text themes/corporate-dark/ -├── theme.yml required -├── styles.yml the Word half's vocabulary — the renderer does not read it -├── layouts.yml which PowerPoint layout each role maps to -├── text-styles.yml the names a deck writes words in — see [Text styles](#text-styles) -└── template.pptx optional, a path +├── theme.yml the look, the roles and the named styles +└── template.pptx optional, the master ``` ## `theme.yml` @@ -32,23 +31,45 @@ themes/corporate-dark/ | `colors` | mapping | see below | `#rrggbb`, stored upper-cased | | `fonts` | mapping | Calibri / Calibri / Consolas | `heading`, `body`, `mono` | | `style` | mapping | see below | the geometry | +| `layouts` | mapping | the library's own | which layout each role maps to — see [Layout roles](#layout-roles) | +| `textStyles` | mapping | none | the names a deck writes words in — see [Text styles](#text-styles) | -The last three hold a mapping. A key is written with a colon in the file whatever it holds, +Five of them hold a mapping. A key is written with a colon in the file whatever it holds, 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. +## The files a theme used to be made of + +A theme was a directory of up to five files, and the three that are no longer read are +**refused** when they are beside the theme, each with a message that says what to do with it: + +> The theme "acme" has a "layouts.yml" beside it. A theme is one file: move its content into the "layouts:" section of its "theme.yml". + +> The theme "acme" has a "text-styles.yml" beside it. A theme is one file: move its content into the "textStyles:" section of its "theme.yml". + +> The theme "acme" has a "styles.yml" beside it. That file is the Word half's, and this half does not read it: its slots name Word styles, which a deck of slides has no use for. Delete it, and the theme is read again. + +`layouts.yml` and `text-styles.yml` are the two that became sections of the theme's own file. +`styles.yml` was the Word half's, and this half loaded it for years without ever reading it. + +They are refused rather than ignored, because a file that is quietly not read is a theme that +looks like the one its author wrote and is not: the author renames a layout in the file +beside the theme, the deck is drawn with the old mapping, and nothing in the output says so. + ## 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. +`theme.yml`: the four keys 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, and so are +`layouts` and `textStyles`: those are the theme's own shape rather than anything a slide may +lay over it. -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. +A slide's values are laid over the template's, and the template's over the deck's, and the +deck'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 @@ -93,18 +114,19 @@ subtle ink `lt2` — which is why the same theme can be dark without being a sec ## Text styles -A theme may name the styles a deck writes words in. `text-styles.yml` is optional: a theme -without one has no named styles, and every named form written against it is a name the theme does -not have. +A theme may name the styles a deck writes words in. The `textStyles:` section is optional: a +theme without one has no named styles, and every named form written against it is a name the +theme does not have. ```yaml -# themes/mine/text-styles.yml -brand: - color: accent1 - bold: true -muted: - color: lt2 - size: 10 +# themes/mine/theme.yml +textStyles: + brand: + color: accent1 + bold: true + muted: + color: lt2 + size: 10 ``` The properties are the [eight attributes](/directives/#a-word-in-a-style) an inline style takes, and @@ -126,8 +148,31 @@ Eight, and only these: 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. +`blank` and `image-slide` have no title box. The `layouts:` section maps each role to a layout +name in the master, and the names are the theme's own: + +```yaml +# themes/mine/theme.yml +layouts: + title: Titelfolie + content: Titel und Inhalt + two-column: Zwei Spalten + section: Abschnitt + blank: Leer + image-text: Bild links + image-title: Bild mit Titel + image-slide: Ganzflächiges Bild +``` + +**A theme with no `layouts:` section is drawn with the library's own mapping**, which is the one +every theme shipping with this library used to carry in a file of its own: `Title Slide`, +`Title and Content`, `Two Content`, `Section Header`, `Blank`, `Picture Left`, +`Picture with Caption` and `Full Bleed Picture`. + +**A theme with a `layouts:` section has the whole mapping.** A role the section does not name is +a role the theme does not map, and a deck that asks for one is told which roles the theme does +map. The section is not a set of overrides over the library's own names; it is the mapping, and +it is either complete or the theme cannot draw every role. **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 — @@ -143,7 +188,7 @@ A **template** is not a theme, and it does not live beside one. | | Theme | Template | | --- | --- | --- | | Where it lives | a directory, `themes//` | a slide of the deck, or a file beside the deck | -| What it is | `theme.yml` and its friends | chrome: the free shapes it draws, and the words in them | +| What it is | `theme.yml` and its master | chrome: the free shapes it draws, and the words in them | | What names it | `theme:` | `template:` | | What it decides | the colours, the type, the grid | the furniture on the slide | @@ -192,9 +237,11 @@ hands the library a **finder** instead, and the name is asked for through it: se ## Word themes -The Word half has themes too, and they are the other shape: a directory with `theme.yml` — -`name`, `description`, `author`, and `name` required — and an optional `styles.yml` holding the -same slot names the [Word configuration](/word/configuration/) page lists. They are listed +The Word half has themes too, and they are the other shape: a `theme.yml` — `name` required, +plus `description` and `author` — whose `styles:` section holds the same slot names the +[Word configuration](/word/configuration/) page lists, and whose `textStyles:` section holds the +named styles a document may write a word in. The two halves keep separate vocabularies: a +Word theme that carries the slides half's `colors:` is refused. They are listed [there](/word/configuration/#themes). A theme is a **starting point**, not a replacement: your own `styles:` sits on top of it, and diff --git a/content/word/configuration.md b/content/word/configuration.md index a170546..41fd8df 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -170,11 +170,19 @@ which refuses a template with no content control instead of appending to it. ## Themes -A theme is a directory: `theme.yml` — `name` required, plus `description` and `author` — and an -optional `styles.yml` holding the same slot names as above. One key of that file is not a slot: -`textStyles` holds the named styles a document may write a word in — see -[Word directives](/word/directives/#a-word-in-a-style). A `theme:` may also name the -`theme.yml` itself, in which case the `styles.yml` beside it is the theme's. +A theme is **one file**: `theme.yml` — `name` required, plus `description` and `author`. Its +`styles:` section holds the same slot names as above, and its `textStyles:` section holds the +named styles a document may write a word in — see +[Word directives](/word/directives/#a-word-in-a-style). A `theme:` may also name the `theme.yml` +itself, in which case that file is the theme's. + +A `styles.yml` beside the theme is refused, and the message says where its content goes: + +> "styles.yml" is not read any more. A theme's styles are in the "styles:" section of its "theme.yml", and its named text styles in the "textStyles:" section. + +It is refused rather than ignored, because a file that is quietly not read is a theme that looks +like the one its author wrote and is not: the author moves a slot into the file beside the +theme, the document is drawn with the built-in look, and nothing in the output says so. Four themes ship, and a document names one with `theme:`: diff --git a/content/word/directives.md b/content/word/directives.md index bd56782..14765a3 100644 --- a/content/word/directives.md +++ b/content/word/directives.md @@ -312,7 +312,8 @@ Resilience :brand[Bakery] is open. The three forms and the eight attributes are the suite's, and are on [How directives work](/directives/#a-word-in-a-style). The named styles come from the theme: the -`textStyles` key of its `styles.yml`, beside the slots — see [Themes](/word/configuration/#themes). +`textStyles:` section of its `theme.yml`, beside the slots — see +[Themes](/word/configuration/#themes). **What this half does with a name it does not know is the opposite of the presentation half's: it writes the directive out as its own source text.** diff --git a/content/word/templates.md b/content/word/templates.md index fc37155..0e9c82f 100644 --- a/content/word/templates.md +++ b/content/word/templates.md @@ -148,16 +148,16 @@ $template->insert('body', $markdown)->save('northwind.docx'); // a .docx, what ## A theme -A theme is a directory: `theme.yml` — `name` required, plus `description` and `author` — and -an optional `styles.yml` holding the same slot names as -[the configuration](/word/configuration/#every-style-slot). One key of that file is not a slot: -`textStyles` holds the named styles a document may write a word in. +A theme is **one file**: `theme.yml` — `name` required, plus `description` and `author`. Its +`styles:` section holds the same slot names as +[the configuration](/word/configuration/#every-style-slot), and its `textStyles:` section holds +the named styles a document may write a word in. A document names one with `theme:`, and the ones that ship are listed with the configuration, on [the themes](/word/configuration/#themes). -A theme may also name the `theme.yml` itself, in which case the `styles.yml` beside it is the -theme's, and a document that names one by path gets that one. +A theme may also name the `theme.yml` itself, in which case that file is the theme's, and a +document that names one by path gets that one. ## The command line diff --git a/src/Finder/MemoryFinder.php b/src/Finder/MemoryFinder.php index beed4c3..c7a15ed 100644 --- a/src/Finder/MemoryFinder.php +++ b/src/Finder/MemoryFinder.php @@ -36,37 +36,51 @@ public function __construct(private readonly array $files = []) } /** - * A finder holding the site's own theme, which is in no directory at all. + * The site's own themes, which are in no directory at all. * - * The theme is here rather than under `themes/` on purpose: an example that named a - * theme beside it would be an example of the disk default, and this one is about the - * other kind. + * Two of them, because a theme file serves one half and the halves keep separate + * vocabularies: the Word half's `theme.yml` carries the slots a document is drawn from, + * and the slides half's carries the colours, the type and the layout roles. One file + * cannot be both — each half refuses the other's keys — so a finder that serves both + * holds a theme for each. + * + * They are here rather than under `themes/` on purpose: an example that named a theme + * beside it would be an example of the disk default, and these are about the other kind. */ - public static function house(): self + public static function site(): self { return new self([ 'house/theme.yml' => <<<'YAML' name: House - description: The site's own theme, which lives in a finder rather than in a directory. + description: The site's own theme for documents, which lives in a finder rather than in a directory. author: markdown-office + styles: + heading.1: + name: Georgia + bold: true + size: 20 + color: "1F3864" + paragraph: + name: Calibri + size: 11 + space: { after: 160 } YAML, - 'house/styles.yml' => <<<'YAML' - heading.1: - name: Georgia - bold: true - size: 20 - color: "1F3864" - paragraph: - name: Calibri - size: 11 - space: { after: 160 } - YAML, - 'house/layouts.yml' => <<<'YAML' - layouts: - title: Title Slide - content: Title and Content - section: Section Header - blank: Blank + 'deck/theme.yml' => <<<'YAML' + name: Deck + description: The site's own theme for decks, which lives in a finder rather than in a directory. + author: markdown-office + dark: true + colors: + dk1: "#1E1E2E" + lt1: "#CDD6F4" + accent1: "#89B4FA" + fonts: + heading: Inter + body: Roboto + style: + margin: 48 + body: 18 + title: 32 YAML, ]); } diff --git a/src/Reference/UnreadableCode.php b/src/Reference/UnreadableCode.php new file mode 100644 index 0000000..126d0ed --- /dev/null +++ b/src/Reference/UnreadableCode.php @@ -0,0 +1,23 @@ + Theme::COLOURS, ]), 'presentation.theme-keys' => new Vocabulary([ - 'ThemeMapping::KEYS' => ThemeMapping::KEYS, + 'ThemeMapping::KEYS and ThemeLoader::SECTIONS' => [ + ...ThemeMapping::KEYS, + ...self::themeSections(), + ], ]), 'presentation.theme-aliases' => new Vocabulary([ 'ThemeMapping::ALIASES' => self::themeAliases(), @@ -152,7 +155,6 @@ public static function all(): array ]), 'style-slots' => new Vocabulary([ 'Styles::defaults()' => \array_keys(Styles::defaults()), - 'StyleSlots::names()' => StyleSlots::names(), ]), 'text-style-attributes' => new Vocabulary([ 'MarkdownPresentation\\Theme\\TextStyle::ATTRIBUTES' => TextStyle::ATTRIBUTES, @@ -169,6 +171,27 @@ public static function all(): array ]; } + /** + * The sections a theme's own file may carry beside the theme's own values. + * + * The loader owns them and keeps them to itself, so they are read out of it rather + * than written down here: a copy is the thing that drifts. The constant is private and + * the library is not this repository's to change, so the reading is a reflection, and + * a constant that is renamed or dropped makes it fail loudly rather than quietly. + * + * @return list + */ + public static function themeSections(): array + { + $sections = (new \ReflectionClass(ThemeLoader::class))->getConstant('SECTIONS'); + + if (!\is_array($sections)) { + throw UnreadableCode::missingConstant(ThemeLoader::class, 'SECTIONS'); + } + + return \array_values($sections); + } + /** * The spellings a theme file may use instead of DrawingML's own. * diff --git a/tests/Claims/deck-settings.php b/tests/Claims/deck-settings.php new file mode 100644 index 0000000..32825be --- /dev/null +++ b/tests/Claims/deck-settings.php @@ -0,0 +1,77 @@ +toHaveCount(2); + expect($slides[0])->toContain('val="101014"'); +}); + +it('lets a slide\'s own background beat the deck\'s', function () { + says(Site::PRESENTATION_DIRECTIVES, 'A slide\'s own settings are laid over the template\'s'); + + $slides = Deck::slides(DECK_SETTINGS); + + expect($slides[1])->toContain('val="202020"'); + expect($slides[1])->not->toContain('val="101014"'); +}); + +it('lets a template\'s own background beat the deck\'s', function () { + says(Site::PRESENTATION_DIRECTIVES, 'the template\'s over the deck\'s'); + + $slides = Deck::slides(DECK_SETTINGS_TEMPLATED); + + expect($slides[0])->toContain('val="101014"'); + expect($slides[1])->toContain('val="303030"'); +}); + +it('refuses the deck\'s settings on a slide that is not the first', function () { + says(Site::PRESENTATION_DIRECTIVES, 'it is refused on any slide but the first'); + + $message = Deck::refusal( + "# One\n\n---\ndeck-settings:\n background: \"#101014\"\n---\n\n## Two\n", + ); + + expect($message)->toContain('which is a deck directive and belongs in the frontmatter of slide 1'); +}); + +it('refuses a setting that is not one, by name', function () { + says(Site::PRESENTATION_DIRECTIVES, 'is refused by name rather than left out'); + + $message = Deck::refusal("---\ndeck-settings:\n colour: \"#101014\"\n---\n\n# One\n"); + + expect($message)->toContain('names "colour", which is not a setting'); +}); + +it('refuses a value that is not a mapping of settings', function () { + $message = Deck::refusal("---\ndeck-settings: red\n---\n\n# One\n"); + + expect($message)->toContain('is not a mapping of the settings the deck carries'); +}); diff --git a/tests/Claims/finder.php b/tests/Claims/finder.php index 6ead631..f8b6e20 100644 --- a/tests/Claims/finder.php +++ b/tests/Claims/finder.php @@ -206,20 +206,17 @@ function finderAdapter(array $files): object }); it('draws the same document from a theme in a finder as from a theme in a directory', function () { - $theme = HOUSE_THEME_YAML; - $styles = "heading.1:\n name: Georgia\n bold: true\n size: 20\n color: \"1F3864\"\n"; + $theme = HOUSE_THEME_YAML . "styles:\n heading.1:\n name: Georgia\n bold: true\n size: 20\n color: \"1F3864\"\n"; $markdown = "---\ntheme: house\n---\n\n# The house style\n\nA paragraph.\n"; $root = Directory::make('finder'); \mkdir($root . '/themes/house', 0o777, true); \file_put_contents($root . '/themes/house/theme.yml', $theme); - \file_put_contents($root . '/themes/house/styles.yml', $styles); $fromDirectory = Document::path($markdown, $root); $fromFinder = Document::path($markdown, null, new MemoryFinder([ HOUSE_THEME_FILE => $theme, - 'house/styles.yml' => $styles, ])); // The parts rather than the bytes: a zip stores timestamps, and two correct @@ -232,7 +229,6 @@ function finderAdapter(array $files): object it('draws the same deck from a theme in a finder as from a theme in a directory', function () { $theme = HOUSE_THEME_YAML; - $layouts = "layouts:\n title: Title Slide\n content: Title and Content\n"; $markdown = "---\ntheme: house\n---\n\n# The house style\n\n## A deck\n\nWords.\n"; $root = Directory::make('finder'); @@ -240,12 +236,10 @@ function finderAdapter(array $files): object \mkdir($root . '/themes/house', 0o777, true); \file_put_contents($root . '/themes/house/theme.yml', $theme); - \file_put_contents($root . '/themes/house/layouts.yml', $layouts); $fromDirectory = Deck::slides($markdown, $root); $fromFinder = Deck::slides($markdown, $elsewhere, new MemoryFinder([ HOUSE_THEME_FILE => $theme, - 'house/layouts.yml' => $layouts, ])); expect($fromFinder)->toBe($fromDirectory); @@ -255,7 +249,7 @@ function finderAdapter(array $files): object }); it('resolves a theme named in code once', function () { - $finder = MemoryFinder::house(); + $finder = MemoryFinder::site(); $converter = new MarkdownToWord( "# The house style\n", @@ -269,7 +263,7 @@ function finderAdapter(array $files): object }); it('names the themes that resolve when a theme does not', function () { - $resolver = new WordThemeResolver(files: MemoryFinder::house()); + $resolver = new WordThemeResolver(files: MemoryFinder::site()); $message = Refusal::of(static fn (): string => $resolver->resolve('nowhere')->name()); @@ -341,13 +335,16 @@ public function files(string $theme): array }); it('serves both halves with one adapter', function () { - $finder = MemoryFinder::house(); + $finder = MemoryFinder::site(); expect($finder)->toBeInstanceOf(WordFileFinder::class); expect($finder)->toBeInstanceOf(SlidesFileFinder::class); + // A theme file serves one half, so the finder holds one for each: the Word half's + // carries the slots a document is drawn from, and the slides half's the colours, the + // type and the layout roles. $document = "---\ntheme: house\n---\n\n# The house style\n"; - $deck = "---\ntheme: house\n---\n\n# The house style\n\n---\n\n## A deck\n"; + $deck = "---\ntheme: deck\n---\n\n# The house style\n\n---\n\n## A deck\n"; expect(Document::body($document, null, $finder))->toContain('The house style'); expect(Deck::slides($deck, '.', $finder))->toHaveCount(2); diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index 9a12598..51e0600 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -5,7 +5,6 @@ use MarkdownOffice\Documentation\Tests\Support\Deck; use MarkdownOffice\Documentation\Tests\Support\Document; use MarkdownOffice\Documentation\Tests\Support\Theme; -use MarkdownPresentation\Theme\ThemeLoader; use MarkdownWord\Configuration\TextStyles; use MarkdownWord\WordToMarkdown; use MarkdownOffice\Documentation\Tests\Support\Site; @@ -25,7 +24,7 @@ $message = Theme::slidesRefusal( Theme::DECK, - "style:\n color: FF0000\n", + "textStyles:\n style:\n color: FF0000\n", ); expect($message)->toContain('reserved word'); @@ -58,10 +57,39 @@ expect($message)->toContain('which is Markdown'); }); -it('reads the named styles of a theme from the file the page names', function () { - says(Site::PRESENTATION_THEMES, 'text-styles.yml'); +it('reads the named styles of a theme from the section the page names', function () { + says(Site::PRESENTATION_THEMES, 'The `textStyles:` section is optional'); - expect(ThemeLoader::TEXT_STYLES_FILE)->toBe('text-styles.yml'); + $message = Theme::slidesRefusal( + Theme::DECK, + ); + + expect($message)->toContain('a theme names them in its "textStyles:" section'); +}); + +it('refuses each file of the old theme format, and says where the content goes', function () { + says(Site::PRESENTATION_THEMES, 'move its content into the "layouts:" section of its "theme.yml"'); + says(Site::PRESENTATION_THEMES, 'move its content into the "textStyles:" section of its "theme.yml"'); + says(Site::PRESENTATION_THEMES, 'That file is the Word half\'s, and this half does not read it'); + + expect(Theme::slidesRetired(Theme::DECK, 'layouts.yml', "layouts:\n content: Title and Content\n")) + ->toContain('move its content into the "layouts:" section'); + + expect(Theme::slidesRetired(Theme::DECK, 'text-styles.yml', "brand:\n color: FF0000\n")) + ->toContain('move its content into the "textStyles:" section'); + + expect(Theme::slidesRetired(Theme::DECK, 'styles.yml', "heading.1:\n name: Georgia\n")) + ->toContain('That file is the Word half\'s, and this half does not read it'); +}); + +it('refuses the file the Word half\'s slots used to be in, and says where they go', function () { + says('word/configuration.md', 'A `styles.yml` beside the theme is refused'); + + expect(Theme::wordRetired( + "---\ntheme: mine\n---\n\n# One\n", + 'styles.yml', + "heading.1:\n name: Georgia\n", + ))->toContain('A theme\'s styles are in the "styles:" section of its "theme.yml"'); }); it('refuses a text style the theme does not have, and names the styles it has', function () { @@ -69,7 +97,7 @@ $message = Theme::slidesRefusal( "---\ntheme: mine\n---\n\nResilience :bakery[Bakery] is open.\n", - "brand:\n color: FF0000\n bold: true\nmuted:\n color: accent1\n size: 10\n", + "textStyles:\n brand:\n color: FF0000\n bold: true\n muted:\n color: accent1\n size: 10\n", ); expect($message)->toContain('The styles are: brand, muted.'); @@ -137,8 +165,8 @@ expect(Deck::run($slide, 'Bakery'))->not->toContain('b="1"'); }); -it('reads the named styles of a Word theme from the key the page names', function () { - says('word/configuration.md', '`textStyles`'); +it('reads the named styles of a Word theme from the section the page names', function () { + says('word/configuration.md', '`textStyles:`'); expect(TextStyles::KEY)->toBe('textStyles'); }); diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php index a77f6a0..0ac150d 100644 --- a/tests/Support/Theme.php +++ b/tests/Support/Theme.php @@ -21,34 +21,32 @@ final class Theme public const DECK = "---\ntheme: mine\n---\n\nResilience :brand[Bakery] is open.\n"; /** - * A theme's named styles, with one style in them. + * A theme's named styles, as the section of a theme's file that carries them. * * The style is the one the deck above writes in, and it is red and bold, so that a * claim can look for a colour and a weight and find both. */ - public const BRAND = "brand:\n color: FF0000\n bold: true\n"; + public const BRAND = "textStyles:\n brand:\n color: FF0000\n bold: true\n"; + + /** + * A theme's slots, as the section of a theme's file that carries them. + */ + public const SLOTS = "styles:\n heading.1:\n name: Georgia\n bold: true\n size: 20\n color: \"1F3864\"\n"; /** * A directory holding a theme of the slides half's shape, and the directory it is in. * - * `$textStyles` is the theme's `text-styles.yml`, written as YAML because the file is - * what the library reads, and null for a theme with no named styles at all. + * `$textStyles` is the theme's `textStyles:` section, written as YAML because the + * section is what the library reads, and null for a theme with no named styles at all. * - * A `layouts.yml` as well, or the theme maps no layout and the deck is refused before - * it is drawn. + * A theme with no `layouts:` section is drawn with the library's own mapping, so a + * theme of this shape needs none. */ public static function slides(?string $textStyles = null): string { $root = self::directory(); - \file_put_contents( - $root . '/themes/mine/layouts.yml', - "layouts:\n content: Title and Content\n", - ); - - if ($textStyles !== null) { - \file_put_contents($root . '/themes/mine/text-styles.yml', $textStyles); - } + \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n" . ($textStyles ?? '')); return $root; } @@ -81,16 +79,14 @@ public static function slidesSlide(string $markdown, string $textStyles): string /** * A directory holding a theme of the Word half's shape, and the directory it is in. * - * `$styles` is the theme's `styles.yml`, written as YAML because the file is what the - * library reads. + * `$styles` is the theme's `styles:` section, written as YAML because the section is + * what the library reads. */ public static function word(?string $styles = null): string { $root = self::directory(); - if ($styles !== null) { - \file_put_contents($root . '/themes/mine/styles.yml', $styles); - } + \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n" . ($styles ?? '')); return $root; } @@ -107,14 +103,47 @@ public static function wordRefusal(string $markdown, string $styles): string } /** - * A directory holding a theme of the slides half's shape, and the directory it is in. + * A message for a theme with a file of the old format beside it. + * + * The file is written after the theme, because the theme's own file is what the + * library reads first: a refusal about the file beside it is only reached when the + * theme itself is one the library can read. + */ + public static function slidesRetired(string $markdown, string $file, string $contents): string + { + return Directory::during( + self::slides(), + static function (string $root) use ($markdown, $file, $contents): string { + \file_put_contents($root . '/themes/mine/' . $file, $contents); + + return Deck::refusal($markdown, $root); + }, + ); + } + + /** + * A message for a Word theme with a file of the old format beside it. + */ + public static function wordRetired(string $markdown, string $file, string $contents): string + { + return Directory::during( + self::word(), + static function (string $root) use ($markdown, $file, $contents): string { + \file_put_contents($root . '/themes/mine/' . $file, $contents); + + return Document::refusal($markdown, $root); + }, + ); + } + + /** + * A directory holding a theme of the slides half's shape. */ private static function directory(): string { $root = Directory::make('theme'); \mkdir($root . '/themes/mine', 0o777, true); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n"); return $root; } diff --git a/tools/lib/Runner.php b/tools/lib/Runner.php index 56775ba..9902061 100644 --- a/tools/lib/Runner.php +++ b/tools/lib/Runner.php @@ -243,7 +243,7 @@ private function build(string $direction, string $source, string $cache): array */ private function finder(): MemoryFinder { - return MemoryFinder::house(); + return MemoryFinder::site(); } /** From d3eccbe7471b30739cada3763a95c00330590553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 21:26:42 +0100 Subject: [PATCH 02/10] The slides half's four pages are carried in the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle claimed to be about decks as well as about documents, and carried the slides half's command line because of it. The slides half's own pages were not carried, so the slides half's theme format and the deck's settings reached the site and not an agent reading the bundle — and a bundle that taught only the Word half's one-file theme would teach half of it. "Markdown Presentation" joins the sections, so a page added to that section and not carried is a failure the checker names rather than a gap nobody notices, and the four pages are carried under names that say which section they are from. The slides half's own gallery of decks is still not in the bundle, and both the skill and the page that describes it now say so. --- content/skills/index.md | 8 +++++--- skill/mdword/SKILL.md | 11 ++++++++--- skill/mdword/pages.json | 24 ++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/content/skills/index.md b/content/skills/index.md index 8b80e22..269a62d 100644 --- a/content/skills/index.md +++ b/content/skills/index.md @@ -83,9 +83,11 @@ mdword/ └── scripts/ the render tools ``` -The **references** are the pages of this site, one file each, with the frontmatter off. The -**examples** are the decks and documents the pages show, built and named, and the library's -own twenty-three — which between them are the catalogue of what the library can do. +The **references** are the pages of this site, one file each, with the frontmatter off. They +cover both halves: the Word half's pages and the slides half's four, and the pages the two +have in common. The **examples** are the decks and documents the pages show, built and named, +and the Word half's own twenty-three — which between them are the catalogue of what that half +can do. The slides half's own gallery of decks is not in the bundle. The **scripts** render a document so that it can be looked at, which is the one step a prose answer cannot do for itself. diff --git a/skill/mdword/SKILL.md b/skill/mdword/SKILL.md index 69fe9f9..ac6f1ed 100644 --- a/skill/mdword/SKILL.md +++ b/skill/mdword/SKILL.md @@ -26,13 +26,18 @@ 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 slides half as a whole — its dialect, its library's API, and reading a deck back | `references/presentation-index.md` | +| The slides half's frontmatter keys, its markers, and its free shapes | `references/presentation-directives.md` | +| The slides half's themes — one file, the colours, the type and the eight layout roles | `references/presentation-themes.md` | +| Every exception the slides half throws | `references/presentation-errors.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` | -The **examples** are beside these: `examples/site/` holds the documents this documentation -shows, and `examples/library/` holds the library's own twenty-three — the catalogue of what -it can do, one file each. +The **examples** are beside these: `examples/site/` holds the documents and the decks this +documentation shows, and `examples/library/` holds the Word half's own twenty-three — the +catalogue of what it can do, one file each. The slides half's own gallery of decks is not in +the bundle. ## The loop diff --git a/skill/mdword/pages.json b/skill/mdword/pages.json index 7b4bd46..04dc088 100644 --- a/skill/mdword/pages.json +++ b/skill/mdword/pages.json @@ -8,10 +8,13 @@ "", "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", - "The pages outside them — the suite itself — are carried or not as this file says,", + "so. The pages outside them \u2014 the suite itself \u2014 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.", + "about documents, and a page that is not there is a page an agent cannot read. The", + "slides half's own four pages are carried for the same reason \u2014 a theme is one file in", + "both halves, and a bundle that taught only the Word half's format would teach half of", + "it.", "", "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", @@ -19,6 +22,7 @@ ], "sections": [ "Markdown Word", + "Markdown Presentation", "In general" ], "references": [ @@ -50,6 +54,22 @@ "file": "errors.md", "page": "word/errors.md" }, + { + "file": "presentation-index.md", + "page": "presentation/index.md" + }, + { + "file": "presentation-directives.md", + "page": "presentation/directives.md" + }, + { + "file": "presentation-errors.md", + "page": "presentation/errors.md" + }, + { + "file": "presentation-themes.md", + "page": "presentation/themes.md" + }, { "file": "directive-rules.md", "page": "general/directives.md" From eb50bf7bbc965d43044eb5ec06fe0e27852567a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Tue, 3 Nov 2026 21:27:15 +0100 Subject: [PATCH 03/10] The message names the file it refuses, and the page says so The Word half's refusal names the path of the file beside the theme, not the file's name, and the page quoted the name. The ellipsis is the path's shape. --- content/word/configuration.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content/word/configuration.md b/content/word/configuration.md index 41fd8df..5267ad0 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -176,9 +176,10 @@ named styles a document may write a word in — see [Word directives](/word/directives/#a-word-in-a-style). A `theme:` may also name the `theme.yml` itself, in which case that file is the theme's. -A `styles.yml` beside the theme is refused, and the message says where its content goes: +A `styles.yml` beside the theme is refused, and the message names the file and says where its +content goes: -> "styles.yml" is not read any more. A theme's styles are in the "styles:" section of its "theme.yml", and its named text styles in the "textStyles:" section. +> "…/styles.yml" is not read any more. A theme's styles are in the "styles:" section of its "theme.yml", and its named text styles in the "textStyles:" section. It is refused rather than ignored, because a file that is quietly not read is a theme that looks like the one its author wrote and is not: the author moves a slot into the file beside the From 24c2d4b7664f5a7cd6b7886b91ca2349b9fe8911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 01:02:48 +0100 Subject: [PATCH 04/10] The claims the review found are corrected, and the pages that made them guard them The two halves' vocabularies overlap, so a theme file that carries only a name and a named style is read by both, and the pages said one file cannot be both. The slides half did read the styles file it loaded, so the page says it loaded it and never used it. The example titled "every slide" had one slide, and the index page still called a theme a directory. The pages that were corrected are the ones that carry the correction, so the claims are in tests and in the list of phrases the site must not say again. The command line has changed since the branch was written: it refuses to write a theme beside a file of the old format, and a role the deck has no layout for takes the library's own name rather than nothing. The page says both, and the sample report it quotes is the program's own output, rebuilt from the fixture the report was made from. --- content/command-line/theme.md | 40 ++++++++++++++++--- content/general/files.md | 17 ++++---- content/presentation/directives.md | 12 ++++++ content/presentation/index.md | 2 +- content/presentation/themes.md | 6 +-- content/skills/index.md | 2 +- content/word/configuration.md | 2 +- skill/mdword/SKILL.md | 2 +- src/Finder/MemoryFinder.php | 10 ++--- tests/Claims/counts.php | 11 +++++ tests/Claims/deck-settings.php | 32 +++++++++++++++ tests/Claims/finder.php | 46 +++++++++++++++++++-- tests/Claims/text-styles.php | 64 ++++++++++++++++++++++++++++-- tests/Pest.php | 13 +++++- tests/Support/Example.php | 58 +++++++++++++++++++++++++++ tests/Support/NoExample.php | 25 ++++++++++++ tests/Support/Site.php | 20 ++++++++++ tools/check-command-line.php | 20 ++++++++++ 18 files changed, 347 insertions(+), 35 deletions(-) create mode 100644 tests/Support/Example.php create mode 100644 tests/Support/NoExample.php diff --git a/content/command-line/theme.md b/content/command-line/theme.md index 3651900..05afd02 100644 --- a/content/command-line/theme.md +++ b/content/command-line/theme.md @@ -27,14 +27,31 @@ mdoffice theme deck.pptx # the same run: init is the default The directory is the deck's own name without its extension, so `deck.pptx` gives `deck/` and `talks/talk.pptx` gives `talks/talk/`. -**A theme that is already there is not replaced without being asked.** A theme is a directory of -files somebody has edited, and replacing one is how an afternoon of work goes missing: +**A theme that is already there is not replaced without being asked.** The theme's own files are +in a directory somebody has edited, and replacing one is how an afternoon of work goes missing: ```text mdoffice theme: There is already a theme in "deck". mdoffice theme: Pass --force to replace it. ``` +**A directory that holds a file of the old format is not written into.** A theme is one file, +and the library refuses a theme that has a `layouts.yml`, a `text-styles.yml` or a `styles.yml` +beside it — so a theme written into such a directory is a theme no deck can be drawn in. The +command says so before it writes, and `--force` does not help: it says a theme is to be +replaced, not that a file is to be deleted. + +```text +mdoffice theme: The theme "deck" has a "layouts.yml" beside it. A theme is one file: move its content into the "layouts:" section of its "theme.yml", and delete the file. +mdoffice theme: The command will not write a theme beside a file of the old format. +mdoffice theme: Delete it, or move it out of the way. +``` + +**A theme the command wrote before no longer works.** It wrote three files — `theme.yml`, +`layouts.yml` and `template.pptx` — and a theme with a `layouts.yml` beside it is a theme the +library refuses. Move the content of the `layouts.yml` into the `layouts:` section of the +`theme.yml` beside it, and delete the file. + ## What is written Two files go into the directory. @@ -49,8 +66,12 @@ A deck with no master has no template to write, so the directory holds the theme **Every role is mapped.** The roles the deck's own layouts cover are named the deck's way, and the rest take the library's own names — the names the library draws the layouts it generates under. A theme's `layouts:` section is the whole mapping, so a role it leaves out is a role no -slide can be drawn on, and the roles the deck has no layout for are reported rather than -pointed at a layout that is something else. +slide can be drawn on. The roles the deck has no layout for are listed in the report, so that +somebody can point them somewhere else, but they are mapped all the same. + +**Two roles may not share a name.** A name is one layout, and two roles on one name are drawn in +the same geometry. A role whose library name is already the name of one of the deck's own layouts +takes a name of its own instead, and the report says which role and why. **The theme is read back before the command reports success.** The library's own reader reads it and the library's own geometry reads the boxes of the template, so a theme this command cannot @@ -69,8 +90,7 @@ scale the sizes below the title. **The layouts** are mapped by the placeholders they hold, not by their names: a deck calls its layouts whatever it likes, and the same layout is called different things in two offices. The -roles the deck has no layout for are reported rather than pointed at a layout that is something -else. +roles the deck has no layout for take the library's own names, and are listed in the report. **The template** is the deck's layouts without its slides, and without the pictures on them. A template places, and the slides of the deck it came from are not a placement. @@ -132,6 +152,14 @@ mdoffice theme: wrote the theme for "probe.pptx" into "/tmp/probe-theme". the template places 0 of the 3 roles the deck's own layouts map. ``` +The report says how the roles were split, and it says it in two forms. A deck whose own layout +is called the library's name for a role leaves that role to take a name of its own, and the +report counts it apart from the two: + +```text + the deck's own layouts map 1 of the 8 roles; the library's own names map 6, and the rest take a name of their own. +``` + A deck with no master has no template, and the report says so where the template's line would be: diff --git a/content/general/files.md b/content/general/files.md index 00bd9ad..cd6745c 100644 --- a/content/general/files.md +++ b/content/general/files.md @@ -90,11 +90,12 @@ now, and `styles.yml`, `layouts.yml` and `text-styles.yml` are the format it use of: the library asks the finder whether one of them is there, and refuses the theme when it is. The other five are read. -The two halves keep separate vocabularies, and a theme file serves one of them: the Word -half's `theme.yml` may carry `styles:` and `textStyles:`, and the slides half's may carry -`dark:`, `colors:`, `fonts:`, `style:`, `layouts:` and `textStyles:`. Each half refuses the -other's keys, so one file cannot be both. The finder is never told which is which — it is asked -for a name. +The two halves keep separate vocabularies, and they part at the look: the Word half's +`theme.yml` may carry `styles:`, and the slides half's may carry `dark:`, `colors:`, `fonts:`, +`style:` and `layouts:`. Each half refuses the other's look, so one file cannot carry both. The +keys they share — `name:`, `description:`, `author:` and `textStyles:` — are legal in both, and +a file that carries only those is read by both, each half drawing it its own way. The finder is +never told which is which — it is asked for a name. ## A finder answers; it does not refuse @@ -311,8 +312,8 @@ asked the finder for it. ```` {% endexample %} -And a deck, from the same source. The finder holds a theme for each half, because a theme file -serves one of them: +And a deck, from the same source. The finder holds a theme for each half, because each carries +the look of its own half: {% example "A deck whose theme came from a finder" %} ````md presentation finder @@ -320,7 +321,7 @@ serves one of them: theme: deck --- -# The house style +# The deck style ## A deck whose theme came from a finder diff --git a/content/presentation/directives.md b/content/presentation/directives.md index f519d17..cd1c024 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -378,6 +378,18 @@ deck-settings: ## What changed - Two products shipped + +--- + +## What it cost + +- Two engineers, for a quarter + +--- + +## What happens next + +- A second release ```` {% endexample %} diff --git a/content/presentation/index.md b/content/presentation/index.md index 91f5ddf..0d6eef9 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -85,7 +85,7 @@ description: What changed, what it cost, and what happens next. #### A theme -A theme is a directory, and `theme.yml` is what it has to hold: the name, an optional description +A theme is **one file**, and `theme.yml` is what it has to hold: the name, an optional description and author, the colours, the fonts and the geometry — and, when it says anything, the layout roles and the named text styles. A `theme:` may also name the `theme.yml` itself, in which case the theme's master beside it is the theme's. The keys it takes, the colour slots, the geometry diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 074b244..921ed05 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -45,14 +45,14 @@ by hand behave differently from one written by a machine, and this one is for ma A theme was a directory of up to five files, and the three that are no longer read are **refused** when they are beside the theme, each with a message that says what to do with it: -> The theme "acme" has a "layouts.yml" beside it. A theme is one file: move its content into the "layouts:" section of its "theme.yml". +> The theme "acme" has a "layouts.yml" beside it. A theme is one file: move its content into the "layouts:" section of its "theme.yml", and delete the file. -> The theme "acme" has a "text-styles.yml" beside it. A theme is one file: move its content into the "textStyles:" section of its "theme.yml". +> The theme "acme" has a "text-styles.yml" beside it. A theme is one file: move its content into the "textStyles:" section of its "theme.yml", and delete the file. > The theme "acme" has a "styles.yml" beside it. That file is the Word half's, and this half does not read it: its slots name Word styles, which a deck of slides has no use for. Delete it, and the theme is read again. `layouts.yml` and `text-styles.yml` are the two that became sections of the theme's own file. -`styles.yml` was the Word half's, and this half loaded it for years without ever reading it. +`styles.yml` was the Word half's, and this half loaded it for years without ever using it. They are refused rather than ignored, because a file that is quietly not read is a theme that looks like the one its author wrote and is not: the author renames a layout in the file diff --git a/content/skills/index.md b/content/skills/index.md index 269a62d..ca65ea2 100644 --- a/content/skills/index.md +++ b/content/skills/index.md @@ -86,7 +86,7 @@ mdword/ The **references** are the pages of this site, one file each, with the frontmatter off. They cover both halves: the Word half's pages and the slides half's four, and the pages the two have in common. The **examples** are the decks and documents the pages show, built and named, -and the Word half's own twenty-three — which between them are the catalogue of what that half +and the Word half's own twenty-four — which between them are the catalogue of what that half can do. The slides half's own gallery of decks is not in the bundle. The **scripts** render a document so that it can be looked at, which is the one step a diff --git a/content/word/configuration.md b/content/word/configuration.md index 5267ad0..656ab55 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -179,7 +179,7 @@ itself, in which case that file is the theme's. A `styles.yml` beside the theme is refused, and the message names the file and says where its content goes: -> "…/styles.yml" is not read any more. A theme's styles are in the "styles:" section of its "theme.yml", and its named text styles in the "textStyles:" section. +> "…/styles.yml" is not read any more. A theme's styles are in the "styles:" section of its "theme.yml", and its named text styles in the "textStyles:" section. Delete the file, and the theme is read again. It is refused rather than ignored, because a file that is quietly not read is a theme that looks like the one its author wrote and is not: the author moves a slot into the file beside the diff --git a/skill/mdword/SKILL.md b/skill/mdword/SKILL.md index ac6f1ed..dafdf30 100644 --- a/skill/mdword/SKILL.md +++ b/skill/mdword/SKILL.md @@ -35,7 +35,7 @@ documentation, not by recalling it. | Installing this skill | `references/install.md` | The **examples** are beside these: `examples/site/` holds the documents and the decks this -documentation shows, and `examples/library/` holds the Word half's own twenty-three — the +documentation shows, and `examples/library/` holds the Word half's own twenty-four — the catalogue of what it can do, one file each. The slides half's own gallery of decks is not in the bundle. diff --git a/src/Finder/MemoryFinder.php b/src/Finder/MemoryFinder.php index c7a15ed..c1e3569 100644 --- a/src/Finder/MemoryFinder.php +++ b/src/Finder/MemoryFinder.php @@ -38,11 +38,11 @@ public function __construct(private readonly array $files = []) /** * The site's own themes, which are in no directory at all. * - * Two of them, because a theme file serves one half and the halves keep separate - * vocabularies: the Word half's `theme.yml` carries the slots a document is drawn from, - * and the slides half's carries the colours, the type and the layout roles. One file - * cannot be both — each half refuses the other's keys — so a finder that serves both - * holds a theme for each. + * Two of them, because each carries the look of its own half: the Word half's + * `theme.yml` carries the slots a document is drawn from, and the slides half's carries + * the colours, the type and the layout roles. Each half refuses the other's look, so + * one file cannot carry both — but a file that carries neither half's look is read by + * both, and each half draws it its own way. * * They are here rather than under `themes/` on purpose: an example that named a theme * beside it would be an example of the disk default, and these are about the other kind. diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php index 18bb6c2..bf2e611 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -97,3 +97,14 @@ says(Site::PRESENTATION_INDEX, words(\count($methods)) . ' methods'); }); + +it('counts the Word half\'s own examples as the library ships them', function () { + $examples = \glob(Site::root() . '/../markdown-word/examples/markdown/*.md') ?: []; + + $count = \count($examples); + + $phrase = 'the Word half\'s own ' . words($count); + + says('skills/index.md', $phrase); + saysIn(Site::skill(), $phrase); +}); diff --git a/tests/Claims/deck-settings.php b/tests/Claims/deck-settings.php index 32825be..ea2c0fe 100644 --- a/tests/Claims/deck-settings.php +++ b/tests/Claims/deck-settings.php @@ -3,6 +3,7 @@ declare(strict_types=1); use MarkdownOffice\Documentation\Tests\Support\Deck; +use MarkdownOffice\Documentation\Tests\Support\Example; use MarkdownOffice\Documentation\Tests\Support\Site; /** @@ -25,6 +26,11 @@ */ const DECK_SETTINGS_TEMPLATED = "---\ndeck-settings:\n background: \"#101014\"\n---\n\n---\ntemplate: brand\ntemplate-settings:\n background: \"#303030\"\n---\n:::content{left=4.5% top=3.913% width=29.996% height=4.969% fill=none padding=0 color=FFFFFF font-size=11 font-family=Calibri align=left vertical-align=middle}\nResilience\n:::\n\n---\nlayout: blank\ntemplate: brand\n---\n\n## Two\n"; +/** + * The same deck, with a template and a slide that names a background of its own. + */ +const DECK_SETTINGS_TEMPLATED_AND_SLIDDEN = "---\ndeck-settings:\n background: \"#101014\"\n---\n\n---\ntemplate: brand\ntemplate-settings:\n background: \"#303030\"\n---\n:::content{left=4.5% top=3.913% width=29.996% height=4.969% fill=none padding=0 color=FFFFFF font-size=11 font-family=Calibri align=left vertical-align=middle}\nResilience\n:::\n\n---\nlayout: blank\ntemplate: brand\nbackground: \"#404040\"\n---\n\n## Two\n"; + it('draws every slide of a deck with the deck\'s own background', function () { says(Site::PRESENTATION_DIRECTIVES, 'Every slide is drawn with them'); @@ -52,6 +58,32 @@ expect($slides[1])->toContain('val="303030"'); }); +it('lets a slide\'s own background beat a template\'s', function () { + says(Site::PRESENTATION_DIRECTIVES, 'A slide that draws a template and names a `background:` of its own'); + + $slides = Deck::slides(DECK_SETTINGS_TEMPLATED_AND_SLIDDEN); + + expect($slides[1])->toContain('val="404040"'); + expect($slides[1])->not->toContain('val="303030"'); + expect($slides[1])->not->toContain('val="101014"'); +}); + +it('shows a deck whose accent every slide is drawn with', function () { + $source = Example::of( + Site::PRESENTATION_DIRECTIVES, + 'A deck whose accent every slide is drawn with', + ); + + $slides = Deck::slides($source); + + // More than one slide, or the title is a claim about a deck that has none to make. + expect($slides)->toHaveCount(3); + + foreach ($slides as $slide) { + expect($slide)->toContain('toBeInstanceOf(WordFileFinder::class); expect($finder)->toBeInstanceOf(SlidesFileFinder::class); - // A theme file serves one half, so the finder holds one for each: the Word half's - // carries the slots a document is drawn from, and the slides half's the colours, the - // type and the layout roles. + // The finder holds a theme for each half: each carries the look of its own half — + // the Word half's the slots a document is drawn from, and the slides half's the + // colours, the type and the layout roles — and each half refuses the other's look. $document = "---\ntheme: house\n---\n\n# The house style\n"; - $deck = "---\ntheme: deck\n---\n\n# The house style\n\n---\n\n## A deck\n"; + $deck = "---\ntheme: deck\n---\n\n# The deck style\n\n---\n\n## A deck\n"; expect(Document::body($document, null, $finder))->toContain('The house style'); expect(Deck::slides($deck, '.', $finder))->toHaveCount(2); }); +it('reads a theme file that carries neither half\'s look with both loaders', function () { + // The two vocabularies part at the look and not at the name: a file that names + // itself and carries a named style is a theme file for both halves. + $finder = new MemoryFinder([ + 'both/theme.yml' => "name: Shared\ntextStyles:\n brand:\n color: FF0000\n", + ]); + + $word = (new WordThemeLoader())->load('both', 'both', $finder); + $slides = (new SlidesThemeLoader())->load('both', 'both', $finder); + + expect($word->theme()->name())->toBe('Shared') + ->and($word->theme()->textStyles()->names())->toBe(['brand']) + ->and($slides->theme()->name())->toBe('Shared') + ->and($slides->theme()->textStyles()->names())->toBe(['brand']); +}); + +it('refuses the other half\'s look in a theme file', function () { + $slides = new MemoryFinder([ + 'both/theme.yml' => "name: Shared\nstyles:\n heading.1:\n name: Georgia\n", + ]); + + expect(static fn (): mixed => (new SlidesThemeLoader())->load('both', 'both', $slides)) + ->toThrow(SlidesMalformedTheme::class, '"styles" is not a key of a theme'); + + $word = new MemoryFinder([ + 'both/theme.yml' => "name: Shared\ncolors:\n accent1: \"#123456\"\n", + ]); + + expect(static fn (): mixed => (new WordThemeLoader())->load('both', 'both', $word)) + ->toThrow(WordMalformedTheme::class, 'Unknown key "colors"'); +}); + +it('says that a theme file carrying only the shared keys is read by both halves', function () { + says(Site::FILES, 'a file that carries only those is read by both'); +}); + it('says which half composes which theme file', function () { $word = [WordThemeLoader::THEME_FILE, WordThemeLoader::STYLES_FILE]; $slides = [ diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index 51e0600..a7ca84c 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -19,6 +19,18 @@ * the library that has to behave that way. */ +/** + * The refusal messages a page quotes, as the page writes them. + * + * @return list + */ +function quotedRefusals(string $page): array +{ + \preg_match_all('/^> (.*)$/m', Site::page($page), $matches); + + return $matches[1]; +} + it('refuses a theme that names a style "style"', function () { says(Site::GENERAL_DIRECTIVES, 'a theme may not name a style `style`'); @@ -72,14 +84,16 @@ says(Site::PRESENTATION_THEMES, 'move its content into the "textStyles:" section of its "theme.yml"'); says(Site::PRESENTATION_THEMES, 'That file is the Word half\'s, and this half does not read it'); + // The whole message and not a piece of it: the message the library used to print is + // a prefix of the one it prints now, so a check for the prefix passes for both. expect(Theme::slidesRetired(Theme::DECK, 'layouts.yml', "layouts:\n content: Title and Content\n")) - ->toContain('move its content into the "layouts:" section'); + ->toBe('The theme "mine" has a "layouts.yml" beside it. A theme is one file: move its content into the "layouts:" section of its "theme.yml", and delete the file.'); expect(Theme::slidesRetired(Theme::DECK, 'text-styles.yml', "brand:\n color: FF0000\n")) - ->toContain('move its content into the "textStyles:" section'); + ->toBe('The theme "mine" has a "text-styles.yml" beside it. A theme is one file: move its content into the "textStyles:" section of its "theme.yml", and delete the file.'); expect(Theme::slidesRetired(Theme::DECK, 'styles.yml', "heading.1:\n name: Georgia\n")) - ->toContain('That file is the Word half\'s, and this half does not read it'); + ->toBe('The theme "mine" has a "styles.yml" beside it. That file is the Word half\'s, and this half does not read it: its slots name Word styles, which a deck of slides has no use for. Delete it, and the theme is read again.'); }); it('refuses the file the Word half\'s slots used to be in, and says where they go', function () { @@ -89,7 +103,49 @@ "---\ntheme: mine\n---\n\n# One\n", 'styles.yml', "heading.1:\n name: Georgia\n", - ))->toContain('A theme\'s styles are in the "styles:" section of its "theme.yml"'); + ))->toMatch( + '/^"[^"]*\/styles\.yml" is not read any more\. A theme\'s styles are in the "styles:" section of ' + . 'its "theme\.yml", and its named text styles in the "textStyles:" section\. Delete the file, ' + . 'and the theme is read again\.$/', + ); +}); + +it('quotes the refusals as the library prints them', function () { + $themes = quotedRefusals(Site::PRESENTATION_THEMES); + + expect($themes)->toHaveCount(3); + + // The page writes the theme's name as "acme", and the library writes the name the + // deck named: the same sentence with another name in it. + expect(\str_replace('"mine"', '"acme"', Theme::slidesRetired( + Theme::DECK, + 'layouts.yml', + "layouts:\n content: Title and Content\n", + )))->toBe($themes[0]); + + expect(\str_replace('"mine"', '"acme"', Theme::slidesRetired( + Theme::DECK, + 'text-styles.yml', + "brand:\n color: FF0000\n", + )))->toBe($themes[1]); + + expect(\str_replace('"mine"', '"acme"', Theme::slidesRetired( + Theme::DECK, + 'styles.yml', + "heading.1:\n name: Georgia\n", + )))->toBe($themes[2]); + + $configuration = quotedRefusals('word/configuration.md'); + + expect($configuration)->toHaveCount(1); + + // The page writes the path the library prints as an ellipsis, because the path is + // where the file happened to be and the sentence is about the file. + expect((string) \preg_replace( + '/^"[^"]*"/', + '"…/styles.yml"', + Theme::wordRetired("---\ntheme: mine\n---\n\n# One\n", 'styles.yml', "heading.1:\n name: Georgia\n"), + ))->toBe($configuration[0]); }); it('refuses a text style the theme does not have, and names the styles it has', function () { diff --git a/tests/Pest.php b/tests/Pest.php index e6c23a7..1bb4481 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,9 +12,20 @@ * page that says "Five themes ship" would be guarding a spelling rather than a claim. */ function says(string $path, string $phrase): void +{ + saysIn(Site::page($path), $phrase); +} + +/** + * Assert that a piece of text says a phrase, whatever case it is written in. + * + * The text rather than a page, for the files that are not pages of the site: the skill's + * own file is one of them, and the bundle's copy of it is made by the build. + */ +function saysIn(string $text, string $phrase): void { $found = \str_contains( - \mb_strtolower(Site::flat(Site::page($path))), + \mb_strtolower(Site::flat($text)), \mb_strtolower($phrase), ); diff --git a/tests/Support/Example.php b/tests/Support/Example.php new file mode 100644 index 0000000..ed09997 --- /dev/null +++ b/tests/Support/Example.php @@ -0,0 +1,58 @@ + 'eighteen', 19 => 'nineteen', 20 => 'twenty', + 21 => 'twenty-one', + 22 => 'twenty-two', + 23 => 'twenty-three', + 24 => 'twenty-four', + 25 => 'twenty-five', + 26 => 'twenty-six', + 27 => 'twenty-seven', + 28 => 'twenty-eight', + 29 => 'twenty-nine', ]; return $words[$number] ?? (string) $number; diff --git a/tools/check-command-line.php b/tools/check-command-line.php index 394fa34..013a3f7 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -105,6 +105,26 @@ 'the errors page called a loss by a method it does not have' => '$loss->feature()', 'the suite page said the presentation pins a commit on dev-master' => 'to a **commit on `dev-master`**', 'the suite page said the presentation validates without --strict' => '`composer validate` runs without', + 'the pages called a theme a directory' => 'A theme is a directory', + 'the themes page listed the files a theme was made of' => '`styles.yml`, `layouts.yml` and `template.pptx` are optional', + 'the themes page called the named styles a file of their own' => '`text-styles.yml` is optional', + 'the themes page described the layouts file as a bare mapping' => 'the file may be a bare mapping or nested under a single `layouts:` key', + 'the themes page called the theme a set of friends' => '`theme.yml` and its friends', + 'the Word pages called the styles a file of their own' => 'an optional `styles.yml` holding the same slot names', + 'the Word pages called a key of the styles file a slot' => 'One key of that file is not a slot', + 'the Word pages said the file beside the theme is the theme\'s' => 'the `styles.yml` beside it is the theme\'s', + 'the files page said the two halves keep their text styles apart' => 'carries `textStyles:` inside it', + 'the files page said the finder was asked for the slots and the theme' => 'asked the finder for both of them', + 'the files page said one file cannot serve both halves' => 'so one file cannot be both', + 'the directives page took the named styles from a file of their own' => 'come from the theme\'s `text-styles.yml`', + 'the directives page pointed the layout role at the layouts file' => 'the theme\'s `layouts.yml` maps onto a box', + 'the presentation index counted the frontmatter keys wrongly' => 'closed set of fifteen', + 'the directives page counted the frontmatter keys wrongly' => 'one of these fifteen', + 'the errors page said the styles file names a slot' => '`styles.yml` names a slot that does not exist', + 'the presentation index pointed at the library\'s own build script' => '`examples/build.php` in the repository is', + 'the themes page said the styles file was never read' => 'loaded it for years without ever reading it', + 'the command-line page said three files go into a theme' => 'Three files go into the directory', + 'the command-line page said the roles are reported rather than mapped' => 'reported rather than pointed at a layout that is something else', ]; /** From 0db42994158ab359aea10e3cd6f896394e2008b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 01:19:48 +0100 Subject: [PATCH 05/10] The two halves part at the look, and the pages that said otherwise say so The suite's two halves share a theme's name, its description, its author and its named text styles, so a theme file carrying only those is read by both. What they part at is the look, and a file carrying the other half's look is refused. The files page and the themes page said the two halves keep separate vocabularies, which is not so, and both libraries have corrected the claim. The pages now say it as the libraries do, and the phrase is in the list of claims the site must not make again. --- content/general/files.md | 12 ++++++------ content/presentation/themes.md | 6 +++--- tests/Claims/behaviour.php | 14 ++++++++++++++ tools/check-command-line.php | 1 + 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/content/general/files.md b/content/general/files.md index cd6745c..61467e3 100644 --- a/content/general/files.md +++ b/content/general/files.md @@ -90,12 +90,12 @@ now, and `styles.yml`, `layouts.yml` and `text-styles.yml` are the format it use of: the library asks the finder whether one of them is there, and refuses the theme when it is. The other five are read. -The two halves keep separate vocabularies, and they part at the look: the Word half's -`theme.yml` may carry `styles:`, and the slides half's may carry `dark:`, `colors:`, `fonts:`, -`style:` and `layouts:`. Each half refuses the other's look, so one file cannot carry both. The -keys they share — `name:`, `description:`, `author:` and `textStyles:` — are legal in both, and -a file that carries only those is read by both, each half drawing it its own way. The finder is -never told which is which — it is asked for a name. +The two halves part at the look: the Word half's `theme.yml` may carry `styles:`, and the slides +half's may carry `dark:`, `colors:`, `fonts:`, `style:` and `layouts:`. Each half refuses the +other's look, so one file cannot carry both. The keys they share — `name:`, `description:`, +`author:` and `textStyles:` — are legal in both, and a file that carries only those is read by +both, each half drawing it its own way. The finder is never told which is which — it is asked +for a name. ## A finder answers; it does not refuse diff --git a/content/presentation/themes.md b/content/presentation/themes.md index 921ed05..b270784 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -240,9 +240,9 @@ hands the library a **finder** instead, and the name is asked for through it: se The Word half has themes too, and they are the other shape: a `theme.yml` — `name` required, plus `description` and `author` — whose `styles:` section holds the same slot names the [Word configuration](/word/configuration/) page lists, and whose `textStyles:` section holds the -named styles a document may write a word in. The two halves keep separate vocabularies: a -Word theme that carries the slides half's `colors:` is refused. They are listed -[there](/word/configuration/#themes). +named styles a document may write a word in. The two halves part at the look, and a Word theme +that carries the slides half's `colors:` is refused because that is the other half's look. They +are listed [there](/word/configuration/#themes). A theme is a **starting point**, not a replacement: your own `styles:` sits on top of it, and which keys you wrote is remembered so a partial override does not silently discard the rest. diff --git a/tests/Claims/behaviour.php b/tests/Claims/behaviour.php index 9cc0c87..6a334db 100644 --- a/tests/Claims/behaviour.php +++ b/tests/Claims/behaviour.php @@ -58,3 +58,17 @@ expect(Deck::refusal("---\ntransition: none\n---\n\n# One\n")) ->toContain('names "transition"'); }); + +it('says that the two halves part at the look', function () { + says( + Site::FILES, + 'The two halves part at the look: the Word half\'s `theme.yml` may carry `styles:`, and the slides half\'s may carry `dark:`, `colors:`, `fonts:`, `style:` and `layouts:`', + ); +}); + +it('says that a Word theme is refused for carrying the other half\'s look', function () { + says( + Site::PRESENTATION_THEMES, + 'The two halves part at the look, and a Word theme that carries the slides half\'s `colors:` is refused because that is the other half\'s look', + ); +}); diff --git a/tools/check-command-line.php b/tools/check-command-line.php index 013a3f7..e4ba1bf 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -125,6 +125,7 @@ 'the themes page said the styles file was never read' => 'loaded it for years without ever reading it', 'the command-line page said three files go into a theme' => 'Three files go into the directory', 'the command-line page said the roles are reported rather than mapped' => 'reported rather than pointed at a layout that is something else', + 'the files and themes pages said the two halves keep separate vocabularies' => 'keep separate vocabularies', ]; /** From 6e393c256d0a3b23e6666e779220a7f30bf23a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 01:30:44 +0100 Subject: [PATCH 06/10] The section list is read from the library, and the literals that repeated are named --- src/Reference/UnreadableCode.php | 23 ----------------------- src/Reference/Vocabularies.php | 17 ++++++----------- tests/Claims/deck-settings.php | 30 ++++++++++++++++++++++-------- tests/Claims/text-styles.php | 32 ++++++++++++++++++++++---------- 4 files changed, 50 insertions(+), 52 deletions(-) delete mode 100644 src/Reference/UnreadableCode.php diff --git a/src/Reference/UnreadableCode.php b/src/Reference/UnreadableCode.php deleted file mode 100644 index 126d0ed..0000000 --- a/src/Reference/UnreadableCode.php +++ /dev/null @@ -1,23 +0,0 @@ - */ public static function themeSections(): array { - $sections = (new \ReflectionClass(ThemeLoader::class))->getConstant('SECTIONS'); - - if (!\is_array($sections)) { - throw UnreadableCode::missingConstant(ThemeLoader::class, 'SECTIONS'); - } - - return \array_values($sections); + return \array_values(ThemeLoader::SECTIONS); } /** diff --git a/tests/Claims/deck-settings.php b/tests/Claims/deck-settings.php index ea2c0fe..5d1cb4a 100644 --- a/tests/Claims/deck-settings.php +++ b/tests/Claims/deck-settings.php @@ -31,13 +31,27 @@ */ const DECK_SETTINGS_TEMPLATED_AND_SLIDDEN = "---\ndeck-settings:\n background: \"#101014\"\n---\n\n---\ntemplate: brand\ntemplate-settings:\n background: \"#303030\"\n---\n:::content{left=4.5% top=3.913% width=29.996% height=4.969% fill=none padding=0 color=FFFFFF font-size=11 font-family=Calibri align=left vertical-align=middle}\nResilience\n:::\n\n---\nlayout: blank\ntemplate: brand\nbackground: \"#404040\"\n---\n\n## Two\n"; +/** + * The colours the decks above are drawn with, as the package writes them. + * + * A colour is written into the package as `val="RRGGBB"`, so these are the hex without + * the `#` the frontmatter names it with. + */ +const DECK_BACKGROUND = 'val="101014"'; + +const SLIDE_BACKGROUND = 'val="202020"'; + +const TEMPLATE_BACKGROUND = 'val="303030"'; + +const SLIDE_OVER_TEMPLATE = 'val="404040"'; + it('draws every slide of a deck with the deck\'s own background', function () { says(Site::PRESENTATION_DIRECTIVES, 'Every slide is drawn with them'); $slides = Deck::slides(DECK_SETTINGS); expect($slides)->toHaveCount(2); - expect($slides[0])->toContain('val="101014"'); + expect($slides[0])->toContain(DECK_BACKGROUND); }); it('lets a slide\'s own background beat the deck\'s', function () { @@ -45,8 +59,8 @@ $slides = Deck::slides(DECK_SETTINGS); - expect($slides[1])->toContain('val="202020"'); - expect($slides[1])->not->toContain('val="101014"'); + expect($slides[1])->toContain(SLIDE_BACKGROUND); + expect($slides[1])->not->toContain(DECK_BACKGROUND); }); it('lets a template\'s own background beat the deck\'s', function () { @@ -54,8 +68,8 @@ $slides = Deck::slides(DECK_SETTINGS_TEMPLATED); - expect($slides[0])->toContain('val="101014"'); - expect($slides[1])->toContain('val="303030"'); + expect($slides[0])->toContain(DECK_BACKGROUND); + expect($slides[1])->toContain(TEMPLATE_BACKGROUND); }); it('lets a slide\'s own background beat a template\'s', function () { @@ -63,9 +77,9 @@ $slides = Deck::slides(DECK_SETTINGS_TEMPLATED_AND_SLIDDEN); - expect($slides[1])->toContain('val="404040"'); - expect($slides[1])->not->toContain('val="303030"'); - expect($slides[1])->not->toContain('val="101014"'); + expect($slides[1])->toContain(SLIDE_OVER_TEMPLATE); + expect($slides[1])->not->toContain(TEMPLATE_BACKGROUND); + expect($slides[1])->not->toContain(DECK_BACKGROUND); }); it('shows a deck whose accent every slide is drawn with', function () { diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index a7ca84c..4a94798 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -31,6 +31,18 @@ function quotedRefusals(string $page): array return $matches[1]; } +/** The Word half's slots, as a `styles.yml` held them. */ +const WORD_SLOT = "heading.1:\n name: Georgia\n"; + +/** The page that names the Word half's theme, and its refusals. */ +const WORD_CONFIGURATION = 'word/configuration.md'; + +/** The theme's name as the library prints it: the name the deck gave. */ +const DECLARED_NAME = '"mine"'; + +/** The theme's name as a page writes it, which is not the deck's own. */ +const PAGE_NAME = '"acme"'; + it('refuses a theme that names a style "style"', function () { says(Site::GENERAL_DIRECTIVES, 'a theme may not name a style `style`'); @@ -92,17 +104,17 @@ function quotedRefusals(string $page): array expect(Theme::slidesRetired(Theme::DECK, 'text-styles.yml', "brand:\n color: FF0000\n")) ->toBe('The theme "mine" has a "text-styles.yml" beside it. A theme is one file: move its content into the "textStyles:" section of its "theme.yml", and delete the file.'); - expect(Theme::slidesRetired(Theme::DECK, 'styles.yml', "heading.1:\n name: Georgia\n")) + expect(Theme::slidesRetired(Theme::DECK, 'styles.yml', WORD_SLOT)) ->toBe('The theme "mine" has a "styles.yml" beside it. That file is the Word half\'s, and this half does not read it: its slots name Word styles, which a deck of slides has no use for. Delete it, and the theme is read again.'); }); it('refuses the file the Word half\'s slots used to be in, and says where they go', function () { - says('word/configuration.md', 'A `styles.yml` beside the theme is refused'); + says(WORD_CONFIGURATION, 'A `styles.yml` beside the theme is refused'); expect(Theme::wordRetired( "---\ntheme: mine\n---\n\n# One\n", 'styles.yml', - "heading.1:\n name: Georgia\n", + WORD_SLOT, ))->toMatch( '/^"[^"]*\/styles\.yml" is not read any more\. A theme\'s styles are in the "styles:" section of ' . 'its "theme\.yml", and its named text styles in the "textStyles:" section\. Delete the file, ' @@ -117,25 +129,25 @@ function quotedRefusals(string $page): array // The page writes the theme's name as "acme", and the library writes the name the // deck named: the same sentence with another name in it. - expect(\str_replace('"mine"', '"acme"', Theme::slidesRetired( + expect(\str_replace(DECLARED_NAME, PAGE_NAME, Theme::slidesRetired( Theme::DECK, 'layouts.yml', "layouts:\n content: Title and Content\n", )))->toBe($themes[0]); - expect(\str_replace('"mine"', '"acme"', Theme::slidesRetired( + expect(\str_replace(DECLARED_NAME, PAGE_NAME, Theme::slidesRetired( Theme::DECK, 'text-styles.yml', "brand:\n color: FF0000\n", )))->toBe($themes[1]); - expect(\str_replace('"mine"', '"acme"', Theme::slidesRetired( + expect(\str_replace(DECLARED_NAME, PAGE_NAME, Theme::slidesRetired( Theme::DECK, 'styles.yml', - "heading.1:\n name: Georgia\n", + WORD_SLOT, )))->toBe($themes[2]); - $configuration = quotedRefusals('word/configuration.md'); + $configuration = quotedRefusals(WORD_CONFIGURATION); expect($configuration)->toHaveCount(1); @@ -144,7 +156,7 @@ function quotedRefusals(string $page): array expect((string) \preg_replace( '/^"[^"]*"/', '"…/styles.yml"', - Theme::wordRetired("---\ntheme: mine\n---\n\n# One\n", 'styles.yml', "heading.1:\n name: Georgia\n"), + Theme::wordRetired("---\ntheme: mine\n---\n\n# One\n", 'styles.yml', WORD_SLOT), ))->toBe($configuration[0]); }); @@ -222,7 +234,7 @@ function quotedRefusals(string $page): array }); it('reads the named styles of a Word theme from the section the page names', function () { - says('word/configuration.md', '`textStyles:`'); + says(WORD_CONFIGURATION, '`textStyles:`'); expect(TextStyles::KEY)->toBe('textStyles'); }); From 488a5c291274d618e4f542e819fdcc3fdfd0c2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 09:24:47 +0100 Subject: [PATCH 07/10] The slides half's gallery is carried, and a gallery that has drifted is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle carried the Word half's own gallery and said so, and said that the slides half's was not in it. It is now carried beside the Word half's, and the examples the site shows are in examples/site/ beside them: three directories under examples/, each named for where its examples come from. The themes are not carried with them. They ship with the library, and the resolver's last step is the theme that ships with the package, so an agent who has installed the library can build every deck in either gallery. A theme a deck names by a path is written from the deck's own directory, and that directory is carried whole, so it travels with the copy. The copy is made by the build, so it cannot drift while the build runs — but a bundle built against another checkout can, and nothing said so. The checker compares both galleries with the libraries, byte for byte, the way it already compares the scripts, and the pages that describe the bundle are the ones that say what it carries. The loop and the command line named only the Word half, while the bundle describes decks and carries four pages about them. Both now cover the slides half, and the description says a document or a deck. --- content/skills/index.md | 22 +++-- skill/mdword/SKILL.md | 39 ++++++--- skill/mdword/galleries.json | 27 ++++++ src/Bundle/Gallery.php | 162 ++++++++++++++++++++++++++++++++++++ tests/Claims/counts.php | 11 --- tests/Claims/skill.php | 52 ++++++++++++ tests/Skill/gallery.php | 52 ++++++++++++ tests/Support/Bundle.php | 96 +++++++++++++++++++++ tools/check-skill.php | 51 ++++++++---- tools/write-skill.js | 49 +++++++---- 10 files changed, 498 insertions(+), 63 deletions(-) create mode 100644 skill/mdword/galleries.json create mode 100644 src/Bundle/Gallery.php create mode 100644 tests/Claims/skill.php create mode 100644 tests/Skill/gallery.php create mode 100644 tests/Support/Bundle.php diff --git a/content/skills/index.md b/content/skills/index.md index ca65ea2..19ce02c 100644 --- a/content/skills/index.md +++ b/content/skills/index.md @@ -2,7 +2,7 @@ layout: layouts/base.njk permalink: /skills/ title: The skill -summary: A directory an agent reads, holding this site's documentation and the library's examples — and where each agent harness looks for one. +summary: A directory an agent reads, holding this site's documentation and the two libraries' examples — and where each agent harness looks for one. section: The skill order: 0 --- @@ -18,9 +18,9 @@ when to use it — and beside it the files it points at: the documentation, the the scripts that render a document so that it can be looked at. **The one for this suite is `mdword`**, and it is built from this site. The pages in it are -the pages of this site; the examples in it are this site's own and the library's. Nothing in -it is written twice: it is a copy made by the build, and a copy that is made by the build -cannot drift from what it was made from. +the pages of this site; the examples in it are this site's own and the two libraries' own. +Nothing in it is written twice: it is a copy made by the build, and a copy that is made by +the build cannot drift from what it was made from. ## Install it @@ -77,17 +77,23 @@ mdword/ ├── SKILL.md what the library does, and how to use it ├── VERSION the version of this bundle ├── references/ the documentation, one file per page -├── examples/ this site's examples, and the library's +├── examples/ this site's examples, and the two halves' own │ ├── site/ -│ └── library/ +│ ├── word/ +│ └── slides/ └── scripts/ the render tools ``` The **references** are the pages of this site, one file each, with the frontmatter off. They cover both halves: the Word half's pages and the slides half's four, and the pages the two have in common. The **examples** are the decks and documents the pages show, built and named, -and the Word half's own twenty-four — which between them are the catalogue of what that half -can do. The slides half's own gallery of decks is not in the bundle. +and the two halves' own galleries — `examples/word/` and `examples/slides/` — which between +them are the catalogue of what each half can do. + +**The themes are not carried.** They ship with the library, and the resolver's last step is +the theme that ships with it, so the bundle needs no second copy of a thing the library +already has. A theme named by a path is written from the deck's own directory, and that +directory is carried whole, so it travels with the copy. The **scripts** render a document so that it can be looked at, which is the one step a prose answer cannot do for itself. diff --git a/skill/mdword/SKILL.md b/skill/mdword/SKILL.md index dafdf30..a7277ad 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 — 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. +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 template or theme, to read a Word document or a deck back to Markdown, or to check that a document or a deck the library produced looks right. --- # mdword @@ -35,27 +35,39 @@ documentation, not by recalling it. | Installing this skill | `references/install.md` | The **examples** are beside these: `examples/site/` holds the documents and the decks this -documentation shows, and `examples/library/` holds the Word half's own twenty-four — the -catalogue of what it can do, one file each. The slides half's own gallery of decks is not in -the bundle. +documentation shows, and the two halves' own galleries — `examples/word/` the Word half's +catalogue of what it can do, one file each, and `examples/slides/` the slides half's, the +same way. + +**The themes are not carried.** They ship with the library, and the resolver's last step is +the theme that ships with it, so the bundle needs no second copy of a thing the library +already has. A theme named by a path is written from the deck's own directory, and that +directory is carried whole, so it travels with the copy. ## The loop -Write the Markdown, build the document, render it, **look at it**, and fix the Markdown. +Write the Markdown, build it, render it, **look at it**, and fix the Markdown. ```bash -mdoffice word to-docx notes.md -o notes.docx # build +mdoffice word to-docx notes.md -o notes.docx # build a document +mdoffice slides to-pptx deck.md -o deck.pptx # build a deck php scripts/render-document.php notes.docx --out notes.pdf # render ``` +`scripts/render-document.php` takes a deck as well as a document: it reads the extension and +draws a `.pptx` with DeckCraft and a `.docx` with WordCraft, so one command renders either. + A **template** is rendered as well as a document — `php scripts/render-document.php report.dotx --out report.pdf` — and the PDF is named after the template. **Then read the PDF.** A document that has not been looked at has not been checked, and the thing a document gets wrong is almost always something only a picture shows: a line that -wraps, a table that runs off the page, a picture in the wrong place. +wraps, a table that runs off the page, a picture in the wrong place. A deck fails the same +way — a slide that overflows is a slide the room cannot read — and `mdoffice slides to-pptx +--strict` refuses it rather than shrinking it to fit. -For a picture of every page rather than a PDF, WordCraft renders them directly: +For a picture of every page rather than a PDF, the application renders them directly — +WordCraft for a document, DeckCraft for a deck: ```bash php scripts/render.php notes.docx --port 7981 --out pages/ @@ -102,12 +114,15 @@ mdoffice word to-docx notes.md # to .docx mdoffice word to-odt notes.md # to .odt mdoffice word to-rtf notes.md # to .rtf mdoffice word to-markdown notes.docx # and back +mdoffice slides to-pptx deck.md # to .pptx +mdoffice slides to-markdown deck.pptx # and back ``` -It decides which way the data has to go **from the content, not the name**: a Word document -is a zip and Markdown is text, and the four bytes that say which is which are part of the -format. Exit `0` on success and `1` for anything that did not finish — a mistake the caller -can put right or a defect they cannot. +The Word part decides which way the data has to go **from the content, not the name**: a Word +document is a zip and Markdown is text, and the four bytes that say which is which are part +of the format. The slides part names the direction instead, because a deck is a `.pptx` either +way. Exit `0` on success and `1` for anything that did not finish — a mistake the caller can +put right or a defect they cannot. The command line is not part of this library; it is `mdoffice`, one program over both converters. `references/command-line-install.md` says how to get it. diff --git a/skill/mdword/galleries.json b/skill/mdword/galleries.json new file mode 100644 index 0000000..8ea844e --- /dev/null +++ b/skill/mdword/galleries.json @@ -0,0 +1,27 @@ +{ + "_comment": [ + "The galleries the bundle carries, and where each one comes from.", + "", + "The two halves of the suite each ship a gallery of examples: the catalogue of what", + "that half can do, one file each. Both are carried, and both are copied from the", + "library rather than written again here — a copy the build makes cannot drift from", + "what it was made from, and the checker says so when one has.", + "", + "The 'source' is where the gallery is copied from, relative to the suite's directory:", + "the library's own checkout and the examples inside it. The 'path' is where its gallery", + "is carried in the bundle, and it is the path the prose names.", + "", + "The 'exclude' are the parts of a gallery the bundle does not carry: the built", + "output, and the Finder's own bookkeeping. A gallery is the sources, and a copy of", + "the output is a second copy of something the library builds.", + "", + "This file is read by the build that writes the bundle and by the checker that", + "refuses a bundle that has fallen behind, because two copies of one list are two", + "answers to one question." + ], + "exclude": ["out", ".DS_Store"], + "galleries": { + "word": { "source": "markdown-word/examples", "path": "examples/word" }, + "slides": { "source": "markdown-presentation/examples", "path": "examples/slides" } + } +} diff --git a/src/Bundle/Gallery.php b/src/Bundle/Gallery.php new file mode 100644 index 0000000..9c4538a --- /dev/null +++ b/src/Bundle/Gallery.php @@ -0,0 +1,162 @@ +, galleries: array} + */ + private static function manifest(string $root): array + { + $file = $root . '/skill/mdword/galleries.json'; + + if (! \is_file($file)) { + throw new \RuntimeException('skill/mdword/galleries.json could not be read.'); + } + + $parsed = \json_decode((string) \file_get_contents($file), true); + + if (! \is_array($parsed)) { + throw new \RuntimeException('skill/mdword/galleries.json could not be read as JSON.'); + } + + return [ + 'exclude' => \array_values((array) ($parsed['exclude'] ?? [])), + 'galleries' => (array) ($parsed['galleries'] ?? []), + ]; + } + + /** + * Where each half's gallery is carried, as the path the prose names. + * + * @return array + */ + public static function paths(string $root): array + { + $paths = []; + + foreach (self::manifest($root)['galleries'] as $half => $gallery) { + $paths[$half] = (string) ($gallery['path'] ?? ''); + } + + return $paths; + } + + /** + * How the galleries in the bundle differ from the ones the libraries ship. + * + * Both directions, because both are wrong in a way a reader cannot act on: a file the + * library has and the bundle does not is a construct the agent cannot see, and a file + * the bundle has and the library does not is a construct the library never had. + * + * @return list + */ + public static function differences(string $root, string $bundle): array + { + $manifest = self::manifest($root); + $problems = []; + + foreach ($manifest['galleries'] as $half => $gallery) { + $library = $root . '/../' . $gallery['source']; + $carried = $bundle . '/' . $gallery['path']; + + if (! \is_dir($library)) { + $problems[] = \sprintf( + 'The %s half\'s gallery is not there. The library is checked out beside this one.', + $half, + ); + + continue; + } + + if (! \is_dir($carried)) { + $problems[] = \sprintf('The bundle does not carry the %s half\'s gallery.', $half); + + continue; + } + + $theirs = self::files($library, $manifest['exclude']); + $ours = self::files($carried, []); + + foreach ($theirs as $file) { + if (! \in_array($file, $ours, true)) { + $problems[] = \sprintf( + 'The bundle does not carry "%s" of the %s half\'s gallery.', + $file, + $half, + ); + + continue; + } + + if (\md5_file($library . '/' . $file) !== \md5_file($carried . '/' . $file)) { + $problems[] = \sprintf( + 'The bundle\'s copy of "%s" is not the one the %s half ships.', + $file, + $half, + ); + } + } + + foreach ($ours as $file) { + if (! \in_array($file, $theirs, true)) { + $problems[] = \sprintf( + 'The bundle carries "%s", which the %s half does not ship.', + $file, + $half, + ); + } + } + } + + return $problems; + } + + /** + * Every file of a gallery, as the path it has inside it. + * + * @param list $excluded The names that are not carried: a directory of that name + * takes its whole subtree with it. + * + * @return list + */ + private static function files(string $directory, array $excluded): array + { + if (! \is_dir($directory)) { + return []; + } + + $filter = new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + static fn (\SplFileInfo $item): bool => ! \in_array($item->getFilename(), $excluded, true), + ); + + $found = []; + + foreach (new \RecursiveIteratorIterator($filter, \RecursiveIteratorIterator::SELF_FIRST) as $item) { + if ($item->isFile()) { + $found[] = \substr($item->getPathname(), \strlen($directory) + 1); + } + } + + \sort($found); + + return $found; + } +} diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php index bf2e611..18bb6c2 100644 --- a/tests/Claims/counts.php +++ b/tests/Claims/counts.php @@ -97,14 +97,3 @@ says(Site::PRESENTATION_INDEX, words(\count($methods)) . ' methods'); }); - -it('counts the Word half\'s own examples as the library ships them', function () { - $examples = \glob(Site::root() . '/../markdown-word/examples/markdown/*.md') ?: []; - - $count = \count($examples); - - $phrase = 'the Word half\'s own ' . words($count); - - says('skills/index.md', $phrase); - saysIn(Site::skill(), $phrase); -}); diff --git a/tests/Claims/skill.php b/tests/Claims/skill.php new file mode 100644 index 0000000..815474d --- /dev/null +++ b/tests/Claims/skill.php @@ -0,0 +1,52 @@ +not->toContain(MISSING_GALLERY); + expect(Site::flat(Site::skill()))->not->toContain(MISSING_GALLERY); +}); + +it('says about the galleries what the site says', function () { + $skill = Site::flat(Site::skill()); + $page = Site::flat(Site::page('skills/index.md')); + + $paths = \array_values(Gallery::paths(Site::root())); + + expect($paths)->not->toBeEmpty(); + + foreach ($paths as $path) { + expect($skill)->toContain($path); + expect($page)->toContain($path); + } + + expect($skill)->toContain('the two halves\' own galleries'); + expect($page)->toContain('the two halves\' own galleries'); +}); diff --git a/tests/Skill/gallery.php b/tests/Skill/gallery.php new file mode 100644 index 0000000..cb8bb71 --- /dev/null +++ b/tests/Skill/gallery.php @@ -0,0 +1,52 @@ + + */ +function pathsNamed(string $text): array +{ + \preg_match_all('/`((?:examples|references|scripts)\/[^`]+)`/', $text, $found); + + return $found[1]; +} + +it('carries the gallery each library ships, byte for byte', function () { + $problems = Gallery::differences(Site::root(), Bundle::built()); + + expect($problems)->toBe([]); +}); + +it('holds every path the prose names', function () { + $bundle = Bundle::built(); + + $named = \array_merge( + pathsNamed(Site::skill()), + pathsNamed(Site::page('skills/index.md')), + ); + + expect($named)->not->toBeEmpty(); + + foreach ($named as $path) { + expect(\file_exists($bundle . '/' . $path))->toBeTrue(); + } +}); diff --git a/tests/Support/Bundle.php b/tests/Support/Bundle.php new file mode 100644 index 0000000..eac0e99 --- /dev/null +++ b/tests/Support/Bundle.php @@ -0,0 +1,96 @@ + ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = \proc_open( + ['node', '--input-type=module', '-e', \sprintf($script, $root)], + $descriptors, + $pipes, + $root, + ); + + if (! \is_resource($process)) { + throw TheMachineryFailed::because('The bundle could not be built.'); + } + + $out = (string) \stream_get_contents($pipes[1]); + $err = (string) \stream_get_contents($pipes[2]); + + \fclose($pipes[1]); + \fclose($pipes[2]); + + $status = \proc_close($process); + + if ($status !== 0) { + throw TheMachineryFailed::because( + \trim($err) === '' ? 'The bundle could not be built.' : \trim($err), + ); + } + + $directory = \trim($out); + + // The bundle is taken away with the run, because a directory of a megabyte and a + // half left in the temporary directory is a side effect nobody asked for. + \register_shutdown_function(static function () use ($directory): void { + Directory::remove($directory); + }); + + return self::$built = $directory; + } +} diff --git a/tools/check-skill.php b/tools/check-skill.php index 54a9dc1..32ac745 100644 --- a/tools/check-skill.php +++ b/tools/check-skill.php @@ -31,6 +31,8 @@ * description is not advertised to the model * - the scripts are the ones the library ships, byte for byte, because a copy that has * drifted is a copy that is wrong + * - the galleries are the ones the two libraries ship, byte for byte, because a gallery + * that has drifted is a gallery that is wrong * - every path the install page names exists in the built tree, because a URL that does * not resolve is the failure a reader meets first * - the library's README points at the skill page @@ -50,6 +52,7 @@ require_once __DIR__ . '/lib/Autoload.php'; +use MarkdownOffice\Documentation\Bundle\Gallery; use MarkdownOffice\Documentation\Tool\Cli; use MarkdownOffice\Documentation\Tool\Report; use MarkdownOffice\Documentation\Tool\UnreadableSkill; @@ -399,13 +402,24 @@ function anchorsOf(string $text): array } } -// 4. Every path a reference file names exists. +// 4. Every path the bundle's prose names exists. +// +// The reference files and the skill's own file: both are read by an agent, and a path in +// either of them that the bundle does not hold is a path that goes nowhere. +$prose = []; + foreach (\glob($bundle . REFERENCE_FILES) ?: [] as $file) { - foreach (paths((string) \file_get_contents($file)) as $path) { + $prose[\str_replace($bundle . '/', '', $file)] = (string) \file_get_contents($file); +} + +$prose['SKILL.md'] = (string) \file_get_contents($bundle . '/SKILL.md'); + +foreach ($prose as $name => $text) { + foreach (paths($text) as $path) { if (!\file_exists($bundle . '/' . $path)) { $report->fail(\sprintf( - 'The reference file "%s" names "%s" and the bundle does not hold it.', - \basename($file), + 'The file "%s" names "%s" and the bundle does not hold it.', + $name, $path, )); } @@ -481,7 +495,17 @@ function anchorsOf(string $text): array } } -// 7. Every path the install page names exists in the built tree. +// 7. The galleries are the ones the libraries ship. +// +// The bundle carries both halves' own galleries, and a gallery that has fallen behind the +// library is a gallery that is wrong: it shows a construct the library no longer draws, or +// misses one it does. The copy is made by the build, so it cannot drift while the build +// runs — but a bundle built against another checkout can, and nothing said so. +foreach (Gallery::differences($root, $bundle) as $problem) { + $report->fail($problem); +} + +// 8. Every path the install page names exists in the built tree. $install = (string) \file_get_contents($root . '/' . SKILL_PAGE); \preg_match_all('#https?://[^/\s]+/skills/([^\s`)\]",]+)#', $install, $found); @@ -501,12 +525,12 @@ function anchorsOf(string $text): array } } -// 8. The library's README points at the skill page. +// 9. The library's README points at the skill page. // // 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. +// 10. 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 @@ -514,15 +538,7 @@ function anchorsOf(string $text): array // // 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 . REFERENCE_FILES) ?: [] 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) { +foreach ($prose as $name => $text) { $directory = \dirname($name); foreach (linksIn($text) as $target) { @@ -589,8 +605,9 @@ function anchorsOf(string $text): array } \printf( - "%d reference file(s), %d page(s) and %d script(s) checked against the bundle.\n", + "%d reference file(s), %d page(s), %d script(s) and %d gallery(s) checked against the bundle.\n", \count($manifest['references']), \count($pages), \count($scripts), + \count(Gallery::paths($root)), ); diff --git a/tools/write-skill.js b/tools/write-skill.js index d412209..b2065ec 100644 --- a/tools/write-skill.js +++ b/tools/write-skill.js @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; -import { dirname, join, relative, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { expandExamples, slug } from "./markdown.js"; @@ -74,6 +74,16 @@ const LIBRARY_TOOLS = [ */ const LIBRARY = join(here, "..", "..", "markdown-word"); +/** + * The galleries the bundle carries, and the libraries they come from. + * + * Read rather than written down here, because the checker reads it too: two copies of one + * list are two answers to one question, and the two drift. + */ +const GALLERIES = JSON.parse( + readFileSync(join(here, "..", "skill", "mdword", "galleries.json"), "utf8"), +); + /** * Where the archive is made by. * @@ -89,8 +99,8 @@ const ZIP = "/usr/bin/zip"; * cannot say anything the site does not say, and it cannot be out of date in a way the site * is not. * - * Four things are in it: the pages of the site, the examples the site built, the examples - * the library ships, and the tools that render a document so that it can be looked at. + * Four things are in it: the pages of the site, the examples the site built, the galleries + * the two libraries ship, and the tools that render a document so that it can be looked at. */ export async function writeSkill({ dir }) { const manifest = JSON.parse( @@ -199,20 +209,29 @@ export async function writeSkill({ dir }) { }), ); - // The examples the library ships: the catalogue of what it can do. - const libraryExamples = join(LIBRARY, "examples"); + // The galleries the libraries ship: the catalogue of what each half can do. + // + // The sources are copied and not the built output, and the manifest says which parts of + // a gallery are output: a gallery that carried the built decks would be a second copy of + // something the library builds, and one that had fallen behind would be a gallery of + // decks the library no longer draws. + await Promise.all( + Object.entries(GALLERIES.galleries).map(async ([half, gallery]) => { + const from = join(here, "..", "..", gallery.source); - if (! existsSync(libraryExamples)) { - throw new Error( - "The library's examples are not there. The library is checked out beside this one, and they " - + "are what the bundle's own catalogue is.", - ); - } + if (! existsSync(from)) { + throw new Error( + `The ${half} half's examples are not there. The library is checked out beside this one, ` + + "and they are what the bundle's own catalogue is.", + ); + } - await cp(libraryExamples, join(bundle, "examples", "library"), { - recursive: true, - filter: (from) => !from.includes("/out/") && !from.endsWith(".DS_Store"), - }); + await cp(from, join(bundle, gallery.path), { + recursive: true, + filter: (path) => ! GALLERIES.exclude.includes(basename(path)), + }); + }), + ); const version = await versionOf(bundle); From 8ee950447ccde5f3374c9ed8e01eab35e585b54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 09:28:24 +0100 Subject: [PATCH 08/10] The gallery check names what is wrong, and the strings it repeats are named --- src/Bundle/Gallery.php | 142 ++++++++++++++++++++++--------- src/Bundle/UnreadableGallery.php | 25 ++++++ tests/Claims/skill.php | 22 +++-- 3 files changed, 141 insertions(+), 48 deletions(-) create mode 100644 src/Bundle/UnreadableGallery.php diff --git a/src/Bundle/Gallery.php b/src/Bundle/Gallery.php index 9c4538a..9322c22 100644 --- a/src/Bundle/Gallery.php +++ b/src/Bundle/Gallery.php @@ -27,13 +27,13 @@ private static function manifest(string $root): array $file = $root . '/skill/mdword/galleries.json'; if (! \is_file($file)) { - throw new \RuntimeException('skill/mdword/galleries.json could not be read.'); + throw UnreadableGallery::unreadable('skill/mdword/galleries.json'); } $parsed = \json_decode((string) \file_get_contents($file), true); if (! \is_array($parsed)) { - throw new \RuntimeException('skill/mdword/galleries.json could not be read as JSON.'); + throw UnreadableGallery::notJson('skill/mdword/galleries.json'); } return [ @@ -73,55 +73,113 @@ public static function differences(string $root, string $bundle): array $problems = []; foreach ($manifest['galleries'] as $half => $gallery) { - $library = $root . '/../' . $gallery['source']; - $carried = $bundle . '/' . $gallery['path']; + foreach (self::differencesIn($half, $gallery, $manifest['exclude'], $root, $bundle) as $problem) { + $problems[] = $problem; + } + } - if (! \is_dir($library)) { - $problems[] = \sprintf( - 'The %s half\'s gallery is not there. The library is checked out beside this one.', - $half, - ); + return $problems; + } - continue; + /** + * How one half's gallery differs from the one the library ships. + * + * @param array{source: string, path: string} $gallery + * @param list $exclude + * + * @return list + */ + private static function differencesIn(string $half, array $gallery, array $exclude, string $root, string $bundle): array + { + $library = $root . '/../' . $gallery['source']; + $carried = $bundle . '/' . $gallery['path']; + + if (! \is_dir($library)) { + return [\sprintf( + 'The %s half\'s gallery is not there. The library is checked out beside this one.', + $half, + )]; + } + + if (! \is_dir($carried)) { + return [\sprintf('The bundle does not carry the %s half\'s gallery.', $half)]; + } + + $theirs = self::files($library, $exclude); + $ours = self::files($carried, []); + + return [ + ...self::missing($half, $theirs, $ours), + ...self::changed($half, $theirs, $ours, $library, $carried), + ...self::extra($half, $ours, $theirs), + ]; + } + + /** + * The files of the library's gallery the bundle does not carry. + * + * @param list $theirs + * @param list $ours + * + * @return list + */ + private static function missing(string $half, array $theirs, array $ours): array + { + $problems = []; + + foreach ($theirs as $file) { + if (! \in_array($file, $ours, true)) { + $problems[] = \sprintf('The bundle does not carry "%s" of the %s half\'s gallery.', $file, $half); } + } - if (! \is_dir($carried)) { - $problems[] = \sprintf('The bundle does not carry the %s half\'s gallery.', $half); + return $problems; + } - continue; + /** + * The files the bundle carries that are not the ones the library ships. + * + * @param list $theirs + * @param list $ours + * + * @return list + */ + private static function extra(string $half, array $ours, array $theirs): array + { + $problems = []; + + foreach ($ours as $file) { + if (! \in_array($file, $theirs, true)) { + $problems[] = \sprintf('The bundle carries "%s", which the %s half does not ship.', $file, $half); } + } - $theirs = self::files($library, $manifest['exclude']); - $ours = self::files($carried, []); - - foreach ($theirs as $file) { - if (! \in_array($file, $ours, true)) { - $problems[] = \sprintf( - 'The bundle does not carry "%s" of the %s half\'s gallery.', - $file, - $half, - ); - - continue; - } - - if (\md5_file($library . '/' . $file) !== \md5_file($carried . '/' . $file)) { - $problems[] = \sprintf( - 'The bundle\'s copy of "%s" is not the one the %s half ships.', - $file, - $half, - ); - } + return $problems; + } + + /** + * The files the bundle carries that are not the library's own. + * + * @param list $theirs + * @param list $ours + * + * @return list + */ + private static function changed(string $half, array $theirs, array $ours, string $library, string $carried): array + { + $problems = []; + + foreach ($theirs as $file) { + if (! \in_array($file, $ours, true)) { + continue; } - foreach ($ours as $file) { - if (! \in_array($file, $theirs, true)) { - $problems[] = \sprintf( - 'The bundle carries "%s", which the %s half does not ship.', - $file, - $half, - ); - } + if (\md5_file($library . '/' . $file) !== \md5_file($carried . '/' . $file)) { + $problems[] = \sprintf( + 'The bundle\'s copy of "%s" is not the one the %s half ships.', + $file, + $half, + ); } } diff --git a/src/Bundle/UnreadableGallery.php b/src/Bundle/UnreadableGallery.php new file mode 100644 index 0000000..586657e --- /dev/null +++ b/src/Bundle/UnreadableGallery.php @@ -0,0 +1,25 @@ +not->toContain(MISSING_GALLERY); + expect(Site::flat(Site::page(SKILL_PAGE)))->not->toContain(MISSING_GALLERY); expect(Site::flat(Site::skill()))->not->toContain(MISSING_GALLERY); }); it('says about the galleries what the site says', function () { $skill = Site::flat(Site::skill()); - $page = Site::flat(Site::page('skills/index.md')); + $page = Site::flat(Site::page(SKILL_PAGE)); $paths = \array_values(Gallery::paths(Site::root())); @@ -47,6 +57,6 @@ expect($page)->toContain($path); } - expect($skill)->toContain('the two halves\' own galleries'); - expect($page)->toContain('the two halves\' own galleries'); + expect($skill)->toContain(BOTH_GALLERIES); + expect($page)->toContain(BOTH_GALLERIES); }); From 5c740569d68d86b9b8cef8d68ea4d0fcc37cdbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 11:02:05 +0100 Subject: [PATCH 09/10] The ten claims the audit found are corrected, and the pages that made them guard them A theme is one file and the pages that said otherwise now say so. The slides half's themes page carries a whole theme, the quoting a colour needs, the three faces, the ten refusals of a named style, the folding of a style's name and the rules a master and a `layouts:` section follow; the Word half carries its own refusals. The resolution order is the one the Word half gives, the theme's name comes back out of a deck, a deck's own settings come back as the slides' own, and the frontmatter does not survive a round trip at all. Every claim is checked against the library that has to behave that way, and the phrases that were wrong are named so that they cannot come back. --- content/general/directives.md | 5 + content/presentation/directives.md | 4 + content/presentation/index.md | 22 ++-- content/presentation/themes.md | 129 ++++++++++++++++++--- content/word/configuration.md | 20 ++++ content/word/round-trip.md | 8 +- content/word/templates.md | 2 +- skill/mdword/SKILL.md | 2 +- tests/Claims/round-trip.php | 35 ++++++ tests/Claims/text-styles.php | 147 ++++++++++++++++++++++-- tests/Claims/themes.php | 173 +++++++++++++++++++++++++++++ tests/Claims/word-templates.php | 5 + tests/Support/Deck.php | 47 ++++++++ tests/Support/Master.php | 40 +++++++ tests/Support/RoundTrip.php | 5 + tests/Support/Site.php | 5 + tests/Support/Theme.php | 38 +++++++ tools/check-command-line.php | 6 + 18 files changed, 662 insertions(+), 31 deletions(-) create mode 100644 tests/Claims/round-trip.php create mode 100644 tests/Claims/themes.php create mode 100644 tests/Support/Master.php diff --git a/content/general/directives.md b/content/general/directives.md index 5398896..f61a772 100644 --- a/content/general/directives.md +++ b/content/general/directives.md @@ -84,6 +84,11 @@ Two of the eight are not the same in both halves. A `color` is a role, a theme s in Markdown Presentation, and six hexadecimal digits in Markdown Word. A `highlight` is drawn by Markdown Word and **refused by name** by Markdown Presentation, which has no way to write one. +**A style's name is folded.** A theme that spells a style `Brand` has it under `:brand[…]` and +`:Brand[…]` alike: a style's name is a word, and a word does not care about case. The refusal for +a name the theme does not have spells the styles the way the theme wrote them, because that is +the spelling the author has to change. + Three forms, one grammar: | Written | Means | diff --git a/content/presentation/directives.md b/content/presentation/directives.md index cd1c024..c62cfec 100644 --- a/content/presentation/directives.md +++ b/content/presentation/directives.md @@ -282,6 +282,10 @@ Every key of every slide's frontmatter must be one of these sixteen: 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. +**`theme-variables:` is the theme's own values for one slide.** It is a `theme.yml` in miniature +— the same keys, the same names, the same spelling — and what a slide may put in it is on +[A slide's own values](/presentation/themes/#a-slides-own-values). + **The ordinary keys are the slide's own, wherever they are written.** A key that holds somebody else's settings says whose: `template-settings:` says the template's, and `deck-settings:` says the deck's. That is what tells a definition from a slide and a slide from a deck — see diff --git a/content/presentation/index.md b/content/presentation/index.md index 0d6eef9..458bdca 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -91,9 +91,9 @@ roles and the named text styles. A `theme:` may also name the `theme.yml` itself the theme's master beside it is the theme's. The keys it takes, the colour slots, the geometry and the themes that ship are all on [Themes and layout](/presentation/themes/). -Resolution is a path, then `themes/` beside the deck, then the package's own. **No silent -fallback to `default`**: a name that does not resolve raises `ThemeNotFound` listing the ones that -do. +Resolution is a path, then `themes/` beside the deck, then `themes/` beside the +working directory, then the package's own. **No silent fallback to `default`**: a name that does +not resolve raises `ThemeNotFound` listing the ones that do. #### A template @@ -239,13 +239,19 @@ author: "markdown-office" Text. ``` -The frontmatter carries what the deck said about itself — its title, its author and its -description — because a deck's own properties are the only thing about it that is not on a +The frontmatter carries what the deck said about itself — its title, its theme, its author and +its description — because a deck's own properties are the only thing about it that is not on a slide. `save()` writes it to a file instead, and `read()` gives the slides back as a list. -What comes back is the text and the structure. What does not is the design: the type sizes, -the colours and the geometry live in the theme and the master, and neither travels to a new -deck. A `.pptx` records what is on a slide, not how it was drawn. +**The theme's name comes back; the theme does not.** The name travels into the `.pptx` and comes +back out of it, so a deck read back into Markdown is drawn again in the theme it was drawn in. +The file itself does not come back: the type sizes, the colours and the geometry live in the +theme and the master, and none of them travels to a new deck. A `.pptx` records what is on a +slide, not how it was drawn. + +**A deck's own settings come back as the slides' own.** A deck that named a background under +`deck-settings:` has it read back as a `background:` on every slide, rather than as the deck's +own layer: the file holds the background of each slide, and that is where the reader finds it. | In the deck | In the Markdown | | --- | --- | diff --git a/content/presentation/themes.md b/content/presentation/themes.md index b270784..1bbf460 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -28,8 +28,8 @@ themes/corporate-dark/ | `description` | string | `null` | | | `author` | string | `null` | | | `dark` | boolean | `false` | boolean only — `dark: yes` is refused | -| `colors` | mapping | see below | `#rrggbb`, stored upper-cased | -| `fonts` | mapping | Calibri / Calibri / Consolas | `heading`, `body`, `mono` | +| `colors` | mapping | see below | `#rrggbb`, stored upper-cased — **quoted**, or YAML reads the `#` as a comment | +| `fonts` | mapping | Calibri / Calibri / Consolas | `heading`, `body`, `mono` — see [The three faces](#the-three-faces) | | `style` | mapping | see below | the geometry | | `layouts` | mapping | the library's own | which layout each role maps to — see [Layout roles](#layout-roles) | | `textStyles` | mapping | none | the names a deck writes words in — see [Text styles](#text-styles) | @@ -58,11 +58,66 @@ They are refused rather than ignored, because a file that is quietly not read is looks like the one its author wrote and is not: the author renames a layout in the file beside the theme, the deck is drawn with the old mapping, and nothing in the output says so. +## A whole theme + +One file, with every section in it: + +```yaml +# themes/mine/theme.yml +name: Resilience Bakery +description: The house look. +author: Markdown Office +dark: true +colors: + dk1: "#1E1E2E" + lt1: "#F5F3EE" + accent1: "#C25E00" +fonts: + heading: "Avenir Next" + body: "Charter" + mono: "Fira Code" +style: + margin: 48 + gutter: 24 + body: 18 + title: 32 + heading-weight: bold + heading-ink: accent +layouts: + title: Titelfolie + content: Titel und Inhalt + two-column: Zwei Spalten + section: Abschnitt + blank: Leer + image-text: Bild links + image-title: Bild mit Titel + image-slide: Ganzflächiges Bild +textStyles: + brand: + color: accent1 + bold: true + muted: + color: lt2 + size: 10 +``` + +**Every colour is quoted.** YAML reads an unquoted `#` as the start of a comment, so +`dk1: #1E1E2E` is a key with no value at all — and the theme is refused for it: + +> In "…/theme.yml": "dk1" is not a colour such as "#1E1E2E". + +The `layouts:` section names all eight roles, because a theme with a `layouts:` section has the +whole mapping: a role it does not name is a role the theme does not map. `textStyles:` is +optional, and so is everything else here except `name:`. + ## 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 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 — +the reader that reads the theme's own file. The frontmatter key that carries it is +**`theme-variables:`**, and every slide may carry one — the +[frontmatter table](/presentation/directives/#frontmatter-directives) lists it among the +sixteen. The three that hold the theme's identity — `name`, `description` and `author` — are not a slide's, so naming one is refused, and so are `layouts` and `textStyles`: those are the theme's own shape rather than anything a slide may lay over it. @@ -93,6 +148,24 @@ dark1 light1 dark2 light2 hyperlink followedHyperlink `dark: true` swaps four of them — the page becomes `dk1`, the ink `lt1`, the panel `dk2`, the subtle ink `lt2` — which is why the same theme can be dark without being a second theme. +## The three faces + +A theme names three of them, and each is a slot rather than a family: + +| Slot | The face it is | +| --- | --- | +| `heading` | the heading and the title | +| `body` | the prose | +| `mono` | the code | + +**A name that is none of the three is a family as written.** A theme that says `body: Charter` +draws its prose in Charter, and a style that says `font: Inter` is drawn in Inter whether or +not the theme names it. + +**A text style's `font:` may name one of the three.** `font: heading` is drawn in the theme's +heading face, `font: body` in its body face and `font: mono` in its code face; a name that is +none of the three is a family as written. + ## `style:` — the geometry @@ -130,13 +203,32 @@ textStyles: ``` The properties are the [eight attributes](/directives/#a-word-in-a-style) an inline style takes, and -a size is in points as it is everywhere else. A colour is the same one a shape's fill is written -in: a role, a theme slot or a hex value. - -When the theme is read, a style that names an attribute that does not exist, a style that carries -no property at all, a style called `style` — the word the raw form is written with — and a -`highlight`, which this half does not draw, are each refused. A typo in the file is never rendered -as silence. +a size is in points as it is everywhere else — and the smallest one is **half a point**. A colour +is the same one a shape's fill is written in: a role, a theme slot or a hex value. + +**Ten mistakes in a theme's named styles are refused when the theme is read** rather than reaching +the slide: + +| Refused | Why | +| --- | --- | +| a style that sets an attribute that does not exist | the attributes are a closed set | +| a style that sets nothing at all | a style that styles nothing is a mistake | +| a style that is not a mapping of attributes | a style carries one attribute per line | +| a style called `style` | that word is how the raw form is written | +| a style that sets a `highlight` | **not drawn by this half** | +| a style that names another one in a different case | `Brand` and `brand` are one word | +| a colour that is not a colour | the message names every colour that would have been right | +| a size that is not a number of points | the message says what a size may be | +| a font that is not a name | a font is a family name | +| a flag that is not `true` or `false` | `yes` is a YAML truthy string rather than the flag | + +Every one of them names what would have worked instead, so a mistake in a theme's +`textStyles:` section is never rendered as silence. A `textStyles:` section that is not a mapping +of styles at all is refused the same way. + +**A style's name is folded**, so a theme that spells a style `Brand` has it under `:brand[…]` and +`:Brand[…]` alike — the rule is on [How directives work](/directives/#a-word-in-a-style), which +both halves keep. ## Layout roles @@ -149,7 +241,7 @@ title content two-column section blank image-text image-title image-slide ``` `blank` and `image-slide` have no title box. The `layouts:` section maps each role to a layout -name in the master, and the names are the theme's own: +name, and the names are the theme's own: ```yaml # themes/mine/theme.yml @@ -174,6 +266,16 @@ a role the theme does not map, and a deck that asks for one is told which roles map. The section is not a set of overrides over the library's own names; it is the mapping, and it is either complete or the theme cannot draw every role. +**A master fills in the roles it covers and says nothing about the rest.** A role the master +has no layout for — or a layout with no placeholders in it, such as Office's own *Blank* — +keeps the library's own geometry, even when the theme's `layouts:` section names it. The section +says which layout a role is; the master says where that layout puts its boxes. + +**A theme with no master still names its layouts.** The library writes the layout parts itself, +and it names them by the mapping above: a theme whose `layouts:` section says +`content: Mein Inhalt` and carries no master has a layout part called *Mein Inhalt*, drawn in +the library's own geometry. + **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 @@ -223,9 +325,10 @@ greys as well as colours. ## Resolution order -1. an explicit path — anything containing `/` +1. a path — written from beside the deck, the way a relative image path is — 2. `themes/` beside the deck -3. the package's own `themes/` +3. `themes/` beside the working directory +4. the package's own `themes/` There is **no silent fallback to `default`**. A name that does not resolve raises `ThemeNotFound` and lists every theme that does. A deck that came out looking like something other than what was diff --git a/content/word/configuration.md b/content/word/configuration.md index 656ab55..77163c4 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -176,6 +176,26 @@ named styles a document may write a word in — see [Word directives](/word/directives/#a-word-in-a-style). A `theme:` may also name the `theme.yml` itself, in which case that file is the theme's. +**Seven mistakes in a theme's named styles are refused when the theme is read** rather than +reaching the document: + +| Refused | Why | +| --- | --- | +| a style that is not a set of properties | a style carries one property per line | +| a style that carries no property at all | a style that styles nothing is a mistake | +| a style that names a property that does not exist | the properties are the eight of an inline style | +| a value that is not one — a `color`, a `size`, a `font` or a `bold` | the message says what the property takes | +| a style called `style` | that word is how the raw form is written | +| a style named after a directive this library draws | the directive is drawn first, and the style would never reach a word | +| a style that names another one in a different case | `Brand` and `brand` are one word | + +Every one of them names what would have worked instead, and a `textStyles:` section that is not a +mapping of styles at all is refused the same way. + +**A style's name is folded**, so a theme that spells a style `Brand` has it under `:brand[…]` and +`:Brand[…]` alike — the rule is on [How directives work](/directives/#a-word-in-a-style), which +both halves keep. + A `styles.yml` beside the theme is refused, and the message names the file and says where its content goes: diff --git a/content/word/round-trip.md b/content/word/round-trip.md index 675d9de..999e707 100644 --- a/content/word/round-trip.md +++ b/content/word/round-trip.md @@ -2,7 +2,7 @@ layout: layouts/base.njk permalink: /word/round-trip/ title: Round trips -summary: Markdown to a document and back. What comes out the same, and the six things that do not. +summary: Markdown to a document and back. What comes out the same, and the seven things that do not. section: Markdown Word order: 4 --- @@ -89,6 +89,12 @@ reads them back should treat directives as one-way. These are the rest of the list. Each one is a property of the formats rather than a bug, and each one is silent unless you look for it. +### The frontmatter does not come back + +The reader writes **no frontmatter at all**, so a document read back has lost its `theme:`, its +`styles:` and its `options:` — and every other key it carried. What comes back is the document's +text and nothing else, and the configuration that drew it is gone with it. + ### A table's header row comes back bold Word records that the runs are bold, not who made them bold: diff --git a/content/word/templates.md b/content/word/templates.md index 0e9c82f..c9c4512 100644 --- a/content/word/templates.md +++ b/content/word/templates.md @@ -14,7 +14,7 @@ There are two, and they answer different questions. | | The design is | You give it | It gives you | | --- | --- | --- | --- | | **A template** | a Word document — a `.docx` or a `.dotx` | the `styleId`s of its styles | its fonts, colours, spacing, headers and footers, and its letterhead | -| **A theme** | a directory of YAML | nothing | a look of your own, in every document that names it | +| **A theme** | one file of YAML — a `theme.yml` | nothing | a look of your own, in every document that names it | A template is a design somebody made in Word. A theme is a design you write down, and it is the only one of the two that travels with the Markdown rather than with the document. diff --git a/skill/mdword/SKILL.md b/skill/mdword/SKILL.md index a7277ad..affa8a3 100644 --- a/skill/mdword/SKILL.md +++ b/skill/mdword/SKILL.md @@ -18,7 +18,7 @@ documentation, not by recalling it. | The directives: `::toc`, `:footnote`, `:style`, and the seven that place objects in a document | `references/directives.md` | | Every frontmatter key, every option and every style slot | `references/configuration.md` | | Rendering into a design that already exists — a Word template, or a theme | `references/templates.md` | -| What comes back when a document is read into Markdown, and the six things that do not | `references/round-trip.md` | +| What comes back when a document is read into Markdown, and the seven things that do not | `references/round-trip.md` | | The three options that decide what happens to a picture | `references/images.md` | | The same three options, and how the slides half differs | `references/general-images.md` | | Every exception the library throws, and what each one means | `references/errors.md` | diff --git a/tests/Claims/round-trip.php b/tests/Claims/round-trip.php new file mode 100644 index 0000000..a2a0601 --- /dev/null +++ b/tests/Claims/round-trip.php @@ -0,0 +1,35 @@ +convert(); + + expect($back)->toBe('# Title'); +}); + +it('says that the frontmatter is one of the things that do not survive', function () { + says(Site::WORD_ROUND_TRIP, 'a document read back has lost its `theme:`, its'); + says(Site::WORD_ROUND_TRIP, '`styles:` and its `options:`'); +}); diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index 4a94798..c192047 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -3,6 +3,7 @@ declare(strict_types=1); use MarkdownOffice\Documentation\Tests\Support\Deck; +use MarkdownOffice\Documentation\Tests\Support\Directory; use MarkdownOffice\Documentation\Tests\Support\Document; use MarkdownOffice\Documentation\Tests\Support\Theme; use MarkdownWord\Configuration\TextStyles; @@ -34,9 +35,6 @@ function quotedRefusals(string $page): array /** The Word half's slots, as a `styles.yml` held them. */ const WORD_SLOT = "heading.1:\n name: Georgia\n"; -/** The page that names the Word half's theme, and its refusals. */ -const WORD_CONFIGURATION = 'word/configuration.md'; - /** The theme's name as the library prints it: the name the deck gave. */ const DECLARED_NAME = '"mine"'; @@ -109,7 +107,7 @@ function quotedRefusals(string $page): array }); it('refuses the file the Word half\'s slots used to be in, and says where they go', function () { - says(WORD_CONFIGURATION, 'A `styles.yml` beside the theme is refused'); + says(Site::WORD_CONFIGURATION, 'A `styles.yml` beside the theme is refused'); expect(Theme::wordRetired( "---\ntheme: mine\n---\n\n# One\n", @@ -125,7 +123,7 @@ function quotedRefusals(string $page): array it('quotes the refusals as the library prints them', function () { $themes = quotedRefusals(Site::PRESENTATION_THEMES); - expect($themes)->toHaveCount(3); + expect($themes)->toHaveCount(4); // The page writes the theme's name as "acme", and the library writes the name the // deck named: the same sentence with another name in it. @@ -147,7 +145,14 @@ function quotedRefusals(string $page): array WORD_SLOT, )))->toBe($themes[2]); - $configuration = quotedRefusals(WORD_CONFIGURATION); + // The fourth is the colour a YAML comment leaves with no value at all. + expect((string) \preg_replace( + '/^In "[^"]*"/', + 'In "…/theme.yml"', + Theme::slidesRefusal(Theme::DECK, "colors:\n dk1: #1E1E2E\n"), + ))->toBe($themes[3]); + + $configuration = quotedRefusals(Site::WORD_CONFIGURATION); expect($configuration)->toHaveCount(1); @@ -160,6 +165,134 @@ function quotedRefusals(string $page): array ))->toBe($configuration[0]); }); +it('lists every mistake a theme\'s named styles may carry, and refuses it', function (string $page, string $styles, string $message) { + says(Site::PRESENTATION_THEMES, 'Ten mistakes in a theme\'s named styles are refused'); + says(Site::PRESENTATION_THEMES, $page); + + expect(Theme::slidesRefusal(Theme::DECK, $styles))->toContain($message); +})->with([ + 'an attribute that does not exist' => [ + 'a style that sets an attribute that does not exist', + "textStyles:\n a:\n colour: FF0000\n", + 'which is not an attribute of a text style', + ], + 'nothing at all' => [ + 'a style that sets nothing at all', + "textStyles:\n a: {}\n", + 'sets nothing', + ], + 'a style that is not a mapping' => [ + 'a style that is not a mapping of attributes', + "textStyles:\n a: notamapping\n", + 'is not a mapping of attributes to values', + ], + 'the reserved word' => [ + 'a style called `style`', + "textStyles:\n style:\n color: FF0000\n", + 'is named with a reserved word', + ], + 'a highlight' => [ + 'a style that sets a `highlight`', + "textStyles:\n a:\n highlight: FF0000\n", + 'sets a "highlight"', + ], + 'another style in another case' => [ + 'a style that names another one in a different case', + "textStyles:\n Brand:\n color: FF0000\n brand:\n color: 00FF00\n", + 'is the same style as', + ], + 'a colour that is not a colour' => [ + 'a colour that is not a colour', + "textStyles:\n a:\n color: chartreuse\n", + 'which is not a colour', + ], + 'a size that is not a size' => [ + 'a size that is not a number of points', + "textStyles:\n a:\n size: big\n", + 'is not a number of points', + ], + 'a font that is not a name' => [ + 'a font that is not a name', + "textStyles:\n a:\n font: 12\n", + 'is not a font name', + ], + 'a flag that is not a flag' => [ + 'a flag that is not `true` or `false`', + "textStyles:\n a:\n bold: yes\n", + 'is not true or false', + ], +]); + +it('lists every mistake a Word theme\'s named styles may carry, and refuses it', function (string $page, string $styles, string $message) { + says(Site::WORD_CONFIGURATION, 'Seven mistakes in a theme\'s named styles are refused'); + says(Site::WORD_CONFIGURATION, $page); + + expect(Theme::wordRefusal("---\ntheme: mine\n---\n\n# One\n", $styles))->toContain($message); +})->with([ + 'a style that is not a set of properties' => [ + 'a style that is not a set of properties', + "textStyles:\n a: notamapping\n", + 'A text style is a set of properties', + ], + 'no properties at all' => [ + 'a style that carries no property at all', + "textStyles:\n a: {}\n", + 'has no properties', + ], + 'a property that does not exist' => [ + 'a style that names a property that does not exist', + "textStyles:\n a:\n colour: FF0000\n", + 'property of the "a" style', + ], + 'a value that is not one' => [ + 'a value that is not one', + "textStyles:\n a:\n color: chartreuse\n", + 'It is six hexadecimal digits', + ], + 'the reserved word' => [ + 'a style called `style`', + "textStyles:\n style:\n color: FF0000\n", + 'cannot be defined', + ], + 'the name of a directive' => [ + 'a style named after a directive this library draws', + "textStyles:\n footnote:\n color: FF0000\n", + 'is the name of a directive this library draws', + ], + 'another style in another case' => [ + 'a style that names another one in a different case', + "textStyles:\n Brand:\n color: FF0000\n brand:\n color: 00FF00\n", + 'is the same style as', + ], +]); + +it('folds a style\'s name, in both halves', function () { + says(Site::GENERAL_DIRECTIVES, 'A style\'s name is folded'); + says(Site::PRESENTATION_THEMES, 'A style\'s name is folded'); + says(Site::WORD_CONFIGURATION, 'A style\'s name is folded'); + + $slide = Theme::slidesSlide( + "---\ntheme: mine\n---\n\nA :brand[Bakery] B :Brand[Croissant] C\n", + "textStyles:\n Brand:\n color: FF0000\n", + ); + + expect(Deck::run($slide, 'Bakery'))->toContain('toContain(' Document::body( + "---\ntheme: mine\n---\n\nA :brand[Bakery] B :Brand[Croissant] C\n", + $root, + ), + ); + + expect(Document::run($body, 'Bakery'))->toContain(''); + expect(Document::run($body, 'Croissant'))->toContain(''); +}); + it('refuses a text style the theme does not have, and names the styles it has', function () { says(Site::PRESENTATION_DIRECTIVES, 'the message names the styles the theme does have'); @@ -234,7 +367,7 @@ function quotedRefusals(string $page): array }); it('reads the named styles of a Word theme from the section the page names', function () { - says(WORD_CONFIGURATION, '`textStyles:`'); + says(Site::WORD_CONFIGURATION, '`textStyles:`'); expect(TextStyles::KEY)->toBe('textStyles'); }); diff --git a/tests/Claims/themes.php b/tests/Claims/themes.php new file mode 100644 index 0000000..cee6d7f --- /dev/null +++ b/tests/Claims/themes.php @@ -0,0 +1,173 @@ + + */ +function slideBoxes(string $slide): array +{ + \preg_match_all( + '#]*>\s*\s*#s', + $slide, + $matches, + \PREG_SET_ORDER, + ); + + return \array_map( + static fn (array $match): string => \implode(',', \array_slice($match, 1)), + $matches, + ); +} + +it('says that a theme\'s colours must be quoted, and refuses one that is not', function () { + says(Site::PRESENTATION_THEMES, 'Every colour is quoted'); + says(Site::PRESENTATION_THEMES, 'YAML reads an unquoted `#` as the start of a comment'); + + $deck = "---\ntheme: mine\n---\n\n# One\n\nText.\n"; + + $message = Theme::slidesRefusal( + $deck, + "colors:\n dk1: #1E1E2E\n", + ); + + expect($message)->toContain('is not a colour such as "#1E1E2E"'); + + // The same colour, quoted, is a colour, and the deck is drawn in it. + $slide = Theme::slidesSlide( + $deck, + "colors:\n dk1: \"#1E1E2E\"\n", + ); + + expect($slide)->toContain('1E1E2E'); +}); + +it('says that a slide\'s own values are named by `theme-variables:`', function () { + says(Site::PRESENTATION_THEMES, 'The frontmatter key that carries it is **`theme-variables:`**'); + says(Site::PRESENTATION_DIRECTIVES, '`theme-variables:` is the theme\'s own values for one slide'); + + $slides = Deck::slides( + "---\ntheme: corporate-dark\n---\n\n# One\n\nWords.\n\n---\ntheme-variables:\n" + . " colors:\n accent1: \"#123456\"\n---\n\n# Two\n\nWords.\n\n## Three\n", + ); + + expect($slides[1])->toContain('not->toContain('toBe(slideBoxes($plain)); + expect(slideBoxes($blank))->toBe(slideBoxes($plain)); +}); + +it('says that a theme with no master still names its layouts', function () { + says(Site::PRESENTATION_THEMES, 'A theme with no master still names its layouts'); + + $root = Theme::slidesWithMaster("layouts:\n content: Mein Inhalt\n", null); + + $layouts = Directory::during( + $root, + static fn (string $root): array => Deck::layouts("---\ntheme: mine\n---\n\n# One\n", $root), + ); + + expect(\array_values($layouts))->toBe(['Mein Inhalt']); +}); + +it('says that the layout names are the theme\'s own, and not the master\'s', function () { + says(Site::PRESENTATION_THEMES, 'The `layouts:` section maps each role to a layout name'); + + expect(Site::flat(Site::page(Site::PRESENTATION_THEMES)))->not->toContain('a layout name in the master'); +}); + +it('says what the three faces are, and draws a style\'s font in the theme\'s face', function () { + says(Site::PRESENTATION_THEMES, '## The three faces'); + says(Site::PRESENTATION_THEMES, '| `heading` | the heading and the title |'); + says(Site::PRESENTATION_THEMES, '| `body` | the prose |'); + says(Site::PRESENTATION_THEMES, '| `mono` | the code |'); + says(Site::PRESENTATION_THEMES, 'A text style\'s `font:` may name one of the three'); + + $slide = Theme::slidesSlide( + "---\ntheme: mine\n---\n\nA :slot[Bakery] B :literal[Croissant] C\n", + "fonts:\n heading: Avenir Next\ntextStyles:\n slot:\n font: heading\n literal:\n font: Inter\n", + ); + + // The slot is the theme's face, and the name that is not a slot is a family as written. + expect(Deck::run($slide, 'Bakery'))->toContain('typeface="Avenir Next"'); + expect(Deck::run($slide, 'Croissant'))->toContain('typeface="Inter"'); +}); + +it('resolves a theme in the same order in both halves', function () { + $slides = Site::section(Site::PRESENTATION_THEMES, 'Resolution order'); + $word = Site::section(Site::WORD_CONFIGURATION, 'Themes'); + + foreach ([ + 'a path — written from beside the deck, the way a relative image path is —', + '`themes/` beside the deck', + '`themes/` beside the working directory', + 'the package\'s own `themes/`', + ] as $step) { + saysIn($slides, $step); + saysIn($word, \str_replace('deck', 'document', $step)); + } + + says(Site::PRESENTATION_INDEX, '`themes/` beside the working directory'); +}); + +it('says that the theme\'s name comes back, and brings it back', function () { + says(Site::PRESENTATION_INDEX, 'The theme\'s name comes back; the theme does not'); + + $markdown = Deck::readBack("---\ntheme: corporate-dark\ntitle: A themed deck\n---\n\n# One\n"); + + expect($markdown)->toContain('theme: "corporate-dark"'); +}); + +it('says that a deck\'s own settings come back as the slides\' own', function () { + says(Site::PRESENTATION_INDEX, 'A deck\'s own settings come back as the slides\' own'); + + $markdown = Deck::readBack( + "---\ntheme: corporate-dark\ndeck-settings:\n background: \"#101014\"\n---\n\n# One\n\n---\n## Two\n", + ); + + // The background of every slide, and not the deck's own layer. + expect(\substr_count($markdown, 'background: "#101014"'))->toBe(2); + expect($markdown)->not->toContain('deck-settings:'); +}); diff --git a/tests/Claims/word-templates.php b/tests/Claims/word-templates.php index 7139d93..77ec08a 100644 --- a/tests/Claims/word-templates.php +++ b/tests/Claims/word-templates.php @@ -27,6 +27,11 @@ */ const CONTENT = 'The content.'; +it('says that a theme is one file and not a directory', function () { + says(Site::WORD_TEMPLATES, 'one file of YAML'); + says(Site::WORD_TEMPLATES, 'A theme is **one file**: `theme.yml`'); +}); + it('says that body is this library own convention, and names the constant', function () { says(Site::WORD_TEMPLATES, '`body` is this library\'s convention, not Word\'s'); diff --git a/tests/Support/Deck.php b/tests/Support/Deck.php index 1c35d28..228b837 100644 --- a/tests/Support/Deck.php +++ b/tests/Support/Deck.php @@ -6,7 +6,9 @@ use MarkdownPresentation\Document\DeckParser; use MarkdownPresentation\Files\FileFinder; +use MarkdownPresentation\PowerPointToMarkdown; use MarkdownPresentation\Render\PresentationRenderer; +use MarkdownPresentation\Reverse\Options; use MarkdownPresentation\Theme\ThemeResolver; /** @@ -91,6 +93,51 @@ public static function shapes(string $markdown, string $directory = '.'): array return $names; } + /** + * The Markdown a deck is read back as. + * + * The other direction, and the only one that says what a deck carries: the file is + * written, read, and the Markdown it is made of is what comes back. + */ + public static function readBack(string $markdown, string $directory = '.'): string + { + return (new PowerPointToMarkdown( + self::render($markdown, $directory), + new Options(mediaDirectory: null), + ))->convert(); + } + + /** + * The name each layout part of a deck carries, by the part it is in. + * + * @return array + */ + public static function layouts(string $markdown, string $directory = '.'): array + { + $zip = new \ZipArchive(); + $zip->open(self::render($markdown, $directory)); + + $layouts = []; + + for ($number = 1; $number <= $zip->numFiles; $number++) { + $name = (string) $zip->getNameIndex($number - 1); + + if (\preg_match('#^ppt/slideLayouts/slideLayout(\d+)\.xml$#', $name) !== 1) { + continue; + } + + \preg_match('#]*name="([^"]*)"#', (string) $zip->getFromName($name), $match); + + $layouts[$name] = $match[1] ?? ''; + } + + $zip->close(); + + \ksort($layouts); + + return $layouts; + } + /** * The deck written out, and the path it was written to. * diff --git a/tests/Support/Master.php b/tests/Support/Master.php new file mode 100644 index 0000000..18c8773 --- /dev/null +++ b/tests/Support/Master.php @@ -0,0 +1,40 @@ + $layouts The names the layouts carry. + */ + public static function of(array $layouts): string + { + $presentation = new PhpPresentation(); + $master = $presentation->getAllMasterSlides()[0]; + + foreach ($layouts as $name) { + $master->createSlideLayout()->setLayoutName($name); + } + + $path = \sys_get_temp_dir() . '/markdown-office-master-' . \bin2hex(\random_bytes(6)) . '.pptx'; + + IOFactory::createWriter($presentation, 'PowerPoint2007')->save($path); + + return $path; + } +} diff --git a/tests/Support/RoundTrip.php b/tests/Support/RoundTrip.php index d0aec55..791feb7 100644 --- a/tests/Support/RoundTrip.php +++ b/tests/Support/RoundTrip.php @@ -45,6 +45,11 @@ public static function losses(): array private static function documents(): array { return [ + 'The frontmatter does not come back' => [ + "---\ntheme: corporate\nstyles:\n heading.1:\n name: Georgia\n" + . "options:\n hardBreak: line\n---\n\n# Title\n", + '# Title', + ], "A table's header row comes back bold" => [ "| A | B |\n| --- | --- |\n| 1 | 2 |\n", "| **A** | **B** |\n| :-- | :-- |\n| 1 | 2 |", diff --git a/tests/Support/Site.php b/tests/Support/Site.php index d406937..092dd13 100644 --- a/tests/Support/Site.php +++ b/tests/Support/Site.php @@ -34,6 +34,11 @@ final class Site */ public const WORD_DIRECTIVES = 'word/directives.md'; + /** + * The page that documents the Word half's themes. + */ + public const WORD_CONFIGURATION = 'word/configuration.md'; + /** * The page that documents where a document's files come from. */ diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php index 0ac150d..59ac01b 100644 --- a/tests/Support/Theme.php +++ b/tests/Support/Theme.php @@ -51,6 +51,44 @@ public static function slides(?string $textStyles = null): string return $root; } + /** + * A directory holding a theme of the slides half's shape, with a master beside it. + * + * `$sections` is what the theme's own file carries beside its name. + * + * @param list|null $layouts The names the master's layouts carry, or null for a + * theme with no master at all. + */ + public static function slidesWithMaster(string $sections, ?array $layouts): string + { + $root = self::directory(); + + \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n" . $sections); + + if ($layouts !== null) { + \copy(Master::of($layouts), $root . '/themes/mine/template.pptx'); + } + + return $root; + } + + /** + * The slide a deck is drawn as, against a theme with a master beside it. + * + * `$markdown` is the deck and `$sections` is what the theme's own file carries beside + * its name. + * + * @param list|null $layouts The names the master's layouts carry, or null for a + * theme with no master at all. + */ + public static function slidesMasterSlide(string $markdown, string $sections, ?array $layouts): string + { + return Directory::during( + self::slidesWithMaster($sections, $layouts), + static fn (string $root): string => Deck::slide($markdown, $root), + ); + } + /** * The message the library gives for a deck drawn against a theme of the slides half. * diff --git a/tools/check-command-line.php b/tools/check-command-line.php index e4ba1bf..9b80867 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -126,6 +126,12 @@ 'the command-line page said three files go into a theme' => 'Three files go into the directory', 'the command-line page said the roles are reported rather than mapped' => 'reported rather than pointed at a layout that is something else', 'the files and themes pages said the two halves keep separate vocabularies' => 'keep separate vocabularies', + 'the Word templates page called a theme a directory of YAML' => 'a directory of YAML', + 'the presentation index said the design does not travel at all' => 'neither travels to a new deck', + 'the presentation index left the theme out of the frontmatter' => 'its title, its author and its description', + 'the themes page said a layout name is in the master' => 'a layout name in the master', + 'the themes page gave three steps of the resolution order' => 'an explicit path', + 'the themes page listed four of the ten refusals' => 'which this half does not draw, are each refused', ]; /** From a28d748bce25b5cd27145310b2c49ec01e13cfe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Wed, 4 Nov 2026 11:15:26 +0100 Subject: [PATCH 10/10] The literals the new tests repeat are named, as the rest of them are --- tests/Claims/text-styles.php | 27 ++++++++++++++++++--------- tests/Support/Theme.php | 13 ++++++++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index c192047..882c8c2 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -41,12 +41,21 @@ function quotedRefusals(string $page): array /** The theme's name as a page writes it, which is not the deck's own. */ const PAGE_NAME = '"acme"'; +/** A theme that names a style called `style`, which is the raw form's own name. */ +const RESERVED_STYLE = "textStyles:\n style:\n color: FF0000\n"; + +/** A deck that names a theme of its own and writes nothing in it. */ +const THEME_ONLY = "---\ntheme: mine\n---\n\n# One\n"; + +/** The claim a page makes about a named style's name, in all three halves' words. */ +const FOLDED = 'A style\'s name is folded'; + it('refuses a theme that names a style "style"', function () { says(Site::GENERAL_DIRECTIVES, 'a theme may not name a style `style`'); $message = Theme::slidesRefusal( Theme::DECK, - "textStyles:\n style:\n color: FF0000\n", + RESERVED_STYLE, ); expect($message)->toContain('reserved word'); @@ -110,7 +119,7 @@ function quotedRefusals(string $page): array says(Site::WORD_CONFIGURATION, 'A `styles.yml` beside the theme is refused'); expect(Theme::wordRetired( - "---\ntheme: mine\n---\n\n# One\n", + THEME_ONLY, 'styles.yml', WORD_SLOT, ))->toMatch( @@ -161,7 +170,7 @@ function quotedRefusals(string $page): array expect((string) \preg_replace( '/^"[^"]*"/', '"…/styles.yml"', - Theme::wordRetired("---\ntheme: mine\n---\n\n# One\n", 'styles.yml', WORD_SLOT), + Theme::wordRetired(THEME_ONLY, 'styles.yml', WORD_SLOT), ))->toBe($configuration[0]); }); @@ -188,7 +197,7 @@ function quotedRefusals(string $page): array ], 'the reserved word' => [ 'a style called `style`', - "textStyles:\n style:\n color: FF0000\n", + RESERVED_STYLE, 'is named with a reserved word', ], 'a highlight' => [ @@ -227,7 +236,7 @@ function quotedRefusals(string $page): array says(Site::WORD_CONFIGURATION, 'Seven mistakes in a theme\'s named styles are refused'); says(Site::WORD_CONFIGURATION, $page); - expect(Theme::wordRefusal("---\ntheme: mine\n---\n\n# One\n", $styles))->toContain($message); + expect(Theme::wordRefusal(THEME_ONLY, $styles))->toContain($message); })->with([ 'a style that is not a set of properties' => [ 'a style that is not a set of properties', @@ -251,7 +260,7 @@ function quotedRefusals(string $page): array ], 'the reserved word' => [ 'a style called `style`', - "textStyles:\n style:\n color: FF0000\n", + RESERVED_STYLE, 'cannot be defined', ], 'the name of a directive' => [ @@ -267,9 +276,9 @@ function quotedRefusals(string $page): array ]); it('folds a style\'s name, in both halves', function () { - says(Site::GENERAL_DIRECTIVES, 'A style\'s name is folded'); - says(Site::PRESENTATION_THEMES, 'A style\'s name is folded'); - says(Site::WORD_CONFIGURATION, 'A style\'s name is folded'); + foreach ([Site::GENERAL_DIRECTIVES, Site::PRESENTATION_THEMES, Site::WORD_CONFIGURATION] as $page) { + says($page, FOLDED); + } $slide = Theme::slidesSlide( "---\ntheme: mine\n---\n\nA :brand[Bakery] B :Brand[Croissant] C\n", diff --git a/tests/Support/Theme.php b/tests/Support/Theme.php index 59ac01b..fb47d79 100644 --- a/tests/Support/Theme.php +++ b/tests/Support/Theme.php @@ -33,6 +33,13 @@ final class Theme */ public const SLOTS = "styles:\n heading.1:\n name: Georgia\n bold: true\n size: 20\n color: \"1F3864\"\n"; + /** + * The name a theme of the tests' own carries, and where its file is. + */ + private const NAME = "name: Mine\n"; + + private const FILE = '/themes/mine/theme.yml'; + /** * A directory holding a theme of the slides half's shape, and the directory it is in. * @@ -46,7 +53,7 @@ public static function slides(?string $textStyles = null): string { $root = self::directory(); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n" . ($textStyles ?? '')); + \file_put_contents($root . self::FILE, self::NAME . ($textStyles ?? '')); return $root; } @@ -63,7 +70,7 @@ public static function slidesWithMaster(string $sections, ?array $layouts): stri { $root = self::directory(); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n" . $sections); + \file_put_contents($root . self::FILE, self::NAME . $sections); if ($layouts !== null) { \copy(Master::of($layouts), $root . '/themes/mine/template.pptx'); @@ -124,7 +131,7 @@ public static function word(?string $styles = null): string { $root = self::directory(); - \file_put_contents($root . '/themes/mine/theme.yml', "name: Mine\n" . ($styles ?? '')); + \file_put_contents($root . self::FILE, self::NAME . ($styles ?? '')); return $root; }