diff --git a/content/command-line/theme.md b/content/command-line/theme.md index e3c05af..05afd02 100644 --- a/content/command-line/theme.md +++ b/content/command-line/theme.md @@ -27,24 +27,52 @@ 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 -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. 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 read is a failure here rather than a theme nobody can use. @@ -61,8 +89,8 @@ 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 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. @@ -79,15 +107,64 @@ 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. +``` + +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: + +```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/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/general/files.md b/content/general/files.md index 28d0c65..61467e3 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,24 @@ 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 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 @@ -299,20 +307,21 @@ 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 each carries +the look of its own half: {% example "A deck whose theme came from a finder" %} ````md presentation finder --- -theme: house +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 5fd0841..c62cfec 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,15 @@ 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. +**`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 +[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 +304,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 +334,69 @@ 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 + +--- + +## What it cost + +- Two engineers, for a quarter + +--- + +## What happens next + +- A second release +```` +{% 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 +442,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 +486,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..458bdca 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 @@ -85,15 +85,15 @@ 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/). +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 +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 @@ -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: @@ -238,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 1dd14e1..1bbf460 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` @@ -29,26 +28,103 @@ 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) | -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", 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", 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 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 +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 above that hold a value — `dark`, `colors`, `fonts` and `style` — -read by the reader that reads the theme's own file. The three that hold the theme's identity -— `name`, `description` and `author` — are not a slide's, so naming one is refused. - -A slide's values are laid over the template's, and the template's over the theme's, and one -value laid over another leaves the rest alone: a slide that names one colour leaves the other -eleven as they were. +`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 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. + +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 @@ -72,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 @@ -93,28 +187,48 @@ 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 -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 @@ -126,8 +240,41 @@ 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, 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 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 — @@ -143,7 +290,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 | @@ -178,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 @@ -192,10 +340,12 @@ 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 -[there](/word/configuration/#themes). +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 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/content/skills/index.md b/content/skills/index.md index 8b80e22..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,15 +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. 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 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/content/word/configuration.md b/content/word/configuration.md index a170546..77163c4 100644 --- a/content/word/configuration.md +++ b/content/word/configuration.md @@ -170,11 +170,40 @@ 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. + +**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: + +> "…/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 +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/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 fc37155..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. @@ -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/skill/mdword/SKILL.md b/skill/mdword/SKILL.md index 69fe9f9..affa8a3 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 @@ -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` | @@ -26,31 +26,48 @@ 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 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/ @@ -97,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/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" diff --git a/src/Bundle/Gallery.php b/src/Bundle/Gallery.php new file mode 100644 index 0000000..9322c22 --- /dev/null +++ b/src/Bundle/Gallery.php @@ -0,0 +1,220 @@ +, galleries: array} + */ + private static function manifest(string $root): array + { + $file = $root . '/skill/mdword/galleries.json'; + + if (! \is_file($file)) { + throw UnreadableGallery::unreadable('skill/mdword/galleries.json'); + } + + $parsed = \json_decode((string) \file_get_contents($file), true); + + if (! \is_array($parsed)) { + throw UnreadableGallery::notJson('skill/mdword/galleries.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) { + foreach (self::differencesIn($half, $gallery, $manifest['exclude'], $root, $bundle) as $problem) { + $problems[] = $problem; + } + } + + return $problems; + } + + /** + * 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); + } + } + + return $problems; + } + + /** + * 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); + } + } + + 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; + } + + 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; + } + + /** + * 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/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 @@ + <<<'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/Vocabularies.php b/src/Reference/Vocabularies.php index 438d85e..4a1994c 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -16,9 +16,9 @@ use MarkdownPresentation\Template\TemplatesFile; use MarkdownPresentation\Theme\LayoutRole; use MarkdownPresentation\Theme\StyleMapping; -use MarkdownPresentation\Theme\StyleSlots; use MarkdownPresentation\Theme\TextStyle; use MarkdownPresentation\Theme\Theme; +use MarkdownPresentation\Theme\ThemeLoader; use MarkdownPresentation\Theme\ThemeMapping; use MarkdownWord\Configuration\Options; use MarkdownWord\Configuration\Styles; @@ -63,7 +63,10 @@ public static function all(): array 'Theme::COLOURS' => 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,22 @@ public static function all(): array ]; } + /** + * The sections a theme's own file may carry beside the theme's own values. + * + * Read out of the loader rather than written down here: a copy is the thing that + * drifts. The constant is public, and it is public because the loader's own refusal + * offers it — {@see \MarkdownPresentation\Exception\MalformedTheme::unknownKey()} + * names the whole vocabulary — so a reader of the format is given the list and then + * asked to find it somewhere else. + * + * @return list + */ + public static function themeSections(): array + { + return \array_values(ThemeLoader::SECTIONS); + } + /** * The spellings a theme file may use instead of DrawingML's own. * 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/tests/Claims/deck-settings.php b/tests/Claims/deck-settings.php new file mode 100644 index 0000000..5d1cb4a --- /dev/null +++ b/tests/Claims/deck-settings.php @@ -0,0 +1,123 @@ +toHaveCount(2); + expect($slides[0])->toContain(DECK_BACKGROUND); +}); + +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(SLIDE_BACKGROUND); + expect($slides[1])->not->toContain(DECK_BACKGROUND); +}); + +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(DECK_BACKGROUND); + expect($slides[1])->toContain(TEMPLATE_BACKGROUND); +}); + +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(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 () { + $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('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..9200d03 100644 --- a/tests/Claims/finder.php +++ b/tests/Claims/finder.php @@ -12,10 +12,12 @@ use MarkdownOffice\Documentation\Tests\Support\Refusal; use MarkdownOffice\Documentation\Tests\Support\Site; use MarkdownOffice\Documentation\Tests\Support\Snippet; +use MarkdownPresentation\Exception\MalformedTheme as SlidesMalformedTheme; use MarkdownPresentation\Exception\UnservedTemplate; use MarkdownPresentation\Files\FileFinder as SlidesFileFinder; use MarkdownPresentation\Theme\ThemeLoader as SlidesThemeLoader; use MarkdownPresentation\Theme\ThemeResolver as SlidesThemeResolver; +use MarkdownWord\Exception\MalformedTheme as WordMalformedTheme; use MarkdownWord\Exception\ThemeNotFound; use MarkdownWord\Files\DiskFinder as WordDiskFinder; use MarkdownWord\Files\FileFinder as WordFileFinder; @@ -206,20 +208,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 +231,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 +238,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 +251,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 +265,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,18 +337,57 @@ 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); + // 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: house\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/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/skill.php b/tests/Claims/skill.php new file mode 100644 index 0000000..c9ecf23 --- /dev/null +++ b/tests/Claims/skill.php @@ -0,0 +1,62 @@ +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(SKILL_PAGE)); + + $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(BOTH_GALLERIES); + expect($page)->toContain(BOTH_GALLERIES); +}); diff --git a/tests/Claims/text-styles.php b/tests/Claims/text-styles.php index 9a12598..882c8c2 100644 --- a/tests/Claims/text-styles.php +++ b/tests/Claims/text-styles.php @@ -3,9 +3,9 @@ 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 MarkdownPresentation\Theme\ThemeLoader; use MarkdownWord\Configuration\TextStyles; use MarkdownWord\WordToMarkdown; use MarkdownOffice\Documentation\Tests\Support\Site; @@ -20,12 +20,42 @@ * 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]; +} + +/** The Word half's slots, as a `styles.yml` held them. */ +const WORD_SLOT = "heading.1:\n name: Georgia\n"; + +/** 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"'; + +/** 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, - "style:\n color: FF0000\n", + RESERVED_STYLE, ); expect($message)->toContain('reserved word'); @@ -58,10 +88,218 @@ 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'); + + $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'); + + // 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")) + ->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")) + ->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', 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(Site::WORD_CONFIGURATION, 'A `styles.yml` beside the theme is refused'); + + expect(Theme::wordRetired( + THEME_ONLY, + 'styles.yml', + 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, ' + . 'and the theme is read again\.$/', + ); +}); + +it('quotes the refusals as the library prints them', function () { + $themes = quotedRefusals(Site::PRESENTATION_THEMES); + + 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. + 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(DECLARED_NAME, PAGE_NAME, Theme::slidesRetired( + Theme::DECK, + 'text-styles.yml', + "brand:\n color: FF0000\n", + )))->toBe($themes[1]); + + expect(\str_replace(DECLARED_NAME, PAGE_NAME, Theme::slidesRetired( + Theme::DECK, + 'styles.yml', + WORD_SLOT, + )))->toBe($themes[2]); + + // 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); + + // 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(THEME_ONLY, 'styles.yml', WORD_SLOT), + ))->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`', + RESERVED_STYLE, + '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(THEME_ONLY, $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`', + RESERVED_STYLE, + '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 () { + 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", + "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(ThemeLoader::TEXT_STYLES_FILE)->toBe('text-styles.yml'); + 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 () { @@ -69,7 +307,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 +375,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(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/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/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/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/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 @@ + $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/NoExample.php b/tests/Support/NoExample.php new file mode 100644 index 0000000..aab08aa --- /dev/null +++ b/tests/Support/NoExample.php @@ -0,0 +1,25 @@ + [ + "---\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 45f6582..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. */ @@ -71,6 +76,17 @@ public static function page(string $path): string return (string) \file_get_contents($file); } + /** + * The skill's own file, as it is written. + * + * The bundle's `SKILL.md` is a copy of this file, so a claim about what it says is a + * claim about a file that is not a page of the site. + */ + public static function skill(): string + { + return (string) \file_get_contents(self::root() . '/skill/mdword/SKILL.md'); + } + /** * The body of a section of a page, as the page wrote it. * @@ -193,6 +209,15 @@ public static function words(int $number): string 18 => '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/tests/Support/Theme.php b/tests/Support/Theme.php index a77f6a0..fb47d79 100644 --- a/tests/Support/Theme.php +++ b/tests/Support/Theme.php @@ -21,38 +21,81 @@ 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"; + + /** + * 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. * - * `$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", - ); + \file_put_contents($root . self::FILE, self::NAME . ($textStyles ?? '')); + + 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(); - if ($textStyles !== null) { - \file_put_contents($root . '/themes/mine/text-styles.yml', $textStyles); + \file_put_contents($root . self::FILE, self::NAME . $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. * @@ -81,16 +124,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 . self::FILE, self::NAME . ($styles ?? '')); return $root; } @@ -107,14 +148,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/check-command-line.php b/tools/check-command-line.php index 394fa34..9b80867 100644 --- a/tools/check-command-line.php +++ b/tools/check-command-line.php @@ -105,6 +105,33 @@ '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', + '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', ]; /** 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/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(); } /** 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);