Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions deploy/seed-demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions theme/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
43 changes: 43 additions & 0 deletions theme/templates/collection-blog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/**
* The Journal (/blog) — the restaurant's blog, served by the official Blog plugin
* over a plain `blog` collection. Published posts, newest first.
*
* @var array{handle:string,name:string} $collection
* @var list<array<string,mixed>> $entries
* @var callable $e
*/
?>
<section class="menu-page">
<div class="wrap">
<header class="menu-head">
<p class="eyebrow">The Copper Table &middot; Journal</p>
<h1>From the pass</h1>
<p class="lede">Notes from the kitchen &mdash; what&rsquo;s on, where it comes from, and what&rsquo;s new.</p>
</header>

<?php if ($entries === []): ?>
<p class="empty">No entries yet. Check back soon.</p>
<?php endif; ?>

<ul class="journal-list">
<?php foreach ($entries as $post): ?>
<?php
$fields = is_array($post['fields'] ?? null) ? $post['fields'] : [];
$slug = (string) ($post['slug'] ?? '');
$summary = trim((string) ($fields['summary'] ?? ''));
$date = (string) ($post['published_at'] ?? '');
?>
<li class="journal-item">
<a class="journal-link" href="/blog/<?= $e($slug) ?>">
<h2 class="journal-title"><?= $e((string) ($post['title'] ?? '')) ?></h2>
</a>
<?php if ($date !== ''): ?><p class="journal-date"><?= $e(date('F j, Y', (int) strtotime($date))) ?></p><?php endif; ?>
<?php if ($summary !== ''): ?><p class="journal-summary"><?= $e($summary) ?></p><?php endif; ?>
<a class="journal-more" href="/blog/<?= $e($slug) ?>">Read the entry &rarr;</a>
</li>
<?php endforeach; ?>
</ul>
</div>
</section>
61 changes: 61 additions & 0 deletions theme/templates/entry-blog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/**
* A single Journal post (/blog/{slug}). The Blog plugin contributes the SEO <head>
* (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<string,mixed> $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)]+)\)/', '<a href="$2" rel="noopener">$1</a>', $s);
$s = (string) preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $s);
$s = (string) preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/', '<em>$1</em>', $s);
return (string) preg_replace('/`([^`]+)`/', '<code>$1</code>', $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 .= '<h3>' . $inline(trim($m[1])) . '</h3>';
continue;
}
if (preg_match('/^##\s+(.+)/s', $block, $m) === 1) {
$html .= '<h2>' . $inline(trim($m[1])) . '</h2>';
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 .= '<ul>';
foreach ($lines as $l) {
$html .= '<li>' . $inline(trim((string) preg_replace('/^\s*-\s+/', '', $l))) . '</li>';
}
$html .= '</ul>';
continue;
}
$html .= '<p>' . $inline(implode(' ', array_map('trim', $lines))) . '</p>';
}
return $html;
};
?>
<article class="page journal-article">
<div class="wrap measure">
<p class="eyebrow"><a href="/blog">The Copper Table &middot; Journal</a></p>
<h1 class="journal-article-title"><?= $e((string) ($entry['title'] ?? '')) ?></h1>
<?php if ($date !== ''): ?><p class="journal-date"><?= $e(date('F j, Y', (int) strtotime($date))) ?></p><?php endif; ?>
<div class="prose"><?= $render($body) ?></div>
<p class="journal-back"><a href="/blog">&larr; All entries</a></p>
</div>
</article>
1 change: 1 addition & 0 deletions theme/templates/header.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<nav class="site-nav" aria-label="Primary">
<a href="/">Home</a>
<a href="/menu_items">Menu</a>
<a href="/blog">Journal</a>
<a href="/order">Order</a>
</nav>
</div>
Expand Down
23 changes: 15 additions & 8 deletions theme/templates/layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,29 @@
$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 <head> (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');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $e($pageTitle) ?></title>
<?php if (!empty($meta['description'])): ?>
<meta name="description" content="<?= $e($meta['description']) ?>">
<?php if (!$pluginOwnsHead): ?>
<?php if (!empty($meta['description'])): ?>
<meta name="description" content="<?= $e($meta['description']) ?>">
<?php endif; ?>
<?php if (!empty($meta['canonical'])): ?>
<link rel="canonical" href="<?= $e($meta['canonical']) ?>">
<?php endif; ?>
<meta property="og:site_name" content="<?= $e($appName) ?>">
<meta property="og:title" content="<?= $e($pageTitle) ?>">
<meta property="og:type" content="<?= $e($meta['og_type'] ?? 'website') ?>">
<?php endif; ?>
<?php if (!empty($meta['canonical'])): ?>
<link rel="canonical" href="<?= $e($meta['canonical']) ?>">
<?php endif; ?>
<meta property="og:site_name" content="<?= $e($appName) ?>">
<meta property="og:title" content="<?= $e($pageTitle) ?>">
<meta property="og:type" content="<?= $e($meta['og_type'] ?? 'website') ?>">
<?= $head ?? '' ?>
<link rel="stylesheet" href="/theme/assets/app.css?v=<?= $e($cssVer) ?>">
</head>
Expand Down
Loading