@sdelsad/commodity-desk-daily 1.0.18 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build_post.py ADDED
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Soft Commodity Trading toolkit — essay builder.
4
+
5
+ The site's Writing section reads `writing/index.json` from the bucket. This
6
+ renders one essay to `writing/<slug>.html`, upserts its entry in that manifest
7
+ and rebuilds `writing/index.html`, so publishing a piece is three uploads and
8
+ no hand-written HTML.
9
+
10
+ python3 build_post.py --notes essay.md --slug basis-is-a-verb \
11
+ --title "Basis Is a Verb" \
12
+ --dek "One sentence under the headline." \
13
+ --date "14 August 2026" --outdir writing
14
+
15
+ Writes:
16
+ writing/basis-is-a-verb.html the essay
17
+ writing/index.json the manifest the site reads
18
+ writing/index.html the standalone index of everything
19
+
20
+ Publish all three with the connector's publish_asset, at those exact paths:
21
+
22
+ publish_asset(path="writing/basis-is-a-verb.html", ...)
23
+ publish_asset(path="writing/index.json", content_type="application/json")
24
+ publish_asset(path="writing/index.html", ...)
25
+
26
+ The essay uses the same renderer, palette and typography as the episode pages,
27
+ so the reading surfaces match. Zero runtime dependencies.
28
+ """
29
+ import argparse
30
+ import html
31
+ import json
32
+ import os
33
+ import re
34
+ import sys
35
+
36
+ try:
37
+ import build_page as bp
38
+ except ImportError:
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+ import build_page as bp
41
+
42
+ SITE_URL = bp.SITE_URL
43
+ BUCKET = bp.BUCKET
44
+ WRITING_URL = f"{BUCKET}/writing"
45
+ AUTHOR = bp.AUTHOR
46
+
47
+
48
+ def read_time(md):
49
+ words = len(re.findall(r"\S+", re.sub(r"```.*?```", "", md, flags=re.DOTALL)))
50
+ return max(1, round(words / 230))
51
+
52
+
53
+ POST_TEMPLATE = """<!DOCTYPE html>
54
+ <html lang="en" data-theme="light">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <title>{title} · Sébastien Delsad</title>
59
+ <meta name="description" content="{dek_attr}">
60
+ <meta name="author" content="{author}">
61
+ <link rel="canonical" href="{url}">
62
+ <meta property="og:type" content="article">
63
+ <meta property="og:title" content="{title}">
64
+ <meta property="og:description" content="{dek_attr}">
65
+ <meta property="og:url" content="{url}">
66
+ <meta property="og:image" content="{ogimage}">
67
+ <meta property="article:published_time" content="{iso}">
68
+ <meta name="twitter:card" content="summary_large_image">
69
+ <meta name="twitter:image" content="{ogimage}">
70
+ <meta name="theme-color" content="#faf7f1" media="(prefers-color-scheme: light)">
71
+ <meta name="theme-color" content="#14110e" media="(prefers-color-scheme: dark)">
72
+ <link rel="icon" href="{favicon}">
73
+ <script type="application/ld+json">
74
+ {jsonld}
75
+ </script>
76
+ <style>{css}</style>
77
+ <script>{headjs}</script>
78
+ </head>
79
+ <body>
80
+ <div id="bar" aria-hidden="true"></div>
81
+ <button class="themebtn" type="button" aria-label="Switch colour theme">
82
+ <svg class="moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
83
+ <svg class="sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
84
+ <span class="lbl"></span>
85
+ </button>
86
+ <div class="wrap">
87
+ <header>
88
+ <div class="masthead">
89
+ <a href="{site}">Sébastien Delsad</a>
90
+ <span class="epno">Writing</span>
91
+ </div>
92
+ <h1>{title}</h1>
93
+ {dekblock}
94
+ <div class="meta">{date}{datesep}<b>{minutes} min read</b></div>
95
+ {toc}
96
+ </header>
97
+ <main>
98
+ {body}
99
+ </main>
100
+ <nav class="epnav" aria-label="More">
101
+ <a class="epprev" href="index.html"><span class="dir">← All writing</span>
102
+ <span class="ept">Essays</span></a>
103
+ <a class="epnext" href="{site}"><span class="dir">Home →</span>
104
+ <span class="ept">Sébastien Delsad</span></a>
105
+ </nav>
106
+ <footer>
107
+ <span class="sig"><b>Sébastien Delsad</b> — commodity trading &amp; data science.</span>
108
+ <span><a href="{site}">Home</a> · <a href="{feed}">Podcast RSS</a></span>
109
+ </footer>
110
+ </div>
111
+ <script>{js}</script>
112
+ </body>
113
+ </html>
114
+ """
115
+
116
+ INDEX_TEMPLATE = """<!DOCTYPE html>
117
+ <html lang="en" data-theme="light">
118
+ <head>
119
+ <meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width, initial-scale=1">
121
+ <title>Writing · Sébastien Delsad</title>
122
+ <meta name="description" content="Essays on commodity trading, data science and the machinery behind the daily briefing.">
123
+ <link rel="canonical" href="{url}">
124
+ <meta property="og:type" content="website">
125
+ <meta property="og:title" content="Writing · Sébastien Delsad">
126
+ <meta property="og:description" content="Essays on commodity trading, data science and the machinery behind the daily briefing.">
127
+ <meta property="og:url" content="{url}">
128
+ <meta property="og:image" content="{ogimage}">
129
+ <meta name="twitter:card" content="summary_large_image">
130
+ <meta name="theme-color" content="#faf7f1" media="(prefers-color-scheme: light)">
131
+ <meta name="theme-color" content="#14110e" media="(prefers-color-scheme: dark)">
132
+ <link rel="icon" href="{favicon}">
133
+ <style>{css}
134
+ .postlist{{list-style:none;margin:36px 0 0;padding:0}}
135
+ .postlist li{{border-top:1px solid var(--line);padding:26px 0}}
136
+ .postlist li:last-child{{border-bottom:1px solid var(--line)}}
137
+ .postlist .pm{{font:12px/1 var(--sans);letter-spacing:.12em;text-transform:uppercase;
138
+ color:var(--ink-soft);margin:0 0 9px}}
139
+ .postlist h2{{font:400 25px/1.25 var(--serif);color:var(--ink);margin:0 0 8px;
140
+ border:none;padding:0;font-variant-caps:normal;letter-spacing:-.015em}}
141
+ .postlist h2 a{{text-decoration:none;color:inherit;
142
+ border-bottom:1px solid var(--line)}}
143
+ .postlist h2 a:hover{{border-color:var(--gold)}}
144
+ .postlist p{{color:var(--ink-soft);margin:0;font-size:17px}}
145
+ .nothing{{border:1px dashed var(--line);border-radius:12px;padding:28px 24px;
146
+ margin-top:32px;color:var(--ink-soft)}}
147
+ .nothing p{{margin:0 0 10px}} .nothing p:last-child{{margin:0}}
148
+ </style>
149
+ <script>{headjs}</script>
150
+ </head>
151
+ <body>
152
+ <button class="themebtn" type="button" aria-label="Switch colour theme">
153
+ <svg class="moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
154
+ <svg class="sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
155
+ <span class="lbl"></span>
156
+ </button>
157
+ <div class="wrap">
158
+ <header>
159
+ <div class="masthead">
160
+ <a href="{site}">Sébastien Delsad</a>
161
+ <span class="epno">Writing</span>
162
+ </div>
163
+ <h1>Essays</h1>
164
+ <p class="dek">Longer pieces that are not tied to a day's market — notes on
165
+ method, on the machinery behind the shows, and on whatever the reading
166
+ turns up.</p>
167
+ </header>
168
+ <main>
169
+ {list}
170
+ </main>
171
+ <footer>
172
+ <span class="sig"><b>Sébastien Delsad</b> — commodity trading &amp; data science.</span>
173
+ <span><a href="{site}">Home</a> · <a href="{feed}">Podcast RSS</a></span>
174
+ </footer>
175
+ </div>
176
+ <script>{js}</script>
177
+ </body>
178
+ </html>
179
+ """
180
+
181
+
182
+ def load_manifest(path):
183
+ if not os.path.exists(path):
184
+ return []
185
+ try:
186
+ data = json.load(open(path, encoding="utf-8"))
187
+ except (ValueError, OSError):
188
+ return []
189
+ posts = data if isinstance(data, list) else data.get("posts", [])
190
+ return [p for p in posts if isinstance(p, dict) and p.get("slug")]
191
+
192
+
193
+ def sort_posts(posts):
194
+ return sorted(posts, key=lambda p: (bp.iso_date(p.get("date", "")) or "", p["slug"]),
195
+ reverse=True)
196
+
197
+
198
+ def index_html(posts):
199
+ if not posts:
200
+ return ('<div class="nothing"><p><strong>Nothing published yet.</strong> '
201
+ 'The first essay is still in draft.</p>'
202
+ '<p>In the meantime, every episode of <a href="' + SITE_URL +
203
+ '">Soft Commodity Trading</a> carries a written edition — the '
204
+ 'same lesson, recomposed for the eye.</p></div>')
205
+ items = []
206
+ for p in posts:
207
+ meta = " · ".join(x for x in (p.get("date", ""),
208
+ f"{p['minutes']} min read" if p.get("minutes") else "")
209
+ if x)
210
+ items.append(
211
+ f'<li>{f"<p class=\'pm\'>{html.escape(meta)}</p>" if meta else ""}'
212
+ f'<h2><a href="{html.escape(p["slug"])}.html">'
213
+ f'{html.escape(p.get("title", p["slug"]))}</a></h2>'
214
+ + (f'<p>{html.escape(p["dek"])}</p>' if p.get("dek") else "")
215
+ + "</li>")
216
+ return f'<ol class="postlist">{"".join(items)}</ol>'
217
+
218
+
219
+ def main():
220
+ ap = argparse.ArgumentParser()
221
+ ap.add_argument("--notes", help="the essay in markdown; omit to only "
222
+ "rebuild the index from the manifest")
223
+ ap.add_argument("--slug", default="", help="the filename, without .html")
224
+ ap.add_argument("--title", default="")
225
+ ap.add_argument("--dek", default="")
226
+ ap.add_argument("--date", default="", help='e.g. "14 August 2026"')
227
+ ap.add_argument("--minutes", type=int, default=0)
228
+ ap.add_argument("--outdir", default="writing")
229
+ args = ap.parse_args()
230
+
231
+ os.makedirs(args.outdir, exist_ok=True)
232
+ manifest_path = os.path.join(args.outdir, "index.json")
233
+ posts = load_manifest(manifest_path)
234
+
235
+ if args.notes:
236
+ if not args.slug or not args.title:
237
+ print("--slug and --title are required with --notes", file=sys.stderr)
238
+ return 1
239
+ md = open(args.notes, encoding="utf-8").read()
240
+ md = re.sub(r"\A\s*#\s+.*?\n", "", md, count=1) # the page has a header
241
+ body = bp.tidy_rules(bp.render(md))
242
+ minutes = args.minutes or read_time(md)
243
+ iso = bp.iso_date(args.date)
244
+ url = f"{WRITING_URL}/{args.slug}.html"
245
+ data = {
246
+ "@context": "https://schema.org", "@type": "BlogPosting",
247
+ "headline": args.title, "url": url,
248
+ "description": args.dek or args.title,
249
+ "author": {"@type": "Person", "name": AUTHOR},
250
+ "image": f"{BUCKET}/og.png",
251
+ "wordCount": len(re.findall(r"\S+", md)),
252
+ }
253
+ if iso:
254
+ data["datePublished"] = iso
255
+ page = POST_TEMPLATE.format(
256
+ title=html.escape(args.title),
257
+ dek_attr=html.escape(args.dek or args.title, quote=True),
258
+ dekblock=(f'<p class="dek">{html.escape(args.dek)}</p>'
259
+ if args.dek.strip() else ""),
260
+ date=html.escape(args.date), datesep=" · " if args.date else "",
261
+ minutes=minutes, url=url, iso=iso, site=SITE_URL, feed=bp.FEED_URL,
262
+ ogimage=f"{BUCKET}/og.png", favicon=bp.FAVICON,
263
+ author=html.escape(AUTHOR, quote=True),
264
+ jsonld=json.dumps(data, ensure_ascii=False, indent=2),
265
+ css=bp.CSS, js=bp.JS, headjs=bp.HEAD_JS, toc=bp.toc_html(), body=body)
266
+ out = os.path.join(args.outdir, f"{args.slug}.html")
267
+ open(out, "w", encoding="utf-8").write(page)
268
+ print(f"[post] {out} | {len(page):,} bytes")
269
+
270
+ posts = [p for p in posts if p["slug"] != args.slug]
271
+ posts.append({"slug": args.slug, "title": args.title, "dek": args.dek,
272
+ "date": args.date, "minutes": minutes})
273
+
274
+ posts = sort_posts(posts)
275
+ with open(manifest_path, "w", encoding="utf-8") as fh:
276
+ json.dump(posts, fh, ensure_ascii=False, indent=2)
277
+ fh.write("\n")
278
+ print(f"[post] {manifest_path} | {len(posts)} post(s)")
279
+
280
+ idx = INDEX_TEMPLATE.format(
281
+ url=f"{WRITING_URL}/index.html", site=SITE_URL, feed=bp.FEED_URL,
282
+ ogimage=f"{BUCKET}/og.png", favicon=bp.FAVICON,
283
+ css=bp.CSS, js=bp.JS, headjs=bp.HEAD_JS, list=index_html(posts))
284
+ idx_path = os.path.join(args.outdir, "index.html")
285
+ open(idx_path, "w", encoding="utf-8").write(idx)
286
+ print(f"[post] {idx_path} | {len(idx):,} bytes")
287
+ return 0
288
+
289
+
290
+ if __name__ == "__main__":
291
+ sys.exit(main())