gutterpress 0.10.0-alpha.3 → 0.10.0-beta.1

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 (37) hide show
  1. package/README.md +2 -0
  2. package/dist/api/index.js +2 -2
  3. package/dist/{audit-xxegv0t0.js → audit-85zn35v8.js} +4 -4
  4. package/dist/{build-w0vb6mgq.js → build-1faqmp0y.js} +6 -4
  5. package/dist/{cli-5czby0dd.js → cli-5tffrg48.js} +90 -23
  6. package/dist/{cli-f4rcbt2t.js → cli-7e79d7se.js} +1 -1
  7. package/dist/{cli-yp3p0sf2.js → cli-afm8pa0h.js} +327 -24
  8. package/dist/{cli-gk3wsdpb.js → cli-v935c2ws.js} +1 -1
  9. package/dist/{cli-revgt4pr.js → cli-yzf38679.js} +1 -0
  10. package/dist/cli.js +16 -16
  11. package/dist/{doctor-ctgn1dnt.js → doctor-69v5c9wy.js} +2 -2
  12. package/dist/engine/compiler/build.d.ts +1 -1
  13. package/dist/engine/shared/flush.d.ts +59 -0
  14. package/dist/engine/shared/margin-box-support.d.ts +24 -0
  15. package/dist/{engine-eng4xh2q.js → engine-hrtn0b28.js} +1 -1
  16. package/dist/{engine-q5skhgms.js → engine-nbfdy0pp.js} +2 -2
  17. package/dist/{gutterpress-agent-1ctgfz92.js → gutterpress-agent-cazqstr1.js} +49 -1
  18. package/dist/{gutterpress-viewer-cem7dmr5.js → gutterpress-viewer-fb6fhwsy.js} +137 -51
  19. package/dist/{index-s416qpdv.js → index-052x5fef.js} +89 -22
  20. package/dist/{index-mvehxh0e.js → index-c2b6gyeg.js} +47 -4
  21. package/dist/{index-a4kr77td.js → index-wsydvp08.js} +287 -23
  22. package/dist/index.js +3 -3
  23. package/dist/lib/build-runner.d.ts +7 -0
  24. package/dist/lib/engine.d.ts +9 -0
  25. package/dist/lib/markdown/gutterpress-css.d.ts +9 -1
  26. package/dist/lib/markdown/markers.d.ts +51 -1
  27. package/dist/{lint-gsh6qwrq.js → lint-17404bsx.js} +4 -4
  28. package/dist/{new-nkhycqy4.js → new-vsgpxzek.js} +4 -4
  29. package/dist/{plugin-129wcs93.js → plugin-s4yfpx6p.js} +4 -4
  30. package/dist/{preflight-41hcejg9.js → preflight-1h3apvqh.js} +4 -4
  31. package/dist/{preview-pftsvr9z.js → preview-cqf383g4.js} +6 -5
  32. package/dist/{preview-interface-fnqb2y4v.js → preview-interface-hg5eqv88.js} +193 -4
  33. package/dist/{publish-dnjvb4jg.js → publish-arvycera.js} +4 -4
  34. package/dist/render.js +83 -7
  35. package/dist/{repair-a3enjttn.js → repair-vmwh2bhe.js} +4 -4
  36. package/dist/{validate-ph1xffqy.js → validate-ztzq7p35.js} +4 -4
  37. package/package.json +1 -1
@@ -2,16 +2,16 @@ import {
2
2
  executeValidation,
3
3
  publishTargetFor,
4
4
  reportMissingTools
5
- } from "./cli-5czby0dd.js";
5
+ } from "./cli-5tffrg48.js";
6
6
  import {
7
7
  log
8
- } from "./cli-gk3wsdpb.js";
8
+ } from "./cli-v935c2ws.js";
9
9
  import {
10
10
  UsageError,
11
11
  rejectExtraPositionals,
12
12
  rejectUnknownFlags
13
- } from "./cli-f4rcbt2t.js";
14
- import"./cli-yp3p0sf2.js";
13
+ } from "./cli-7e79d7se.js";
14
+ import"./cli-afm8pa0h.js";
15
15
  import"./cli-c41yr7he.js";
16
16
  import {
17
17
  EXIT_CODES
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  previewArgs
3
- } from "./cli-revgt4pr.js";
3
+ } from "./cli-yzf38679.js";
4
4
  import {
5
5
  openPath,
6
6
  runBuild,
7
7
  splitOutPath,
8
8
  startPreviewServer
9
- } from "./cli-5czby0dd.js";
9
+ } from "./cli-5tffrg48.js";
10
10
  import {
11
11
  log
12
- } from "./cli-gk3wsdpb.js";
12
+ } from "./cli-v935c2ws.js";
13
13
  import {
14
14
  UsageError,
15
15
  parseEngine,
@@ -18,8 +18,8 @@ import {
18
18
  rejectExtraPositionals,
19
19
  rejectUnknownFlags,
20
20
  resolvePort
21
- } from "./cli-f4rcbt2t.js";
22
- import"./cli-yp3p0sf2.js";
21
+ } from "./cli-7e79d7se.js";
22
+ import"./cli-afm8pa0h.js";
23
23
  import"./cli-c41yr7he.js";
24
24
  import {
25
25
  BuildError
@@ -82,6 +82,7 @@ var preview_default = defineCommand({
82
82
  skipLint: !!args["skip-lint"],
83
83
  skipPreValidate: !!args["skip-pre-validate"],
84
84
  skipPostValidate: !!args["skip-post-validate"],
85
+ allowShrink: !!args["allow-shrink"],
85
86
  engine,
86
87
  rawArgs: args
87
88
  });
@@ -21,6 +21,9 @@
21
21
  var ignoreScrollUntil = 0;
22
22
  var lastSourceLine = -1;
23
23
  var lastSourceChapter = null;
24
+ // Find-in-book state (see previewAPI.find below).
25
+ var findQuery = '';
26
+ var findTotal = 0;
24
27
 
25
28
  function refreshPages() {
26
29
  pages = Array.from(document.querySelectorAll('.gp-sheet')).sort(function (a, b) {
@@ -328,6 +331,125 @@
328
331
  return null;
329
332
  }
330
333
 
334
+ // Full hit stack at a point, top-most first. Degrades to the single
335
+ // elementFromPoint() hit on hosts without elementsFromPoint().
336
+ function elementStackAtPoint(x, y) {
337
+ try {
338
+ if (typeof document.elementsFromPoint === 'function') {
339
+ var els = document.elementsFromPoint(x, y);
340
+ if (els && els.length) return Array.prototype.slice.call(els);
341
+ }
342
+ } catch (_e) { /* fall through to the single-element path */ }
343
+ var el = elementAtPoint(x, y);
344
+ return el ? [el] : [];
345
+ }
346
+
347
+ // Computed z-index as a number; NaN for 'auto'/unset/unsupported hosts
348
+ // (NaN < 0 is false, so those never qualify as behind-layered below).
349
+ function zIndexOf(el) {
350
+ try {
351
+ if (typeof window.getComputedStyle === 'function') {
352
+ return parseFloat(window.getComputedStyle(el).zIndex);
353
+ }
354
+ } catch (_e) { /* unsupported host */ }
355
+ return NaN;
356
+ }
357
+
358
+ // Why elementsFromPoint (plural) exists here: a `.gp-behind` image — the
359
+ // image-properties "Layer" facet sets `z-index: -1` to paint a pinned
360
+ // plate UNDER the page's own text (gutterpress-css.ts's depth ladder) —
361
+ // hit-tests beneath the covering paragraph boxes and the annotated
362
+ // `.page`/`.section` containers too, so document.elementFromPoint() can
363
+ // NEVER return it. Every right-click used to resolve the covering element
364
+ // instead, leaving the image's context menu unreachable at EVERY point.
365
+ // Probe the full hit stack for the first (= top-most in paint order, so
366
+ // the upper of two overlapping plates wins) image layered at negative z.
367
+ // ONLY negative-z images qualify: a normally-layered image that merely
368
+ // overlaps other content is already reachable at its uncovered points,
369
+ // and stealing the covering content's right-clicks would invert the bug.
370
+ // Computed style, not a `gp-behind` class test, mirrors the build-time
371
+ // engine.layer.trapped audit — book CSS can layer an image behind with a
372
+ // bare `z-index: -1` of its own.
373
+ function behindImageInStack(stack) {
374
+ for (var i = 0; i < stack.length; i++) {
375
+ var el = stack[i];
376
+ if (el.tagName && el.tagName.toLowerCase() === 'img' && zIndexOf(el) < 0) return el;
377
+ }
378
+ return null;
379
+ }
380
+
381
+ // The .gp-sheet whose box contains a viewport point, or null. The sheet
382
+ // chrome lives in the hit-transparent .gp-layer (viewer.css), so it can
383
+ // never appear in the hit stack — this is a geometric scan of the cached
384
+ // page list (refreshPages), not a DOM hit test.
385
+ function sheetAtPoint(x, y) {
386
+ if (pages.length === 0) refreshPages();
387
+ for (var i = 0; i < pages.length; i++) {
388
+ var r = pages[i].getBoundingClientRect();
389
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return pages[i];
390
+ }
391
+ return null;
392
+ }
393
+
394
+ // The annotated .page/.spread wrapper that OWNS a sheet: greatest
395
+ // client-rect intersection with the sheet's box — the same rect-vs-sheet
396
+ // resolution nativeRectsFor() uses. Null when the page has no author
397
+ // @page/@spread wrapper (plain flowed content) — callers then keep the
398
+ // plain hit result.
399
+ function pageMarkerElForSheet(sheet) {
400
+ var sheetRect = sheet.getBoundingClientRect();
401
+ var candidates = document.querySelectorAll('.page[data-source-range], .spread[data-source-range]');
402
+ var best = null;
403
+ var bestArea = 0;
404
+ for (var i = 0; i < candidates.length; i++) {
405
+ var rects = rectsOf(candidates[i]);
406
+ for (var j = 0; j < rects.length; j++) {
407
+ var r = rects[j];
408
+ var width = Math.min(r.right, sheetRect.right) - Math.max(r.left, sheetRect.left);
409
+ var height = Math.min(r.bottom, sheetRect.bottom) - Math.max(r.top, sheetRect.top);
410
+ var area = width > 0 && height > 0 ? width * height : 0;
411
+ if (area > bestArea) {
412
+ bestArea = area;
413
+ best = candidates[i];
414
+ }
415
+ }
416
+ }
417
+ return best;
418
+ }
419
+
420
+ // The element getContextTargetAt() resolves the payload from. `topmostOnly`
421
+ // (the keyboard path) skips the behind-image probe: its anchor is a
422
+ // SYNTHETIC block-center point (keyboardAnchorPoint()), not a user-aimed
423
+ // pointer position — probing beneath it would hijack every keyboard menu
424
+ // on a page with a background plate.
425
+ function contextPointEl(x, y, topmostOnly) {
426
+ var stack = elementStackAtPoint(x, y);
427
+ var topEl = stack.length ? stack[0] : null;
428
+ if (topmostOnly || !topEl || !topEl.closest) return topEl;
429
+ // The user aimed at visible interactive content — a directly-hit image
430
+ // (an <img> only ever matches closest('img') as itself: images have no
431
+ // descendants) or a link's own text — never probe beneath those.
432
+ if (topEl.closest('img') || topEl.closest('a')) return topEl;
433
+ // Margin-box furniture (running headers, page numbers) keeps its native
434
+ // context menu even when a full-bleed plate runs beneath it — the
435
+ // contextmenu listener's kind==='none' contract below.
436
+ if (topEl.closest('.gp-marginbox')) return topEl;
437
+ var behind = behindImageInStack(stack);
438
+ if (behind) return behind;
439
+ // Margin band (protocol v7): the point sits in a page's margin — outside
440
+ // every author box, so nothing annotated is under it (the sheet chrome
441
+ // and the run/strip boxes are hit-transparent viewer chrome). When the
442
+ // point still lies inside a .gp-sheet, resolve to the annotated
443
+ // .page/.spread that owns that sheet, so right-clicking anywhere on the
444
+ // paper reaches the @page marker, not just the content box.
445
+ if (!resolveAnnotatedBlock(topEl)) {
446
+ var sheet = sheetAtPoint(x, y);
447
+ var owner = sheet ? pageMarkerElForSheet(sheet) : null;
448
+ if (owner) return owner;
449
+ }
450
+ return topEl;
451
+ }
452
+
331
453
  function elementOf(node) {
332
454
  if (!node) return null;
333
455
  return node.nodeType === 1 ? node : (node.parentElement || null);
@@ -451,6 +573,21 @@
451
573
 
452
574
  var block = resolveAnnotatedBlock(pointEl);
453
575
 
576
+ // Secondary page-marker target (protocol v7): resolveAnnotatedBlock()
577
+ // returns the INNERMOST annotated block, so inside a @section the
578
+ // enclosing @page marker never wins the primary slot. Surface the
579
+ // enclosing .page/.spread/.chapter wrapper's own marker line as a
580
+ // secondary field — the same always-populated pattern image/link/
581
+ // selection use — so the menu can offer "Edit page marker…" when it
582
+ // differs from the primary target.
583
+ var pageMarkerEl = pointEl && pointEl.closest ? pointEl.closest('.page, .spread, .chapter') : null;
584
+ var pageMarkerRange = pageMarkerEl ? sourceRangeOf(pageMarkerEl) : null;
585
+ var pageMarker = pageMarkerRange ? {
586
+ chapter: chapterOf(pageMarkerEl),
587
+ range: pageMarkerRange,
588
+ blockTag: pageMarkerEl.tagName ? pageMarkerEl.tagName.toLowerCase() : null
589
+ } : null;
590
+
454
591
  var kind;
455
592
  if (selection) kind = 'selection';
456
593
  else if (image) kind = 'image';
@@ -468,7 +605,8 @@
468
605
  rect: plainRect(block || pointEl),
469
606
  image: image,
470
607
  link: link,
471
- selection: selection
608
+ selection: selection,
609
+ pageMarker: pageMarker
472
610
  };
473
611
  }
474
612
 
@@ -657,13 +795,21 @@
657
795
  // v6 (WORK PACKAGE B item 2): getRectsFor()/setEditMask() dropped the
658
796
  // {ref} form entirely — {chapter, range} is the only target shape now
659
797
  // (the native viewer never mints a ref — it never clones).
660
- getProtocolVersion: function () { return 6; },
798
+ // v7: getContextTargetAt() gained the `pageMarker` secondary field (the
799
+ // enclosing .page/.spread/.chapter marker) and the margin-band fallback
800
+ // (a point inside a .gp-sheet but outside every author box resolves to
801
+ // the sheet's owning .page/.spread). An older SPA simply ignores the
802
+ // extra field; a newer SPA feature-detects by the field's presence.
803
+ getProtocolVersion: function () { return 7; },
661
804
 
662
805
  // Resolve the annotated element/selection at a viewport point (protocol
663
806
  // v4). Pure read; see buildContextTarget() above for the full contract.
807
+ // Point resolution prefers a behind-layered (negative z-index) image the
808
+ // top-most hit covers — see contextPointEl(); `spec.topmostOnly` opts a
809
+ // synthetic-anchor caller (the keyboard listener) out of that probe.
664
810
  getContextTargetAt: function (spec) {
665
811
  spec = spec || {};
666
- return buildContextTarget(elementAtPoint(spec.x, spec.y));
812
+ return buildContextTarget(contextPointEl(spec.x, spec.y, spec.topmostOnly));
667
813
  },
668
814
 
669
815
  // All fragment rects for one logical block (protocol v6, §5.3), targeted
@@ -846,6 +992,47 @@
846
992
  return { count: els.length };
847
993
  },
848
994
 
995
+ // ── Find in book (the app's Ctrl+F) ─────────────────────────────────
996
+ // window.find() runs inside THIS frame, so it can only ever match book
997
+ // content — never the app's toolbar chrome or the editor. Each call with
998
+ // the same query advances the native selection (wrapping at the ends)
999
+ // and Chromium scrolls the match into view; a NEW query restarts from
1000
+ // the top and counts total occurrences once (case-insensitive).
1001
+ find: function (query, backwards) {
1002
+ query = String(query == null ? '' : query);
1003
+ if (!query) return api.clearFind();
1004
+ if (query !== findQuery) {
1005
+ findQuery = query;
1006
+ var text = (document.body.textContent || '').toLowerCase();
1007
+ var needle = query.toLowerCase();
1008
+ findTotal = 0;
1009
+ var idx = 0;
1010
+ while (findTotal < 10000) {
1011
+ idx = text.indexOf(needle, idx);
1012
+ if (idx === -1) break;
1013
+ findTotal++;
1014
+ idx += needle.length;
1015
+ }
1016
+ var sel = window.getSelection();
1017
+ if (sel) sel.removeAllRanges();
1018
+ }
1019
+ var found = false;
1020
+ try {
1021
+ found = window.find(query, false, !!backwards, true, false, false, false);
1022
+ } catch (_e) {
1023
+ found = false;
1024
+ }
1025
+ return { found: !!found, total: findTotal };
1026
+ },
1027
+
1028
+ clearFind: function () {
1029
+ findQuery = '';
1030
+ findTotal = 0;
1031
+ var sel = window.getSelection();
1032
+ if (sel) sel.removeAllRanges();
1033
+ return { found: false, total: 0 };
1034
+ },
1035
+
849
1036
  clearHighlights: function (group) {
850
1037
  var sel = group ? '.gutterpress-hl[data-gutterpress-hl-group="' + group + '"]' : '.gutterpress-hl';
851
1038
  var els = Array.from(document.querySelectorAll(sel));
@@ -932,7 +1119,9 @@
932
1119
  var isMenuKey = e.key === 'ContextMenu';
933
1120
  if (!isShiftF10 && !isMenuKey) return;
934
1121
  var anchor = keyboardAnchorPoint();
935
- var detail = api.getContextTargetAt({ x: anchor.x, y: anchor.y });
1122
+ // topmostOnly: the anchor is a synthetic block-center point, so the
1123
+ // behind-image probe must not run — see contextPointEl().
1124
+ var detail = api.getContextTargetAt({ x: anchor.x, y: anchor.y, topmostOnly: true });
936
1125
  detail.x = anchor.x;
937
1126
  detail.y = anchor.y;
938
1127
  detail.via = 'keyboard';
@@ -7,17 +7,17 @@ import {
7
7
  publishCredentialKey,
8
8
  publishProviderFor,
9
9
  runPublish
10
- } from "./cli-5czby0dd.js";
10
+ } from "./cli-5tffrg48.js";
11
11
  import {
12
12
  FileTokenStore,
13
13
  log
14
- } from "./cli-gk3wsdpb.js";
14
+ } from "./cli-v935c2ws.js";
15
15
  import {
16
16
  UsageError,
17
17
  rejectExtraPositionals,
18
18
  rejectUnknownFlags
19
- } from "./cli-f4rcbt2t.js";
20
- import"./cli-yp3p0sf2.js";
19
+ } from "./cli-7e79d7se.js";
20
+ import"./cli-afm8pa0h.js";
21
21
  import"./cli-c41yr7he.js";
22
22
  import {
23
23
  EXIT_CODES
package/dist/render.js CHANGED
@@ -172,6 +172,9 @@ function warn(env, line, type, message, marker) {
172
172
  env.layoutWarnings = [];
173
173
  env.layoutWarnings.push({ line, type, message, marker });
174
174
  }
175
+ function proseEscapeHint(kind) {
176
+ return ` If this line is prose that happens to begin with "@${kind}" — a wrapped sentence, for` + ` instance — escape it as \\@${kind} or write it as \`@${kind}\` in backticks, and it stays text.`;
177
+ }
175
178
  function addClasses(token, baseClass, extraClass) {
176
179
  const cls = [];
177
180
  if (baseClass)
@@ -233,14 +236,14 @@ function plugin(md, pluginOptions = {}) {
233
236
  const unknown = parsed.__unknownTokens;
234
237
  delete parsed.__unknownTokens;
235
238
  for (const t of unknown) {
236
- warn(state.env, startLine + 1, "unrecognized_marker_token", `@${parsed.kind}: "${t}" is not something a marker understands, so it was kept verbatim as a class name. Write a class as .my-class (or {.my-class}), an id as #my-id, and anything else as key=value.`, parsed);
239
+ warn(state.env, startLine + 1, "unrecognized_marker_token", `@${parsed.kind}: "${t}" is not something a marker understands, so it was kept verbatim as a class name. Write a class as .my-class (or {.my-class}), an id as #my-id, and anything else as key=value.` + proseEscapeHint(parsed.kind), parsed);
237
240
  }
238
241
  }
239
242
  if (parsed.__extraBareTokens) {
240
243
  const { tokens, named } = parsed.__extraBareTokens;
241
244
  delete parsed.__extraBareTokens;
242
245
  const list = tokens.map((t) => `"${t}"`).join(", ");
243
- warn(state.env, startLine + 1, "extra_bare_marker_token", named ? `@${parsed.kind}: only the first plain word (${list.split(", ")[0]}) is used as the name; the rest (${tokens.slice(1).map((t) => `"${t}"`).join(", ")}) became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.slice(1).join(" .")}.` : `@${parsed.kind}: ${list} are several plain words, but a marker has only one name slot — so NONE of them was used as the name and they all became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.join(" .")}.`, parsed);
246
+ warn(state.env, startLine + 1, "extra_bare_marker_token", named ? `@${parsed.kind}: only the first plain word (${list.split(", ")[0]}) is used as the name; the rest (${tokens.slice(1).map((t) => `"${t}"`).join(", ")}) became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.slice(1).join(" .")}.` : `@${parsed.kind}: ${list} are several plain words, but a marker has only one name slot — so NONE of them was used as the name and they all became class names instead. Quote a name that contains spaces ("${tokens.join(" ")}"), or write classes as .${tokens.join(" .")}.` + proseEscapeHint(parsed.kind), parsed);
244
247
  }
245
248
  state.line = startLine + 1;
246
249
  return true;
@@ -332,7 +335,12 @@ function plugin(md, pluginOptions = {}) {
332
335
  t.meta = { line: meta.__line };
333
336
  addClasses(t, "spread", meta.attrs && meta.attrs.class ? meta.attrs.class : "");
334
337
  attachDataAttrs(t, "spread", meta.name, meta.attrs || {});
335
- stack.open({ kind: "spread", noPagesYet: true, sawAnyPage: false }, t);
338
+ stack.open({
339
+ kind: "spread",
340
+ classes: meta.attrs && meta.attrs.class || "",
341
+ noPagesYet: true,
342
+ sawAnyPage: false
343
+ }, t);
336
344
  }
337
345
  function openPage(meta) {
338
346
  const chapter = stack.get("chapter");
@@ -347,7 +355,7 @@ function plugin(md, pluginOptions = {}) {
347
355
  const label = chapter ? chapter.label : "";
348
356
  if (label)
349
357
  t.attrSet("data-chapter-label", label);
350
- stack.open({ kind: "page" }, t);
358
+ stack.open({ kind: "page", classes: explicit }, t);
351
359
  if (label && !chapter.openerEmitted) {
352
360
  const opener = new state.Token("html_block", "", 0);
353
361
  opener.content = `<div class="chapter-opener" data-chapter-label="${escapeAttr(label)}">${escapeHtml(label)}</div>
@@ -370,13 +378,51 @@ function plugin(md, pluginOptions = {}) {
370
378
  t.meta = { hasColumnBreak: false, line: meta.__line };
371
379
  stack.open({
372
380
  kind: "section",
381
+ classes: meta.attrs && meta.attrs.class || "",
382
+ sawContent: false,
373
383
  meta: { name: meta.name || null, attrs: { ...meta.attrs || {} } },
374
384
  openToken: t
375
385
  }, t);
376
386
  }
387
+ function breakHost() {
388
+ return stack.get("section") || stack.get("page") || stack.get("spread");
389
+ }
390
+ function gridClassOf(frame) {
391
+ if (!frame || !frame.classes)
392
+ return null;
393
+ return frame.classes.split(/\s+/).find((c) => c.startsWith("gp-grid-")) || null;
394
+ }
395
+ function warnBreakInsideGrid(kind, line, meta) {
396
+ const host = breakHost();
397
+ const gridClass = gridClassOf(host);
398
+ if (!gridClass)
399
+ return;
400
+ warn(state.env, line, "break_inside_grid", `@${kind} inside a grid container: the enclosing @${host.kind} carries .${gridClass}, so this break's <div> becomes a grid ITEM — it takes a cell of its own, corrupts the grid's placement, and print and the live preview then disagree about which page the content after it lands on. Move the break outside the grid @${host.kind}, or remove it — grid rows already flow and fragment across pages on their own.`, meta);
401
+ }
402
+ function warnIfEmptyDecoratedSection(closingKind, closingLine) {
403
+ const sec = stack.get("section");
404
+ if (!sec || sec.sawContent)
405
+ return;
406
+ const attrs = sec.meta && sec.meta.attrs || {};
407
+ const decorations = [];
408
+ for (const c of (attrs.class || "").split(/\s+/).filter(Boolean))
409
+ decorations.push(`.${c}`);
410
+ for (const [k, v] of Object.entries(attrs)) {
411
+ if (k === "class")
412
+ continue;
413
+ decorations.push(k === "id" ? `#${v}` : `${k}=${v}`);
414
+ }
415
+ if (!decorations.length)
416
+ return;
417
+ const openLine = sec.openToken && sec.openToken.meta && Number.isFinite(sec.openToken.meta.line) ? sec.openToken.meta.line : closingLine;
418
+ warn(state.env, openLine, "empty_section", `This @section (${decorations.join(" ")}) was closed by the @${closingKind} on line ${closingLine} with no content between the two markers, so its styling applies to an empty element and nothing prints the layout it asked for. Delete one of the two markers, or move the content that belongs inside the section between them.`, null);
419
+ }
377
420
  for (let i = 0;i < state.tokens.length; i++) {
378
421
  const tok = state.tokens[i];
379
422
  if (tok.type !== "layout_marker") {
423
+ const openSectionFrame = stack.get("section");
424
+ if (openSectionFrame)
425
+ openSectionFrame.sawContent = true;
380
426
  out.push(tok);
381
427
  continue;
382
428
  }
@@ -402,6 +448,7 @@ function plugin(md, pluginOptions = {}) {
402
448
  continue;
403
449
  }
404
450
  if (kind === "section") {
451
+ warnIfEmptyDecoratedSection("section", line);
405
452
  stack.close("section");
406
453
  const spread = stack.get("spread");
407
454
  if (spread && spread.noPagesYet) {
@@ -430,6 +477,7 @@ function plugin(md, pluginOptions = {}) {
430
477
  continue;
431
478
  }
432
479
  if (kind === "page-break") {
480
+ warnBreakInsideGrid("page-break", line, meta);
433
481
  const t = new state.Token("layout_page_break", "div", 0);
434
482
  t.meta = { line };
435
483
  t.attrSet("class", "gp-page-break");
@@ -438,6 +486,7 @@ function plugin(md, pluginOptions = {}) {
438
486
  continue;
439
487
  }
440
488
  if (kind === "column-break") {
489
+ warnBreakInsideGrid("column-break", line, meta);
441
490
  const openSectionFrame = stack.get("section");
442
491
  if (openSectionFrame && openSectionFrame.openToken && !openSectionFrame.openToken.meta.hasColumnBreak) {
443
492
  const sectionCls = openSectionFrame.openToken.attrGet("class") || "";
@@ -453,6 +502,7 @@ function plugin(md, pluginOptions = {}) {
453
502
  continue;
454
503
  }
455
504
  if (kind === "end-section") {
505
+ warnIfEmptyDecoratedSection("end-section", line);
456
506
  stack.close("section");
457
507
  continue;
458
508
  }
@@ -540,7 +590,7 @@ body { margin: 0; }
540
590
  .gp-page-break { break-before: page; }
541
591
  .page { break-before: page; }
542
592
  .spread { break-before: page; }
543
- :where(.page, .spread) { position: relative; }
593
+ :where(.page, .spread) { position: relative; display: flow-root; box-sizing: border-box; min-height: calc(var(--gp-content-h, 1px) - 1px); }
544
594
  .gp-column-break { break-after: column; height: 0; font-size: 0; line-height: 0; visibility: hidden; }
545
595
 
546
596
  :where(h1,h2,h3,h4,h5,h6) { break-after: avoid; }
@@ -624,6 +674,26 @@ var GUTTERPRESS_CSS = `
624
674
  .gp-columns-2 { columns: 2; column-gap: var(--gp-column-gap, 1.5em); }
625
675
  .gp-columns-3 { columns: 3; column-gap: var(--gp-column-gap, 1.5em); }
626
676
 
677
+ /* grid runs — the SLOTTED counterpart to the column runs above. Grid places
678
+ each child into the next cell, across then down (deterministic slots: card
679
+ layouts, stat blocks, image-plus-caption pairs); columns FLOW one text run
680
+ down then across. Same neutral-primitive rationale as .gp-columns-*, and
681
+ permanent vocabulary for the same reason: standard CSS Grid verbatim, no
682
+ spec gap to remove later. MEASURED (Chromium 151, gp-grid evidence pack):
683
+ grid rows fragment across sheets with EXACT print/viewer parity — 2- and
684
+ 3-col, unequal item heights, mid-row cuts, multi-sheet overflow,
685
+ break-inside:avoid, gap geometry — so a grid taller than the page is safe,
686
+ no fit-one-page constraint. Two things to know, not fix:
687
+ - on a min-height page root (MARKER_CSS), default align-content
688
+ stretches rows apart to fill the page — identically in both engines.
689
+ Authors wanting packed rows set align-content: start.
690
+ - a @page-break / @column-break marker DIRECTLY inside a grid container
691
+ becomes a grid item and corrupts placement (the one measured parity
692
+ break); markers.js diagnoses it (break_inside_grid).
693
+ --gp-grid-gap is author-settable. */
694
+ .gp-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gp-grid-gap, 1.5em); }
695
+ .gp-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--gp-grid-gap, 1.5em); }
696
+
627
697
  /* shape wrap — text follows the image's alpha silhouette instead of its
628
698
  rectangular box. shape-outside only applies to floats, so this is inert
629
699
  without .gp-left/.gp-right (and under .gp-pin, which un-floats). The
@@ -686,8 +756,14 @@ img.gp-shape {
686
756
  opacity, filter, transform on it traps the negative layer inside).
687
757
  Core keeps .page/.spread at 'position: relative; z-index: auto'
688
758
  precisely so they are not stacking contexts.
689
- - a clipping ancestor (overflow other than visible), the same mechanism
690
- that clips a .gp-bleed plate back to the wrapper's width.
759
+ - a clipping ancestor (overflow other than visible) — but only where
760
+ the art actually overhangs that ancestor's clip box on a clipped
761
+ axis: the overhang is cut off, the same mechanism that clips a
762
+ .gp-bleed plate back to the wrapper's width. Clipping never reorders
763
+ layers — within-bounds art under a clipping .page prints whole and
764
+ still behind (measured; see the build audit's comment in
765
+ engine/compiler/build.ts), and a static wrapper's overflow never
766
+ binds an abspos .gp-pin at all.
691
767
  The build-time engine.layer.trapped audit reports both against the live
692
768
  ancestor chain. printsafe/page-containment is only an early source hint for
693
769
  declarations written directly on .page/.spread. */
@@ -5,17 +5,17 @@ import {
5
5
  isUnbornRepo,
6
6
  repairRepo,
7
7
  verifyRepoReadable
8
- } from "./cli-5czby0dd.js";
8
+ } from "./cli-5tffrg48.js";
9
9
  import {
10
10
  FileTokenStore,
11
11
  defaultConfigDir
12
- } from "./cli-gk3wsdpb.js";
12
+ } from "./cli-v935c2ws.js";
13
13
  import {
14
14
  UsageError,
15
15
  rejectExtraPositionals,
16
16
  rejectUnknownFlags
17
- } from "./cli-f4rcbt2t.js";
18
- import"./cli-yp3p0sf2.js";
17
+ } from "./cli-7e79d7se.js";
18
+ import"./cli-afm8pa0h.js";
19
19
  import"./cli-c41yr7he.js";
20
20
  import"./cli-46ycxe6r.js";
21
21
  import"./cli-k1065rkg.js";
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  executeAndReport
3
- } from "./cli-5czby0dd.js";
3
+ } from "./cli-5tffrg48.js";
4
4
  import {
5
5
  log
6
- } from "./cli-gk3wsdpb.js";
6
+ } from "./cli-v935c2ws.js";
7
7
  import {
8
8
  UsageError,
9
9
  rejectExtraPositionals,
10
10
  rejectUnknownFlags
11
- } from "./cli-f4rcbt2t.js";
12
- import"./cli-yp3p0sf2.js";
11
+ } from "./cli-7e79d7se.js";
12
+ import"./cli-afm8pa0h.js";
13
13
  import"./cli-c41yr7he.js";
14
14
  import {
15
15
  EXIT_CODES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gutterpress",
3
- "version": "0.10.0-alpha.3",
3
+ "version": "0.10.0-beta.1",
4
4
  "description": "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
5
5
  "author": "itlackey",
6
6
  "license": "MPL-2.0",