diff --git a/README.md b/README.md
index d123abf..96a0ac1 100644
--- a/README.md
+++ b/README.md
@@ -66,9 +66,10 @@ keys, the layout roles, the free shapes, the colour slots, the theme keys, the s
exceptions, the themes that ship, the ceilings, the Word directives, the attributes each of the
Word directives reads, the keys a Word document's frontmatter may carry, the options the reader of
a Word document trusts, the features a Word format cannot carry, the Word options, the Word style
-slots and the inline style attributes — and reads the page that carries it for the names it lists.
-The two are compared in both directions: a name the code does not have is a page offering something
-that does not work, and a name the code has and the page does not is a feature nobody can find.
+slots, the inline style attributes, the methods of a finder and the roles a finder resolves a name
+in — and reads the page that carries it for the names it lists. The two are compared in both
+directions: a name the code does not have is a page offering something that does not work, and a
+name the code has and the page does not is a feature nobody can find.
A vocabulary the two halves share is read out of both of them and held to one list, so the
attributes an inline style takes are the same eight in the deck and in the document — and the
@@ -154,9 +155,10 @@ when the example itself contains a fenced block.
The fence's tag says which way the example runs: the format it starts in, and the format it ends
in. `md word` builds a Word document from Markdown, `md presentation` builds a deck, and
-`word md` reads a Word document back into Markdown. The body of the fence is the input — the
-Markdown for the first two, the path of the document for the last, relative to the site's own
-directory:
+`word md` reads a Word document back into Markdown. A third word — `finder` — says where the
+example's files come from: `md word finder` builds the document with its theme in a finder rather
+than in a directory. The body of the fence is the input — the Markdown for the first two, the path
+of the document for the last, relative to the site's own directory:
````markdown
{% example "A Word document read back to Markdown" %}
@@ -225,7 +227,7 @@ GitHub serves a repository of that name at the root of the organisation's site,
name under a path of its own. That is the same shape the site will have on its own domain, so
the build does not change when the domain is pointed at GitHub — only the address does.
-The build checks its own links before it publishes:
+The build checks its own links, and the skill it publishes, before it publishes:
```bash
npm run check:site
@@ -233,7 +235,8 @@ npm run check:site
Every root-relative link in the built HTML has to point at a page that is in the site, and
every page has to be canonical at the address it is served from. A site whose links 404 is
-worse than no site, and nothing else says so.
+worse than no site, and nothing else says so. The skill is checked with them, because a
+bundle that has fallen behind the pages it was made from is quietly wrong.
### The custom domain
@@ -249,7 +252,7 @@ to it, and the site is published at the GitHub Pages address until then.
content/ the pages, one directory per chapter
_includes/ the layout, the stylesheet, the wordmark
_documents/ the documents the examples read. Committed: an input must not change.
-src/ the reference tables, read out of the libraries and compared with the pages
+src/ the reference tables, read out of the libraries and compared with the pages, and the finder the examples are built with
tests/ the claims the pages make in prose
tools/ render-examples.php, make-input-document.php, the three checks, and lib/
_examples/ built artifacts, published under the site's own path. Not committed.
diff --git a/composer.json b/composer.json
index 67da845..44828a0 100644
--- a/composer.json
+++ b/composer.json
@@ -84,7 +84,7 @@
"@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice",
"@test"
],
- "check:site": ["@php tools/check-site-links.php", "@php tools/check-skill.php"],
+ "check:site": "npm run check:site",
"check:links": "@php tools/check-readme-links.php ../markdown-word ../markdown-presentation ../mdoffice"
},
"minimum-stability": "dev",
diff --git a/content/general/errors.md b/content/general/errors.md
index d86b636..ac07d43 100644
--- a/content/general/errors.md
+++ b/content/general/errors.md
@@ -4,7 +4,7 @@ permalink: /errors/
title: Errors in general
summary: The exception to catch for each package, the two classes that share a name, and what the messages say about paths and losses.
section: In general
-order: 3
+order: 4
---
An exception is only useful if it says what to do next. Every message in this suite names the
@@ -32,9 +32,10 @@ try {
so one `catch` does not also catch the failures of the libraries underneath. The failures a deck
author can fix by naming something else extend `Exception\InvalidInput` below it.
-Five sit beside `InvalidInput` rather than under it, and nothing a deck names fixes any of them: a
-file that cannot be read, a target that cannot be written, a conversion with nothing to convert, or
-a dependency installed without the fixes the library needs.
+Six sit beside `InvalidInput` rather than under it, and nothing a deck names fixes any of them: a
+file that cannot be read, a target that cannot be written, a conversion with nothing to convert, a
+dependency installed without the fixes the library needs, or a source that cannot serve the master
+a theme carries.
## The two `UnknownDirective`
diff --git a/content/general/files.md b/content/general/files.md
new file mode 100644
index 0000000..28d0c65
--- /dev/null
+++ b/content/general/files.md
@@ -0,0 +1,321 @@
+---
+layout: layouts/base.njk
+permalink: /files/
+title: Where the files come from
+summary: The finder both halves read their files through — where a theme, a picture and a template come from, and what decides what may be read.
+section: In general
+order: 3
+---
+
+A document's theme, its pictures and the template it is drawn into are files, and **the finder is
+where they come from**.
+
+Both halves of this suite have one, and the two have the same shape —
+`MarkdownWord\Files\FileFinder` and `MarkdownPresentation\Files\FileFinder`. The library composes
+the names and asks; a finder answers, and it never knows what a theme is. That is what lets a
+directory of themes on disk and a database of themes that never had a directory serve the same
+library.
+
+## The four methods
+
+
+
+| Method | Answers |
+| --- | --- |
+| `read` | the contents of a file a name identifies, or `null` when there is none |
+| `path` | the path of a file a name identifies, when the finder holds it as a file |
+| `themes` | the themes that resolve, sorted |
+| `files` | the names of the files of a theme, sorted |
+
+```php
+namespace MarkdownWord\Files;
+
+interface FileFinder
+{
+ public function read(string $name, ?string $role = null): ?string;
+
+ public function path(string $name, ?string $role = null): ?string;
+
+ public function themes(): array;
+
+ public function files(string $theme): array;
+}
+```
+
+`read()` is what the library wants for YAML, for Markdown and for a picture: the bytes themselves.
+`path()` is for the two things a package is opened as — a theme's master and a document's
+`template_file:`. A finder that holds a file answers both. A finder that holds only bytes answers
+`read()`, and the library stages what it cannot be handed as bytes into a file of its own: a
+`.pptx` is a package, and a package is opened as a file.
+
+`themes()` is for a failure that has to name what would have worked. `files()` is for the question
+a null cannot answer on its own: a theme that carries no master and a source that cannot serve one
+are different problems, and the listing tells them apart.
+
+## The three roles
+
+The role is the **kind** of file, and it says which world a name is resolved in. It is a hint and
+never a filter: a source that keeps its themes in a database may answer for a picture as readily
+as for a theme.
+
+
+
+| Role | What it is |
+| --- | --- |
+| `theme` | a file of a theme — its manifest, its slots, its layouts, its master |
+| `picture` | an image a document names |
+| `document` | anything else a document names by path — a `.dotx`, a deck's templates file |
+
+The three names are the same in both halves, and the disk finder holds one list of directories for
+each of them.
+
+## The names the library composes
+
+The finder answers for the names it is handed, and the names are the library's own. The halves do
+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 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
+for a name.
+
+## A finder answers; it does not refuse
+
+**A file that is not there is `null`, and nothing else.** A finder never throws.
+
+That is not a style preference. Each half of this suite has its own exception classes, so a finder
+that refused would throw one half's exception and the other half would not catch it — and the two
+interfaces would stop being the same.
+
+**The scope check therefore stays with the library**, where the messages are. The finder resolves a
+name to a path; the library holds that path to the directories this render may read and refuses
+what is out of reach in its own words. The two cases stay apart, because the finder is what makes
+them apart: a name that resolves to nothing is `null`, and the library reports it as not there; a
+name that resolves to a file outside the scope is a path, and the library refuses it.
+
+The scope is a property of the *render*, not of the *storage*. A finder the calling program brought
+serves that program's own files, and the program vouches for them.
+
+## The disk finder
+
+A finder is optional: without one the files are read from the disk. `Files\DiskFinder` is the one
+that reads them from there, and it has the same shape in both halves. It holds the bases — the
+directories a name is looked for in — and nothing else.
+
+```php
+use MarkdownWord\Files\DiskFinder;
+
+$finder = new DiskFinder(
+ theme: ['themes'],
+ picture: ['images'],
+ document: ['.'],
+);
+```
+
+The bases are given per role and are tried in the order they are given, so the calling program
+decides where a document's files come from.
+
+**A theme is resolved by its directory first.** The first base where `/` is a
+directory is the theme's directory, and the theme's files are looked for inside it. A theme
+directory that exists but holds no manifest still shadows a later one, and is reported as a theme
+without a manifest rather than as a theme that is not there.
+
+**A name that is already a path is the path, whatever the role.** A name beginning with `/` names
+a file, and resolving it against a base would name a file nobody meant.
+
+## The Word half
+
+`MarkdownToWord` takes the finder as `files:`, and it takes `roots:` as well — the directories a
+document may name a theme in, for a shared library of themes beside the project.
+
+```php
+use MarkdownWord\MarkdownToWord;
+
+(new MarkdownToWord($markdown, files: $finder))->save('report.docx');
+```
+
+A theme can be named in code, and it resolves through the finder:
+
+```php
+(new MarkdownToWord($markdown, files: $finder))->withTheme('house')->save('report.docx');
+```
+
+The name is resolved **once**: the theme that came back is kept, so a source that counted its
+reads would say the name was asked for once and not once for the name and once for the document.
+
+A document's own file is asked for as a `document`. A source that holds it as a file has it opened
+where it is; a source that holds the bytes has them staged into a file of the library's own, and
+`Template\TemplateFile::release()` gives the staged file back when the template is done with — a
+caller who lets the resolver go without saying so has it said for them.
+
+```php
+use MarkdownWord\Template\TemplateFile;
+
+$template = new TemplateFile(directory: __DIR__, files: $finder);
+
+$template->resolve('report.dotx');
+```
+
+`MarkdownTemplate` takes the same finder, for the document it inserts rather than for the
+template it opens:
+
+```php
+use MarkdownWord\Template\MarkdownTemplate;
+
+(new MarkdownTemplate('report.dotx', files: $finder))
+ ->insert($markdown)
+ ->save('report.docx');
+```
+
+## The slides half
+
+A deck's files come from the same kind of source, in two places: the theme from the resolver, and
+the pictures and the templates file from the renderer.
+
+```php
+use MarkdownPresentation\Render\PresentationRenderer;
+use MarkdownPresentation\Theme\ThemeResolver;
+
+$renderer = new PresentationRenderer(
+ themes: new ThemeResolver(files: $finder),
+ files: $finder,
+);
+```
+
+A theme can be named in code here too, and it outranks the deck's own `theme:` — which is still
+checked, because a value of the wrong type is a mistake in the deck whatever theme it is drawn in.
+
+```php
+$renderer = (new PresentationRenderer(themes: new ThemeResolver(files: $finder), files: $finder))
+ ->withTheme('house');
+```
+
+**A theme's master is a file, and a file the library stages.** A source that holds the master as
+bytes has them staged into one, and the deck is drawn in the master's geometry all the same. The
+staged file is the render's, and it is given back when the render is over — nothing of the
+library's is left on the disk afterwards, whether the render worked or not.
+
+**A master the source cannot serve is refused by name.** A theme that carries no master is a theme
+that works, and nothing is said about it. A theme that carries one and a source that cannot hand
+it over is refused with `MarkdownPresentation\Exception\UnservedTemplate` rather than drawn
+without: a deck without the master's geometry is in the wrong design, with nothing in the file
+saying so. The listing of a theme's files is what tells the two apart.
+
+A deck can come back as bytes, the way a document can:
+
+```php
+$bytes = (new PresentationRenderer())->toString($deck);
+```
+
+## One adapter, both halves
+
+The two interfaces have the same method names, the same parameter types and the same return types,
+so one class implements both. This one holds its files in memory, and it is the whole of a source
+that has bytes and no paths:
+
+```php
+use MarkdownPresentation\Files\FileFinder as SlidesFileFinder;
+use MarkdownWord\Files\FileFinder as WordFileFinder;
+
+final class MyFinder implements WordFileFinder, SlidesFileFinder
+{
+ /**
+ * @param array $files The files it holds, by name.
+ */
+ public function __construct(private readonly array $files = [])
+ {
+ }
+
+ public function read(string $name, ?string $role = null): ?string
+ {
+ return $this->files[$name] ?? null;
+ }
+
+ public function path(string $name, ?string $role = null): ?string
+ {
+ return null;
+ }
+
+ public function themes(): array
+ {
+ $names = [];
+
+ foreach (\array_keys($this->files) as $name) {
+ $names[\explode('/', $name, 2)[0]] = true;
+ }
+
+ $found = \array_keys($names);
+
+ \sort($found);
+
+ return $found;
+ }
+
+ public function files(string $theme): array
+ {
+ $names = [];
+
+ foreach (\array_keys($this->files) as $name) {
+ if (\str_starts_with($name, $theme . '/')) {
+ $names[\substr($name, \strlen($theme) + 1)] = true;
+ }
+ }
+
+ $found = \array_keys($names);
+
+ \sort($found);
+
+ return $found;
+ }
+}
+```
+
+The claims suite checks that this one and the library's interface have not drifted apart, because
+that is what lets one adapter serve both.
+
+## An example, built through a finder
+
+The document below was built with its theme in a finder rather than in a directory. The theme is
+the site's own, and the site keeps it the way a plugin keeps its themes: in memory, under a name
+the library composes.
+
+{% example "A document whose theme came from a finder" %}
+````md word finder
+---
+theme: house
+---
+
+# The house style
+
+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.
+````
+{% endexample %}
+
+And a deck, from the same source:
+
+{% example "A deck whose theme came from a finder" %}
+````md presentation finder
+---
+theme: house
+---
+
+# The house style
+
+## A deck whose theme came from a finder
+
+The theme is in the site's own source, not in a directory beside the deck.
+````
+{% endexample %}
diff --git a/content/presentation/directives.md b/content/presentation/directives.md
index 2d1a806..d7ec9da 100644
--- a/content/presentation/directives.md
+++ b/content/presentation/directives.md
@@ -260,14 +260,14 @@ Every key of every slide's frontmatter must be one of these nine:
| `author` | string | the theme's, else `markdown-presentation` | **first only** |
| `description` | string | `null` | **first only** |
| `template` | string, or a list of them | none | any |
-| `chrome` | string | none | **first only** |
+| `templates-file` | string | none | **first only** |
The layout roles are listed with the [themes](/presentation/themes/#layout-roles). Anything else —
including Slidev's own `two-cols` — is refused with the list of what would have worked.
A 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 `chrome:` belong on the slide that opens the deck.
+`author:`, `description:` and `templates-file:` belong on the slide that opens the deck.
{% example %}
````md presentation "The four keys that describe a deck"
@@ -374,18 +374,18 @@ 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.
-### The chrome file
+### The templates file
A deck may keep its templates in a file beside it and name it in the deck's frontmatter:
```markdown
---
theme: ./theme.yml
-chrome: ./chrome.md
+templates-file: ./templates.md
---
```
-`chrome.md` is a Markdown file of template definitions in the same syntax as a deck, and it is
+`templates.md` is a Markdown file of template definitions in the same syntax as a deck, and it is
resolved against the deck's own directory — so a deck and the furniture it shares travel together.
A file that is not there is a failure, and so is a file outside the directories the render may
read: a path a deck wrote is held to the same scope as a picture in it. A file that holds a slide
diff --git a/content/presentation/errors.md b/content/presentation/errors.md
index 085019b..c40d808 100644
--- a/content/presentation/errors.md
+++ b/content/presentation/errors.md
@@ -21,9 +21,10 @@ is about both libraries, is on [Errors in general](/errors/).
| `UnknownMarker` | `::click=0`, backwards, over 500, unclosed, or a stray closer |
| `UnknownLayoutRole` | `layout:` is not one of the layout roles |
| `UnknownTemplate` | a slide names a template the deck does not have — the message names the templates |
-| `MalformedChrome` | a template holds ordinary Markdown, or a chrome file holds a slide |
-| `ChromeNotFound` | the chrome file a deck names is not there, or is outside the roots |
-| `ChromeTooLarge` | the chrome file a deck names is over the ceiling |
+| `MalformedChrome` | a template holds ordinary Markdown |
+| `MalformedTemplatesFile` | the templates file a deck names holds a slide |
+| `TemplatesFileNotFound` | the templates file a deck names is not there, or is outside the roots |
+| `TemplatesFileTooLarge` | the templates file a deck names is over the ceiling |
| `UnsupportedColumn` | more `::column` markers than the layout has columns |
| `UnsupportedImageFormat` | not an image, no decoder, or over the ceiling |
| `UnsupportedLink` | `javascript:`, `data:`, `phar://`, `file://`, a UNC share |
@@ -46,10 +47,14 @@ is about both libraries, is on [Errors in general](/errors/).
| `FileNotWritable` | **not** `InvalidInput`; the target cannot be written |
| `NothingToConvert` | **not** `InvalidInput`; there was no deck to convert |
| `UnpatchedDependency` | **not** `InvalidInput`; the installed PHPPresentation lacks the fixes |
+| `UnservedTemplate` | **not** `InvalidInput`; the source of the theme cannot serve its master |
-Five sit beside `InvalidInput` rather than under it: nothing a deck names fixes a file that cannot
-be read, a target that cannot be written, a conversion with nothing to convert, or a dependency
-that was installed without the fixes the library needs.
+Six sit beside `InvalidInput` rather than under it: nothing a deck names fixes a file that cannot
+be read, a target that cannot be written, a conversion with nothing to convert, a dependency that
+was installed without the fixes the library needs, or a source that cannot serve the master a
+theme carries.
-The three that carry a template are about the deck rather than about a file on disk: a name
-nothing is under, a body that holds the wrong thing, and a file that is not there.
+A template failure is the deck author's to fix: a name nothing is under, a body that holds the
+wrong thing, a file that holds a slide, a file that is not there and a file over the ceiling. A
+master the source cannot serve is not: that is the program's to fix, and nothing the deck names
+changes it.
diff --git a/content/presentation/index.md b/content/presentation/index.md
index 3ef602d..06b6d5c 100644
--- a/content/presentation/index.md
+++ b/content/presentation/index.md
@@ -19,12 +19,13 @@ PHP 8.2 or newer, with `ext-dom`, `ext-gd` and `ext-zip`. `ext-imagick` is optio
**Both directions.** Markdown to `.pptx`, and a `.pptx` back to Markdown — what comes back is
the Markdown this package writes decks in, so a deck can be taken apart, edited as text and put
-in again under a different theme. `PresentationRenderer` has three methods, `render()`,
-`overflows()` and `unknownFonts()`; the reader is `PowerPointToMarkdown`, with `convert()`,
-`save()`, `toMarkdown()` and `read()`. The two that report rather than write are
-`overflows()` — every block that had to be made smaller than it asked for — and `unknownFonts()`
-— every face the deck named that there is no table of advance widths for. What the round trip
-does not keep is written down in [Reading a deck back](#reading-a-deck-back).
+in again under a different theme. `PresentationRenderer` has five methods, `render()`,
+`overflows()`, `unknownFonts()`, `withTheme()` and `toString()`; the reader is
+`PowerPointToMarkdown`, with `convert()`, `getOptions()`, `save()`, `toMarkdown()` and `read()`.
+The two that report rather than write are `overflows()` — every block that had to be made smaller
+than it asked for — and `unknownFonts()` — every face the deck named that there is no table of
+advance widths for. What the round trip does not keep is written down in
+[Reading a deck back](#reading-a-deck-back).
---
@@ -260,6 +261,25 @@ What the round trip does not keep is written down in the package's own
[README](https://github.com/markdown-office/markdown-presentation#reading-a-deck-back): the
design, the layout, the underline, a text style, a link inside a run, and a chart.
+### Where a deck's files come from
+
+The theme a deck names, the pictures it shows and the templates file it imports are read from disk
+by default. A program that keeps its own files somewhere else — in a database, say — says where
+they are with a **finder**, and hands it over:
+
+```php
+use MarkdownPresentation\Render\PresentationRenderer;
+use MarkdownPresentation\Theme\ThemeResolver;
+
+$renderer = new PresentationRenderer(
+ themes: new ThemeResolver(files: $finder),
+ files: $finder,
+);
+```
+
+What a finder is, what the three roles are, and why the scope check stays with the library are on
+[Where the files come from](/files/).
+
### The rendering pipeline
`render()` checks every slide's frontmatter **before** anything is built, so a deck whose fourth
@@ -280,7 +300,7 @@ PowerPoint open the deck as `[Repaired]`.
| `MAX_SLIDES` | 1000 |
| `MAX_STEP` | 500 |
| `MAX_PIXELS` | 16,777,216 — 4096 × 4096, enforced on the file header |
-| `MAX_BYTES` | 1,048,576 — the size of a chrome file |
+| `MAX_BYTES` | 1,048,576 — the size of a templates file |
| `MAX_SHAPES` | 100 — the shapes one slide may carry |
| `MAX_PART_BYTES` | 4,194,304 — one part of a theme's template |
@@ -308,9 +328,10 @@ try {
}
```
-Five sit beside `InvalidInput` rather than under it: nothing a deck names fixes a file that cannot
-be read, a target that cannot be written, a conversion with nothing to convert, or a dependency
-installed without the fixes the library needs.
+Six sit beside `InvalidInput` rather than under it: nothing a deck names fixes a file that cannot
+be read, a target that cannot be written, a conversion with nothing to convert, a dependency
+installed without the fixes the library needs, or a source that cannot serve the master a theme
+carries.
The whole catalogue is on [Errors](/presentation/errors/).
@@ -320,4 +341,4 @@ The whole catalogue is on [Errors](/presentation/errors/).
[Markdown Word](/word/) · [The directive syntax](/directives/syntax/) ·
[Presentation directives](/presentation/directives/) · [Themes and layout](/presentation/themes/) ·
-[Images](/images/) · [Errors](/presentation/errors/)
\ No newline at end of file
+[Images](/images/) · [Where the files come from](/files/) · [Errors](/presentation/errors/)
\ No newline at end of file
diff --git a/content/presentation/themes.md b/content/presentation/themes.md
index b74d8db..39d4c4f 100644
--- a/content/presentation/themes.md
+++ b/content/presentation/themes.md
@@ -134,8 +134,8 @@ A **template** is not a theme, and it does not live beside one.
A theme's own `template.pptx` is a **third thing** and shares only the name: it is the PowerPoint
file that says where the theme's layouts put their boxes, and it says nothing about the furniture
on a slide. A template that says where the furniture goes is written in Markdown, beside the deck
-or in it — `chrome: ./chrome.md` resolves against the deck's own directory, so a deck and the
-furniture it shares travel together.
+or in it — `templates-file: ./templates.md` resolves against the deck's own directory, so a deck
+and the furniture it shares travel together.
The rules a template follows, and the four things that decide what one is, are on
[Presentation directives](/presentation/directives/#slide-templates).
@@ -170,6 +170,10 @@ There is **no silent fallback to `default`**. A name that does not resolve raise
and lists every theme that does. A deck that came out looking like something other than what was
asked for is worse than a deck that did not build.
+That order is the disk's. A program that keeps its themes somewhere else — in a database, say —
+hands the library a **finder** instead, and the name is asked for through it: see
+[Where the files come from](/files/).
+
## Word themes
The Word half has themes too, and they are the other shape: a directory with `theme.yml` —
diff --git a/content/word/configuration.md b/content/word/configuration.md
index 59e9cd7..a170546 100644
--- a/content/word/configuration.md
+++ b/content/word/configuration.md
@@ -192,3 +192,7 @@ then `themes/` beside the document, then `themes/` beside the workin
then the package's own `themes/`, and
**there is no silent fallback** — a name that does not resolve raises `ThemeNotFound` listing the
ones that do.
+
+That order is the disk's. A program that keeps its themes somewhere else — in a database, say —
+hands the library a **finder** instead, and the name is asked for through it: see
+[Where the files come from](/files/).
diff --git a/content/word/index.md b/content/word/index.md
index fb502f2..5e387ba 100644
--- a/content/word/index.md
+++ b/content/word/index.md
@@ -264,6 +264,21 @@ $converter->pendingHyperlinks(); // for deferred rendering
`pendingLosses()` is empty for `.docx`, which carries everything. `.odt` and `.rtf` drop named
features and report them rather than letting the document quietly lose them.
+### Where a document's files come from
+
+The theme a document names and the pictures it shows are read from disk by default. A program that
+keeps its own files somewhere else — in a database, say — says where they are with a **finder**,
+and hands it over:
+
+```php
+use MarkdownWord\MarkdownToWord;
+
+(new MarkdownToWord($markdown, files: $finder))->save('report.docx');
+```
+
+What a finder is, what the three roles are, and why the scope check stays with the library are
+on [Where the files come from](/files/).
+
### Errors
One type to catch: `MarkdownWord\Exception\InvalidConfiguration`. `UnknownConfigurationKey` for
@@ -295,4 +310,5 @@ built from this site, so it is never out of step with it.
[Markdown Presentation](/presentation/) · [The directive syntax](/directives/syntax/) ·
[Word directives](/word/directives/) · [Images](/images/) ·
-[Round trips](/word/round-trip/) · [Errors](/word/errors/) · [The skill](/skills/)
\ No newline at end of file
+[Round trips](/word/round-trip/) · [Where the files come from](/files/) ·
+[Errors](/word/errors/) · [The skill](/skills/)
diff --git a/package.json b/package.json
index 5487709..b435a87 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"build": "npm run examples && eleventy",
"build:strict": "php tools/render-examples.php --strict && eleventy",
"serve": "npm run examples && eleventy --serve --port=${PORT:-8123}",
- "check:site": "php tools/check-site-links.php",
+ "check:site": "php tools/check-site-links.php && php tools/check-skill.php",
"clean": "rm -rf _site _examples _data/examples.json"
},
"devDependencies": {
diff --git a/skill/mdword/SKILL.md b/skill/mdword/SKILL.md
index db1367a..d5cf857 100644
--- a/skill/mdword/SKILL.md
+++ b/skill/mdword/SKILL.md
@@ -25,6 +25,7 @@ documentation, not by recalling it.
| How a directive is written — names, labels, attributes, fences | `references/directive-syntax.md` |
| What the two halves of the suite do with a name they know, and with one they do not | `references/directive-rules.md` |
| The errors both halves throw | `references/errors-general.md` |
+| Where a document's files come from — the finder, the three roles, and the names the library composes | `references/general-files.md` |
| The command line, and the options each command takes | `references/command-line-word.md`, `references/command-line-basic.md` |
| Installing the command line | `references/command-line-install.md` |
| Installing this skill | `references/install.md` |
diff --git a/skill/mdword/pages.json b/skill/mdword/pages.json
index afbff8f..7ec5825 100644
--- a/skill/mdword/pages.json
+++ b/skill/mdword/pages.json
@@ -65,6 +65,10 @@
"file": "errors-general.md",
"page": "general/errors.md"
},
+ {
+ "file": "general-files.md",
+ "page": "general/files.md"
+ },
{
"file": "command-line-word.md",
"page": "command-line/word.md"
diff --git a/src/Finder/MemoryFinder.php b/src/Finder/MemoryFinder.php
new file mode 100644
index 0000000..beed4c3
--- /dev/null
+++ b/src/Finder/MemoryFinder.php
@@ -0,0 +1,159 @@
+ */
+ private array $reads = [];
+
+ /** @var array */
+ private array $roles = [];
+
+ /**
+ * @param array $files The files it holds, by name.
+ */
+ public function __construct(private readonly array $files = [])
+ {
+ }
+
+ /**
+ * A finder holding the site's own theme, which is in no directory at all.
+ *
+ * The theme is here rather than under `themes/` on purpose: an example that named a
+ * theme beside it would be an example of the disk default, and this one is about the
+ * other kind.
+ */
+ public static function house(): self
+ {
+ return new self([
+ 'house/theme.yml' => <<<'YAML'
+ name: House
+ description: The site's own theme, which lives in a finder rather than in a directory.
+ author: markdown-office
+ 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
+ YAML,
+ ]);
+ }
+
+ public function read(string $name, ?string $role = null): ?string
+ {
+ $this->roles[$name] = $role;
+ $this->reads[$name] = ($this->reads[$name] ?? 0) + 1;
+
+ return $this->files[$name] ?? null;
+ }
+
+ /**
+ * The path of a file a name identifies, when this finder holds it as a file.
+ *
+ * Null, always: this finder holds bytes and not files, so the library stages what it
+ * cannot be handed as bytes.
+ */
+ public function path(string $name, ?string $role = null): ?string
+ {
+ $this->roles[$name] = $role;
+
+ return null;
+ }
+
+ public function themes(): array
+ {
+ $names = [];
+
+ foreach (\array_keys($this->files) as $name) {
+ $names[\explode('/', $name, 2)[0]] = true;
+ }
+
+ $found = \array_keys($names);
+
+ \sort($found);
+
+ return $found;
+ }
+
+ public function files(string $theme): array
+ {
+ $names = [];
+
+ foreach (\array_keys($this->files) as $name) {
+ if (\str_starts_with($name, $theme . '/')) {
+ $names[\substr($name, \strlen($theme) + 1)] = true;
+ }
+ }
+
+ $found = \array_keys($names);
+
+ \sort($found);
+
+ return $found;
+ }
+
+ /**
+ * The files it holds, by name.
+ *
+ * @return array
+ */
+ public function held(): array
+ {
+ return $this->files;
+ }
+
+ /**
+ * How many times the contents of a name were asked for.
+ *
+ * The count is what says a name was resolved twice rather than once: a theme named
+ * in code is resolved when the name is given, and a theme named in a document's own
+ * block is resolved when the document is drawn.
+ */
+ public function reads(string $name): int
+ {
+ return $this->reads[$name] ?? 0;
+ }
+
+ /**
+ * The role a name was asked for in.
+ *
+ * The role says which world the name is resolved in, so it is what tells a picture
+ * asked for as a picture from a theme asked for as a theme.
+ */
+ public function askedFor(string $name): ?string
+ {
+ return $this->roles[$name] ?? null;
+ }
+}
diff --git a/src/Reference/Code.php b/src/Reference/Code.php
index 17ef444..ca403aa 100644
--- a/src/Reference/Code.php
+++ b/src/Reference/Code.php
@@ -9,7 +9,7 @@
use MarkdownWord\Render\Handlers;
/**
- * The names the Word half reads out of what it is given.
+ * What the libraries have, read out of their code.
*
* The lists the site checks are read out of the library's own code rather than written
* down here: a name written down here is a second copy of the thing this checks.
@@ -20,6 +20,151 @@
*/
final class Code
{
+ /**
+ * The public methods of a class, by name.
+ *
+ * The constructor is left out: it is how the class is made rather than what it does,
+ * and a page that lists what a class can be asked for does not list how it is built.
+ * An interface has none, so this is the whole of one.
+ *
+ * @param class-string $class
+ *
+ * @return list
+ */
+ public static function methods(string $class): array
+ {
+ $names = [];
+
+ foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->getName() === '__construct') {
+ continue;
+ }
+
+ $names[] = $method->getName();
+ }
+
+ \sort($names);
+
+ return $names;
+ }
+
+ /**
+ * The roles a finder resolves a name in.
+ *
+ * Read out of the disk finder's own bases: a finder holds one list of directories per
+ * role, and the roles are the names of those lists. The library composes the name and
+ * the role, and the finder is what turns a role into a set of places to look.
+ *
+ * @param class-string $finder
+ *
+ * @return list
+ */
+ public static function finderRoles(string $finder): array
+ {
+ $roles = [];
+
+ foreach ((new \ReflectionClass($finder))->getConstructor()?->getParameters() ?? [] as $parameter) {
+ $roles[] = $parameter->getName();
+ }
+
+ \sort($roles);
+
+ return $roles;
+ }
+
+ /**
+ * The shape of an interface: every method's name, its parameters and its return type.
+ *
+ * A parameter is a type and a default rather than a name: the name is the library's
+ * own, and an adapter written for one half serves the other whatever it calls its
+ * arguments. The default is part of it, because a method that takes an optional
+ * argument and a method that does not are two shapes.
+ *
+ * @param class-string $interface
+ *
+ * @return array, returns: string}>
+ */
+ public static function shape(string $interface): array
+ {
+ $shape = [];
+
+ foreach ((new \ReflectionClass($interface))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+ $parameters = [];
+
+ foreach ($method->getParameters() as $parameter) {
+ $type = (string) ($parameter->getType() ?? 'mixed');
+
+ $parameters[] = $parameter->isDefaultValueAvailable()
+ ? $type . ' = ' . \str_replace("\n", '', \var_export($parameter->getDefaultValue(), true))
+ : $type;
+ }
+
+ $shape[$method->getName()] = [
+ 'parameters' => $parameters,
+ 'returns' => (string) ($method->getReturnType() ?? 'mixed'),
+ ];
+ }
+
+ \ksort($shape);
+
+ return $shape;
+ }
+
+ /**
+ * The signature of every public method of an interface, as it is written.
+ *
+ * The parameter names are kept, unlike in {@see self::shape()}: a page that shows an
+ * interface shows it as it is written, so a parameter renamed is a page that has to
+ * change with it.
+ *
+ * @param class-string $interface
+ *
+ * @return list
+ */
+ public static function signatures(string $interface): array
+ {
+ $signatures = [];
+
+ foreach ((new \ReflectionClass($interface))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
+ $parameters = [];
+
+ foreach ($method->getParameters() as $parameter) {
+ $written = (string) ($parameter->getType() ?? 'mixed') . ' $' . $parameter->getName();
+
+ if ($parameter->isDefaultValueAvailable()) {
+ $written .= ' = ' . self::defaultOf($parameter->getDefaultValue());
+ }
+
+ $parameters[] = $written;
+ }
+
+ $signatures[] = $method->getName() . '(' . \implode(', ', $parameters) . '): '
+ . (string) ($method->getReturnType() ?? 'mixed');
+ }
+
+ \sort($signatures);
+
+ return $signatures;
+ }
+
+ /**
+ * A default value, as the source writes it.
+ *
+ * `var_export()` writes `NULL` and an array over three lines, where the source writes
+ * `null` and `[]` — and the source is what a page copies.
+ */
+ private static function defaultOf(mixed $value): string
+ {
+ return match (true) {
+ $value === null => 'null',
+ $value === true => 'true',
+ $value === false => 'false',
+ \is_string($value) => "'" . $value . "'",
+ \is_array($value) => '[' . \implode(', ', \array_map(self::defaultOf(...), $value)) . ']',
+ default => (string) $value,
+ };
+ }
+
/**
* The attributes each of the Word half's directives reads.
*
diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php
index 57d8dd6..438d85e 100644
--- a/src/Reference/Vocabularies.php
+++ b/src/Reference/Vocabularies.php
@@ -5,13 +5,15 @@
namespace MarkdownOffice\Documentation\Reference;
use MarkdownPresentation\Exception\Exception as PresentationException;
+use MarkdownPresentation\Files\DiskFinder as SlidesDiskFinder;
+use MarkdownPresentation\Files\FileFinder as SlidesFileFinder;
use MarkdownPresentation\Render\CommonMark\BlockTranslator;
use MarkdownPresentation\Render\Free\FreeShapes;
use MarkdownPresentation\Render\Furniture;
use MarkdownPresentation\Render\ImageConverter;
use MarkdownPresentation\Render\PresentationRenderer;
use MarkdownPresentation\Render\TemplateGeometry;
-use MarkdownPresentation\Template\ChromeFile;
+use MarkdownPresentation\Template\TemplatesFile;
use MarkdownPresentation\Theme\LayoutRole;
use MarkdownPresentation\Theme\StyleMapping;
use MarkdownPresentation\Theme\StyleSlots;
@@ -22,6 +24,8 @@
use MarkdownWord\Configuration\Styles;
use MarkdownWord\Configuration\TextStyles;
use MarkdownWord\Exception\Exception as WordException;
+use MarkdownWord\Files\DiskFinder as WordDiskFinder;
+use MarkdownWord\Files\FileFinder as WordFileFinder;
use MarkdownWord\Format;
use MarkdownWord\MarkdownToWord;
use MarkdownWord\Render\Directives;
@@ -79,7 +83,7 @@ public static function all(): array
'presentation.ceilings' => new Vocabulary(
[
'PresentationRenderer::MAX_SLIDES, BlockTranslator::MAX_STEP, ImageConverter::MAX_PIXELS, '
- . 'ChromeFile::MAX_BYTES, Furniture::MAX_SHAPES, TemplateGeometry::MAX_PART_BYTES'
+ . 'TemplatesFile::MAX_BYTES, Furniture::MAX_SHAPES, TemplateGeometry::MAX_PART_BYTES'
=> [
'MAX_SLIDES',
'MAX_STEP',
@@ -93,7 +97,7 @@ public static function all(): array
'MAX_SLIDES' => PresentationRenderer::MAX_SLIDES,
'MAX_STEP' => BlockTranslator::MAX_STEP,
'MAX_PIXELS' => ImageConverter::MAX_PIXELS,
- 'MAX_BYTES' => ChromeFile::MAX_BYTES,
+ 'MAX_BYTES' => TemplatesFile::MAX_BYTES,
'MAX_SHAPES' => Furniture::MAX_SHAPES,
'MAX_PART_BYTES' => TemplateGeometry::MAX_PART_BYTES,
],
@@ -154,6 +158,14 @@ public static function all(): array
'MarkdownPresentation\\Theme\\TextStyle::ATTRIBUTES' => TextStyle::ATTRIBUTES,
'MarkdownWord\\Configuration\\TextStyles::ATTRIBUTES' => \array_keys(TextStyles::ATTRIBUTES),
]),
+ 'finder-methods' => new Vocabulary([
+ 'MarkdownWord\\Files\\FileFinder' => Code::methods(WordFileFinder::class),
+ 'MarkdownPresentation\\Files\\FileFinder' => Code::methods(SlidesFileFinder::class),
+ ]),
+ 'finder-roles' => new Vocabulary([
+ 'MarkdownWord\\Files\\DiskFinder' => Code::finderRoles(WordDiskFinder::class),
+ 'MarkdownPresentation\\Files\\DiskFinder' => Code::finderRoles(SlidesDiskFinder::class),
+ ]),
];
}
diff --git a/tests/Claims/behaviour.php b/tests/Claims/behaviour.php
index 2853e2d..9cc0c87 100644
--- a/tests/Claims/behaviour.php
+++ b/tests/Claims/behaviour.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use MarkdownOffice\Documentation\Tests\Support\Deck;
+use MarkdownOffice\Documentation\Tests\Support\Document;
use MarkdownOffice\Documentation\Tests\Support\Theme;
use MarkdownOffice\Documentation\Tests\Support\Site;
@@ -45,3 +46,15 @@
expect($message)->toBe('');
});
+
+it('says that the two halves do opposite things with a name they do not know', function () {
+ says(Site::GENERAL_DIRECTIVES, 'Markdown Word is permissive');
+ says(Site::PRESENTATION_DIRECTIVES, 'it refuses it');
+
+ // The Word half writes the name out as the author's own text.
+ expect(Document::body("::somethingelse{key=value}\n"))->toContain('::somethingelse');
+
+ // The slides half refuses it, and names it.
+ expect(Deck::refusal("---\ntransition: none\n---\n\n# One\n"))
+ ->toContain('names "transition"');
+});
diff --git a/tests/Claims/counts.php b/tests/Claims/counts.php
index 2a40296..18bb6c2 100644
--- a/tests/Claims/counts.php
+++ b/tests/Claims/counts.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use MarkdownOffice\Documentation\Reference\Code;
use MarkdownOffice\Documentation\Reference\Vocabularies;
use MarkdownOffice\Documentation\Tests\Support\RoundTrip;
use MarkdownOffice\Documentation\Tests\Support\Site;
@@ -92,10 +93,7 @@
});
it('counts the renderer\'s methods as the code does', function () {
- $methods = \array_filter(
- (new \ReflectionClass(PresentationRenderer::class))->getMethods(\ReflectionMethod::IS_PUBLIC),
- static fn (\ReflectionMethod $method): bool => $method->getName() !== '__construct',
- );
+ $methods = Code::methods(PresentationRenderer::class);
says(Site::PRESENTATION_INDEX, words(\count($methods)) . ' methods');
});
diff --git a/tests/Claims/finder.php b/tests/Claims/finder.php
new file mode 100644
index 0000000..6ead631
--- /dev/null
+++ b/tests/Claims/finder.php
@@ -0,0 +1,403 @@
+
+ */
+function finderNames(string $vocabulary): array
+{
+ $marked = Pages::of(Site::root() . '/content')['marked'][$vocabulary] ?? [];
+
+ return $marked === [] ? [] : $marked[0]->names;
+}
+
+/**
+ * The theme files the page's table names, and the half it says each belongs to.
+ *
+ * @return array
+ */
+function finderThemeFiles(string $page): array
+{
+ \preg_match_all(
+ '/^\| [^|]+ \| `read\(\'([^\']+)\', \'theme\'\)` \| ([^|]+) \|$/m',
+ $page,
+ $matches,
+ \PREG_SET_ORDER,
+ );
+
+ $found = [];
+
+ foreach ($matches as $match) {
+ $found[\basename($match[1])] = \trim($match[2]);
+ }
+
+ return $found;
+}
+
+/**
+ * The half that composes a theme file, in the words the page's table uses.
+ *
+ * @param list $word The files the Word half composes.
+ * @param list $slides The files the slides half composes.
+ */
+function finderHalf(string $file, array $word, array $slides): string
+{
+ $inWord = \in_array($file, $word, true);
+ $inSlides = \in_array($file, $slides, true);
+
+ if ($inWord && $inSlides) {
+ return 'both';
+ }
+
+ if ($inWord) {
+ return 'Word';
+ }
+
+ return 'slides';
+}
+
+/**
+ * The adapter the page shows, loaded and given some files.
+ *
+ * The signatures are checked before the class is loaded, because a class whose methods do
+ * not match the interfaces cannot be loaded at all: a claim that dies of a fatal error says
+ * less about what is wrong than one that says what is. The two halves are checked together,
+ * because the adapter implements both of them.
+ *
+ * @param array $files
+ */
+function finderAdapter(array $files): object
+{
+ $code = Snippet::of(Site::page(Site::FILES), 'implements');
+
+ expect(Snippet::methods($code))->toBe(Code::signatures(WordFileFinder::class));
+ expect(Code::shape(SlidesFileFinder::class))->toBe(Code::shape(WordFileFinder::class));
+
+ return Snippet::adapter($code, $files);
+}
+
+it('gives the two finders the same shape', function () {
+ expect(Code::shape(SlidesFileFinder::class))->toBe(Code::shape(WordFileFinder::class));
+});
+
+it('gives the page\'s interface the signatures the code has', function () {
+ $code = Snippet::of(Site::page(Site::FILES), 'interface FileFinder');
+
+ expect(Snippet::methods($code))->toBe(Code::signatures(WordFileFinder::class));
+});
+
+it('gives the page\'s interface the namespace the code has', function () {
+ $code = Snippet::of(Site::page(Site::FILES), 'interface FileFinder');
+
+ \preg_match('/^namespace (.+);$/m', $code, $match);
+
+ expect($match[1] ?? null)->toBe((new \ReflectionClass(WordFileFinder::class))->getNamespaceName());
+});
+
+it('gives the page\'s adapter the signatures the interface has', function () {
+ $code = Snippet::of(Site::page(Site::FILES), 'implements');
+
+ expect(Snippet::methods($code))->toBe(Code::signatures(WordFileFinder::class));
+});
+
+it('lists every method of a finder', function () {
+ expect(finderNames('finder-methods'))->toBe(Code::methods(WordFileFinder::class));
+});
+
+it('lists every role a finder resolves a name in', function () {
+ expect(finderNames('finder-roles'))->toBe(Code::finderRoles(WordDiskFinder::class));
+});
+
+it('counts the methods of a finder as the code does', function () {
+ says(Site::FILES, words(\count(Code::methods(WordFileFinder::class))) . ' methods');
+});
+
+it('counts the roles as the code does', function () {
+ says(Site::FILES, words(\count(Code::finderRoles(WordDiskFinder::class))) . ' roles');
+});
+
+it('says that a finder answers and does not refuse', function () {
+ says(Site::FILES, 'A finder answers; it does not refuse');
+});
+
+it('answers null for a file that is not there, and refuses nothing', function () {
+ $finder = new WordDiskFinder();
+
+ expect($finder->read(HOUSE_THEME_FILE, 'theme'))->toBeNull();
+ expect($finder->path(HOUSE_THEME_FILE, 'theme'))->toBeNull();
+ expect($finder->files('house'))->toBe([]);
+ expect($finder->themes())->toBe([]);
+});
+
+it('says that the scope check stays with the library', function () {
+ says(Site::FILES, 'The scope check therefore stays with the library');
+});
+
+it('refuses a path the finder answered, in the library\'s own words', function () {
+ $root = Directory::make('finder');
+ $elsewhere = Directory::make('finder');
+
+ \mkdir($elsewhere . '/house', 0o777, true);
+ \file_put_contents($elsewhere . '/' . HOUSE_THEME_FILE, HOUSE_THEME_YAML);
+
+ // The finder answers the path of a theme outside the directories this render may
+ // read, and the library is the one that refuses it.
+ $resolver = new WordThemeResolver(
+ workingDirectory: $root,
+ files: new WordDiskFinder(theme: [$elsewhere]),
+ );
+
+ expect(static fn (): mixed => $resolver->resolve('house'))
+ ->toThrow(ThemeNotFound::class, 'is outside the directories a document may name');
+
+ Directory::remove($root);
+ Directory::remove($elsewhere);
+});
+
+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";
+ $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
+ // conversions of the same input differ most of the time.
+ expect(Package::part($fromFinder, 'word/document.xml'))
+ ->toBe(Package::part($fromDirectory, 'word/document.xml'));
+
+ Directory::remove($root);
+});
+
+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');
+ $elsewhere = Directory::make('finder');
+
+ \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);
+
+ Directory::remove($root);
+ Directory::remove($elsewhere);
+});
+
+it('resolves a theme named in code once', function () {
+ $finder = MemoryFinder::house();
+
+ $converter = new MarkdownToWord(
+ "# The house style\n",
+ parser: CommonMarkParser::withFrontMatter(),
+ files: $finder,
+ );
+
+ $converter->withTheme('house')->save(\sys_get_temp_dir() . '/markdown-office-finder-' . \bin2hex(\random_bytes(6)) . '.docx');
+
+ expect($finder->reads(HOUSE_THEME_FILE))->toBe(1);
+});
+
+it('names the themes that resolve when a theme does not', function () {
+ $resolver = new WordThemeResolver(files: MemoryFinder::house());
+
+ $message = Refusal::of(static fn (): string => $resolver->resolve('nowhere')->name());
+
+ expect($message)->toContain('There is no theme called "nowhere"');
+ expect($message)->toContain('house');
+});
+
+it('says that a master the source cannot serve is refused by name', function () {
+ says(Site::FILES, 'A master the source cannot serve is refused by name');
+});
+
+it('refuses a master the source cannot serve, rather than drawing without it', function () {
+ // A source that lists the master and cannot hand it over: the listing is what tells
+ // a theme that carries no master from a source that cannot serve one.
+ $finder = new class implements SlidesFileFinder {
+ public function read(string $name, ?string $role = null): ?string
+ {
+ return $name === HOUSE_THEME_FILE ? HOUSE_THEME_YAML : null;
+ }
+
+ public function path(string $name, ?string $role = null): ?string
+ {
+ return null;
+ }
+
+ public function themes(): array
+ {
+ return ['house'];
+ }
+
+ public function files(string $theme): array
+ {
+ return ['template.pptx', 'theme.yml'];
+ }
+ };
+
+ $resolver = new SlidesThemeResolver(files: $finder);
+
+ expect(static fn (): mixed => $resolver->resolve('house'))
+ ->toThrow(UnservedTemplate::class, 'cannot serve');
+});
+
+it('resolves a theme as a theme, a picture as a picture and a document as a document', function () {
+ $finder = new MemoryFinder([
+ HOUSE_THEME_FILE => HOUSE_THEME_YAML,
+ 'logo.png' => (string) \base64_decode(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ ),
+ ]);
+
+ $document = Document::path(
+ "---\ntheme: house\n---\n\n::image[Logo]{src=\"logo.png\"}\n",
+ null,
+ $finder,
+ );
+
+ expect($finder->askedFor(HOUSE_THEME_FILE))->toBe('theme');
+ expect($finder->askedFor('logo.png'))->toBe('picture');
+
+ // The picture the finder answered is the picture in the document.
+ expect(Package::part($document, 'word/media/section_image1.png'))->not->toBe('');
+
+ $template = new TemplateFile(files: $finder);
+
+ $message = Refusal::of(static fn (): string => $template->resolve('report.dotx'));
+
+ expect($message)->toContain('report.dotx');
+ expect($finder->askedFor('report.dotx'))->toBe('document');
+});
+
+it('serves both halves with one adapter', function () {
+ $finder = MemoryFinder::house();
+
+ expect($finder)->toBeInstanceOf(WordFileFinder::class);
+ expect($finder)->toBeInstanceOf(SlidesFileFinder::class);
+
+ $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";
+
+ expect(Document::body($document, null, $finder))->toContain('The house style');
+ expect(Deck::slides($deck, '.', $finder))->toHaveCount(2);
+});
+
+it('says which half composes which theme file', function () {
+ $word = [WordThemeLoader::THEME_FILE, WordThemeLoader::STYLES_FILE];
+ $slides = [
+ SlidesThemeLoader::THEME_FILE,
+ SlidesThemeLoader::STYLES_FILE,
+ SlidesThemeLoader::LAYOUTS_FILE,
+ SlidesThemeLoader::TEXT_STYLES_FILE,
+ SlidesThemeLoader::TEMPLATE_FILE,
+ ];
+
+ $expected = [];
+
+ foreach (\array_unique([...$word, ...$slides]) as $file) {
+ $expected[$file] = finderHalf($file, $word, $slides);
+ }
+
+ $written = finderThemeFiles(Site::page(Site::FILES));
+
+ \ksort($expected);
+ \ksort($written);
+
+ expect($written)->toBe($expected);
+});
+
+it('gives the page\'s adapter the sorted lists the interface promises', function () {
+ $adapter = finderAdapter([
+ 'zebra/theme.yml' => "name: Zebra\n",
+ HOUSE_THEME_FILE => HOUSE_THEME_YAML,
+ 'house/styles.yml' => "heading.1:\n name: Georgia\n",
+ ]);
+
+ expect($adapter->themes())->toBe(['house', 'zebra']);
+ expect($adapter->files('house'))->toBe(['styles.yml', 'theme.yml']);
+});
+
+it('answers what the site\'s own adapter answers', function () {
+ $files = [
+ 'zebra/theme.yml' => "name: Zebra\n",
+ HOUSE_THEME_FILE => HOUSE_THEME_YAML,
+ 'house/styles.yml' => "heading.1:\n name: Georgia\n",
+ ];
+
+ $adapter = finderAdapter($files);
+ $own = new MemoryFinder($files);
+
+ expect($adapter->themes())->toBe($own->themes());
+ expect($adapter->files('house'))->toBe($own->files('house'));
+});
diff --git a/tests/Claims/names.php b/tests/Claims/names.php
new file mode 100644
index 0000000..73a4a41
--- /dev/null
+++ b/tests/Claims/names.php
@@ -0,0 +1,55 @@
+
+ */
+function namedMethods(string $sentence): array
+{
+ $named = Names::methods($sentence);
+
+ \sort($named);
+
+ return $named;
+}
+
+it('names the renderer\'s methods as the code does', function () {
+ $page = Site::flat(Site::page(Site::PRESENTATION_INDEX));
+
+ \preg_match('/`PresentationRenderer` has \w+ methods, ([^;]+);/', $page, $match);
+
+ expect(namedMethods($match[1] ?? ''))->toBe(Code::methods(PresentationRenderer::class));
+});
+
+it('names the reader\'s methods as the code does', function () {
+ $page = Site::flat(Site::page(Site::PRESENTATION_INDEX));
+
+ \preg_match('/`PowerPointToMarkdown`, with ([^.]+)\./', $page, $match);
+
+ expect(namedMethods($match[1] ?? ''))->toBe(Code::methods(PowerPointToMarkdown::class));
+});
+
+it('names the finder\'s methods as the code does', function () {
+ $section = Site::section(Site::FILES, 'The four methods');
+
+ expect(namedMethods($section))->toBe(Code::methods(WordFileFinder::class));
+});
diff --git a/tests/Claims/templates.php b/tests/Claims/templates.php
index 4b8e27b..8200b39 100644
--- a/tests/Claims/templates.php
+++ b/tests/Claims/templates.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-use MarkdownOffice\Documentation\Tests\Support\Chrome;
use MarkdownOffice\Documentation\Tests\Support\Deck;
use MarkdownOffice\Documentation\Tests\Support\Site;
+use MarkdownOffice\Documentation\Tests\Support\TemplatesFile;
/**
* The claims the pages make about the slide templates.
@@ -62,24 +62,24 @@
expect(\array_search('circle', $shapes, true))->toBeLessThan(\array_search('rectangle', $shapes, true));
});
-it('reads a chrome file from beside the deck', function () {
- $shapes = Chrome::shapes(
- "---\nchrome: ./chrome.md\n---\n# One\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n",
+it('reads a templates file from beside the deck', function () {
+ $shapes = TemplatesFile::shapes(
+ "---\ntemplates-file: ./templates.md\n---\n# One\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n",
"---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n",
);
expect($shapes[1])->toContain('rectangle');
});
-it('refuses a chrome file that is not there', function () {
- $message = Deck::refusal("---\nchrome: ./nowhere.md\n---\n# One\n", \sys_get_temp_dir());
+it('refuses a templates file that is not there', function () {
+ $message = Deck::refusal("---\ntemplates-file: ./nowhere.md\n---\n# One\n", \sys_get_temp_dir());
- expect($message)->toContain('There is no chrome file at');
+ expect($message)->toContain('There is no templates file at');
});
it('lets the deck\'s own template override an imported one of the same name', function () {
- $shapes = Chrome::shapes(
- "---\nchrome: ./chrome.md\n---\n# One\n\n---\ntemplate: brand\n---\n::circle{left=1% top=1% width=5% height=5%}\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n",
+ $shapes = TemplatesFile::shapes(
+ "---\ntemplates-file: ./templates.md\n---\n# One\n\n---\ntemplate: brand\n---\n::circle{left=1% top=1% width=5% height=5%}\n\n---\nlayout: blank\ntemplate: brand\n---\n\nWords.\n",
"---\ntemplate: brand\n---\n::rectangle{left=0 top=0 width=10% height=10%}\n",
);
diff --git a/tests/Support/Deck.php b/tests/Support/Deck.php
index be270a2..1c35d28 100644
--- a/tests/Support/Deck.php
+++ b/tests/Support/Deck.php
@@ -5,7 +5,9 @@
namespace MarkdownOffice\Documentation\Tests\Support;
use MarkdownPresentation\Document\DeckParser;
+use MarkdownPresentation\Files\FileFinder;
use MarkdownPresentation\Render\PresentationRenderer;
+use MarkdownPresentation\Theme\ThemeResolver;
/**
* Decks, and the messages the library gives for the ones it refuses.
@@ -47,10 +49,10 @@ public static function run(string $slide, string $text): string
*
* @return list
*/
- public static function slides(string $markdown, string $directory = '.'): array
+ public static function slides(string $markdown, string $directory = '.', ?FileFinder $files = null): array
{
$zip = new \ZipArchive();
- $zip->open(self::render($markdown, $directory));
+ $zip->open(self::render($markdown, $directory, $files));
$slides = [];
@@ -91,12 +93,20 @@ public static function shapes(string $markdown, string $directory = '.'): array
/**
* The deck written out, and the path it was written to.
+ *
+ * The finder is the source of the deck's files when there is one: the theme, the
+ * pictures and the templates file are asked for by name rather than looked for on
+ * disk.
*/
- private static function render(string $markdown, string $directory): string
+ private static function render(string $markdown, string $directory, ?FileFinder $files = null): string
{
$path = \sys_get_temp_dir() . '/markdown-office-deck-' . \bin2hex(\random_bytes(6)) . '.pptx';
- (new PresentationRenderer($directory))->render((new DeckParser())->parse($markdown), $path);
+ $renderer = $files === null
+ ? new PresentationRenderer($directory)
+ : new PresentationRenderer($directory, themes: new ThemeResolver(files: $files), files: $files);
+
+ $renderer->render((new DeckParser())->parse($markdown), $path);
return $path;
}
diff --git a/tests/Support/Document.php b/tests/Support/Document.php
index d90b512..1d4b4cb 100644
--- a/tests/Support/Document.php
+++ b/tests/Support/Document.php
@@ -7,6 +7,7 @@
use League\CommonMark\Extension\FrontMatter\FrontMatterExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use MarkdownOffice\CommonMark\Directive\GenericDirectiveExtension;
+use MarkdownWord\Files\FileFinder;
use MarkdownWord\Format;
use MarkdownWord\Loss;
use MarkdownWord\MarkdownToWord;
@@ -20,11 +21,11 @@ final class Document
/**
* A document built from the Markdown, and the path it was written to.
*/
- public static function path(string $markdown, ?string $directory = null): string
+ public static function path(string $markdown, ?string $directory = null, ?FileFinder $files = null): string
{
$path = \sys_get_temp_dir() . '/markdown-office-document-' . \bin2hex(\random_bytes(6)) . '.docx';
- self::converter($markdown, $directory)->save($path);
+ self::converter($markdown, $directory, $files)->save($path);
return $path;
}
@@ -32,9 +33,9 @@ public static function path(string $markdown, ?string $directory = null): string
/**
* The body of a document, as the XML it was written as.
*/
- public static function body(string $markdown, ?string $directory = null): string
+ public static function body(string $markdown, ?string $directory = null, ?FileFinder $files = null): string
{
- return Package::part(self::path($markdown, $directory), 'word/document.xml');
+ return Package::part(self::path($markdown, $directory, $files), 'word/document.xml');
}
/**
@@ -111,13 +112,20 @@ public static function written(string $markdown, Format $format, ?string $direct
* A converter for the Markdown.
*
* The directory is named only where there is one: a document that has no file of its
- * own beside it has nothing to resolve a path against.
+ * own beside it has nothing to resolve a path against. The finder is named only where
+ * there is one: without it the files come from the disk, which is the default.
*/
- private static function converter(string $markdown, ?string $directory): MarkdownToWord
- {
- return $directory === null
- ? new MarkdownToWord($markdown, parser: self::parser())
- : new MarkdownToWord($markdown, parser: self::parser(), directory: $directory);
+ private static function converter(
+ string $markdown,
+ ?string $directory,
+ ?FileFinder $files = null,
+ ): MarkdownToWord {
+ return new MarkdownToWord(
+ $markdown,
+ parser: self::parser(),
+ directory: $directory,
+ files: $files,
+ );
}
/**
diff --git a/tests/Support/Names.php b/tests/Support/Names.php
new file mode 100644
index 0000000..71167f7
--- /dev/null
+++ b/tests/Support/Names.php
@@ -0,0 +1,29 @@
+
+ */
+ public static function methods(string $text): array
+ {
+ \preg_match_all('/`(\w+)\(\)`/', $text, $matches);
+
+ return \array_values(\array_unique($matches[1]));
+ }
+}
diff --git a/tests/Support/NoSnippet.php b/tests/Support/NoSnippet.php
new file mode 100644
index 0000000..5560537
--- /dev/null
+++ b/tests/Support/NoSnippet.php
@@ -0,0 +1,25 @@
+
+ */
+ private static array $loaded = [];
+
+ /**
+ * The fenced PHP of a page that holds a given text.
+ */
+ public static function of(string $page, string $needle): string
+ {
+ \preg_match_all('/```php\n(.*?)\n```/s', $page, $blocks);
+
+ foreach ($blocks[1] as $block) {
+ if (\str_contains($block, $needle)) {
+ return $block;
+ }
+ }
+
+ throw NoSnippet::with($needle);
+ }
+
+ /**
+ * The methods a piece of PHP declares, as they are written.
+ *
+ * A method without a return type is not one: a constructor is not part of an
+ * interface, and a method the code does not say what it returns is not a signature
+ * anybody can check.
+ *
+ * @return list
+ */
+ public static function methods(string $code): array
+ {
+ \preg_match_all(
+ '/function\s+(\w+)\s*\(([^)]*)\)\s*:\s*([^;{]+)[;{]/',
+ $code,
+ $matches,
+ \PREG_SET_ORDER,
+ );
+
+ $found = [];
+
+ foreach ($matches as $match) {
+ $parameters = \trim($match[2]) === ''
+ ? []
+ : \array_map('trim', \explode(',', $match[2]));
+
+ $found[] = $match[1] . '(' . \implode(', ', $parameters) . '): ' . \trim($match[3]);
+ }
+
+ \sort($found);
+
+ return $found;
+ }
+
+ /**
+ * The class a piece of PHP declares.
+ */
+ public static function className(string $code): string
+ {
+ if (\preg_match('/^\s*(?:final\s+)?class\s+(\w+)/m', $code, $match) !== 1) {
+ throw NoSnippet::withoutClass();
+ }
+
+ return $match[1];
+ }
+
+ /**
+ * The adapter a page shows, loaded and given some files.
+ *
+ * The code is loaded rather than read, because what a claim is about is what the
+ * code does: a sample that returns its themes unsorted is a sample that breaks the
+ * contract the page states, however it is written.
+ *
+ * @param array $files The files the adapter holds, by name.
+ */
+ public static function adapter(string $code, array $files): object
+ {
+ $class = self::className($code);
+
+ if (! isset(self::$loaded[$class])) {
+ $file = \sys_get_temp_dir() . '/markdown-office-adapter-' . $class . '.php';
+
+ // The page shows a piece of a file rather than a file, so the opening tag is
+ // missing from it — and a file without one is read as text.
+ \file_put_contents($file, ">
*/
- public static function shapes(string $markdown, string $chrome): array
+ public static function shapes(string $markdown, string $templates): array
{
return Directory::during(
- self::beside($chrome),
+ self::beside($templates),
static fn (string $root): array => Deck::shapes($markdown, $root),
);
}
diff --git a/tools/lib/Runner.php b/tools/lib/Runner.php
index b9cc1fa..56775ba 100644
--- a/tools/lib/Runner.php
+++ b/tools/lib/Runner.php
@@ -7,19 +7,23 @@
use League\CommonMark\Extension\FrontMatter\FrontMatterExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use MarkdownOffice\CommonMark\Directive\GenericDirectiveExtension;
+use MarkdownOffice\Documentation\Finder\MemoryFinder;
use MarkdownWord\MarkdownToWord;
use MarkdownWord\Parser\CommonMarkParser;
use MarkdownWord\WordToMarkdown;
use MarkdownPresentation\Document\DeckParser;
use MarkdownPresentation\Render\PresentationRenderer;
+use MarkdownPresentation\Theme\ThemeResolver;
/**
* Runs a page's example through the library it documents, and keeps what came out.
*
* An example names a direction rather than a renderer: `md word` builds a document from
* Markdown, `word md` reads a document back into Markdown, and the document in the second
- * case is the example's input rather than its output. The direction decides what is built,
- * which library draws it, and what the download on the page says about it.
+ * case is the example's input rather than its output. A third word on the fence — `finder` —
+ * says that the example's files come from a finder rather than from the disk. The direction
+ * decides what is built, which library draws it, and what the download on the page says
+ * about it.
*
* Caching is by content hash rather than by page or by position, so the second page to show
* the same example pays nothing, a build that changes one paragraph re-renders one example, and
@@ -39,6 +43,12 @@ final class Runner
/** Markdown into a PowerPoint deck. */
public const MD_PRESENTATION = 'md-presentation';
+ /** Markdown into a Word document, its files from a finder. */
+ public const MD_WORD_FINDER = 'md-word-finder';
+
+ /** Markdown into a PowerPoint deck, its files from a finder. */
+ public const MD_PRESENTATION_FINDER = 'md-presentation-finder';
+
/** A Word document back into Markdown. */
public const WORD_MD = 'word-md';
@@ -46,11 +56,14 @@ final class Runner
* The directions the site builds, and the library each belongs to.
*
* The key is the pair a fence tag names, with the two formats separated by a dash: the
- * format the example starts in, and the format it ends in.
+ * format the example starts in, and the format it ends in. A third word — `finder` —
+ * says where the example's files come from: a finder rather than the disk.
*/
public const DIRECTIONS = [
self::MD_WORD => 'word',
self::MD_PRESENTATION => 'presentation',
+ self::MD_WORD_FINDER => 'word',
+ self::MD_PRESENTATION_FINDER => 'presentation',
self::WORD_MD => 'word',
];
@@ -212,14 +225,27 @@ private function build(string $direction, string $source, string $cache): array
$document = $cache . '/example.' . (self::DIRECTIONS[$direction] === 'presentation' ? 'pptx' : 'docx');
+ $files = \str_ends_with($direction, '-finder') ? $this->finder() : null;
+
return [
- 'document' => $direction === self::MD_PRESENTATION
- ? $this->presentation($source, $document)
- : $this->word($source, $document),
+ 'document' => self::DIRECTIONS[$direction] === 'presentation'
+ ? $this->presentation($source, $document, $files)
+ : $this->word($source, $document, $files),
'markdown' => $source,
];
}
+ /**
+ * The source the example's files come from, for an example built through a finder.
+ *
+ * The same finder for both halves, because the two interfaces have the same shape:
+ * one adapter serves both packages, which is what the page about the finder shows.
+ */
+ private function finder(): MemoryFinder
+ {
+ return MemoryFinder::house();
+ }
+
/**
* The document a fence names, as a path on this machine.
*
@@ -250,16 +276,24 @@ private function document(string $source): string
* direction that reads one, the document's own bytes are the input: the path is already
* part of the example's hash, and a document that changed under the same name has to
* rebuild rather than be served from the cache.
+ *
+ * The files of a finder are part of the input as well: an example whose theme comes
+ * from a finder is built from the theme's own bytes, so a theme that changed under
+ * the same name has to rebuild too.
*/
private function fingerprint(string $direction, string $source): string
{
- if ($direction !== self::WORD_MD) {
- return $source;
+ if ($direction === self::WORD_MD) {
+ $path = \realpath($this->site . '/' . $source);
+
+ return $path === false ? $source : $source . "\0" . (string) \hash_file('sha256', $path);
}
- $path = \realpath($this->site . '/' . $source);
+ if (\str_ends_with($direction, '-finder')) {
+ return $source . "\0" . (string) \hash('sha256', \serialize($this->finder()->held()));
+ }
- return $path === false ? $source : $source . "\0" . (string) \hash_file('sha256', $path);
+ return $source;
}
/**
@@ -278,22 +312,30 @@ private function fingerprint(string $direction, string $source): string
*
* So both, named. That is what a pipeline reading configuration out of a document wants, and the
* page on Word configuration explains why it is not the default.
+ *
+ * The finder is the source of the document's files when there is one, and the theme the
+ * document names is resolved through it.
*/
- private function word(string $markdown, string $target): string
+ private function word(string $markdown, string $target, ?MemoryFinder $files = null): string
{
(new MarkdownToWord($markdown, parser: new CommonMarkParser([
GithubFlavoredMarkdownExtension::class,
GenericDirectiveExtension::class,
FrontMatterExtension::class,
- ])))->save($target);
+ ]), files: $files))->save($target);
return $target;
}
- private function presentation(string $markdown, string $target): string
+ private function presentation(string $markdown, string $target, ?MemoryFinder $files = null): string
{
$deck = (new DeckParser())->parse($markdown);
- (new PresentationRenderer())->render($deck, $target);
+
+ $renderer = $files === null
+ ? new PresentationRenderer()
+ : new PresentationRenderer(themes: new ThemeResolver(files: $files), files: $files);
+
+ $renderer->render($deck, $target);
return $target;
}
diff --git a/tools/markdown.js b/tools/markdown.js
index 633ab39..84c42cc 100644
--- a/tools/markdown.js
+++ b/tools/markdown.js
@@ -82,7 +82,9 @@ function expansion(name, built, fence, hash) {
lines.push(`Source: \`${directory}/example.md\``);
if (built.document) {
- lines.push(`Built: \`${directory}/example.docx\``);
+ // The name the document was built under, which is not the same for every example: a
+ // deck is a `.pptx` and a document a `.docx`, and the bundle holds what was built.
+ lines.push(`Built: \`${directory}/${basename(built.document)}\``);
}
if (built.previews.length > 0) {
diff --git a/tools/render-examples.php b/tools/render-examples.php
index 9110165..73573ca 100644
--- a/tools/render-examples.php
+++ b/tools/render-examples.php
@@ -143,6 +143,64 @@ function blocks(string $markdown): array
return $found;
}
+/**
+ * The fence of an example: the opening line, the body between the two fences, and the
+ * closing fence.
+ *
+ * The opening line is a fence of three or more backticks, tagged with the direction the
+ * example runs in — `md word`, `md presentation` or `word md`. The closing fence is a run
+ * of the same number of backticks: the same length is what lets an example that itself
+ * contains a fenced block be written with four backticks.
+ *
+ * A third word may follow the direction: `finder`, which says that the example's files
+ * come from a finder rather than from the disk.
+ *
+ * A quoted title may follow the direction on the fence line. It is the same title the
+ * paired tag can carry, and a page that writes the title once in the fence and once in
+ * the tag is a page with two places to keep them in step.
+ *
+ * The closing fence is looked for in a second step, because it has to be as long as the
+ * opening one and its length is only known once the opening one has been read.
+ *
+ * @return array{fence: string, from: string, to: string, finder: string, title: string, body: string, whole: string}|null
+ */
+function fence(string $block): ?array
+{
+ $offset = 0;
+
+ while (\preg_match(
+ '/^(`{3,})(md|word|presentation)[ \t]+(md|word|presentation)(?:[ \t]+(finder))?[ \t]*(?:"([^"]*)")?[ \t]*\n/m',
+ $block,
+ $opening,
+ \PREG_OFFSET_CAPTURE,
+ $offset,
+ ) === 1) {
+ $start = $opening[0][1];
+ $after = $start + \strlen($opening[0][0]);
+
+ // The fence is backticks and nothing else, so it cannot change the pattern.
+ $closing = '/^(' . \preg_quote($opening[1][0], '/') . ')[ \t]*$/m';
+
+ if (\preg_match($closing, $block, $end, \PREG_OFFSET_CAPTURE, $after) === 1) {
+ return [
+ 'fence' => $opening[1][0],
+ 'from' => $opening[2][0],
+ 'to' => $opening[3][0],
+ 'finder' => $opening[4][0] ?? '',
+ 'title' => $opening[5][0] ?? '',
+ 'body' => \substr($block, $after, $end[0][1] - $after),
+ 'whole' => \substr($block, $start, $end[0][1] + \strlen($end[0][0]) - $start),
+ ];
+ }
+
+ // An opening fence that is never closed is not the fence of the block; the next
+ // one may be.
+ $offset = $start + 1;
+ }
+
+ return null;
+}
+
/**
* The example a block holds, or null when the block holds none.
*
@@ -150,21 +208,9 @@ function blocks(string $markdown): array
*/
function pendingFrom(string $file, string $directory, array $block): ?Pending
{
- // A fence of three or more backticks, tagged with the direction it runs in —
- // `md word`, `md presentation` or `word md` — closed by a run of the same length.
- // The same length is what lets an example that itself contains a fenced block be
- // written with four backticks.
- //
- // A quoted title may follow the direction on the fence line. It is the same title the
- // paired tag can carry, and a page that writes the title once in the fence and once
- // in the tag is a page with two places to keep them in step.
- \preg_match(
- '/^(`{3,})(md|word|presentation)[ \t]+(md|word|presentation)[ \t]*(?:"([^"]*)")?[ \t]*\n([\s\S]*?)^\1[ \t]*$/m',
- $block['body'],
- $fence,
- );
+ $fence = fence($block['body']);
- if ($fence === []) {
+ if ($fence === null) {
\fwrite(\STDERR, \sprintf(
" %s: an {%% example %%} block has no `md word`, `md presentation` or `word md` fence.\n",
$file,
@@ -173,7 +219,7 @@ function pendingFrom(string $file, string $directory, array $block): ?Pending
return null;
}
- $direction = $fence[2] . '-' . $fence[3];
+ $direction = $fence['from'] . '-' . $fence['to'] . ($fence['finder'] === 'finder' ? '-finder' : '');
if (! isset(Runner::DIRECTIONS[$direction])) {
\fwrite(\STDERR, \sprintf(
@@ -193,18 +239,18 @@ function pendingFrom(string $file, string $directory, array $block): ?Pending
// frontmatter is matched at the first byte of the file, so an example whose source
// began with a blank line would put `theme:` on slide 2 and be refused for naming a
// deck directive there.
- $source = \trim($fence[5]);
+ $source = \trim($fence['body']);
// The tag wins over the fence, because the tag is the one an author edits when they
// are looking at the `{% example %}` and not at the fence inside it.
$title = \trim(\trim($block['tag']), '"');
- $title = $title === '' ? \trim($fence[4] ?? '', '"') : $title;
+ $title = $title === '' ? \trim($fence['title'], '"') : $title;
$title = $title !== '' ? $title : Runner::DIRECTIONS[$direction] . ' example';
// The key is hashed from the *whole* trimmed block, fences included, because that is
// what the Eleventy shortcode has to hand: it is given the text between the paired
// tags and cannot tell a body from the fence around it without re-parsing it.
- $whole = \trim($fence[0]);
+ $whole = \trim($fence['whole']);
return new Pending(
\str_replace($directory . '/', '', $file),