@cfbender/cesium 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -3
- package/README.md +8 -8
- package/package.json +17 -17
- package/src/cli/commands/serve.ts +1 -2
- package/src/index.ts +4 -1
- package/src/prompt/field-reference.ts +2 -2
- package/src/prompt/system-fragment.md +46 -16
- package/src/render/blocks/catalog.ts +2 -0
- package/src/render/blocks/diff/myers.ts +221 -0
- package/src/render/blocks/diff/parse-unified.ts +101 -0
- package/src/render/blocks/highlight.ts +8 -11
- package/src/render/blocks/markdown.ts +28 -7
- package/src/render/blocks/render.ts +3 -0
- package/src/render/blocks/renderers/code.ts +1 -3
- package/src/render/blocks/renderers/compare-table.ts +3 -4
- package/src/render/blocks/renderers/diagram.ts +2 -5
- package/src/render/blocks/renderers/diff.ts +378 -0
- package/src/render/blocks/renderers/prose.ts +1 -2
- package/src/render/blocks/renderers/timeline.ts +2 -1
- package/src/render/blocks/themes/claret-dark.ts +1 -6
- package/src/render/blocks/themes/claret-light.ts +1 -6
- package/src/render/blocks/types.ts +13 -1
- package/src/render/blocks/validate-block.ts +19 -9
- package/src/render/theme.ts +131 -0
- package/src/render/validate.ts +53 -9
- package/src/server/lifecycle.ts +5 -1
- package/src/storage/index-gen.ts +2 -3
- package/src/tools/publish.ts +1 -3
- package/src/tools/styleguide.ts +3 -7
package/src/render/theme.ts
CHANGED
|
@@ -13,6 +13,9 @@ export interface ThemePalette {
|
|
|
13
13
|
olive: string;
|
|
14
14
|
codeBg: string;
|
|
15
15
|
codeFg: string;
|
|
16
|
+
diffAdd: string; // line-tint and connector color for additions
|
|
17
|
+
diffRemove: string; // line-tint and connector color for deletions
|
|
18
|
+
diffChange: string; // connector color for replacements
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface ThemeFonts {
|
|
@@ -56,6 +59,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
56
59
|
olive: "#8FA86E",
|
|
57
60
|
codeBg: "#2B1F22",
|
|
58
61
|
codeFg: "#DDD3C7",
|
|
62
|
+
diffAdd: "#6D9E60",
|
|
63
|
+
diffRemove: "#C75B5B",
|
|
64
|
+
diffChange: "#D4A85A",
|
|
59
65
|
},
|
|
60
66
|
// claret-light: deep-rose-on-warm-cream — derived from claret.nvim light palette.
|
|
61
67
|
// (this is the old "claret" palette, now renamed)
|
|
@@ -72,6 +78,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
72
78
|
olive: "#5A6B40",
|
|
73
79
|
codeBg: "#180810",
|
|
74
80
|
codeFg: "#DDD3C7",
|
|
81
|
+
diffAdd: "#5A6B40",
|
|
82
|
+
diffRemove: "#9E3838",
|
|
83
|
+
diffChange: "#B07A2A",
|
|
75
84
|
},
|
|
76
85
|
// claret: alias for claret-dark (backward compat)
|
|
77
86
|
claret: {
|
|
@@ -87,6 +96,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
87
96
|
olive: "#8FA86E",
|
|
88
97
|
codeBg: "#2B1F22",
|
|
89
98
|
codeFg: "#DDD3C7",
|
|
99
|
+
diffAdd: "#6D9E60",
|
|
100
|
+
diffRemove: "#C75B5B",
|
|
101
|
+
diffChange: "#D4A85A",
|
|
90
102
|
},
|
|
91
103
|
// Warm: ivory/clay/oat — the html-effectiveness reference palette.
|
|
92
104
|
warm: {
|
|
@@ -102,6 +114,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
102
114
|
olive: "#788C5D",
|
|
103
115
|
codeBg: "#141413",
|
|
104
116
|
codeFg: "#E8E6DE",
|
|
117
|
+
diffAdd: "#788C5D",
|
|
118
|
+
diffRemove: "#C0392B",
|
|
119
|
+
diffChange: "#D97757",
|
|
105
120
|
},
|
|
106
121
|
// Cool: desaturated blue-grey — technical, trustworthy.
|
|
107
122
|
cool: {
|
|
@@ -117,6 +132,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
117
132
|
olive: "#4E8A6A",
|
|
118
133
|
codeBg: "#1B2333",
|
|
119
134
|
codeFg: "#D8E0ED",
|
|
135
|
+
diffAdd: "#4E8A6A",
|
|
136
|
+
diffRemove: "#B6443A",
|
|
137
|
+
diffChange: "#3A7BB8",
|
|
120
138
|
},
|
|
121
139
|
// Mono: black/white/grey — editorial, high-contrast.
|
|
122
140
|
mono: {
|
|
@@ -132,6 +150,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
132
150
|
olive: "#5A7A5A",
|
|
133
151
|
codeBg: "#111111",
|
|
134
152
|
codeFg: "#EBEBEB",
|
|
153
|
+
diffAdd: "#5A7A5A",
|
|
154
|
+
diffRemove: "#A03A2B",
|
|
155
|
+
diffChange: "#666666",
|
|
135
156
|
},
|
|
136
157
|
// Paper: sepia/cream — soft, book-like, warm and aged.
|
|
137
158
|
paper: {
|
|
@@ -147,6 +168,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
|
|
|
147
168
|
olive: "#607848",
|
|
148
169
|
codeBg: "#2A2218",
|
|
149
170
|
codeFg: "#E8DEC8",
|
|
171
|
+
diffAdd: "#607848",
|
|
172
|
+
diffRemove: "#A0392B",
|
|
173
|
+
diffChange: "#B05A2A",
|
|
150
174
|
},
|
|
151
175
|
};
|
|
152
176
|
|
|
@@ -198,6 +222,9 @@ export function themeToCssVars(theme: ThemeTokens): string {
|
|
|
198
222
|
--olive: ${colors.olive};
|
|
199
223
|
--code-bg: ${colors.codeBg};
|
|
200
224
|
--code-fg: ${colors.codeFg};
|
|
225
|
+
--diff-add: ${colors.diffAdd};
|
|
226
|
+
--diff-remove: ${colors.diffRemove};
|
|
227
|
+
--diff-change: ${colors.diffChange};
|
|
201
228
|
--serif: ${fonts.serif};
|
|
202
229
|
--sans: ${fonts.sans};
|
|
203
230
|
--mono: ${fonts.mono};
|
|
@@ -936,6 +963,110 @@ textarea.cs-text { font-family: var(--mono); }
|
|
|
936
963
|
font-weight: 600;
|
|
937
964
|
z-index: 10;
|
|
938
965
|
}
|
|
966
|
+
|
|
967
|
+
/* diff block */
|
|
968
|
+
.diff-block {
|
|
969
|
+
margin: var(--space-6, 1.5rem) 0;
|
|
970
|
+
border: 1.5px solid var(--rule);
|
|
971
|
+
border-radius: 12px;
|
|
972
|
+
overflow: hidden;
|
|
973
|
+
background: var(--code-bg);
|
|
974
|
+
font-family: var(--mono);
|
|
975
|
+
font-size: 13px;
|
|
976
|
+
color: var(--code-fg);
|
|
977
|
+
}
|
|
978
|
+
.diff-block.fallback pre {
|
|
979
|
+
margin: 0;
|
|
980
|
+
padding: 12px 14px;
|
|
981
|
+
white-space: pre;
|
|
982
|
+
overflow-x: auto;
|
|
983
|
+
}
|
|
984
|
+
.diff-header {
|
|
985
|
+
display: flex;
|
|
986
|
+
justify-content: space-between;
|
|
987
|
+
align-items: center;
|
|
988
|
+
padding: 8px 14px;
|
|
989
|
+
border-bottom: 1px solid color-mix(in oklab, var(--rule), transparent 40%);
|
|
990
|
+
background: color-mix(in oklab, var(--code-bg), var(--code-fg) 5%);
|
|
991
|
+
font-size: 12px;
|
|
992
|
+
color: color-mix(in oklab, var(--code-fg), transparent 30%);
|
|
993
|
+
}
|
|
994
|
+
.diff-filename { font-weight: 500; }
|
|
995
|
+
.diff-stat { font-variant-numeric: tabular-nums; display: inline-flex; gap: 8px; }
|
|
996
|
+
.diff-stat .add { color: var(--diff-add); }
|
|
997
|
+
.diff-stat .rem { color: var(--diff-remove); }
|
|
998
|
+
|
|
999
|
+
.diff-grid {
|
|
1000
|
+
display: grid;
|
|
1001
|
+
grid-template-columns: 1fr 60px 1fr;
|
|
1002
|
+
align-items: start;
|
|
1003
|
+
}
|
|
1004
|
+
.diff-side {
|
|
1005
|
+
list-style: none;
|
|
1006
|
+
margin: 0;
|
|
1007
|
+
padding: 8px 0;
|
|
1008
|
+
overflow-x: auto;
|
|
1009
|
+
min-width: 0;
|
|
1010
|
+
}
|
|
1011
|
+
.diff-line {
|
|
1012
|
+
display: grid;
|
|
1013
|
+
grid-template-columns: 3.25em 1fr;
|
|
1014
|
+
gap: 0;
|
|
1015
|
+
height: 22px;
|
|
1016
|
+
line-height: 22px;
|
|
1017
|
+
white-space: pre;
|
|
1018
|
+
}
|
|
1019
|
+
.diff-line .num {
|
|
1020
|
+
text-align: right;
|
|
1021
|
+
padding-right: 12px;
|
|
1022
|
+
color: color-mix(in oklab, var(--code-fg), transparent 60%);
|
|
1023
|
+
user-select: none;
|
|
1024
|
+
font-variant-numeric: tabular-nums;
|
|
1025
|
+
}
|
|
1026
|
+
.diff-line .content {
|
|
1027
|
+
padding-right: 14px;
|
|
1028
|
+
overflow: hidden;
|
|
1029
|
+
text-overflow: ellipsis;
|
|
1030
|
+
}
|
|
1031
|
+
.diff-line.add { background: color-mix(in oklab, transparent, var(--diff-add) 14%); }
|
|
1032
|
+
.diff-line.remove { background: color-mix(in oklab, transparent, var(--diff-remove) 14%); }
|
|
1033
|
+
.diff-line.hunk-sep {
|
|
1034
|
+
background: color-mix(in oklab, var(--code-bg), var(--code-fg) 8%);
|
|
1035
|
+
color: color-mix(in oklab, var(--code-fg), transparent 50%);
|
|
1036
|
+
font-style: italic;
|
|
1037
|
+
font-size: 11px;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.diff-connector {
|
|
1041
|
+
position: relative;
|
|
1042
|
+
align-self: stretch;
|
|
1043
|
+
padding: 8px 0;
|
|
1044
|
+
background: color-mix(in oklab, var(--code-bg), var(--code-fg) 2%);
|
|
1045
|
+
border-left: 1px solid color-mix(in oklab, var(--rule), transparent 60%);
|
|
1046
|
+
border-right: 1px solid color-mix(in oklab, var(--rule), transparent 60%);
|
|
1047
|
+
}
|
|
1048
|
+
.diff-connector svg {
|
|
1049
|
+
display: block;
|
|
1050
|
+
width: 100%;
|
|
1051
|
+
}
|
|
1052
|
+
.diff-conn { stroke-width: 1; }
|
|
1053
|
+
.diff-conn.add { fill: var(--diff-add); stroke: var(--diff-add); fill-opacity: 0.22; }
|
|
1054
|
+
.diff-conn.remove { fill: var(--diff-remove); stroke: var(--diff-remove); fill-opacity: 0.22; }
|
|
1055
|
+
.diff-conn.change { fill: var(--diff-change); stroke: var(--diff-change); fill-opacity: 0.18; }
|
|
1056
|
+
|
|
1057
|
+
.diff-block figcaption {
|
|
1058
|
+
padding: 8px 14px;
|
|
1059
|
+
border-top: 1px solid color-mix(in oklab, var(--rule), transparent 40%);
|
|
1060
|
+
font-size: 12px;
|
|
1061
|
+
font-family: var(--sans);
|
|
1062
|
+
color: color-mix(in oklab, var(--code-fg), transparent 30%);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
@media (max-width: 720px) {
|
|
1066
|
+
.diff-grid { grid-template-columns: 1fr; }
|
|
1067
|
+
.diff-connector { display: none; }
|
|
1068
|
+
.diff-side.before { border-bottom: 1px solid color-mix(in oklab, var(--rule), transparent 60%); }
|
|
1069
|
+
}
|
|
939
1070
|
`;
|
|
940
1071
|
}
|
|
941
1072
|
|
package/src/render/validate.ts
CHANGED
|
@@ -439,7 +439,9 @@ function isPublishKind(val: unknown): val is PublishKind {
|
|
|
439
439
|
// ─── Block validation ─────────────────────────────────────────────────────────
|
|
440
440
|
|
|
441
441
|
type BlockValidationError = { path: string; message: string };
|
|
442
|
-
type BlockValidationResult =
|
|
442
|
+
type BlockValidationResult =
|
|
443
|
+
| { ok: true; blocks: Block[] }
|
|
444
|
+
| { ok: false; errors: BlockValidationError[] };
|
|
443
445
|
|
|
444
446
|
function validateBlock(raw: unknown, path: string, depth: number): BlockValidationError[] {
|
|
445
447
|
const errors: BlockValidationError[] = [];
|
|
@@ -484,7 +486,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
|
|
|
484
486
|
errors.push({ path, message: "section block requires children array" });
|
|
485
487
|
} else {
|
|
486
488
|
if (depth > 3) {
|
|
487
|
-
errors.push({
|
|
489
|
+
errors.push({
|
|
490
|
+
path,
|
|
491
|
+
message: `section nesting depth exceeds maximum of 3 (current depth: ${depth})`,
|
|
492
|
+
});
|
|
488
493
|
} else {
|
|
489
494
|
const children = b["children"] as unknown[];
|
|
490
495
|
for (let i = 0; i < children.length; i++) {
|
|
@@ -518,7 +523,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
|
|
|
518
523
|
}
|
|
519
524
|
case "code": {
|
|
520
525
|
if (typeof b["lang"] !== "string" || (b["lang"] as string).trim() === "") {
|
|
521
|
-
errors.push({
|
|
526
|
+
errors.push({
|
|
527
|
+
path,
|
|
528
|
+
message: 'code block requires a non-empty lang (use "text" if unknown)',
|
|
529
|
+
});
|
|
522
530
|
}
|
|
523
531
|
if (typeof b["code"] !== "string") {
|
|
524
532
|
errors.push({ path, message: "code block requires code field" });
|
|
@@ -580,7 +588,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
|
|
|
580
588
|
const hasSvg = typeof b["svg"] === "string";
|
|
581
589
|
const hasHtml = typeof b["html"] === "string";
|
|
582
590
|
if (hasSvg && hasHtml) {
|
|
583
|
-
errors.push({
|
|
591
|
+
errors.push({
|
|
592
|
+
path,
|
|
593
|
+
message: "diagram block must have exactly one of svg or html, not both",
|
|
594
|
+
});
|
|
584
595
|
} else if (!hasSvg && !hasHtml) {
|
|
585
596
|
errors.push({ path, message: "diagram block requires exactly one of svg or html" });
|
|
586
597
|
}
|
|
@@ -592,6 +603,35 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
|
|
|
592
603
|
}
|
|
593
604
|
break;
|
|
594
605
|
}
|
|
606
|
+
case "diff": {
|
|
607
|
+
const hasPatch = "patch" in b && b["patch"] !== undefined;
|
|
608
|
+
const hasBefore = "before" in b && b["before"] !== undefined;
|
|
609
|
+
const hasAfter = "after" in b && b["after"] !== undefined;
|
|
610
|
+
|
|
611
|
+
if (hasPatch && (hasBefore || hasAfter)) {
|
|
612
|
+
errors.push({
|
|
613
|
+
path,
|
|
614
|
+
message: "provide exactly one of patch or before/after, not both",
|
|
615
|
+
});
|
|
616
|
+
} else if (!hasPatch && !hasBefore && !hasAfter) {
|
|
617
|
+
errors.push({
|
|
618
|
+
path,
|
|
619
|
+
message: "diff block requires either patch or before+after",
|
|
620
|
+
});
|
|
621
|
+
} else if (hasPatch) {
|
|
622
|
+
if (typeof b["patch"] !== "string" || (b["patch"] as string).trim() === "") {
|
|
623
|
+
errors.push({ path, message: "diff block patch must be a non-empty string" });
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
// before/after arm
|
|
627
|
+
if (hasBefore && !hasAfter) {
|
|
628
|
+
errors.push({ path, message: "diff block before/after arm requires both fields" });
|
|
629
|
+
} else if (!hasBefore && hasAfter) {
|
|
630
|
+
errors.push({ path, message: "diff block before/after arm requires both fields" });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
595
635
|
}
|
|
596
636
|
|
|
597
637
|
// Deep field validation against catalog schema — catches unknown fields (with "did you mean"
|
|
@@ -611,7 +651,10 @@ function validateBlocksArray(raw: unknown): BlockValidationResult {
|
|
|
611
651
|
}
|
|
612
652
|
|
|
613
653
|
if (raw.length > 1000) {
|
|
614
|
-
return {
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
errors: [{ path: "blocks", message: "blocks array exceeds maximum of 1000 blocks" }],
|
|
657
|
+
};
|
|
615
658
|
}
|
|
616
659
|
|
|
617
660
|
const allErrors: BlockValidationError[] = [];
|
|
@@ -627,7 +670,10 @@ function validateBlocksArray(raw: unknown): BlockValidationResult {
|
|
|
627
670
|
if (blockType === "hero") {
|
|
628
671
|
heroCount++;
|
|
629
672
|
if (i !== 0) {
|
|
630
|
-
allErrors.push({
|
|
673
|
+
allErrors.push({
|
|
674
|
+
path: `blocks[${i}]`,
|
|
675
|
+
message: "hero block must be the first block if present",
|
|
676
|
+
});
|
|
631
677
|
}
|
|
632
678
|
}
|
|
633
679
|
if (blockType === "tldr") {
|
|
@@ -740,9 +786,7 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
|
|
|
740
786
|
// blocks branch
|
|
741
787
|
const blocksResult = validateBlocksArray(raw["blocks"]);
|
|
742
788
|
if (!blocksResult.ok) {
|
|
743
|
-
const errorMessages = blocksResult.errors
|
|
744
|
-
.map((e) => `${e.path}: ${e.message}`)
|
|
745
|
-
.join("; ");
|
|
789
|
+
const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
746
790
|
return { ok: false, error: `blocks validation failed — ${errorMessages}` };
|
|
747
791
|
}
|
|
748
792
|
return {
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -379,7 +379,11 @@ export async function ensureServerRunning(cfg: LifecycleConfig): Promise<Running
|
|
|
379
379
|
|
|
380
380
|
// Use a spawn-only lock to prevent concurrent spawns. Release it immediately
|
|
381
381
|
// after spawning so the child can acquire its own (.server-start.lock) lock.
|
|
382
|
-
const spawnLock = await acquireLock({
|
|
382
|
+
const spawnLock = await acquireLock({
|
|
383
|
+
lockPath: spawnLockPath,
|
|
384
|
+
timeoutMs: 15_000,
|
|
385
|
+
staleMs: 30_000,
|
|
386
|
+
});
|
|
383
387
|
try {
|
|
384
388
|
// Re-check after acquiring lock
|
|
385
389
|
const existingAfterLock = readPidFile(pidFilePath);
|
package/src/storage/index-gen.ts
CHANGED
|
@@ -236,9 +236,8 @@ function indexJs(): string {
|
|
|
236
236
|
function renderEntryCard(entry: IndexEntry): string {
|
|
237
237
|
const isSuperseded = entry.supersededBy !== null ? "1" : "0";
|
|
238
238
|
const kindPill = `<span class="pill">${esc(entry.kind)}</span>`;
|
|
239
|
-
const inputModeBadge =
|
|
240
|
-
? ` <span class="tag">${esc(entry.inputMode)}</span>`
|
|
241
|
-
: "";
|
|
239
|
+
const inputModeBadge =
|
|
240
|
+
entry.inputMode !== undefined ? ` <span class="tag">${esc(entry.inputMode)}</span>` : "";
|
|
242
241
|
const dateStr = `<span class="card-date">${esc(formatDate(entry.createdAt))}</span>`;
|
|
243
242
|
const supersededBadge =
|
|
244
243
|
entry.supersedes !== null
|
package/src/tools/publish.ts
CHANGED
|
@@ -117,9 +117,7 @@ export function createPublishTool(
|
|
|
117
117
|
html: tool.schema
|
|
118
118
|
.string()
|
|
119
119
|
.optional()
|
|
120
|
-
.describe(
|
|
121
|
-
"Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks.",
|
|
122
|
-
),
|
|
120
|
+
.describe("Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks."),
|
|
123
121
|
blocks: tool.schema
|
|
124
122
|
.array(tool.schema.any())
|
|
125
123
|
.optional()
|
package/src/tools/styleguide.ts
CHANGED
|
@@ -101,21 +101,17 @@ export async function generateStyleguideMarkdown(): Promise<string> {
|
|
|
101
101
|
"- Inline: `**bold**`, `*italic*`, `` `code` ``, `[text](href)` (relative or anchor only).",
|
|
102
102
|
);
|
|
103
103
|
lines.push(
|
|
104
|
-
|
|
104
|
+
'- HTML safelist: `<kbd>`, `<span class="pill">`, `<span class="tag">`. Anything else is escaped.',
|
|
105
105
|
);
|
|
106
106
|
lines.push("");
|
|
107
107
|
lines.push("## When to reach for raw_html / diagram");
|
|
108
108
|
lines.push("");
|
|
109
|
-
lines.push(
|
|
110
|
-
"- `diagram` — inline SVG visualizations or bespoke composed HTML diagrams.",
|
|
111
|
-
);
|
|
109
|
+
lines.push("- `diagram` — inline SVG visualizations or bespoke composed HTML diagrams.");
|
|
112
110
|
lines.push(
|
|
113
111
|
"- `raw_html` — anything genuinely creative that doesn't fit a known block type." +
|
|
114
112
|
" Include a `purpose` string describing what you're building.",
|
|
115
113
|
);
|
|
116
|
-
lines.push(
|
|
117
|
-
"- Critique flags raw_html overuse (>2 blocks or >30% of body characters).",
|
|
118
|
-
);
|
|
114
|
+
lines.push("- Critique flags raw_html overuse (>2 blocks or >30% of body characters).");
|
|
119
115
|
|
|
120
116
|
return lines.join("\n");
|
|
121
117
|
}
|