ghostwriter-muse-pages 11.0.0 → 12.0.0
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_pages.py +183 -9
- package/kit.js +216 -10
- package/package.json +1 -1
package/build_pages.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Build the book's pages (Overview, Book Setup, Voice, Stories, Notes, Outline, Manuscript, Ghostwriter).
|
|
2
|
+
"""Build the book's pages (Overview, Book Setup, Voice, Stories, Notes, Outline, Manuscript, Cover, Ghostwriter).
|
|
3
3
|
|
|
4
4
|
Reads <book>/Dashboard/book.json the status and outline, written by the ghostwriter
|
|
5
5
|
<book>/START-HERE.md only its "Method:" line, when book.json does not say which version is in use
|
|
@@ -8,23 +8,25 @@ Reads <book>/Dashboard/book.json the status and outline, written by t
|
|
|
8
8
|
<book>/2-Plan/04-story-bank.md the Stories page, read as it is
|
|
9
9
|
<book>/2-Plan/00-notes.md the notes board, read as it is
|
|
10
10
|
<book>/5-Manuscript/*.md the chapters, read as they are
|
|
11
|
-
|
|
11
|
+
<book>/2-Plan/cover/cover.json the Cover page, with the picture it names if the author brought one
|
|
12
|
+
Writes <book>/Dashboard/Overview.html, Setup.html, Voice.html, Stories.html, Notes.html, Outline.html, Manuscript.html, Cover.html, Ghostwriter.html
|
|
13
|
+
<book>/Dashboard/cover-picture.* a copy of the author's own cover picture, for the pages in the folder
|
|
12
14
|
<book>/Dashboard/_live/ only with --live: book-dashboard.html to publish, plus data/*.json to load into it
|
|
13
15
|
|
|
14
16
|
Usage: python3 build_pages.py --book "/path/to/Book Folder" [--live]
|
|
15
17
|
python3 build_pages.py --version
|
|
16
|
-
Python 3 standard library only. This one file is the whole builder: the design of the pages is not in it.
|
|
18
|
+
Python 3 standard library only (a large cover picture is made smaller when the Pillow library happens to be installed). This one file is the whole builder: the design of the pages is not in it.
|
|
17
19
|
Every page it writes is a few lines that load the page code, at one exact version, from the address in KIT_URL.
|
|
18
20
|
"""
|
|
19
21
|
import argparse, json, os, re, sys
|
|
20
22
|
|
|
21
|
-
BUILDER = "0.
|
|
22
|
-
KIT_VERSION = "
|
|
23
|
+
BUILDER = "0.9.0" # this builder's own version
|
|
24
|
+
KIT_VERSION = "12.0.0" # the exact version of the page code every page loads
|
|
23
25
|
KIT_URL = "https://cdn.jsdelivr.net/npm/ghostwriter-muse-pages@" + KIT_VERSION + "/kit.js"
|
|
24
26
|
FONTS_URL = "https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Inter:wght@400;500;600&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
|
25
27
|
GHOSTWRITERS = {"lawrence", "kenji", "saira", "octavio", "zora", "boo", "imani", "indigo", "idris", "ryu", "marcus-aurelius", "frederick-douglass", "jane-austen",
|
|
26
28
|
"li-bai", "mary-shelley", "murasaki-shikibu", "phillis-wheatley", "rumi", "sor-juana", "william-shakespeare"}
|
|
27
|
-
PAGES = {"overview": "Overview.html", "setup": "Setup.html", "voice": "Voice.html", "stories": "Stories.html", "notes": "Notes.html", "outline": "Outline.html", "manuscript": "Manuscript.html", "ghostwriter": "Ghostwriter.html"}
|
|
29
|
+
PAGES = {"overview": "Overview.html", "setup": "Setup.html", "voice": "Voice.html", "stories": "Stories.html", "notes": "Notes.html", "outline": "Outline.html", "manuscript": "Manuscript.html", "cover": "Cover.html", "ghostwriter": "Ghostwriter.html"}
|
|
28
30
|
STAGE_COUNT = 10
|
|
29
31
|
PAGE_KIT = int(KIT_VERSION.split(".")[0]) # the page version written beside the live link in START-HERE.md
|
|
30
32
|
STORY_FIELDS = {"home": "home", "role": "role", "when": "when", "narrative": "narrative", "how it ended": "ended", "teaching point": "point", "people": "people",
|
|
@@ -374,6 +376,157 @@ def read_stories(plan, problems):
|
|
|
374
376
|
return {"status": status, "confirmed": confirmed, "items": items}
|
|
375
377
|
|
|
376
378
|
|
|
379
|
+
COVER_LAYOUTS = ("paper", "bold", "quiet")
|
|
380
|
+
COVER_FONTS = {"Fraunces", "Cormorant Garamond", "Playfair Display", "DM Serif Display", "Libre Baskerville", "Lora", "Archivo Black", "Archivo", "Bebas Neue", "Oswald", "Space Grotesk", "Inter"}
|
|
381
|
+
HEX = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
382
|
+
VIEWBOX = re.compile(r"^\s*-?\d+(?:\.\d+)?(?:[\s,]+-?\d+(?:\.\d+)?){3}\s*$")
|
|
383
|
+
COVER_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$")
|
|
384
|
+
SVG_BAD = re.compile(r"<\s*(?:script|foreignObject|image|use|a|text|tspan|style|iframe|animate\w*|set|svg)\b|\bon[a-z]+\s*=|href\s*=|javascript:|<!|<\?", re.I)
|
|
385
|
+
PART = 180000 # letters of a picture carried by one document on the live page
|
|
386
|
+
PICTURE_MAX = 1500000 # bytes: the largest picture the pages carry
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def picture_kind(data):
|
|
390
|
+
if data[:3] == b"\xff\xd8\xff":
|
|
391
|
+
return "image/jpeg", "jpg"
|
|
392
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
393
|
+
return "image/png", "png"
|
|
394
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
395
|
+
return "image/webp", "webp"
|
|
396
|
+
if data[:4] == b"GIF8":
|
|
397
|
+
return "image/gif", "gif"
|
|
398
|
+
return None, None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def shrink(data, mime):
|
|
402
|
+
"""Make a large picture smaller, if this computer happens to have the Pillow library. Without it the picture is used as it is."""
|
|
403
|
+
if mime == "image/gif":
|
|
404
|
+
return data, mime
|
|
405
|
+
try:
|
|
406
|
+
import io
|
|
407
|
+
from PIL import Image, ImageOps
|
|
408
|
+
im = Image.open(io.BytesIO(data))
|
|
409
|
+
if max(im.size) <= 1500 and len(data) <= 350000:
|
|
410
|
+
return data, mime
|
|
411
|
+
im = ImageOps.exif_transpose(im)
|
|
412
|
+
im.thumbnail((1500, 1500))
|
|
413
|
+
buf = io.BytesIO()
|
|
414
|
+
im.convert("RGB").save(buf, "JPEG", quality=86, optimize=True)
|
|
415
|
+
return (buf.getvalue(), "image/jpeg") if len(buf.getvalue()) < len(data) else (data, mime)
|
|
416
|
+
except Exception:
|
|
417
|
+
return data, mime
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cover_lettering(src, where, problems):
|
|
421
|
+
"""The typefaces, colors, and line breaks of one cover. Anything not on the lists is left out, and the page uses its own choice."""
|
|
422
|
+
out = {}
|
|
423
|
+
for k in ("titleFont", "textFont"):
|
|
424
|
+
if src.get(k):
|
|
425
|
+
if src[k] in COVER_FONTS:
|
|
426
|
+
out[k] = src[k]
|
|
427
|
+
else:
|
|
428
|
+
problems.append('cover.json: %s names the typeface "%s", which the pages do not carry. It is left out. Use one of: %s.' % (where, src[k], ", ".join(sorted(COVER_FONTS))))
|
|
429
|
+
if isinstance(src.get("titleLines"), list) and 1 <= len(src["titleLines"]) <= 6:
|
|
430
|
+
out["titleLines"] = [str(x) for x in src["titleLines"]]
|
|
431
|
+
if isinstance(src.get("caps"), bool):
|
|
432
|
+
out["caps"] = src["caps"]
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def read_cover(book_dir, dash, problems):
|
|
437
|
+
"""The Cover page is built from 2-Plan/cover/cover.json, written by the ghostwriter, and from the picture it names, if the
|
|
438
|
+
author brought one. Returns (the cover as the folder pages carry it, the cover as the live page carries it, the picture's parts)."""
|
|
439
|
+
path = os.path.join(book_dir, "2-Plan", "cover", "cover.json")
|
|
440
|
+
if not os.path.exists(path):
|
|
441
|
+
return None, None, []
|
|
442
|
+
try:
|
|
443
|
+
raw = json.load(open(path, encoding="utf-8"))
|
|
444
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
445
|
+
problems.append("2-Plan/cover/cover.json is not valid JSON (%s), so the Cover page is not built. Fix the file and build again." % e)
|
|
446
|
+
return None, None, []
|
|
447
|
+
if not isinstance(raw, dict):
|
|
448
|
+
problems.append("2-Plan/cover/cover.json must be one object. See the cover reference.")
|
|
449
|
+
return None, None, []
|
|
450
|
+
out, seen = {"concepts": []}, set()
|
|
451
|
+
for c in raw.get("concepts") or []:
|
|
452
|
+
if not isinstance(c, dict) or not COVER_ID.match(str(c.get("id", ""))) or c.get("id") == "own" or not str(c.get("name", "")).strip():
|
|
453
|
+
problems.append('cover.json: each direction needs an id (small letters, digits, hyphens; not "own") and a name. One was left out.')
|
|
454
|
+
continue
|
|
455
|
+
if c["id"] in seen:
|
|
456
|
+
problems.append('cover.json: two directions share the id "%s". The second one was left out.' % c["id"])
|
|
457
|
+
continue
|
|
458
|
+
if len(out["concepts"]) == 4:
|
|
459
|
+
problems.append("cover.json: the Cover page shows at most four directions. The rest were left out.")
|
|
460
|
+
break
|
|
461
|
+
seen.add(c["id"])
|
|
462
|
+
where = 'the direction "%s"' % c["name"]
|
|
463
|
+
d = {"id": c["id"], "name": str(c["name"]).strip(), "why": str(c.get("why", "")).strip(), "layout": c.get("layout") if c.get("layout") in COVER_LAYOUTS else "paper"}
|
|
464
|
+
if c.get("layout") not in COVER_LAYOUTS:
|
|
465
|
+
problems.append("cover.json: %s has no layout, or one the pages do not know. It is drawn as \"paper\". Use one of: %s." % (where, ", ".join(COVER_LAYOUTS)))
|
|
466
|
+
colors = {k: v for k, v in (c.get("colors") or {}).items() if k in ("paper", "ink", "accent") and HEX.match(str(v))} if isinstance(c.get("colors"), dict) else {}
|
|
467
|
+
if len(colors) < 3:
|
|
468
|
+
problems.append("cover.json: %s needs three colors, paper, ink, and accent, each written like #1F3B2D. The page fills in the missing ones." % where)
|
|
469
|
+
d["colors"] = colors
|
|
470
|
+
d.update(cover_lettering(c, where, problems))
|
|
471
|
+
m = c.get("motif")
|
|
472
|
+
if isinstance(m, dict) and str(m.get("svg", "")).strip():
|
|
473
|
+
svg = str(m["svg"]).strip()
|
|
474
|
+
if len(svg) > 20000 or SVG_BAD.search(svg) or not VIEWBOX.match(str(m.get("viewBox", ""))):
|
|
475
|
+
problems.append("cover.json: the drawing for %s was left out. A drawing is plain shapes only (path, rect, circle, ellipse, line, polygon, polyline, g, and gradients), at most 20,000 letters, with a viewBox of four numbers, and no words, pictures, links, or scripts." % where)
|
|
476
|
+
else:
|
|
477
|
+
d["motif"] = {"viewBox": str(m["viewBox"]).strip(), "svg": svg}
|
|
478
|
+
out["concepts"].append(d)
|
|
479
|
+
out["prompts"] = [{"label": str(x.get("label", "")).strip(), "why": str(x.get("why", "")).strip(), "say": str(x["say"]).strip()}
|
|
480
|
+
for x in (raw.get("prompts") or []) if isinstance(x, dict) and str(x.get("say", "")).strip()][:6]
|
|
481
|
+
b = raw.get("back")
|
|
482
|
+
if isinstance(b, dict) and (b.get("headline") or b.get("text")):
|
|
483
|
+
text = b.get("text") if isinstance(b.get("text"), list) else [b.get("text")] if b.get("text") else []
|
|
484
|
+
status = str(b.get("status", "")).strip()
|
|
485
|
+
d = re.search(r"\d{4}-\d{2}-\d{2}", status)
|
|
486
|
+
out["back"] = {"status": "confirmed" if status.upper().startswith("CONFIRMED") else "draft", "confirmed": d.group(0) if d else "",
|
|
487
|
+
"headline": str(b.get("headline", "")).strip(), "text": [str(x).strip() for x in text if str(x).strip()][:8], "bio": str(b.get("bio", "")).strip()}
|
|
488
|
+
live, parts, own = dict(out), [], raw.get("own")
|
|
489
|
+
if isinstance(own, dict) and str(own.get("file", "")).strip():
|
|
490
|
+
rel = str(own["file"]).strip().replace("\\", "/")
|
|
491
|
+
src = os.path.normpath(os.path.join(book_dir, rel))
|
|
492
|
+
data = None
|
|
493
|
+
if not src.startswith(book_dir + os.sep) or not os.path.isfile(src):
|
|
494
|
+
problems.append('cover.json: the picture "%s" is not in the book folder, so the author\'s own cover is not shown. Give its place inside the book folder, for example 2-Plan/cover/my-cover.jpg.' % rel)
|
|
495
|
+
else:
|
|
496
|
+
data = open(src, "rb").read()
|
|
497
|
+
mime, ext = picture_kind(data)
|
|
498
|
+
if not mime:
|
|
499
|
+
problems.append('cover.json: "%s" is not a JPEG, PNG, WebP, or GIF picture, so it is not shown. Ask the author for one of those, or save a copy as JPEG.' % rel)
|
|
500
|
+
data = None
|
|
501
|
+
else:
|
|
502
|
+
data, mime = shrink(data, mime)
|
|
503
|
+
ext = picture_kind(data)[1]
|
|
504
|
+
if len(data) > PICTURE_MAX:
|
|
505
|
+
problems.append('cover.json: the picture "%s" is %.1f MB, which is more than the pages can carry (1.5 MB). Save a smaller copy beside it (1,500 pixels tall is plenty, as a JPEG), name that copy in own.file, and build again.' % (rel, len(data) / 1e6))
|
|
506
|
+
data = None
|
|
507
|
+
if data is not None:
|
|
508
|
+
import base64, hashlib
|
|
509
|
+
name = "cover-picture." + ext
|
|
510
|
+
open(os.path.join(dash, name), "wb").write(data)
|
|
511
|
+
b64, sig = base64.b64encode(data).decode("ascii"), hashlib.sha256(data).hexdigest()[:12]
|
|
512
|
+
chunks = [b64[i:i + PART] for i in range(0, len(b64), PART)]
|
|
513
|
+
parts = [{"sig": sig, "i": i, "of": len(chunks), "data": ch} for i, ch in enumerate(chunks)]
|
|
514
|
+
base = {"words": own.get("words") is not False, "note": str(own.get("note", "")).strip()}
|
|
515
|
+
if not base["words"]:
|
|
516
|
+
base["lettering"] = cover_lettering(own.get("lettering") if isinstance(own.get("lettering"), dict) else {}, "the author's own picture", problems)
|
|
517
|
+
ink = (own.get("lettering") or {}).get("ink") if isinstance(own.get("lettering"), dict) else None
|
|
518
|
+
if HEX.match(str(ink or "")):
|
|
519
|
+
base["lettering"]["ink"] = ink
|
|
520
|
+
out["own"] = dict(base, src=name)
|
|
521
|
+
live["own"] = dict(base, sig=sig, parts=len(chunks), type=mime, bytes=len(data))
|
|
522
|
+
chosen = str(raw.get("chosen", "") or "").strip()
|
|
523
|
+
if chosen and not (chosen in seen or (chosen == "own" and "own" in out)):
|
|
524
|
+
problems.append('cover.json: chosen is "%s", which is not one of the directions%s. No cover is marked as chosen.' % (chosen, "" if "own" in out else ", and there is no picture of the author's own"))
|
|
525
|
+
chosen = ""
|
|
526
|
+
out["chosen"] = live["chosen"] = chosen
|
|
527
|
+
return out, live, parts
|
|
528
|
+
|
|
529
|
+
|
|
377
530
|
def read_version(book_dir, state, problems):
|
|
378
531
|
"""The version marker in the top bar of every page. The ghostwriter writes state.version at each refresh:
|
|
379
532
|
{"method": "0.8.1", "plugin": "0.8.0", "checked": "2026-10-10"}. Anything that is not a plain version or date is dropped.
|
|
@@ -452,6 +605,7 @@ def main():
|
|
|
452
605
|
board = read_notes(os.path.join(book_dir, "2-Plan", "00-notes.md"), notes)
|
|
453
606
|
plan = os.path.join(book_dir, "2-Plan")
|
|
454
607
|
setup, voice, stories = read_setup(plan), read_voice(plan), read_stories(plan, notes)
|
|
608
|
+
cover, cover_live, cover_parts = read_cover(book_dir, dash, notes)
|
|
455
609
|
if OLD_STAGES:
|
|
456
610
|
notes.append("book.json lists 11 steps. The Proposal is no longer a step: write state.stages with 10 values and state.stage from 1 to 10 (see references/pages.md). This build dropped the sixth value for you.")
|
|
457
611
|
|
|
@@ -461,7 +615,7 @@ def main():
|
|
|
461
615
|
title = state["title"].replace("&", "&").replace("<", "").replace(">", "")
|
|
462
616
|
|
|
463
617
|
# 1. The pages for the book folder: one small file per page, carrying the book's data with it.
|
|
464
|
-
snap = {"state": state, "outline": outline, "manuscript": manuscript, "notes": board, "setup": setup, "voice": voice, "stories": stories}
|
|
618
|
+
snap = {"state": state, "outline": outline, "manuscript": manuscript, "notes": board, "setup": setup, "voice": voice, "stories": stories, "cover": cover}
|
|
465
619
|
for key, fname in PAGES.items():
|
|
466
620
|
doc = ('<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
|
467
621
|
"<style>img{max-width:100%}[hidden]{display:none!important}</style></head><body>\n"
|
|
@@ -471,7 +625,7 @@ def main():
|
|
|
471
625
|
# 2. The live page and its data, for sessions that can publish it.
|
|
472
626
|
live = os.path.join(dash, "_live")
|
|
473
627
|
if a.live:
|
|
474
|
-
write_live(live, title, state, outline, manuscript, board, setup, voice, stories)
|
|
628
|
+
write_live(live, title, state, outline, manuscript, board, setup, voice, stories, cover_live, cover_parts)
|
|
475
629
|
|
|
476
630
|
# 3. Say what was built, in numbers the ghostwriter can check against the folder.
|
|
477
631
|
total = 0
|
|
@@ -496,11 +650,20 @@ def main():
|
|
|
496
650
|
("%d lines, %s" % (len(setup["items"]), setup["status"])) if setup else "not written yet",
|
|
497
651
|
("%d lines, %s" % (len(voice["items"]) + len((voice.get("style") or {}).get("items", [])), voice["status"])) if voice else "not written yet",
|
|
498
652
|
("%d, %s" % (len(stories["items"]), stories["status"])) if stories else "not written yet"))
|
|
653
|
+
if cover is None:
|
|
654
|
+
print(" cover: not started")
|
|
655
|
+
else:
|
|
656
|
+
own = (cover_live or {}).get("own")
|
|
657
|
+
names = {c["id"]: c["name"] for c in cover["concepts"]}
|
|
658
|
+
print(" cover: %d direction%s (%d with a drawing); the author's own picture: %s; chosen: %s" % (
|
|
659
|
+
len(cover["concepts"]), "" if len(cover["concepts"]) == 1 else "s", sum(1 for c in cover["concepts"] if "motif" in c),
|
|
660
|
+
("%d KB, carried as coverimg/p-00%s" % (round(own["bytes"] / 1000), " to p-%02d" % (own["parts"] - 1) if own["parts"] > 1 else "")) if own else "none",
|
|
661
|
+
"the author's own picture" if cover["chosen"] == "own" else names.get(cover["chosen"], "nothing yet")))
|
|
499
662
|
for n in notes:
|
|
500
663
|
print("NOTE " + n)
|
|
501
664
|
|
|
502
665
|
|
|
503
|
-
def write_live(live, title, state, outline, manuscript, board, setup, voice, stories):
|
|
666
|
+
def write_live(live, title, state, outline, manuscript, board, setup, voice, stories, cover=None, cover_parts=()):
|
|
504
667
|
os.makedirs(os.path.join(live, "data", "manuscript"), exist_ok=True)
|
|
505
668
|
open(os.path.join(live, "book-dashboard.html"), "w", encoding="utf-8").write(shell(title + " Dashboard"))
|
|
506
669
|
json.dump(state, open(os.path.join(live, "data", "state.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
@@ -514,6 +677,17 @@ def write_live(live, title, state, outline, manuscript, board, setup, voice, sto
|
|
|
514
677
|
json.dump(body, open(os.path.join(live, "data", "notes", note["id"] + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
515
678
|
for key, ch in manuscript.items():
|
|
516
679
|
json.dump(ch, open(os.path.join(live, "data", "manuscript", key + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
680
|
+
if cover is not None:
|
|
681
|
+
json.dump(cover, open(os.path.join(live, "data", "cover.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
682
|
+
cdir = os.path.join(live, "data", "coverimg")
|
|
683
|
+
if cover_parts or os.path.isdir(cdir):
|
|
684
|
+
os.makedirs(cdir, exist_ok=True)
|
|
685
|
+
for part in cover_parts:
|
|
686
|
+
json.dump(part, open(os.path.join(cdir, "p-%02d.json" % part["i"]), "w", encoding="utf-8"))
|
|
687
|
+
for name in sorted(os.listdir(cdir)): # parts left from an earlier, larger picture are emptied: nothing in the folder is ever deleted
|
|
688
|
+
m = re.match(r"^p-(\d\d)\.json$", name)
|
|
689
|
+
if m and int(m.group(1)) >= len(cover_parts):
|
|
690
|
+
json.dump({"sig": "", "i": int(m.group(1)), "of": 0, "data": ""}, open(os.path.join(cdir, name), "w", encoding="utf-8"))
|
|
517
691
|
|
|
518
692
|
|
|
519
693
|
if __name__ == "__main__":
|