From b8b10b7e8f661ab3e6514e35aeba3b970b484ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Thu, 29 Oct 2026 16:39:04 +0100 Subject: [PATCH 1/6] The pages call the file a templates file, not a chrome file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library renamed the key that points at a file of template definitions: `chrome:` is `templates-file:` now, and the file it names is a templates file. The word "chrome" keeps its other meaning — the shapes a template draws — and the pages keep it there. The exceptions follow the library: `ChromeNotFound` and `ChromeTooLarge` are `TemplatesFileNotFound` and `TemplatesFileTooLarge`, and a templates file that holds a slide is now `MalformedTemplatesFile` rather than a case of `MalformedChrome`. --- content/presentation/directives.md | 10 +++++----- content/presentation/errors.md | 12 ++++++----- content/presentation/index.md | 2 +- content/presentation/themes.md | 4 ++-- src/Reference/Vocabularies.php | 6 +++--- tests/Claims/templates.php | 18 ++++++++--------- .../Support/{Chrome.php => TemplatesFile.php} | 20 +++++++++---------- 7 files changed, 37 insertions(+), 35 deletions(-) rename tests/Support/{Chrome.php => TemplatesFile.php} (51%) 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..5d6fbb6 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 | @@ -51,5 +52,6 @@ Five sit beside `InvalidInput` rather than under it: nothing a deck names fixes 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. -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. +The four 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, a file that holds a slide, and a file that is +not there. diff --git a/content/presentation/index.md b/content/presentation/index.md index 3ef602d..c173f74 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -280,7 +280,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 | diff --git a/content/presentation/themes.md b/content/presentation/themes.md index b74d8db..811e195 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). diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 57d8dd6..6b17272 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -11,7 +11,7 @@ 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; @@ -79,7 +79,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 +93,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, ], 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/Chrome.php b/tests/Support/TemplatesFile.php similarity index 51% rename from tests/Support/Chrome.php rename to tests/Support/TemplatesFile.php index d051640..04a601e 100644 --- a/tests/Support/Chrome.php +++ b/tests/Support/TemplatesFile.php @@ -5,38 +5,38 @@ namespace MarkdownOffice\Documentation\Tests\Support; /** - * A deck's own directory, and the chrome file beside it. + * A deck's own directory, and the templates file beside it. * * A template may be imported from a file beside the deck, so a claim about one needs a * deck's directory to put the file in — and the deck has to be rendered from there, or the * path is resolved against somewhere else. */ -final class Chrome +final class TemplatesFile { /** - * A directory holding a chrome file, and the directory it is in. + * A directory holding a templates file, and the directory it is in. */ public static function beside(string $markdown): string { - $root = Directory::make('chrome'); + $root = Directory::make('templates'); - \file_put_contents($root . '/chrome.md', $markdown); + \file_put_contents($root . '/templates.md', $markdown); return $root; } /** - * The shapes of a deck drawn against a chrome file. + * The shapes of a deck drawn against a templates file. * - * The chrome is written beside the deck and taken away again, whatever the deck says: - * a file left behind is a file the next test draws against. + * The templates file is written beside the deck and taken away again, whatever the deck + * says: a file left behind is a file the next test draws against. * * @return list> */ - 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), ); } From 52e1158f67a8e54b8e0cd8a6088a190a6920fa5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Thu, 29 Oct 2026 16:43:48 +0100 Subject: [PATCH 2/6] The four template failures are the deck author's to fix --- content/presentation/errors.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/content/presentation/errors.md b/content/presentation/errors.md index 5d6fbb6..5a49f67 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -52,6 +52,5 @@ Five sit beside `InvalidInput` rather than under it: nothing a deck names fixes 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. -The four 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, a file that holds a slide, and a file that is -not there. +The four that carry a template are the deck author's to fix: a name nothing is under, a body +that holds the wrong thing, a file that holds a slide, and a file that is not there. From 65acb8117f2e389809558a170dfa2223e77f71b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Fri, 30 Oct 2026 00:05:26 +0100 Subject: [PATCH 3/6] The pages say where a document's files come from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both libraries now read every file through a `Files\FileFinder`, and the two interfaces have the same shape — so one adapter serves both packages, and a program that keeps its themes in a database writes it once. The page is in the half that is about both libraries, because the finder is: one shape, one list of methods, one set of roles. The two halves' own pages point at it, and the halves' differences — the Word half stages a template, the slides half stages a master and refuses one it cannot serve — are told there. Two vocabularies come with it, read out of the code rather than written down: the four methods a finder has, and the three roles a name is resolved in. The claims suite holds the two interfaces to each other, so they cannot drift apart without a test failing — which is what lets one adapter serve both. The examples on the page are built through a finder: the builder learned a third word for a fence, `finder`, and the theme they use is in memory rather than in a directory. The slides half gained an exception, `UnservedTemplate`, and the renderer's method count and the number of exceptions beside `InvalidInput` both moved, so the pages that state them say the new numbers. --- README.md | 16 +- content/general/errors.md | 9 +- content/general/files.md | 311 +++++++++++++++++++++++++++++++++ content/presentation/errors.md | 14 +- content/presentation/index.md | 41 +++-- content/presentation/themes.md | 4 + content/word/configuration.md | 4 + content/word/index.md | 18 +- src/Finder/MemoryFinder.php | 159 +++++++++++++++++ src/Reference/Code.php | 84 ++++++++- src/Reference/Vocabularies.php | 12 ++ tests/Claims/behaviour.php | 13 ++ tests/Claims/finder.php | 256 +++++++++++++++++++++++++++ tests/Support/Deck.php | 18 +- tests/Support/Document.php | 28 +-- tests/Support/Site.php | 5 + tools/lib/Runner.php | 70 ++++++-- tools/render-examples.php | 11 +- 18 files changed, 1013 insertions(+), 60 deletions(-) create mode 100644 content/general/files.md create mode 100644 src/Finder/MemoryFinder.php create mode 100644 tests/Claims/finder.php diff --git a/README.md b/README.md index ab7d893..ef98a9f 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" %} @@ -237,7 +239,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/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..6ab83da --- /dev/null +++ b/content/general/files.md @@ -0,0 +1,311 @@ +--- +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 renders into are files. They were files on +this machine, and now they are files wherever the calling program keeps them: **the finder is +where a document's files 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 *names* and asks. A finder answers. It never knows what a theme is, and it +never knows the files a theme is made of: it answers for the names it is handed, and lists what is +under a name it is handed. 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 has to be 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. It says which world a name is resolved in, and 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 | Where it is looked for | +| --- | --- | --- | +| `theme` | a file of a theme — its manifest, its slots, its master | the theme directories | +| `picture` | an image a document names | the picture roots | +| `document` | anything else a document names by path — a `.dotx`, a deck's templates file | the document's own directory, and the template roots | + +The three names are the same in both halves, so one adapter serves both. + +## The names the library composes + +The finder answers for the names it is handed, and the names are the library's own. + +| The library wants | It asks | +| --- | --- | +| the theme `corporate`'s manifest | `read('corporate/theme.yml', 'theme')` | +| the theme `corporate`'s slots | `read('corporate/styles.yml', 'theme')` | +| the theme `corporate`'s master | `read('corporate/template.pptx', 'theme')` | +| the picture `logo.png` | `read('logo.png', 'picture')` | +| the document's `report.dotx` | `read('report.dotx', 'document')` | + +The two halves' file vocabularies differ, and that is fine: the Word half's `styles.yml` carries +`textStyles:` inside it, and the slides half has a separate `text-styles.yml`. 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, which is the whole reason there is one shape rather than two. + +**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 + +`Files\DiskFinder` is the default in both halves and has the same shape. 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 +the staged file goes when the template is done with — `Template\TemplateFile::release()` gives it +back, and 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 `template.pptx` is a package, and +a package is opened as a file: a source that holds the master as bytes rather than as a file 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; + } + + return \array_keys($names); + } + + 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; + } + } + + return \array_keys($names); + } +} +``` + +The claims suite checks that the two 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/errors.md b/content/presentation/errors.md index 5a49f67..c40d808 100644 --- a/content/presentation/errors.md +++ b/content/presentation/errors.md @@ -47,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 four that carry a template are the deck author's to fix: a name nothing is under, a body -that holds the wrong thing, a file that holds a slide, 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 c173f74..6bb558d 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()`, `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 @@ -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 811e195..39d4c4f 100644 --- a/content/presentation/themes.md +++ b/content/presentation/themes.md @@ -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 db32f3f..994c3ea 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 @@ -287,4 +302,5 @@ The whole catalogue is on [Errors](/word/errors/). [Markdown Presentation](/presentation/) · [The directive syntax](/directives/syntax/) · [Word directives](/word/directives/) · [Images](/images/) · -[Round trips](/word/round-trip/) · [Errors](/word/errors/) \ No newline at end of file +[Round trips](/word/round-trip/) · [Where the files come from](/files/) · +[Errors](/word/errors/) \ No newline at end of file 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..3aac534 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,88 @@ */ final class Code { + /** + * The public methods of an interface, by name. + * + * @param class-string $interface + * + * @return list + */ + public static function finderMethods(string $interface): array + { + $names = []; + + foreach ((new \ReflectionClass($interface))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $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 attributes each of the Word half's directives reads. * diff --git a/src/Reference/Vocabularies.php b/src/Reference/Vocabularies.php index 6b17272..2c60926 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -5,6 +5,8 @@ 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; @@ -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; @@ -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::finderMethods(WordFileFinder::class), + 'MarkdownPresentation\\Files\\FileFinder' => Code::finderMethods(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/finder.php b/tests/Claims/finder.php new file mode 100644 index 0000000..6a378c5 --- /dev/null +++ b/tests/Claims/finder.php @@ -0,0 +1,256 @@ + + */ +function finderNames(string $vocabulary): array +{ + $marked = Pages::of(Site::root() . '/content')['marked'][$vocabulary] ?? []; + + return $marked === [] ? [] : $marked[0]->names; +} + +it('gives the two finders the same shape', function () { + expect(Code::shape(SlidesFileFinder::class))->toBe(Code::shape(WordFileFinder::class)); +}); + +it('lists every method of a finder', function () { + expect(finderNames('finder-methods'))->toBe(Code::finderMethods(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::finderMethods(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.yml', 'theme'))->toBeNull(); + expect($finder->path('house/theme.yml', '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.yml', "name: House\n"); + + // 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 = "name: House\n"; + $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.yml' => $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 = "name: House\n"; + $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.yml' => $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.yml'))->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.yml' ? "name: House\n" : 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.yml' => "name: House\n", + '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.yml'))->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); +}); 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/Site.php b/tests/Support/Site.php index 72c3849..5b2f941 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 where a document's files come from. + */ + public const FILES = 'general/files.md'; + /** * The page that documents what does not survive a round trip in the Word half. */ 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/render-examples.php b/tools/render-examples.php index 9110165..6604733 100644 --- a/tools/render-examples.php +++ b/tools/render-examples.php @@ -155,11 +155,14 @@ function pendingFrom(string $file, string $directory, array $block): ?Pending // 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. \preg_match( - '/^(`{3,})(md|word|presentation)[ \t]+(md|word|presentation)[ \t]*(?:"([^"]*)")?[ \t]*\n([\s\S]*?)^\1[ \t]*$/m', + '/^(`{3,})(md|word|presentation)[ \t]+(md|word|presentation)(?:[ \t]+(finder))?[ \t]*(?:"([^"]*)")?[ \t]*\n([\s\S]*?)^\1[ \t]*$/m', $block['body'], $fence, ); @@ -173,7 +176,7 @@ function pendingFrom(string $file, string $directory, array $block): ?Pending return null; } - $direction = $fence[2] . '-' . $fence[3]; + $direction = $fence[2] . '-' . $fence[3] . (($fence[4] ?? '') === 'finder' ? '-finder' : ''); if (! isset(Runner::DIRECTIONS[$direction])) { \fwrite(\STDERR, \sprintf( @@ -193,12 +196,12 @@ 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[6]); // 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[5] ?? '', '"') : $title; $title = $title !== '' ? $title : Runner::DIRECTIONS[$direction] . ' example'; // The key is hashed from the *whole* trimmed block, fences included, because that is From b714c6ea87bf3eafa315ad5f5c51e5413941b8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Fri, 30 Oct 2026 00:25:25 +0100 Subject: [PATCH 4/6] =?UTF-8?q?The=20fence=20of=20an=20example=20is=20foun?= =?UTF-8?q?d=20in=20two=20steps=20The=20example=20builder=20read=20the=20w?= =?UTF-8?q?hole=20fence=20of=20an=20example=20with=20one=20pattern:=20the?= =?UTF-8?q?=20opening=20fence,=20the=20direction=20the=20example=20runs=20?= =?UTF-8?q?in,=20the=20words=20that=20may=20follow=20it,=20the=20body,=20a?= =?UTF-8?q?nd=20the=20closing=20fence=20of=20the=20same=20length.=20Sonar?= =?UTF-8?q?=20counted=20that=20pattern=20at=2024,=20where=2020=20is=20the?= =?UTF-8?q?=20most=20it=20allows,=20and=20the=20count=20was=20fair=20?= =?UTF-8?q?=E2=80=94=20one=20pattern=20was=20carrying=20five=20jobs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It carries two now. The opening fence is one pattern, and the closing fence is another, built from the fence that was opened: a closing fence has to be as long as the opening one, and its length is only known once the opening one has been read. The pattern that finds the opening fence counts 19, and the one that finds the closing fence counts two. What the builder accepts has not changed. The same fences are read, the same bodies are taken out of them, and the same blocks are hashed into the same keys: a check of the new search against the old pattern over every block of the site and over twenty thousand made-up ones found no difference, and a build from an empty cache writes the manifest it wrote before. The claims suite named the house theme's file and what that file holds in eight places and in five. They are two constants now, named for what they are. --- tests/Claims/finder.php | 32 ++++++++++----- tools/render-examples.php | 85 +++++++++++++++++++++++++++++---------- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/tests/Claims/finder.php b/tests/Claims/finder.php index 6a378c5..2a4e88b 100644 --- a/tests/Claims/finder.php +++ b/tests/Claims/finder.php @@ -37,6 +37,16 @@ * of both. */ +/** + * The house theme's file, as a finder names it. + */ +const HOUSE_THEME_FILE = 'house/theme.yml'; + +/** + * What that file holds: a theme with a name and nothing else. + */ +const HOUSE_THEME_YAML = "name: House\n"; + /** * The names a marked block of a vocabulary lists. * @@ -76,8 +86,8 @@ function finderNames(string $vocabulary): array it('answers null for a file that is not there, and refuses nothing', function () { $finder = new WordDiskFinder(); - expect($finder->read('house/theme.yml', 'theme'))->toBeNull(); - expect($finder->path('house/theme.yml', 'theme'))->toBeNull(); + 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([]); }); @@ -91,7 +101,7 @@ function finderNames(string $vocabulary): array $elsewhere = Directory::make('finder'); \mkdir($elsewhere . '/house', 0o777, true); - \file_put_contents($elsewhere . '/house/theme.yml', "name: House\n"); + \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. @@ -108,7 +118,7 @@ function finderNames(string $vocabulary): array }); it('draws the same document from a theme in a finder as from a theme in a directory', function () { - $theme = "name: House\n"; + $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"; @@ -120,7 +130,7 @@ function finderNames(string $vocabulary): array $fromDirectory = Document::path($markdown, $root); $fromFinder = Document::path($markdown, null, new MemoryFinder([ - 'house/theme.yml' => $theme, + HOUSE_THEME_FILE => $theme, 'house/styles.yml' => $styles, ])); @@ -133,7 +143,7 @@ function finderNames(string $vocabulary): array }); it('draws the same deck from a theme in a finder as from a theme in a directory', function () { - $theme = "name: House\n"; + $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"; @@ -146,7 +156,7 @@ function finderNames(string $vocabulary): array $fromDirectory = Deck::slides($markdown, $root); $fromFinder = Deck::slides($markdown, $elsewhere, new MemoryFinder([ - 'house/theme.yml' => $theme, + HOUSE_THEME_FILE => $theme, 'house/layouts.yml' => $layouts, ])); @@ -167,7 +177,7 @@ function finderNames(string $vocabulary): array $converter->withTheme('house')->save(\sys_get_temp_dir() . '/markdown-office-finder-' . \bin2hex(\random_bytes(6)) . '.docx'); - expect($finder->reads('house/theme.yml'))->toBe(1); + expect($finder->reads(HOUSE_THEME_FILE))->toBe(1); }); it('names the themes that resolve when a theme does not', function () { @@ -189,7 +199,7 @@ function finderNames(string $vocabulary): array $finder = new class implements SlidesFileFinder { public function read(string $name, ?string $role = null): ?string { - return $name === 'house/theme.yml' ? "name: House\n" : null; + return $name === HOUSE_THEME_FILE ? HOUSE_THEME_YAML : null; } public function path(string $name, ?string $role = null): ?string @@ -216,7 +226,7 @@ public function files(string $theme): array it('resolves a theme as a theme, a picture as a picture and a document as a document', function () { $finder = new MemoryFinder([ - 'house/theme.yml' => "name: House\n", + HOUSE_THEME_FILE => HOUSE_THEME_YAML, 'logo.png' => (string) \base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', ), @@ -228,7 +238,7 @@ public function files(string $theme): array $finder, ); - expect($finder->askedFor('house/theme.yml'))->toBe('theme'); + 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. diff --git a/tools/render-examples.php b/tools/render-examples.php index 6604733..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,24 +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 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. - \preg_match( - '/^(`{3,})(md|word|presentation)[ \t]+(md|word|presentation)(?:[ \t]+(finder))?[ \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, @@ -176,7 +219,7 @@ function pendingFrom(string $file, string $directory, array $block): ?Pending return null; } - $direction = $fence[2] . '-' . $fence[3] . (($fence[4] ?? '') === 'finder' ? '-finder' : ''); + $direction = $fence['from'] . '-' . $fence['to'] . ($fence['finder'] === 'finder' ? '-finder' : ''); if (! isset(Runner::DIRECTIONS[$direction])) { \fwrite(\STDERR, \sprintf( @@ -196,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[6]); + $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[5] ?? '', '"') : $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), From bfc738587f24253b28b859642b25a9a374317e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Fri, 30 Oct 2026 08:01:52 +0100 Subject: [PATCH 5/6] The finder page is carried, and every copy of it is checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page about the finder was added to a section the skill bundle claims to cover, and the bundle did not carry it: `tools/check-skill.php` says so, and the pipeline never ran the check that would have said it — the npm script the workflow calls was the weaker of the two the repository held under the same name. The bundle carries the page now, the skill check runs beside the link check, and the composer script delegates rather than keeping a second and weaker copy of the same name. The page makes two copies of the code: the interface it shows and the adapter a reader would take. Both are compared with the code now, so a signature that moves and an adapter that stops sorting are failures rather than prose that is quietly wrong. The names the page lists say which half composes them, and a claim reads that column out of the two theme loaders rather than out of the page. The method names the pages state in prose were claims nothing checked: the slides half's index named five methods of a renderer and four of a reader, and a rename would have left the sentence standing with every gate green. They are asked of the code now, and the count of a list is asked of it in one place rather than two. The page is shorter for it. The sentences that repeated the library's own docblocks are gone, `Files\DiskFinder` is no longer called the default where a finder is optional and the disk is what is left without one, and the roles table no longer says where a role is looked for in a way that was false. --- README.md | 5 +- composer.json | 2 +- content/general/files.md | 104 +++++++++++++++------------- content/presentation/index.md | 8 +-- package.json | 2 +- skill/mdword/SKILL.md | 1 + skill/mdword/pages.json | 4 ++ src/Reference/Code.php | 71 +++++++++++++++++-- src/Reference/Vocabularies.php | 4 +- tests/Claims/counts.php | 6 +- tests/Claims/finder.php | 122 ++++++++++++++++++++++++++++++++- tests/Claims/names.php | 55 +++++++++++++++ tests/Support/Names.php | 29 ++++++++ tests/Support/NoSnippet.php | 25 +++++++ tests/Support/Snippet.php | 122 +++++++++++++++++++++++++++++++++ tools/markdown.js | 4 +- 16 files changed, 496 insertions(+), 68 deletions(-) create mode 100644 tests/Claims/names.php create mode 100644 tests/Support/Names.php create mode 100644 tests/Support/NoSnippet.php create mode 100644 tests/Support/Snippet.php diff --git a/README.md b/README.md index e3a730b..96a0ac1 100644 --- a/README.md +++ b/README.md @@ -227,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 @@ -235,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 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/files.md b/content/general/files.md index 6ab83da..28d0c65 100644 --- a/content/general/files.md +++ b/content/general/files.md @@ -7,17 +7,14 @@ section: In general order: 3 --- -A document's theme, its pictures and the template it renders into are files. They were files on -this machine, and now they are files wherever the calling program keeps them: **the finder is -where a document's files come from.** +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 *names* and asks. A finder answers. It never knows what a theme is, and it -never knows the files a theme is made of: it answers for the names it is handed, and lists what is -under a name it is handed. That is what lets a directory of themes on disk and a database of -themes that never had a directory serve the same library. +`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 @@ -46,46 +43,51 @@ interface FileFinder ``` `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 has to be opened as — a theme's master and a document's +`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. +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. It says which world a name is resolved in, and it is a hint and +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 | Where it is looked for | -| --- | --- | --- | -| `theme` | a file of a theme — its manifest, its slots, its master | the theme directories | -| `picture` | an image a document names | the picture roots | -| `document` | anything else a document names by path — a `.dotx`, a deck's templates file | the document's own directory, and the template roots | +| 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, so one adapter serves both. +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 library wants | It asks | -| --- | --- | -| the theme `corporate`'s manifest | `read('corporate/theme.yml', 'theme')` | -| the theme `corporate`'s slots | `read('corporate/styles.yml', 'theme')` | -| the theme `corporate`'s master | `read('corporate/template.pptx', 'theme')` | -| the picture `logo.png` | `read('logo.png', 'picture')` | -| the document's `report.dotx` | `read('report.dotx', 'document')` | +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 two halves' file vocabularies differ, and that is fine: the Word half's `styles.yml` carries -`textStyles:` inside it, and the slides half has a separate `text-styles.yml`. The finder is never -told which is which — it is asked for a name. +| 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 @@ -93,7 +95,7 @@ told which is which — it is asked for a name. 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, which is the whole reason there is one shape rather than 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 @@ -106,7 +108,8 @@ serves that program's own files, and the program vouches for them. ## The disk finder -`Files\DiskFinder` is the default in both halves and has the same shape. It holds the bases — the +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 @@ -152,8 +155,8 @@ reads would say the name was asked for once and not once for the name and once f 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 -the staged file goes when the template is done with — `Template\TemplateFile::release()` gives it -back, and a caller who lets the resolver go without saying so has it said for them. +`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; @@ -197,11 +200,10 @@ $renderer = (new PresentationRenderer(themes: new ThemeResolver(files: $finder), ->withTheme('house'); ``` -**A theme's master is a file, and a file the library stages.** A `template.pptx` is a package, and -a package is opened as a file: a source that holds the master as bytes rather than as a file 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 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 @@ -217,9 +219,9 @@ $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: +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; @@ -252,7 +254,11 @@ final class MyFinder implements WordFileFinder, SlidesFileFinder $names[\explode('/', $name, 2)[0]] = true; } - return \array_keys($names); + $found = \array_keys($names); + + \sort($found); + + return $found; } public function files(string $theme): array @@ -265,13 +271,17 @@ final class MyFinder implements WordFileFinder, SlidesFileFinder } } - return \array_keys($names); + $found = \array_keys($names); + + \sort($found); + + return $found; } } ``` -The claims suite checks that the two have not drifted apart, because that is what lets one adapter -serve both. +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 diff --git a/content/presentation/index.md b/content/presentation/index.md index 6bb558d..06b6d5c 100644 --- a/content/presentation/index.md +++ b/content/presentation/index.md @@ -21,10 +21,10 @@ PHP 8.2 or newer, with `ext-dom`, `ext-gd` and `ext-zip`. `ext-imagick` is optio 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 five methods, `render()`, `overflows()`, `unknownFonts()`, `withTheme()` and `toString()`; 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 +`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). --- 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/Reference/Code.php b/src/Reference/Code.php index 3aac534..ca403aa 100644 --- a/src/Reference/Code.php +++ b/src/Reference/Code.php @@ -21,17 +21,25 @@ final class Code { /** - * The public methods of an interface, by name. + * The public methods of a class, by name. * - * @param class-string $interface + * 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 finderMethods(string $interface): array + public static function methods(string $class): array { $names = []; - foreach ((new \ReflectionClass($interface))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + foreach ((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getName() === '__construct') { + continue; + } + $names[] = $method->getName(); } @@ -102,6 +110,61 @@ public static function shape(string $interface): array 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 2c60926..438d85e 100644 --- a/src/Reference/Vocabularies.php +++ b/src/Reference/Vocabularies.php @@ -159,8 +159,8 @@ public static function all(): array 'MarkdownWord\\Configuration\\TextStyles::ATTRIBUTES' => \array_keys(TextStyles::ATTRIBUTES), ]), 'finder-methods' => new Vocabulary([ - 'MarkdownWord\\Files\\FileFinder' => Code::finderMethods(WordFileFinder::class), - 'MarkdownPresentation\\Files\\FileFinder' => Code::finderMethods(SlidesFileFinder::class), + '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), 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 index 2a4e88b..17a346a 100644 --- a/tests/Claims/finder.php +++ b/tests/Claims/finder.php @@ -11,8 +11,10 @@ use MarkdownOffice\Documentation\Tests\Support\Package; use MarkdownOffice\Documentation\Tests\Support\Refusal; use MarkdownOffice\Documentation\Tests\Support\Site; +use MarkdownOffice\Documentation\Tests\Support\Snippet; 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\ThemeNotFound; use MarkdownWord\Files\DiskFinder as WordDiskFinder; @@ -20,6 +22,7 @@ use MarkdownWord\MarkdownToWord; use MarkdownWord\Parser\CommonMarkParser; use MarkdownWord\Template\TemplateFile; +use MarkdownWord\Theme\ThemeLoader as WordThemeLoader; use MarkdownWord\Theme\ThemeResolver as WordThemeResolver; /** @@ -59,12 +62,75 @@ function finderNames(string $vocabulary): array 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 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::finderMethods(WordFileFinder::class)); + expect(finderNames('finder-methods'))->toBe(Code::methods(WordFileFinder::class)); }); it('lists every role a finder resolves a name in', function () { @@ -72,7 +138,7 @@ function finderNames(string $vocabulary): array }); it('counts the methods of a finder as the code does', function () { - says(Site::FILES, words(\count(Code::finderMethods(WordFileFinder::class))) . ' methods'); + says(Site::FILES, words(\count(Code::methods(WordFileFinder::class))) . ' methods'); }); it('counts the roles as the code does', function () { @@ -264,3 +330,55 @@ public function files(string $theme): array 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) { + $inWord = \in_array($file, $word, true); + $inSlides = \in_array($file, $slides, true); + + $expected[$file] = $inWord && $inSlides ? 'both' : ($inWord ? '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/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, " 0) { From 072c50e17147d57d978abfbe0ec1331daef13c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gra=C3=9Fl?= Date: Fri, 30 Oct 2026 08:11:55 +0100 Subject: [PATCH 6/6] The nested ternary is a method, and the file is required once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar read the claim about which half composes which theme file and found a ternary inside a ternary. The answer was right and the reading was not: three clauses in one expression, and a reader has to hold all of them to see which one applies. It is a method now — `finderHalf()` says whether a file is composed by both halves, by the Word half or by the slides half, in the words the page's table uses — and the claim compares the page's column with the two theme loaders' lists through it. The loader that reads the adapter the page shows used `require`, and the rule is right to ask about it: the file is a temporary one, written from the page's own code, read to declare the class, and removed again as soon as the class is in memory. Nothing re-reads it — a class cannot be declared twice in one process, so the loader already remembers the classes it has loaded and hands the one that is already there to the second claim that asks for it. The file was read once anyway, and `require_once` says so where the guard used to be the only thing that said it. The claims can still fail. With the helper answering "both" for every file, the claim about the halves goes red; with the page's table saying "Word" where the library says "both", it goes red as well. With the page's adapter no longer sorting its themes, the two claims about the adapter go red, so the class is loaded and run rather than merely read. --- tests/Claims/finder.php | 27 +++++++++++++++++++++++---- tests/Support/Snippet.php | 2 +- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/Claims/finder.php b/tests/Claims/finder.php index 17a346a..6ead631 100644 --- a/tests/Claims/finder.php +++ b/tests/Claims/finder.php @@ -85,6 +85,28 @@ function finderThemeFiles(string $page): array 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. * @@ -344,10 +366,7 @@ public function files(string $theme): array $expected = []; foreach (\array_unique([...$word, ...$slides]) as $file) { - $inWord = \in_array($file, $word, true); - $inSlides = \in_array($file, $slides, true); - - $expected[$file] = $inWord && $inSlides ? 'both' : ($inWord ? 'Word' : 'slides'); + $expected[$file] = finderHalf($file, $word, $slides); } $written = finderThemeFiles(Site::page(Site::FILES)); diff --git a/tests/Support/Snippet.php b/tests/Support/Snippet.php index 457d660..169d18d 100644 --- a/tests/Support/Snippet.php +++ b/tests/Support/Snippet.php @@ -108,7 +108,7 @@ public static function adapter(string $code, array $files): object // missing from it — and a file without one is read as text. \file_put_contents($file, "