diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index 8edd5f3..9e4b7b7 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -186,6 +186,48 @@ ]); echo " settings: home -> home, brand -> The Copper Table\n"; +// --- 3d) The Journal (a `blog` collection, served by the official Blog plugin) --- +// A plain content collection named `blog`; the Blog plugin turns it into a real +// blog (per-post SEO head, an RSS feed at /ext/blog/feed.xml, /tag/{tag} archives), +// and the theme renders the list + detail (collection-blog / entry-blog). +$collections->create('blog', 'Journal', '#', '', ['kind' => 'collection', 'permissions' => []], [ + ['handle' => 'summary', 'label' => 'Summary', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'body', 'label' => 'Body', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'cover', 'label' => 'Cover', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'tags', 'label' => 'Tags', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'canonical_url', 'label' => 'Canonical URL', 'type' => 'text', 'required' => false, 'options' => []], +]); +$blogCol = $repo->findByHandle('blog'); +$posts = [ + [ + 'title' => 'The spring menu is here', + 'date' => '2026-03-18 09:00:00', + 'tags' => 'menu, seasonal', + 'summary' => 'Peas, asparagus and the first herbs of the year are on. A few winter plates step down; the salmon stays.', + 'body' => "The season turned, and so did the pass. From this week the board leans green: sweet peas, grilled asparagus, and the first soft herbs from the garden up the road.\n\n## What is new\n\n- **Guacamole**, hand-mashed with lime and warm corn chips, back on the appetizers.\n- **Grilled Salmon** with seasonal greens and brown butter, which is not going anywhere.\n- A lighter **Apple Crisp** to finish, oat crumble and vanilla cream.\n\nThe short list is the point. We cook what is good this week and let it change when the market does. Come hungry.", + ], + [ + 'title' => 'Where the fish comes from', + 'date' => '2026-02-27 09:00:00', + 'tags' => 'sourcing, kitchen', + 'summary' => 'Our salmon is a day-boat catch from the same supplier we have used since 2014. Here is why that matters on the plate.', + 'body' => "People ask about the salmon, so here is the honest answer.\n\nIt comes in from a day-boat supplier we have worked with since we opened in 2014. Landed one day, on your plate the next. No middle week in a freezer.\n\n## Why we bother\n\nFresh fish needs almost nothing done to it. A hot pan, a little brown butter, the greens that are good that morning. When the raw material is right, the kitchen's job is to stay out of the way.\n\nIf a delivery is not up to it, it does not go on the board that night. That is the whole rule.", + ], + [ + 'title' => 'You can now order online', + 'date' => '2026-01-15 09:00:00', + 'tags' => 'news, ordering', + 'summary' => 'Pickup orders straight from the menu, paid on the site. The same system the floor runs on, opened up to the street.', + 'body' => "Something we could not do ten years ago: you can now order from the menu and pay right here on the site, for pickup.\n\nIt runs on the very same system the floor uses for dine-in orders, so the kitchen sees your ticket the moment it lands, in the same queue as the room. No second screen, no lost slips.\n\n## How it works\n\n- Browse the [menu](/menu_items) and add what you want.\n- Leave a name and phone, pay, and pick a time.\n- We fire it when it is due, not the second it is placed, so it is hot when you arrive.\n\nDine-in is still the heart of the place. This is just the door held open a little wider.", + ], +]; +foreach ($posts as $p) { + $entries->save($blogCol, new EntryInput($p['title'], $slug($p['title']), 'published', [ + 'summary' => $p['summary'], 'body' => $p['body'], 'cover' => '', 'tags' => $p['tags'], 'canonical_url' => '', + ], $p['date']), null, null); +} +echo " journal: 1 blog collection, " . count($posts) . " posts\n"; + // --- 4) Live floor / orders / reservations --------------------------------- $storage = static fn (): PluginStorage => new PluginStorage($db); $tables = new Tables($storage); diff --git a/theme/assets/app.css b/theme/assets/app.css index 304cd76..1aa7acb 100644 --- a/theme/assets/app.css +++ b/theme/assets/app.css @@ -217,3 +217,25 @@ img { max-width: 100%; height: auto; } .brand-name { display: none; } body { font-size: 16px; } } + +/* ---------- Journal (blog) ---------- */ +.journal-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2.25rem; } +.journal-item { border-bottom: 1px solid var(--rule); padding-bottom: 2rem; } +.journal-item:last-child { border-bottom: 0; padding-bottom: 0; } +.journal-title { font-family: var(--serif); font-weight: 400; font-size: 1.7rem; margin: 0; color: var(--text); } +.journal-link { display: inline-block; } +.journal-link:hover .journal-title { color: var(--gold-bright); } +.journal-date { color: var(--muted); font-size: .78rem; letter-spacing: .12em; text-transform: uppercase; margin: .45rem 0 0; } +.journal-summary { color: var(--muted); margin: .75rem 0 0; max-width: 54ch; } +.journal-more { display: inline-block; margin-top: .85rem; font-size: .9rem; } +.journal-article-title { font-family: var(--serif); font-weight: 400; font-size: 2.5rem; line-height: 1.08; margin: .5rem 0 0; text-wrap: balance; } +.journal-article .eyebrow a { color: var(--gold); } +.journal-back { margin-top: 2.75rem; padding-top: 1.5rem; border-top: 1px solid var(--rule); } +.prose { margin-top: 1.6rem; font-size: 1.06rem; } +.prose > * + * { margin-top: 1.1rem; } +.prose h2 { font-family: var(--serif); font-weight: 400; font-size: 1.55rem; margin-top: 2.1rem; } +.prose h3 { font-family: var(--serif); font-weight: 400; font-size: 1.25rem; margin-top: 1.7rem; } +.prose ul { margin: 1.1rem 0 0 1.2rem; } +.prose li + li { margin-top: .35rem; } +.prose a { text-decoration: underline; text-underline-offset: 2px; } +.prose code { background: var(--surface); padding: .12rem .38rem; border-radius: 4px; font-size: .9em; } diff --git a/theme/templates/collection-blog.php b/theme/templates/collection-blog.php new file mode 100644 index 0000000..509c401 --- /dev/null +++ b/theme/templates/collection-blog.php @@ -0,0 +1,43 @@ +> $entries + * @var callable $e + */ +?> + diff --git a/theme/templates/entry-blog.php b/theme/templates/entry-blog.php new file mode 100644 index 0000000..0c0733b --- /dev/null +++ b/theme/templates/entry-blog.php @@ -0,0 +1,61 @@ + + * (canonical, Open Graph article, Twitter, JSON-LD); this template renders the body. + * The body is a small, safe Markdown subset rendered in-theme (the restaurant image + * ships no Markdown plugin), escaped-first so post content can never inject HTML. + * + * @var array $entry + * @var callable $e + */ +$fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; +$body = (string) ($fields['body'] ?? ''); +$date = (string) ($entry['published_at'] ?? ''); + +$inline = static function (string $s) use ($e): string { + $s = $e($s); + $s = (string) preg_replace('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', '$1', $s); + $s = (string) preg_replace('/\*\*([^*]+)\*\*/', '$1', $s); + $s = (string) preg_replace('/(?$1', $s); + return (string) preg_replace('/`([^`]+)`/', '$1', $s); +}; +$render = static function (string $src) use ($inline): string { + $html = ''; + foreach (preg_split('/\n{2,}/', trim($src)) ?: [] as $block) { + $block = trim($block); + if ($block === '') { + continue; + } + if (preg_match('/^###\s+(.+)/s', $block, $m) === 1) { + $html .= '

' . $inline(trim($m[1])) . '

'; + continue; + } + if (preg_match('/^##\s+(.+)/s', $block, $m) === 1) { + $html .= '

' . $inline(trim($m[1])) . '

'; + continue; + } + $lines = preg_split('/\n/', $block) ?: []; + $bullets = array_filter($lines, static fn (string $l): bool => preg_match('/^\s*-\s+/', $l) === 1); + if ($lines !== [] && count($bullets) === count($lines)) { + $html .= ''; + continue; + } + $html .= '

' . $inline(implode(' ', array_map('trim', $lines))) . '

'; + } + return $html; +}; +?> +
+ +
diff --git a/theme/templates/header.php b/theme/templates/header.php index ea252a7..915c4a1 100644 --- a/theme/templates/header.php +++ b/theme/templates/header.php @@ -16,6 +16,7 @@ diff --git a/theme/templates/layout.php b/theme/templates/layout.php index a7de0dd..0ae06eb 100644 --- a/theme/templates/layout.php +++ b/theme/templates/layout.php @@ -16,6 +16,11 @@ $pageTitle = isset($title) && $title !== '' && $title !== $appName ? $title . ' · ' . $appName : $appName; $meta = $meta ?? []; $cssVer = substr((string) @hash_file('crc32b', __DIR__ . '/../assets/app.css'), 0, 8); +// On blog/tag pages the Blog plugin contributes the full, correct (canonical, +// Open Graph article, Twitter, JSON-LD), so the shell defers its own canonical/OG +// there to avoid emitting them twice. +$path = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ''); +$pluginOwnsHead = str_starts_with($path, '/blog') || str_starts_with($path, '/tag'); ?> @@ -23,15 +28,17 @@ <?= $e($pageTitle) ?> - - + + + + + + + + + + - - - - - -