diff --git a/README.md b/README.md index a8e0645..1d53000 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ buzz/ ├── resources/assets/ │ ├── css/app.css # @theme static tokens (colour only — see below) + the few hand-authored rules │ └── fonts/ # Self-hosted Playfair Display + Source Serif 4 (OFL, Google Fonts, latin subset) -└── app/Providers/AssetServiceProvider.php +└── app/ + ├── Cms/Bindings/ArticleBinding.php # Block Bindings source: the byline's reading time + └── Providers/AssetServiceProvider.php ``` One rule decides where a file goes: **the theme root holds what WordPress reads itself** @@ -68,6 +70,12 @@ docblock (`Title`, `Slug`, `Categories`…). Most of Buzz's patterns are, since — the colophon's year, here — is a Blade view in `resources/views/patterns/*.blade.php`, registered by Pollora. +The byline's reading time ("4 min read", in the article and in the index) is a plain +`core/paragraph` bound to the theme's own [Block Bindings](https://pollora.dev/blocks/block-bindings/) +source, `app/Cms/Bindings/ArticleBinding.php` (`%theme_name%/article`, fields `reading_time` and +`word_count`): a PHP method computes it from the post's words, in a query loop for each post, and +the editor shows it too. Add a field to the class, and it is offered in the block's "Attributes" panel. + WordPress caches the list of a theme's `patterns/` files: a new file shows up once that cache is cleared (`wp eval 'wp_get_theme()->delete_pattern_cache();'`), or at once with `WP_DEVELOPMENT_MODE=theme`. diff --git a/app/Cms/Bindings/ArticleBinding.php b/app/Cms/Bindings/ArticleBinding.php new file mode 100644 index 0000000..195ae02 --- /dev/null +++ b/app/Cms/Bindings/ArticleBinding.php @@ -0,0 +1,52 @@ +wordCount($context); + + if ($words === null) { + return null; + } + + $minutes = max(1, (int) ceil($words / self::WORDS_PER_MINUTE)); + + return sprintf(_n('%d min read', '%d min read', $minutes, '%theme_name%'), $minutes); + } + + #[BindingField(label: 'Word count')] + public function wordCount(BindingContext $context): ?int + { + $post = $context->post(); + + if ($post === null) { + return null; + } + + $text = wp_strip_all_tags(strip_shortcodes((string) $post->post_content)); + + return count(preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []); + } +} diff --git a/bin/tests/e2e/buzz.spec.ts b/bin/tests/e2e/buzz.spec.ts index 20d28e0..14e3430 100644 --- a/bin/tests/e2e/buzz.spec.ts +++ b/bin/tests/e2e/buzz.spec.ts @@ -18,16 +18,18 @@ test.beforeAll(() => { const category = wp('term', 'create', 'category', `${tag}-cat`, '--porcelain'); ids.post = create('--post_type=post', `--post_title=Headline ${tag}`, `--post_content=
Body ${tag}
`, `--post_category=${category}`); + ids.long = create('--post_type=post', `--post_title=Long read ${tag}`, `--post_content=${'word '.repeat(700)}
`); ids.page = create('--post_type=page', `--post_title=Page ${tag}`, `--post_content=Page body ${tag}
`); ids.category = category; urls.post = link(ids.post); + urls.long = link(ids.long); urls.page = link(ids.page); urls.category = new URL(wp('term', 'get', 'category', category, '--field=url')).pathname; }); test.afterAll(() => { - for (const key of ['post', 'page']) { + for (const key of ['post', 'long', 'page']) { if (ids[key]) { wp('post', 'delete', ids[key], '--force'); } @@ -79,6 +81,20 @@ test('a post renders its title and body', async ({ page }) => { await expect(page.locator('main')).toContainText(`Body ${tag}`); }); +test('the byline says how long a post takes to read, from its own words', async ({ page }) => { + // 700 words at 230 a minute: the theme's own binding source, buzz/article. + await visit(page, urls.long); + await expect(page.locator('main')).toContainText('4 min read'); + + await visit(page, urls.post); + await expect(page.locator('main')).toContainText('1 min read'); + + // In the query loop, each item reads its own post. + await visit(page, '/'); + await expect(page.locator('main .buzz-index-list > li').filter({ hasText: `Long read ${tag}` })).toContainText('4 min read'); + await expect(page.locator('main .buzz-index-list > li').filter({ hasText: `Headline ${tag}` })).toContainText('1 min read'); +}); + test('a page renders its title and body', async ({ page }) => { await visit(page, urls.page); diff --git a/patterns/article.php b/patterns/article.php index c8a9328..3aecd07 100644 --- a/patterns/article.php +++ b/patterns/article.php @@ -23,6 +23,14 @@ + + +·
+ + + +Reading time
+ diff --git a/patterns/index-list.php b/patterns/index-list.php index 1f79c4d..df65817 100644 --- a/patterns/index-list.php +++ b/patterns/index-list.php @@ -26,6 +26,14 @@ + + +·
+ + + +Reading time
+