ghostwriter-muse-pages 10.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.
Files changed (3) hide show
  1. package/build_pages.py +218 -9
  2. package/kit.js +280 -19
  3. package/package.json +1 -1
package/build_pages.py CHANGED
@@ -1,29 +1,32 @@
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
+ <book>/START-HERE.md only its "Method:" line, when book.json does not say which version is in use
5
6
  <book>/2-Plan/01-book-setup.md the Book Setup page, read as it is
6
7
  <book>/2-Plan/02-voice-profile.md the Voice page, with 05-style-sheet.md, read as they are
7
8
  <book>/2-Plan/04-story-bank.md the Stories page, read as it is
8
9
  <book>/2-Plan/00-notes.md the notes board, read as it is
9
10
  <book>/5-Manuscript/*.md the chapters, read as they are
10
- Writes <book>/Dashboard/Overview.html, Setup.html, Voice.html, Stories.html, Notes.html, Outline.html, Manuscript.html, Ghostwriter.html
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
11
14
  <book>/Dashboard/_live/ only with --live: book-dashboard.html to publish, plus data/*.json to load into it
12
15
 
13
16
  Usage: python3 build_pages.py --book "/path/to/Book Folder" [--live]
14
17
  python3 build_pages.py --version
15
- 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.
16
19
  Every page it writes is a few lines that load the page code, at one exact version, from the address in KIT_URL.
17
20
  """
18
21
  import argparse, json, os, re, sys
19
22
 
20
- BUILDER = "0.8.0" # this builder's own version
21
- KIT_VERSION = "10.0.0" # the exact version of the page code every page loads
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
22
25
  KIT_URL = "https://cdn.jsdelivr.net/npm/ghostwriter-muse-pages@" + KIT_VERSION + "/kit.js"
23
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"
24
27
  GHOSTWRITERS = {"lawrence", "kenji", "saira", "octavio", "zora", "boo", "imani", "indigo", "idris", "ryu", "marcus-aurelius", "frederick-douglass", "jane-austen",
25
28
  "li-bai", "mary-shelley", "murasaki-shikibu", "phillis-wheatley", "rumi", "sor-juana", "william-shakespeare"}
26
- 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"}
27
30
  STAGE_COUNT = 10
28
31
  PAGE_KIT = int(KIT_VERSION.split(".")[0]) # the page version written beside the live link in START-HERE.md
29
32
  STORY_FIELDS = {"home": "home", "role": "role", "when": "when", "narrative": "narrative", "how it ended": "ended", "teaching point": "point", "people": "people",
@@ -36,6 +39,8 @@ WHEN = re.compile(r"^(worked out\s+|about\s+|around\s+|circa\s+|c\.\s*|~\s*)?(\d
36
39
  STATUSES = {"done", "waiting", "current", "todo"}
37
40
  PROPOSED = re.compile(r"\s*\(proposed[^)]*\)\s*\.?\s*$", re.I)
38
41
  MARKER = re.compile(r"^\[(AUTHOR TO CONFIRM|STORY NEEDED):\s*(.*?)\]?$")
42
+ VERSION = re.compile(r"^\d+(\.\d+){1,2}$")
43
+ DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
39
44
 
40
45
 
41
46
  OLD_STAGES = []
@@ -371,6 +376,186 @@ def read_stories(plan, problems):
371
376
  return {"status": status, "confirmed": confirmed, "items": items}
372
377
 
373
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
+
530
+ def read_version(book_dir, state, problems):
531
+ """The version marker in the top bar of every page. The ghostwriter writes state.version at each refresh:
532
+ {"method": "0.8.1", "plugin": "0.8.0", "checked": "2026-10-10"}. Anything that is not a plain version or date is dropped.
533
+ When the method version is missing, it is read from the Method line of START-HERE.md."""
534
+ given = state.get("version") if isinstance(state.get("version"), dict) else {}
535
+ out = {}
536
+ for k in ("method", "plugin"):
537
+ if VERSION.match(str(given.get(k, "")).strip()):
538
+ out[k] = str(given[k]).strip()
539
+ if DAY.match(str(given.get("checked", "")).strip()):
540
+ out["checked"] = str(given["checked"]).strip()
541
+ if "method" not in out:
542
+ try:
543
+ for line in open(os.path.join(book_dir, "START-HERE.md"), encoding="utf-8"):
544
+ m = re.match(r"^\s*Method:\s*(\d+(?:\.\d+){1,2})\b", line)
545
+ if m:
546
+ out["method"] = m.group(1)
547
+ break
548
+ except (OSError, UnicodeDecodeError):
549
+ pass
550
+ problems.append('book.json has no state.version, so the version marker at the top of the pages %s. Write state.version at every refresh (see the pages reference).'
551
+ % ('shows the Method line of START-HERE.md (%s) and no date' % out["method"] if "method" in out else "is left off"))
552
+ elif "checked" not in out:
553
+ problems.append("state.version has no checked date (YYYY-MM-DD), so the pages cannot say when the version was last confirmed.")
554
+ out["builder"], out["pages"] = BUILDER, KIT_VERSION
555
+ state["version"] = out
556
+ return out
557
+
558
+
374
559
  def untag(text):
375
560
  """Take a closing '(proposed)' or '(proposed, please check)' off a value, so the author reads a clean line."""
376
561
  return PROPOSED.sub("", text or "").strip()
@@ -420,15 +605,17 @@ def main():
420
605
  board = read_notes(os.path.join(book_dir, "2-Plan", "00-notes.md"), notes)
421
606
  plan = os.path.join(book_dir, "2-Plan")
422
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)
423
609
  if OLD_STAGES:
424
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.")
425
611
 
426
612
  if state.get("ghostwriter") not in GHOSTWRITERS:
427
613
  state["ghostwriter"] = "lawrence"
614
+ version = read_version(book_dir, state, notes)
428
615
  title = state["title"].replace("&", "&amp;").replace("<", "").replace(">", "")
429
616
 
430
617
  # 1. The pages for the book folder: one small file per page, carrying the book's data with it.
431
- 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}
432
619
  for key, fname in PAGES.items():
433
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">'
434
621
  "<style>img{max-width:100%}[hidden]{display:none!important}</style></head><body>\n"
@@ -438,7 +625,7 @@ def main():
438
625
  # 2. The live page and its data, for sessions that can publish it.
439
626
  live = os.path.join(dash, "_live")
440
627
  if a.live:
441
- 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)
442
629
 
443
630
  # 3. Say what was built, in numbers the ghostwriter can check against the folder.
444
631
  total = 0
@@ -447,6 +634,8 @@ def main():
447
634
  state["stage"], state["ghostwriter"], len(state.get("prompts", [])),
448
635
  "" if state.get("prompts") else " (none written: the Overview falls back to the older next-step fields)",
449
636
  ", live files in Dashboard/_live" if a.live else ""))
637
+ print(" version shown at the top of the pages: %s%s%s" % (
638
+ version.get("method", "none"), (", plugin " + version["plugin"]) if "plugin" in version else "", (", the latest as of " + version["checked"]) if "checked" in version else ""))
450
639
  for c in (outline or {}).get("chapters", []):
451
640
  ch = manuscript.get(c["key"])
452
641
  w = sum(s["words"] for s in ch["sections"]) if ch else 0
@@ -461,11 +650,20 @@ def main():
461
650
  ("%d lines, %s" % (len(setup["items"]), setup["status"])) if setup else "not written yet",
462
651
  ("%d lines, %s" % (len(voice["items"]) + len((voice.get("style") or {}).get("items", [])), voice["status"])) if voice else "not written yet",
463
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")))
464
662
  for n in notes:
465
663
  print("NOTE " + n)
466
664
 
467
665
 
468
- 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=()):
469
667
  os.makedirs(os.path.join(live, "data", "manuscript"), exist_ok=True)
470
668
  open(os.path.join(live, "book-dashboard.html"), "w", encoding="utf-8").write(shell(title + " Dashboard"))
471
669
  json.dump(state, open(os.path.join(live, "data", "state.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
@@ -479,6 +677,17 @@ def write_live(live, title, state, outline, manuscript, board, setup, voice, sto
479
677
  json.dump(body, open(os.path.join(live, "data", "notes", note["id"] + ".json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
480
678
  for key, ch in manuscript.items():
481
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"))
482
691
 
483
692
 
484
693
  if __name__ == "__main__":