@kernhq/module-quire 0.13.0 → 0.14.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 (101) hide show
  1. package/dist/contract/models.d.ts +185 -0
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +133 -0
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/permissions.d.ts +14 -0
  6. package/dist/contract/permissions.d.ts.map +1 -1
  7. package/dist/contract/permissions.js +82 -0
  8. package/dist/contract/permissions.js.map +1 -1
  9. package/dist/contract/properties.d.ts +4 -4
  10. package/dist/contract/router.d.ts +542 -15
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +155 -8
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +526 -15
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +166 -23
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/export/html.d.ts +67 -0
  19. package/dist/server/export/html.d.ts.map +1 -0
  20. package/dist/server/export/html.js +206 -0
  21. package/dist/server/export/html.js.map +1 -0
  22. package/dist/server/export/markdown.d.ts +51 -0
  23. package/dist/server/export/markdown.d.ts.map +1 -0
  24. package/dist/server/export/markdown.js +312 -0
  25. package/dist/server/export/markdown.js.map +1 -0
  26. package/dist/server/export/pdf.d.ts +20 -0
  27. package/dist/server/export/pdf.d.ts.map +1 -0
  28. package/dist/server/export/pdf.js +91 -0
  29. package/dist/server/export/pdf.js.map +1 -0
  30. package/dist/server/export/zip.d.ts +31 -0
  31. package/dist/server/export/zip.d.ts.map +1 -0
  32. package/dist/server/export/zip.js +158 -0
  33. package/dist/server/export/zip.js.map +1 -0
  34. package/dist/server/import/csv.d.ts +77 -0
  35. package/dist/server/import/csv.d.ts.map +1 -0
  36. package/dist/server/import/csv.js +263 -0
  37. package/dist/server/import/csv.js.map +1 -0
  38. package/dist/server/import/html.d.ts +52 -0
  39. package/dist/server/import/html.d.ts.map +1 -0
  40. package/dist/server/import/html.js +472 -0
  41. package/dist/server/import/html.js.map +1 -0
  42. package/dist/server/import/markdown.d.ts +63 -0
  43. package/dist/server/import/markdown.d.ts.map +1 -0
  44. package/dist/server/import/markdown.js +692 -0
  45. package/dist/server/import/markdown.js.map +1 -0
  46. package/dist/server/import/plan.d.ts +70 -0
  47. package/dist/server/import/plan.d.ts.map +1 -0
  48. package/dist/server/import/plan.js +761 -0
  49. package/dist/server/import/plan.js.map +1 -0
  50. package/dist/server/import/ydoc.d.ts +35 -0
  51. package/dist/server/import/ydoc.d.ts.map +1 -0
  52. package/dist/server/import/ydoc.js +91 -0
  53. package/dist/server/import/ydoc.js.map +1 -0
  54. package/dist/server/import/zip.d.ts +63 -0
  55. package/dist/server/import/zip.d.ts.map +1 -0
  56. package/dist/server/import/zip.js +308 -0
  57. package/dist/server/import/zip.js.map +1 -0
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/schema.d.ts +445 -1
  62. package/dist/server/schema.d.ts.map +1 -1
  63. package/dist/server/schema.js +146 -0
  64. package/dist/server/schema.js.map +1 -1
  65. package/dist/server/services/databases.d.ts +5 -5
  66. package/dist/server/services/export.d.ts +176 -0
  67. package/dist/server/services/export.d.ts.map +1 -0
  68. package/dist/server/services/export.js +822 -0
  69. package/dist/server/services/export.js.map +1 -0
  70. package/dist/server/services/import.d.ts +109 -0
  71. package/dist/server/services/import.d.ts.map +1 -0
  72. package/dist/server/services/import.js +570 -0
  73. package/dist/server/services/import.js.map +1 -0
  74. package/dist/server/services/index.d.ts +28 -1
  75. package/dist/server/services/index.d.ts.map +1 -1
  76. package/dist/server/services/index.js +62 -1
  77. package/dist/server/services/index.js.map +1 -1
  78. package/dist/server/services/publications.d.ts +34 -1
  79. package/dist/server/services/publications.d.ts.map +1 -1
  80. package/dist/server/services/publications.js +62 -3
  81. package/dist/server/services/publications.js.map +1 -1
  82. package/dist/server/services/versions.d.ts +1 -1
  83. package/migrations/0010_transfers.sql +154 -0
  84. package/migrations/meta/_journal.json +7 -0
  85. package/package.json +1 -1
  86. package/src/client/components/ExportDialog.svelte +685 -0
  87. package/src/client/components/ImportDialog.svelte +702 -0
  88. package/src/client/components/ImportReport.svelte +310 -0
  89. package/src/client/components/SidebarSpaces.svelte +79 -0
  90. package/src/client/i18n.ts +614 -0
  91. package/src/client/index.ts +32 -0
  92. package/src/client/mock.ts +318 -0
  93. package/src/client/module.ts +36 -0
  94. package/src/client/pages/PageView.svelte +36 -0
  95. package/src/client/pages/TransfersPage.svelte +570 -0
  96. package/src/client/permissions.ts +11 -0
  97. package/src/client/query.ts +23 -0
  98. package/src/client/transfers.ts +142 -0
  99. package/src/contract/models.ts +152 -0
  100. package/src/contract/permissions.ts +84 -0
  101. package/src/contract/router.ts +170 -7
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What an exported page is dressed in.
3
+ *
4
+ * Deliberately not a copy of `prose.css`: the same shapes, in the values a standalone file can rely
5
+ * on. Every colour pair here was chosen against the surface it sits on — body ink on paper is
6
+ * #2c2a26 on #fdfcfa in light and #e8e4dc on #171614 in dark, and the muted ink is a colour rather
7
+ * than an opacity, because `opacity` fades text against whatever is behind it and a "muted" row at
8
+ * 0.5 is a row nobody can read.
9
+ *
10
+ * The contrast is arithmetic and was computed rather than judged: every foreground below was
11
+ * measured against the surface it actually sits on — including each callout tint, the highlight and
12
+ * the code ground, in both themes — and the worst pair is the muted trail at **5.33:1**, which
13
+ * clears AA for body text. Re-measure before changing a value rather than adjusting it by eye.
14
+ */
15
+ export declare const EXPORT_STYLES: string;
16
+ /**
17
+ * A stand-in for an address `renderPageDoc` would otherwise refuse, swapped back afterwards.
18
+ *
19
+ * This exists because of a rule in `render.ts` that is right and inconvenient. `safeHref` accepts a
20
+ * fragment, a root-relative path, and `http`/`https`/`mailto` — and **nothing else**, deliberately:
21
+ * a bare relative path is something it cannot resolve, and `data:` is a document that can carry
22
+ * script. An exported file needs exactly the two it refuses. `media/diagram.png` and
23
+ * `../getting-started/index.html` are what makes an archive open on a laptop with nothing running,
24
+ * and a `data:` URI is what puts a picture into a PDF without handing Chromium a URL into our
25
+ * storage.
26
+ *
27
+ * The wrong fix would be to loosen `safeHref`, which is consulted by the published-site renderer on
28
+ * behalf of anonymous readers. So the renderer is given an `https` URL it accepts, and the exporter
29
+ * swaps it for the real one after the HTML is drawn. Two things make that safe rather than clever:
30
+ * the token is exact and unique, so the replacement is a string swap and not a regular expression
31
+ * over markup; and the host is under `.invalid`, the TLD RFC 2606 reserves as permanently
32
+ * unresolvable — so a token that somehow escaped the swap is a picture that cannot load rather than
33
+ * a request to somebody's server.
34
+ */
35
+ export interface ExportLinks {
36
+ /** A token `safeHref` accepts, standing for `target`. The same target always gets the same token. */
37
+ to(target: string): string;
38
+ /** The drawn HTML with every token swapped back. */
39
+ resolve(html: string): string;
40
+ }
41
+ export declare function exportLinks(): ExportLinks;
42
+ export interface ExportedPage {
43
+ /** the page's own id, so a mention of it inside this export can be an anchor */
44
+ id: string;
45
+ title: string;
46
+ /** the titles above it, outermost first — drawn as a trail so a loose file says where it came from */
47
+ trail: string[];
48
+ /** already rendered and escaped by `renderPageDoc`; never raw prose */
49
+ html: string;
50
+ }
51
+ /**
52
+ * One HTML file holding one page or many.
53
+ *
54
+ * `pages` is a list rather than a page because the PDF path needs every page of a subtree in one
55
+ * document — Chromium is handed a document and produces a PDF, so one round trip per page would be
56
+ * one PDF per page and nothing to join them with. The zip path passes a list of one.
57
+ *
58
+ * `dir` is the writing direction of the *content*, not of the reader's locale: an exported Persian
59
+ * handbook is right-to-left wherever it is opened.
60
+ */
61
+ export declare function exportedHtmlDocument(opts: {
62
+ title: string;
63
+ pages: ExportedPage[];
64
+ dir?: 'ltr' | 'rtl' | 'auto';
65
+ lang?: string;
66
+ }): string;
67
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../../src/server/export/html.ts"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa,QAwGlB,CAAA;AAER;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,WAAW;IAC1B,qGAAqG;IACrG,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAA;IAC1B,oDAAoD;IACpD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;CAC9B;AAED,wBAAgB,WAAW,IAAI,WAAW,CAkBzC;AAED,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,sGAAsG;IACtG,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,uEAAuE;IACvE,IAAI,EAAE,MAAM,CAAA;CACb;AAaD;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,YAAY,EAAE,CAAA;IACrB,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,MAAM,CAAA;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,GAAG,MAAM,CAoBT"}
@@ -0,0 +1,206 @@
1
+ /**
2
+ * A page as an HTML file somebody can open with no Kern behind it.
3
+ *
4
+ * The body comes from `renderPageDoc` — the same static renderer the public site uses — so an export
5
+ * and a published page are the same drawing of the same stored version. What this file adds is
6
+ * everything a *file* needs and a page inside an application does not: a document element, a
7
+ * character set, a title, and the styles, inlined.
8
+ *
9
+ * **The stylesheet is written here rather than taken from `@kernhq/ui`, and that is deliberate.**
10
+ * `prose.css` in that package is 480 lines referencing 31 `--kern-*` tokens defined in a second
11
+ * stylesheet that itself references a palette — so "inline the prose styles" is really "inline the
12
+ * whole design system", and an exported file would carry a copy of it that goes stale the moment the
13
+ * palette moves. What a reader of an exported page needs is smaller and does not change: the ink and
14
+ * paper colours, a measure they can read, and a rule for every block the renderer can emit. It is
15
+ * written flat, with no custom properties to resolve, because a file that is opened in a mail client
16
+ * or pasted into a wiki is not guaranteed a cascade.
17
+ *
18
+ * It has to answer for the same things a Kern screen answers for. Dark mode is `prefers-color-scheme`
19
+ * rather than a class, because a file has no shell to set one. `dir` comes from the document element
20
+ * so a Persian or Arabic page reads right-to-left, and every rule that could have been `left` or
21
+ * `right` is a logical property — `margin-inline-start`, `border-inline-start`, `text-align: start` —
22
+ * so nothing has to be mirrored. A table is the one block that cannot be made narrower, so it keeps
23
+ * the same `overflow-x` scroller the application gives it rather than pushing the page sideways.
24
+ */
25
+ import { escapeHtml } from '../render.js';
26
+ /**
27
+ * What an exported page is dressed in.
28
+ *
29
+ * Deliberately not a copy of `prose.css`: the same shapes, in the values a standalone file can rely
30
+ * on. Every colour pair here was chosen against the surface it sits on — body ink on paper is
31
+ * #2c2a26 on #fdfcfa in light and #e8e4dc on #171614 in dark, and the muted ink is a colour rather
32
+ * than an opacity, because `opacity` fades text against whatever is behind it and a "muted" row at
33
+ * 0.5 is a row nobody can read.
34
+ *
35
+ * The contrast is arithmetic and was computed rather than judged: every foreground below was
36
+ * measured against the surface it actually sits on — including each callout tint, the highlight and
37
+ * the code ground, in both themes — and the worst pair is the muted trail at **5.33:1**, which
38
+ * clears AA for body text. Re-measure before changing a value rather than adjusting it by eye.
39
+ */
40
+ export const EXPORT_STYLES = `
41
+ :root { color-scheme: light dark; }
42
+ * { box-sizing: border-box; }
43
+ html { -webkit-text-size-adjust: 100%; }
44
+ body {
45
+ margin: 0;
46
+ padding: 40px 24px 96px;
47
+ background: #fdfcfa;
48
+ color: #2c2a26;
49
+ font: 16px/1.7 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
50
+ }
51
+ main { margin: 0 auto; max-width: 74ch; }
52
+ .kern-page + .kern-page { margin-top: 72px; border-top: 1px solid #e5e0d6; padding-top: 40px; }
53
+ .kern-page-title { margin: 0 0 4px; font-size: 32px; line-height: 1.25; font-weight: 650; }
54
+ .kern-page-trail { margin: 0 0 28px; font-size: 13px; color: #6f6959; }
55
+ .kern-prose > :first-child { margin-top: 0; }
56
+ .kern-prose > :last-child { margin-bottom: 0; }
57
+ .kern-prose p { margin: 0 0 16px; }
58
+ .kern-prose h1, .kern-prose h2, .kern-prose h3,
59
+ .kern-prose h4, .kern-prose h5, .kern-prose h6 {
60
+ margin: 32px 0 12px; line-height: 1.3; font-weight: 650; scroll-margin-top: 16px;
61
+ }
62
+ .kern-prose h1 { font-size: 28px; }
63
+ .kern-prose h2 { font-size: 23px; }
64
+ .kern-prose h3 { font-size: 19px; }
65
+ .kern-prose h4 { font-size: 17px; }
66
+ .kern-prose h5 { font-size: 15px; }
67
+ .kern-prose h6 { font-size: 14px; color: #6f6959; text-transform: uppercase; letter-spacing: 0.04em; }
68
+ .kern-prose a { color: #1c5f7a; text-underline-offset: 2px; }
69
+ .kern-prose ul, .kern-prose ol { margin: 0 0 16px; padding-inline-start: 26px; }
70
+ .kern-prose li { margin: 4px 0; }
71
+ .kern-prose li > p { margin: 0; }
72
+ .kern-prose blockquote {
73
+ margin: 0 0 16px; padding-block: 2px; padding-inline: 16px 0;
74
+ border-inline-start: 3px solid #d8d2c4; color: #4a463d;
75
+ }
76
+ .kern-prose code {
77
+ font: 0.9em/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
78
+ background: #f1eee6; border: 1px solid #e5e0d6; border-radius: 4px; padding: 1px 4px;
79
+ }
80
+ .kern-prose pre {
81
+ margin: 0 0 16px; padding: 14px 16px; overflow-x: auto;
82
+ background: #f6f3ec; border: 1px solid #e5e0d6; border-radius: 8px;
83
+ }
84
+ .kern-prose pre code { background: none; border: 0; padding: 0; font-size: 13px; }
85
+ .kern-prose hr { margin: 28px 0; border: 0; border-top: 1px solid #e5e0d6; }
86
+ .kern-prose img { max-width: 100%; height: auto; border-radius: 6px; }
87
+ .kern-prose mark.kern-highlight { background: #fbeaa8; color: inherit; padding: 0 2px; border-radius: 3px; }
88
+ .kern-table-wrap { margin: 0 0 16px; overflow-x: auto; }
89
+ .kern-prose table.kern-table { border-collapse: collapse; width: 100%; font-size: 15px; }
90
+ .kern-prose table.kern-table td, .kern-prose table.kern-table th {
91
+ border: 1px solid #e5e0d6; padding: 7px 10px; text-align: start; vertical-align: top;
92
+ }
93
+ .kern-prose table.kern-table th { background: #f6f3ec; font-weight: 620; }
94
+ .kern-prose table.kern-table [data-align="center"] { text-align: center; }
95
+ .kern-prose table.kern-table [data-align="end"] { text-align: end; }
96
+ .kern-prose ul.kern-tasks { list-style: none; padding-inline-start: 2px; }
97
+ .kern-prose ul.kern-tasks li { display: flex; gap: 8px; align-items: baseline; }
98
+ .kern-prose ul.kern-tasks li > div { flex: 1; min-width: 0; }
99
+ .kern-prose ul.kern-tasks li > div > p { margin: 0; }
100
+ .kern-prose aside.kern-callout {
101
+ margin: 0 0 16px; padding: 12px 16px; border-radius: 8px;
102
+ border: 1px solid #d8d2c4; border-inline-start-width: 3px; background: #f6f3ec;
103
+ }
104
+ .kern-prose aside.kern-callout > :last-child { margin-bottom: 0; }
105
+ .kern-prose aside.kern-callout[data-callout="info"] { border-color: #a9cbd8; background: #eef6f9; }
106
+ .kern-prose aside.kern-callout[data-callout="success"] { border-color: #a9d3b4; background: #eef8f1; }
107
+ .kern-prose aside.kern-callout[data-callout="warning"] { border-color: #e0cb96; background: #fbf5e6; }
108
+ .kern-prose aside.kern-callout[data-callout="danger"] { border-color: #e0aaa4; background: #fbefee; }
109
+ .kern-prose details.kern-toggle {
110
+ margin: 0 0 16px; padding: 10px 14px; border: 1px solid #e5e0d6; border-radius: 8px;
111
+ }
112
+ .kern-prose details.kern-toggle > summary { cursor: pointer; font-weight: 600; }
113
+ .kern-prose .kern-mention, .kern-prose .kern-page-mention {
114
+ border-radius: 4px; padding: 0 3px; background: #eef2f7; color: #2b4a63;
115
+ }
116
+ @media print {
117
+ body { padding: 0; background: #fff; color: #000; }
118
+ .kern-page { break-after: page; }
119
+ .kern-page:last-child { break-after: auto; }
120
+ .kern-page + .kern-page { margin-top: 0; border-top: 0; padding-top: 0; }
121
+ .kern-prose a { color: inherit; }
122
+ .kern-table-wrap { overflow-x: visible; }
123
+ }
124
+ @media (prefers-color-scheme: dark) {
125
+ body { background: #171614; color: #e8e4dc; }
126
+ .kern-page + .kern-page { border-top-color: #35322c; }
127
+ .kern-page-trail, .kern-prose h6 { color: #9a9384; }
128
+ .kern-prose a { color: #7fc2dd; }
129
+ .kern-prose blockquote { border-inline-start-color: #3f3b33; color: #c6c1b6; }
130
+ .kern-prose code { background: #23211d; border-color: #35322c; }
131
+ .kern-prose pre { background: #1e1d1a; border-color: #35322c; }
132
+ .kern-prose hr { border-top-color: #35322c; }
133
+ .kern-prose mark.kern-highlight { background: #6a5a1e; color: #f4efdd; }
134
+ .kern-prose table.kern-table td, .kern-prose table.kern-table th { border-color: #35322c; }
135
+ .kern-prose table.kern-table th { background: #1e1d1a; }
136
+ .kern-prose aside.kern-callout { border-color: #3f3b33; background: #1e1d1a; }
137
+ .kern-prose aside.kern-callout[data-callout="info"] { border-color: #2d5a6d; background: #16232a; }
138
+ .kern-prose aside.kern-callout[data-callout="success"] { border-color: #2f5c3d; background: #16241b; }
139
+ .kern-prose aside.kern-callout[data-callout="warning"] { border-color: #6a5622; background: #241f13; }
140
+ .kern-prose aside.kern-callout[data-callout="danger"] { border-color: #6d3a34; background: #261917; }
141
+ .kern-prose details.kern-toggle { border-color: #35322c; }
142
+ .kern-prose .kern-mention, .kern-prose .kern-page-mention { background: #22303a; color: #b7d4e4; }
143
+ }
144
+ `.trim();
145
+ export function exportLinks() {
146
+ const byTarget = new Map();
147
+ const byToken = new Map();
148
+ return {
149
+ to(target) {
150
+ const existing = byTarget.get(target);
151
+ if (existing)
152
+ return existing;
153
+ const token = `https://asset.invalid/${byToken.size}`;
154
+ byTarget.set(target, token);
155
+ byToken.set(token, target);
156
+ return token;
157
+ },
158
+ resolve(html) {
159
+ let out = html;
160
+ for (const [token, target] of byToken)
161
+ out = out.replaceAll(token, target);
162
+ return out;
163
+ },
164
+ };
165
+ }
166
+ const section = (page) => [
167
+ `<article class="kern-page" id="p-${escapeHtml(page.id)}">`,
168
+ `<h1 class="kern-page-title">${escapeHtml(page.title || 'Untitled')}</h1>`,
169
+ page.trail.length > 0
170
+ ? `<p class="kern-page-trail">${page.trail.map((part) => escapeHtml(part)).join(' / ')}</p>`
171
+ : '',
172
+ `<div class="kern-prose">${page.html}</div>`,
173
+ '</article>',
174
+ ].join('\n');
175
+ /**
176
+ * One HTML file holding one page or many.
177
+ *
178
+ * `pages` is a list rather than a page because the PDF path needs every page of a subtree in one
179
+ * document — Chromium is handed a document and produces a PDF, so one round trip per page would be
180
+ * one PDF per page and nothing to join them with. The zip path passes a list of one.
181
+ *
182
+ * `dir` is the writing direction of the *content*, not of the reader's locale: an exported Persian
183
+ * handbook is right-to-left wherever it is opened.
184
+ */
185
+ export function exportedHtmlDocument(opts) {
186
+ const dir = opts.dir ?? 'auto';
187
+ return `<!doctype html>
188
+ <html lang="${escapeHtml(opts.lang ?? 'en')}" dir="${dir}">
189
+ <head>
190
+ <meta charset="utf-8">
191
+ <meta name="viewport" content="width=device-width, initial-scale=1">
192
+ <meta name="generator" content="Kern Quire">
193
+ <title>${escapeHtml(opts.title || 'Untitled')}</title>
194
+ <style>
195
+ ${EXPORT_STYLES}
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <main>
200
+ ${opts.pages.map(section).join('\n')}
201
+ </main>
202
+ </body>
203
+ </html>
204
+ `;
205
+ }
206
+ //# sourceMappingURL=html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.js","sourceRoot":"","sources":["../../../src/server/export/html.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAEzC;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwG5B,CAAC,IAAI,EAAE,CAAA;AA4BR,MAAM,UAAU,WAAW;IACzB,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC1C,MAAM,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAA;IACzC,OAAO;QACL,EAAE,CAAC,MAAM;YACP,MAAM,QAAQ,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;YACrC,IAAI,QAAQ;gBAAE,OAAO,QAAQ,CAAA;YAC7B,MAAM,KAAK,GAAG,yBAAyB,OAAO,CAAC,IAAI,EAAE,CAAA;YACrD,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;YAC3B,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;YAC1B,OAAO,KAAK,CAAA;QACd,CAAC;QACD,OAAO,CAAC,IAAI;YACV,IAAI,GAAG,GAAG,IAAI,CAAA;YACd,KAAK,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,IAAI,OAAO;gBAAE,GAAG,GAAG,GAAG,CAAC,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;YAC1E,OAAO,GAAG,CAAA;QACZ,CAAC;KACF,CAAA;AACH,CAAC;AAYD,MAAM,OAAO,GAAG,CAAC,IAAkB,EAAU,EAAE,CAC7C;IACE,oCAAoC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI;IAC3D,+BAA+B,UAAU,CAAC,IAAI,CAAC,KAAK,IAAI,UAAU,CAAC,OAAO;IAC1E,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC;QACnB,CAAC,CAAC,8BAA8B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM;QAC5F,CAAC,CAAC,EAAE;IACN,2BAA2B,IAAI,CAAC,IAAI,QAAQ;IAC5C,YAAY;CACb,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAEd;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAKpC;IACC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,MAAM,CAAA;IAC9B,OAAO;cACK,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,GAAG;;;;;SAK/C,UAAU,CAAC,IAAI,CAAC,KAAK,IAAI,UAAU,CAAC;;EAE3C,aAAa;;;;;EAKb,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;;;;CAInC,CAAA;AACD,CAAC"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A page as Markdown, written from the page document rather than from HTML.
3
+ *
4
+ * Two writers, one source. `renderPageDoc` next door draws the same document as HTML; this one draws
5
+ * it as Markdown, and both read the stored `PageDoc`. Converting the HTML instead would have been
6
+ * less code and would have thrown away everything a heading, a task and a table know about
7
+ * themselves — an `<h2>` is a heading, but a `<ul class="kern-tasks">` full of disabled checkboxes is
8
+ * not a task list to anything that reads it back.
9
+ *
10
+ * **Round-tripping is the requirement, not prettiness.** A heading, a list, a table, a code block, a
11
+ * callout and a task list have to come back as themselves when the file is imported again, so every
12
+ * one of them is written in the dialect an importer can recognise without guessing:
13
+ *
14
+ * - tables and task lists are GitHub-flavoured Markdown, because that is what every reader
15
+ * implements and what an import will parse;
16
+ * - a callout is a blockquote with a GitHub alert marker — `> [!WARNING]` — carrying Quire's own
17
+ * tone name, so it re-reads as the same callout here and degrades to an ordinary blockquote
18
+ * anywhere else. A `<div data-callout>` would round-trip too and would be a raw HTML block in
19
+ * every other reader;
20
+ * - underline and highlight have no Markdown at all and are written as `<u>` and `<mark>`, which
21
+ * is inline HTML a Markdown parser passes through rather than a syntax it will mangle.
22
+ *
23
+ * `PAGE_DOC_NODES` is the list this has to cover, the same list `render.ts` is held to, and
24
+ * `export.int.test.ts` compares this file's table against it in both directions. A node with no
25
+ * writer here does not degrade quietly — it would lose somebody's table.
26
+ */
27
+ import type { PageDoc } from '@kernhq/ui/editor/page-doc';
28
+ export interface MarkdownOptions {
29
+ /**
30
+ * Where the picture with this id sits *relative to the file being written* — `media/diagram.png`.
31
+ *
32
+ * Never a signed storage URL. A presigned GET is the object's key, so it carries the tenant's
33
+ * workspace uuid and the file's uuid, and it stops working an hour after it is minted: writing one
34
+ * into a file somebody keeps is both a leak and a broken picture. A picture with no answer here is
35
+ * dropped, exactly as the HTML renderer drops it.
36
+ */
37
+ fileSrc?: (fileId: string) => string | null;
38
+ /** Where another exported page sits relative to this one. Without one, a mention stays as text. */
39
+ pageHref?: (pageId: string) => string | null;
40
+ }
41
+ /** Every node type this file can write, for the test that compares it against `PAGE_DOC_NODES`. */
42
+ export declare const MARKDOWN_NODES: readonly string[];
43
+ /**
44
+ * A stored page as Markdown. An empty or unreadable document is an empty string, never a throw.
45
+ *
46
+ * The title is not written here. A page's title is a column, not a node in its document, so whether
47
+ * it becomes an `# H1` at the top is a decision about the file — and the file writer next door makes
48
+ * it, once, for both formats.
49
+ */
50
+ export declare function pageDocToMarkdown(doc: PageDoc | null | undefined, options?: MarkdownOptions): string;
51
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../../src/server/export/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,OAAO,EAA4B,MAAM,4BAA4B,CAAA;AAEnF,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IAC3C,mGAAmG;IACnG,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;CAC7C;AAsUD,mGAAmG;AACnG,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAE3C,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAKxG"}
@@ -0,0 +1,312 @@
1
+ /**
2
+ * What a Markdown reader would otherwise take as syntax.
3
+ *
4
+ * Deliberately not every character CommonMark lists. Escaping `.`, `+`, `(` and `)` everywhere turns
5
+ * an ordinary sentence into a thicket of backslashes, and they are only meaningful at the start of a
6
+ * line or inside a link — both of which are handled where they arise. What is escaped here is what
7
+ * changes the meaning of a sentence wherever it appears.
8
+ */
9
+ const escapeInline = (text) => text.replace(/([\\`*_[\]<>|~])/g, '\\$1');
10
+ /**
11
+ * A line that would start a block if it were left alone — a paragraph beginning `- ` or `# `.
12
+ *
13
+ * The ordered-list case is separate because the escape goes in a different place. `\1. ` is not an
14
+ * escape at all: a backslash before anything that is not punctuation is a literal backslash, so it
15
+ * would print one and still start a list. `1\. ` is the one that works.
16
+ */
17
+ const escapeLineStart = (line) => line
18
+ .replace(/^(\s*)(>|#{1,6}(?=\s)|[-*+](?=\s))/, '$1\\$2')
19
+ .replace(/^(\s*)(\d{1,9})([.)])(?=\s)/, '$1$2\\$3');
20
+ /** Every character in the node, ignoring marks. What a fence and a `<pre>` want. */
21
+ function textOf(node) {
22
+ if (typeof node.text === 'string')
23
+ return node.text;
24
+ return (node.content ?? []).map(textOf).join('');
25
+ }
26
+ /**
27
+ * The marks, applied so the result parses.
28
+ *
29
+ * Order matters and is not alphabetical. `code` goes on first because a code span's content is
30
+ * literal — `**bold**` inside backticks is four asterisks and a word — and `link` goes on last
31
+ * because a link's label may hold everything else and nothing may hold a link.
32
+ */
33
+ function applyMarks(text, marks) {
34
+ const has = (type) => (marks ?? []).some((m) => m?.type === type);
35
+ let out = has('code') ? codeSpan(text) : escapeInline(text);
36
+ if (has('highlight'))
37
+ out = `<mark>${out}</mark>`;
38
+ if (has('strike'))
39
+ out = `~~${out}~~`;
40
+ if (has('underline'))
41
+ out = `<u>${out}</u>`;
42
+ if (has('italic'))
43
+ out = `*${out}*`;
44
+ if (has('bold'))
45
+ out = `**${out}**`;
46
+ const link = (marks ?? []).find((m) => m?.type === 'link');
47
+ if (link) {
48
+ const href = typeof link.attrs?.href === 'string' ? link.attrs.href : '';
49
+ if (href)
50
+ out = `[${out}](${encodeLinkTarget(href)})`;
51
+ }
52
+ return out;
53
+ }
54
+ /** A code span whose fence is longer than any run of backticks inside it, so it closes where it should. */
55
+ function codeSpan(text) {
56
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
57
+ const fence = '`'.repeat(longest + 1);
58
+ const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';
59
+ return `${fence}${pad}${text}${pad}${fence}`;
60
+ }
61
+ /** Parentheses and whitespace are what break an inline link target; angle brackets fix both. */
62
+ const encodeLinkTarget = (href) => /[\s()<>]/.test(href) ? `<${href.replaceAll('>', '%3E')}>` : href;
63
+ function inline(nodes, options) {
64
+ return (nodes ?? [])
65
+ .map((node) => {
66
+ if (typeof node.text === 'string')
67
+ return applyMarks(node.text, node.marks);
68
+ const writer = INLINE_WRITERS[node.type ?? ''];
69
+ if (writer)
70
+ return writer(node, options);
71
+ // An inline node with no writer keeps its text rather than vanishing mid-sentence.
72
+ return escapeInline(textOf(node));
73
+ })
74
+ .join('');
75
+ }
76
+ const INLINE_WRITERS = {
77
+ // Two trailing spaces is the line break every Markdown reader implements; a backslash is CommonMark
78
+ // only, and an importer that is not CommonMark would keep the backslash as a character.
79
+ hardBreak: () => ' \n',
80
+ text: (node) => escapeInline(node.text ?? ''),
81
+ /**
82
+ * A person, as `@Ada` — **and this one does not come back.**
83
+ *
84
+ * Stated here because it is the one construct in the writer that is knowingly lossy, and a reader
85
+ * of this table would otherwise assume the pairing with `import/markdown.ts` is total. A page
86
+ * mention resolves against a path in the same archive, so the reader can rebuild it; a person
87
+ * resolves against a *user id*, which is a fact about the instance the export came from. The two
88
+ * spellings that would carry it are both worse than the loss: putting the id in the file makes an
89
+ * export identify people, and importing it would mint a mention of a stranger's id in a workspace
90
+ * that never had that person. So this writes what a mention reads as — the name — and it re-imports
91
+ * as prose. There is no report row for it either, because by then it is a run of text and nothing
92
+ * can tell it from one somebody typed.
93
+ */
94
+ mention: (node) => `@${escapeInline(String(node.attrs?.label ?? ''))}`,
95
+ pageMention: (node, options) => {
96
+ const label = escapeInline(String(node.attrs?.label ?? '') || 'Untitled');
97
+ const id = typeof node.attrs?.id === 'string' ? node.attrs.id : '';
98
+ const href = id ? (options.pageHref?.(id) ?? null) : null;
99
+ // A page that is not in this export cannot be linked to, and a dead link is worse than a name.
100
+ return href ? `[${label}](${encodeLinkTarget(href)})` : label;
101
+ },
102
+ image: (node, options) => {
103
+ const fileId = typeof node.attrs?.fileId === 'string' ? node.attrs.fileId : null;
104
+ const resolved = fileId ? (options.fileSrc?.(fileId) ?? null) : null;
105
+ const src = resolved ?? (typeof node.attrs?.src === 'string' ? node.attrs.src : null);
106
+ if (!src)
107
+ return '';
108
+ const alt = escapeInline(typeof node.attrs?.alt === 'string' ? node.attrs.alt : '');
109
+ const title = typeof node.attrs?.title === 'string' && node.attrs.title ? node.attrs.title : null;
110
+ return `![${alt}](${encodeLinkTarget(src)}${title ? ` "${title.replaceAll('"', '\\"')}"` : ''})`;
111
+ },
112
+ };
113
+ /**
114
+ * Prefix every line, including the blank ones — which is what makes a nested blockquote hold.
115
+ *
116
+ * A blank line gets the prefix trimmed (`>` rather than `> `) and a line with content does not, so
117
+ * the two trailing spaces that mean "line break" survive. Trimming every line would delete them and
118
+ * silently join two lines of an address.
119
+ */
120
+ const prefixLines = (text, first, rest = first) => text
121
+ .split('\n')
122
+ .map((line, i) => line.length === 0 ? (i === 0 ? first : rest).trimEnd() : `${i === 0 ? first : rest}${line}`)
123
+ .join('\n');
124
+ /** Blocks, separated by the blank line that keeps them separate blocks. */
125
+ function blocks(nodes, options) {
126
+ return (nodes ?? [])
127
+ .map((node) => block(node, options))
128
+ .filter((text) => text.length > 0)
129
+ .join('\n\n');
130
+ }
131
+ function block(node, options) {
132
+ const writer = BLOCK_WRITERS[node.type ?? ''];
133
+ if (writer)
134
+ return writer(node, options);
135
+ // Unreachable while the test below holds, and correct if a newer image writes a node this one has
136
+ // never heard of: keep the children rather than losing a paragraph to an unknown wrapper.
137
+ return blocks(node.content, options);
138
+ }
139
+ /**
140
+ * A list, whose marker depends on the list and whose continuation depends on the marker.
141
+ *
142
+ * Every line after the first is indented by the marker's own width, or the reader ends the item and
143
+ * starts a paragraph — which is how a two-paragraph bullet becomes a bullet and an orphan.
144
+ */
145
+ function list(node, options, markerFor) {
146
+ return (node.content ?? [])
147
+ .map((item, index) => {
148
+ const marker = markerFor(index);
149
+ const body = blocks(item.content, options);
150
+ // An empty bullet is still a bullet somebody typed, so the marker survives on its own.
151
+ return body ? prefixLines(body, marker, ' '.repeat(marker.length)) : marker.trimEnd();
152
+ })
153
+ .join('\n');
154
+ }
155
+ /** `info` | `note` | `success` | `warning` | `danger`, upper-cased into a GitHub alert marker. */
156
+ const CALLOUT_TONES = new Set(['info', 'note', 'success', 'warning', 'danger']);
157
+ /**
158
+ * Escape the pipes a cell still has, and only those.
159
+ *
160
+ * `escapeInline` already escaped every pipe in ordinary prose, so a blind `replaceAll` would double
161
+ * them — `Pipe \| here` becomes `Pipe \\| here`, which renders as a backslash and *then* ends the
162
+ * cell. What is left unescaped is a pipe inside a code span, where escaping is deliberately not
163
+ * applied — and GFM says a table cell escapes those too, code span or not. So this walks the string,
164
+ * steps over anything already escaped, and escapes what remains.
165
+ */
166
+ function escapePipes(text) {
167
+ let out = '';
168
+ for (let i = 0; i < text.length; i++) {
169
+ const ch = text[i];
170
+ if (ch === '\\' && i + 1 < text.length) {
171
+ out += ch + text[i + 1];
172
+ i++;
173
+ continue;
174
+ }
175
+ out += ch === '|' ? '\\|' : ch;
176
+ }
177
+ return out;
178
+ }
179
+ /**
180
+ * One cell of a GFM table.
181
+ *
182
+ * A cell is inline only — the format has no way to hold a second paragraph — so a paragraph break
183
+ * becomes `<br><br>`. Losing the block structure inside a cell is the format's limit, and saying so
184
+ * here is better than a table that stops being a table at the first newline.
185
+ *
186
+ * The whitespace before each newline goes with it, and that is not tidiness. A hard break is written
187
+ * ` \n` — two spaces are what makes it a break in prose — so leaving them in front of the `<br>`
188
+ * hands the importer a cell whose text ends in two spaces it never had. They are invisible in every
189
+ * rendering and they *accumulate*: three export-import cycles turned `a` into `a` and six spaces.
190
+ */
191
+ const tableCell = (cell, options) => escapePipes((blocks(cell.content, options) || '')
192
+ .replace(/[ \t]*\n{2,}/g, '<br><br>')
193
+ .replace(/[ \t]*\n/g, '<br>')
194
+ .trim());
195
+ function table(node, options) {
196
+ const rows = (node.content ?? []).filter((row) => (row.content ?? []).length > 0);
197
+ if (rows.length === 0)
198
+ return '';
199
+ const width = Math.max(...rows.map((row) => (row.content ?? []).length));
200
+ const cellsOf = (row) => {
201
+ const cells = (row.content ?? []).map((cell) => tableCell(cell, options));
202
+ while (cells.length < width)
203
+ cells.push('');
204
+ return cells;
205
+ };
206
+ /*
207
+ * GFM has no table without a header row, so a table whose first row is ordinary cells still
208
+ * contributes one — its own first row. Emitting an empty header instead would put a blank band at
209
+ * the top of every table Quire exports, and shift every row's meaning by one on the way back in.
210
+ */
211
+ const [head, ...body] = rows;
212
+ const line = (cells) => `| ${cells.join(' | ')} |`;
213
+ return [
214
+ line(cellsOf(head)),
215
+ line(Array.from({ length: width }, () => '---')),
216
+ ...body.map((row) => line(cellsOf(row))),
217
+ ].join('\n');
218
+ }
219
+ const BLOCK_WRITERS = {
220
+ paragraph: (node, options) => escapeLineStart(inline(node.content, options)),
221
+ heading: (node, options) => {
222
+ const raw = Number(node.attrs?.level);
223
+ const level = Number.isInteger(raw) && raw >= 1 && raw <= 6 ? raw : 1;
224
+ return `${'#'.repeat(level)} ${inline(node.content, options)}`.trimEnd();
225
+ },
226
+ bulletList: (node, options) => list(node, options, () => '- '),
227
+ orderedList: (node, options) => {
228
+ const raw = Number(node.attrs?.start);
229
+ const start = Number.isInteger(raw) && raw > 0 ? raw : 1;
230
+ return list(node, options, (index) => `${start + index}. `);
231
+ },
232
+ listItem: (node, options) => blocks(node.content, options),
233
+ taskList: (node, options) => (node.content ?? [])
234
+ .map((item) => {
235
+ const marker = `- [${item.attrs?.checked === true ? 'x' : ' '}] `;
236
+ const body = blocks(item.content, options);
237
+ return body ? prefixLines(body, marker, ' '.repeat(marker.length)) : marker.trimEnd();
238
+ })
239
+ .join('\n'),
240
+ taskItem: (node, options) => blocks(node.content, options),
241
+ blockquote: (node, options) => prefixLines(blocks(node.content, options), '> '),
242
+ /**
243
+ * A GitHub alert: a blockquote whose first line names the tone.
244
+ *
245
+ * The tone is Quire's own word rather than GitHub's nearest equivalent, because the round trip
246
+ * matters more than the rendering on one website. `[!SUCCESS]` is not one of GitHub's five, so it
247
+ * renders there as a plain blockquote with a line of text at the top — which is a smaller loss
248
+ * than mapping `success` onto `TIP` and importing it back as the wrong colour.
249
+ */
250
+ callout: (node, options) => {
251
+ const tone = String(node.attrs?.tone ?? 'info');
252
+ const marker = CALLOUT_TONES.has(tone) ? tone : 'info';
253
+ return prefixLines(`[!${marker.toUpperCase()}]\n${blocks(node.content, options)}`, '> ');
254
+ },
255
+ codeBlock: (node) => {
256
+ const language = typeof node.attrs?.language === 'string' && /^[a-z0-9#+.-]{1,24}$/i.test(node.attrs.language)
257
+ ? node.attrs.language
258
+ : '';
259
+ const body = textOf(node);
260
+ // Longer than any run of backticks in the body, or a snippet about Markdown closes its own fence.
261
+ const longest = Math.max(2, ...[...body.matchAll(/^`{3,}/gm)].map((m) => m[0].length));
262
+ const fence = '`'.repeat(longest + 1);
263
+ return `${fence}${language}\n${body}\n${fence}`;
264
+ },
265
+ horizontalRule: () => '---',
266
+ table,
267
+ // A row or a cell reached on its own is inside a table that has already written it; reaching one
268
+ // here would mean a malformed document, and its text is better kept than dropped.
269
+ tableRow: (node, options) => blocks(node.content, options),
270
+ tableCell: (node, options) => blocks(node.content, options),
271
+ tableHeader: (node, options) => blocks(node.content, options),
272
+ /**
273
+ * A toggle, as the HTML block Markdown has no syntax for.
274
+ *
275
+ * The blank lines around the body are load-bearing: without them a Markdown reader treats
276
+ * everything between the tags as raw HTML and stops parsing the prose inside, so a bulleted list
277
+ * inside a toggle arrives as literal hyphens.
278
+ */
279
+ details: (node, options) => {
280
+ const summary = (node.content ?? []).find((child) => child.type === 'detailsSummary');
281
+ const rest = (node.content ?? []).filter((child) => child.type !== 'detailsSummary');
282
+ const title = summary ? inline(summary.content, options) : '';
283
+ return `<details>\n<summary>${title}</summary>\n\n${blocks(rest, options)}\n\n</details>`;
284
+ },
285
+ detailsSummary: (node, options) => inline(node.content, options),
286
+ detailsContent: (node, options) => blocks(node.content, options),
287
+ // Blocks in name only: both are inline nodes that can also stand alone as a whole block.
288
+ image: (node, options) => INLINE_WRITERS.image(node, options),
289
+ hardBreak: () => '',
290
+ mention: (node, options) => INLINE_WRITERS.mention(node, options),
291
+ pageMention: (node, options) => INLINE_WRITERS.pageMention(node, options),
292
+ text: (node) => escapeInline(node.text ?? ''),
293
+ };
294
+ /** Every node type this file can write, for the test that compares it against `PAGE_DOC_NODES`. */
295
+ export const MARKDOWN_NODES = [
296
+ ...new Set(['doc', ...Object.keys(BLOCK_WRITERS), ...Object.keys(INLINE_WRITERS)]),
297
+ ];
298
+ /**
299
+ * A stored page as Markdown. An empty or unreadable document is an empty string, never a throw.
300
+ *
301
+ * The title is not written here. A page's title is a column, not a node in its document, so whether
302
+ * it becomes an `# H1` at the top is a decision about the file — and the file writer next door makes
303
+ * it, once, for both formats.
304
+ */
305
+ export function pageDocToMarkdown(doc, options = {}) {
306
+ if (!doc || !Array.isArray(doc.content))
307
+ return '';
308
+ return `${blocks(doc.content, options)
309
+ .replace(/\n{3,}/g, '\n\n')
310
+ .trim()}\n`;
311
+ }
312
+ //# sourceMappingURL=markdown.js.map