[object Object]

โ† back to Ventura Corridor

iter 121+122: og+JSON-LD on per-vertical /issue/:cat + per-feature TTS audio โ€” /issue/:cat now emits og:type/title/description/url/site_name + twitter:card summary_large_image (image=first feature's /share/:id) + JSON-LD CollectionPage with hasPart NewsArticle list (top 30) + RSS alternate link + JSON Feed alternate link, so per-vertical pages share-render with proper social cards. /api/magazine/:id/audio.aiff renders TTS via macOS 'say -v Samantha -r 180' from headline+subhead+editorial+pull-quote, caches by editorial-hash to data/magazine-audio/, 60s timeout, 24h browser cache; /magazine/:id reader toolbar gets ๐Ÿ”Š Listen link

bd681e7c52cc47611c81c7e356d664570c6403d0 ยท 2026-05-06 18:20:14 -0700 ยท SteveStudio2

Files touched

Diff

commit bd681e7c52cc47611c81c7e356d664570c6403d0
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 18:20:14 2026 -0700

    iter 121+122: og+JSON-LD on per-vertical /issue/:cat + per-feature TTS audio โ€” /issue/:cat now emits og:type/title/description/url/site_name + twitter:card summary_large_image (image=first feature's /share/:id) + JSON-LD CollectionPage with hasPart NewsArticle list (top 30) + RSS alternate link + JSON Feed alternate link, so per-vertical pages share-render with proper social cards. /api/magazine/:id/audio.aiff renders TTS via macOS 'say -v Samantha -r 180' from headline+subhead+editorial+pull-quote, caches by editorial-hash to data/magazine-audio/, 60s timeout, 24h browser cache; /magazine/:id reader toolbar gets ๐Ÿ”Š Listen link
---
 data/magazine-audio/1-6e3ce8d703a7.aiff | Bin 0 -> 2150464 bytes
 src/server/index.ts                     |  87 ++++++++++++++++++++++++++++++++
 2 files changed, 87 insertions(+)

diff --git a/data/magazine-audio/1-6e3ce8d703a7.aiff b/data/magazine-audio/1-6e3ce8d703a7.aiff
new file mode 100644
index 0000000..2c3b79b
Binary files /dev/null and b/data/magazine-audio/1-6e3ce8d703a7.aiff differ
diff --git a/src/server/index.ts b/src/server/index.ts
index f9d5f7d..f898742 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1841,6 +1841,59 @@ app.get('/api/magazine/random', async (req, res) => {
   }
 });
 
+// Per-feature TTS audio โ€” /api/magazine/:id/audio.aiff
+// Uses macOS `say` command. Caches by editorial-hash so we don't re-render every visit.
+import { existsSync as _fs_exists, mkdirSync as _fs_mkdir, statSync as _fs_stat, createReadStream as _fs_stream } from 'node:fs';
+import { spawnSync as _spawn_sync } from 'node:child_process';
+import { createHash as _crypto_hash } from 'node:crypto';
+
+app.get('/api/magazine/:id/audio.aiff', async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    if (!Number.isFinite(id)) return res.status(400).send('bad id');
+    const r = await query(
+      `SELECT mf.headline, mf.subhead, mf.editorial, mf.pull_quote, b.name AS biz_name
+       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
+       WHERE mf.id = $1`,
+      [id]
+    );
+    if (r.rowCount === 0) return res.status(404).send('not found');
+    const f = r.rows[0];
+    const text = [
+      'From The Corridor.',
+      f.headline || f.biz_name,
+      f.subhead || '',
+      f.editorial || '',
+      f.pull_quote ? `Quote: ${f.pull_quote}` : '',
+      `End of feature.`
+    ].filter(Boolean).join('\n\n');
+
+    const audioDir = path.resolve(__dirname, '../../data/magazine-audio');
+    if (!_fs_exists(audioDir)) _fs_mkdir(audioDir, { recursive: true });
+    const hash = _crypto_hash('sha1').update(text).digest('hex').slice(0, 12);
+    const filename = `${id}-${hash}.aiff`;
+    const filepath = path.join(audioDir, filename);
+
+    if (!_fs_exists(filepath)) {
+      // Use a Steve-friendly voice. macOS default voices: Samantha, Karen, Daniel, Alex.
+      const voice = process.env.SAY_VOICE || 'Samantha';
+      const result = _spawn_sync('say', ['-v', voice, '-r', '180', '-o', filepath, text], {
+        timeout: 60_000
+      });
+      if (result.status !== 0 || !_fs_exists(filepath)) {
+        return res.status(500).send('say failed: ' + (result.stderr?.toString() || ''));
+      }
+    }
+    const stat = _fs_stat(filepath);
+    res.setHeader('Content-Type', 'audio/aiff');
+    res.setHeader('Content-Length', String(stat.size));
+    res.setHeader('Cache-Control', 'public, max-age=86400');
+    _fs_stream(filepath).pipe(res);
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
 // Outbound tap counter โ€” /api/magazine/:id/tap?to=sponsor|share|website|other
 app.get('/api/magazine/:id/tap', async (req, res) => {
   try {
@@ -2196,10 +2249,43 @@ app.get(['/issue', '/issue/:cat'], async (req, res) => {
   );
   const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
   const rows = r.rows;
+  const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
+  const issueTitle = monthArchive ? `Archive ยท ${monthArchive}` : (cat ? `${cat} on The Corridor` : `The Corridor ยท Volume I`);
+  const issueDesc = cat
+    ? `${rows.length} ${cat} feature${rows.length === 1 ? '' : 's'} on Ventura Boulevard.`
+    : `${rows.length} stories from Ventura Boulevard's restaurants, professionals, and ateliers.`;
+  const issueUrl = `${baseUrl}/issue${cat ? '/' + cat : (monthArchive ? '/' + monthArchive : '')}`;
   res.setHeader('Content-Type', 'text/html; charset=utf-8');
   res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
 <title>The Corridor ยท ${monthArchive ? esc(monthArchive) + ' archive' : 'Issue'}${cat ? ' ยท ' + esc(cat) : ''}</title>
 <meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="description" content="${esc(issueDesc)}">
+<meta property="og:type" content="website">
+<meta property="og:title" content="${esc(issueTitle)}">
+<meta property="og:description" content="${esc(issueDesc)}">
+<meta property="og:url" content="${issueUrl}">
+<meta property="og:site_name" content="The Corridor">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="${esc(issueTitle)}">
+<meta name="twitter:description" content="${esc(issueDesc)}">
+${rows[0] ? `<meta property="og:image" content="${baseUrl}/share/${rows[0].id}"><meta name="twitter:image" content="${baseUrl}/share/${rows[0].id}">` : ''}
+${cat ? `<link rel="alternate" type="application/rss+xml" title="${esc(issueTitle)} feed" href="${baseUrl}/issue/${cat}/feed.xml">` : `<link rel="alternate" type="application/rss+xml" title="The Corridor feed" href="${baseUrl}/issue/feed.xml">`}
+<link rel="alternate" type="application/json" title="The Corridor JSON Feed" href="${baseUrl}/api/magazine.json">
+<script type="application/ld+json">${JSON.stringify({
+  '@context': 'https://schema.org',
+  '@type': 'CollectionPage',
+  name: issueTitle,
+  description: issueDesc,
+  url: issueUrl,
+  publisher: { '@type': 'Organization', name: 'The Corridor' },
+  numberOfItems: rows.length,
+  hasPart: rows.slice(0, 30).map((f: any) => ({
+    '@type': 'NewsArticle',
+    headline: f.headline || f.biz_name,
+    url: `${baseUrl}/magazine/${f.id}`,
+    articleSection: f.category_tag,
+  }))
+})}</script>
 <style>
 @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
 :root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
@@ -2984,6 +3070,7 @@ footer{margin-top:48px;padding:24px 0;text-align:center;font-size:10px;letter-sp
   <div class="breadcrumb"><a href="/magazine.html">โ† The Corridor</a> ยท ${esc(f.category_tag || 'feature')}</div>
   <div>
     <a href="/api/magazine/${id}/tap?to=share&dest=${encodeURIComponent(`${baseUrl}/share/${id}`)}" target="_blank">๐Ÿ“ฑ IG share-card</a>
+    <a href="/api/magazine/${id}/audio.aiff" target="_blank" title="Listen โ€” Samantha reads this feature">๐Ÿ”Š Listen</a>
     <a href="/api/magazine/${id}/tap?to=sponsor&dest=${encodeURIComponent(`${baseUrl}/sponsor/${id}`)}" style="color:var(--accent,#6a3a1a);border-color:var(--accent,#6a3a1a)">โญ Sponsor</a>
     <a href="javascript:window.print()">๐Ÿ–จ Print</a>
     <a href="/pitches.html?id=${f.business_id}" target="_blank">Pipeline โ†—</a>

โ† 93a2f1a iter 120: public /find โ€” reader-facing finder by suite/stree  ยท  back to Ventura Corridor  ยท  iter 123+124: AIFFโ†’M4A audio conversion + scoreboard top-tap ead33e9 โ†’