@bendyline/squisq 2.0.0 → 2.0.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.
package/dist/doc/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  scoreTextSimilarity,
17
17
  validateMarkdownDoc,
18
18
  validateMarkdownSource
19
- } from "../chunk-QVANWDPH.js";
19
+ } from "../chunk-TFJVXUHH.js";
20
20
  import {
21
21
  ASCII_CHAR_H,
22
22
  ASCII_CHAR_W,
@@ -129,7 +129,7 @@ import {
129
129
  wrapWithPersistentLayers,
130
130
  writeCustomTemplatesToFrontmatter,
131
131
  writeCustomThemesToFrontmatter
132
- } from "../chunk-GF3BVNLU.js";
132
+ } from "../chunk-X6DUONGT.js";
133
133
  import {
134
134
  PATH_SHAPE_KINDS,
135
135
  anchorPoint,
@@ -174,7 +174,7 @@ import {
174
174
  getAvailableThemes,
175
175
  getThemeSummaries,
176
176
  resolveTheme
177
- } from "../chunk-6PJRB2CF.js";
177
+ } from "../chunk-W4O2TX5I.js";
178
178
  import {
179
179
  VIEWPORT_PRESETS,
180
180
  createTemplateContext,
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  reanchorSession,
24
24
  traceWordPosAt,
25
25
  vadStep
26
- } from "./chunk-T3TI7GSZ.js";
26
+ } from "./chunk-CY2R2VPR.js";
27
27
  import "./chunk-ET53IIEP.js";
28
28
  import {
29
29
  DEFAULT_TRANSFORM_STYLE_ID,
@@ -69,7 +69,7 @@ import {
69
69
  resolveRef,
70
70
  setByPointer,
71
71
  toPointer
72
- } from "./chunk-QTSJOY54.js";
72
+ } from "./chunk-2PWOO4ZE.js";
73
73
  import {
74
74
  IMAGE_EDIT_ASSETS_PREFIX,
75
75
  IMAGE_EDIT_DEFAULT_BASENAME,
@@ -122,7 +122,7 @@ import {
122
122
  getSegmentAtTime,
123
123
  parseTheme,
124
124
  serializeTheme
125
- } from "./chunk-LYNJEOAC.js";
125
+ } from "./chunk-MMPBOUK7.js";
126
126
  import {
127
127
  calculateBearing,
128
128
  decodeGeohash,
@@ -153,7 +153,7 @@ import {
153
153
  scoreTextSimilarity,
154
154
  validateMarkdownDoc,
155
155
  validateMarkdownSource
156
- } from "./chunk-QVANWDPH.js";
156
+ } from "./chunk-TFJVXUHH.js";
157
157
  import {
158
158
  ASCII_CHAR_H,
159
159
  ASCII_CHAR_W,
@@ -274,7 +274,7 @@ import {
274
274
  wrapWithPersistentLayers,
275
275
  writeCustomTemplatesToFrontmatter,
276
276
  writeCustomThemesToFrontmatter
277
- } from "./chunk-GF3BVNLU.js";
277
+ } from "./chunk-X6DUONGT.js";
278
278
  import {
279
279
  PATH_SHAPE_KINDS,
280
280
  anchorPoint,
@@ -352,7 +352,7 @@ import {
352
352
  resolveTheme,
353
353
  validateTheme,
354
354
  withAlpha
355
- } from "./chunk-6PJRB2CF.js";
355
+ } from "./chunk-W4O2TX5I.js";
356
356
  import {
357
357
  DEFAULT_DOC_FONT,
358
358
  DEFAULT_TITLE_FONT,
@@ -14,10 +14,10 @@ import {
14
14
  resolveRef,
15
15
  setByPointer,
16
16
  toPointer
17
- } from "../chunk-QTSJOY54.js";
18
- import "../chunk-LYNJEOAC.js";
17
+ } from "../chunk-2PWOO4ZE.js";
18
+ import "../chunk-MMPBOUK7.js";
19
19
  import "../chunk-FVJUUTEM.js";
20
- import "../chunk-6PJRB2CF.js";
20
+ import "../chunk-W4O2TX5I.js";
21
21
  import "../chunk-5J4HKRJF.js";
22
22
  import "../chunk-QE7TWGVT.js";
23
23
  export {
@@ -23,7 +23,7 @@ import {
23
23
  reanchorSession,
24
24
  traceWordPosAt,
25
25
  vadStep
26
- } from "../chunk-T3TI7GSZ.js";
26
+ } from "../chunk-CY2R2VPR.js";
27
27
  import {
28
28
  buildNarrationScript,
29
29
  buildNarrationTimingJson,
@@ -33,10 +33,10 @@ import {
33
33
  wordIndexAtChar,
34
34
  wordIndexAtTime,
35
35
  wordPosAtExpectedSyllables
36
- } from "../chunk-GF3BVNLU.js";
36
+ } from "../chunk-X6DUONGT.js";
37
37
  import "../chunk-TUWMK62B.js";
38
38
  import "../chunk-5UYI3RJB.js";
39
- import "../chunk-6PJRB2CF.js";
39
+ import "../chunk-W4O2TX5I.js";
40
40
  import "../chunk-5J4HKRJF.js";
41
41
  import "../chunk-R2YZTCLO.js";
42
42
  import "../chunk-AMIR72JP.js";
@@ -9,7 +9,7 @@ import {
9
9
  getSegmentAtTime,
10
10
  parseTheme,
11
11
  serializeTheme
12
- } from "../chunk-LYNJEOAC.js";
12
+ } from "../chunk-MMPBOUK7.js";
13
13
  import {
14
14
  getDocPlaybackDuration,
15
15
  resolveMediaSchedule
@@ -49,7 +49,7 @@ import {
49
49
  resolveTheme,
50
50
  validateTheme,
51
51
  withAlpha
52
- } from "../chunk-6PJRB2CF.js";
52
+ } from "../chunk-W4O2TX5I.js";
53
53
  import {
54
54
  DEFAULT_DOC_FONT,
55
55
  DEFAULT_TITLE_FONT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Headless utilities for doc/block rendering, spatial math, Markdown, and storage",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -115,6 +115,7 @@ squisq-custom-themes: ${encoded}
115
115
  A small editor for big ideas.
116
116
  `;
117
117
  const doc = markdownToDoc(parseMarkdown(sourceMd));
118
+ expect(doc.themeId).toBe('my-brand');
118
119
  expect(doc.customThemes).toHaveLength(1);
119
120
  expect(doc.customThemes![0]).toEqual(brand);
120
121
 
@@ -155,14 +155,14 @@ describe('parseMarkdown / stringifyMarkdown', () => {
155
155
  });
156
156
 
157
157
  it('parses mentions from @[Name](scheme:id)', () => {
158
- const doc = parse('Hey @[Leo](gezel:leo), take a look.');
158
+ const doc = parse('Hey @[Leo](person:leo), take a look.');
159
159
  const p = doc.children[0] as MarkdownParagraph;
160
160
  const text0 = p.children[0] as MarkdownText;
161
161
  expect(text0).toEqual({ type: 'text', value: 'Hey ' });
162
162
  const mention = p.children[1] as MarkdownMention;
163
163
  expect(mention).toEqual({
164
164
  type: 'mention',
165
- targetKind: 'gezel',
165
+ targetKind: 'person',
166
166
  targetId: 'leo',
167
167
  displayName: 'Leo',
168
168
  });
@@ -176,7 +176,7 @@ describe('parseMarkdown / stringifyMarkdown', () => {
176
176
  // re-parsing `\:` produces the unescaped url, so the round-trip
177
177
  // preserves the mention semantically even if the raw text changes
178
178
  // on the first pass.
179
- const input = 'Hey @[Leo](gezel:leo), ping @[Tess](gezel:tess) too.\n';
179
+ const input = 'Hey @[Leo](person:leo), ping @[Tess](person:tess) too.\n';
180
180
  const once = stringifyMarkdown(parseMarkdown(input));
181
181
  const twice = stringifyMarkdown(parseMarkdown(once));
182
182
  expect(twice).toBe(once);
@@ -184,7 +184,7 @@ describe('parseMarkdown / stringifyMarkdown', () => {
184
184
  const doc = parseMarkdown(once) as MarkdownDocument;
185
185
  const p = doc.children[0] as MarkdownParagraph;
186
186
  const m1 = p.children.find((c) => c.type === 'mention') as MarkdownMention;
187
- expect(m1.targetKind).toBe('gezel');
187
+ expect(m1.targetKind).toBe('person');
188
188
  expect(m1.targetId).toBe('leo');
189
189
  expect(m1.displayName).toBe('Leo');
190
190
  });
@@ -944,6 +944,15 @@ describe('multi-line frontmatter round-trips', () => {
944
944
  path: 'C:\\temp\\"quoted"',
945
945
  });
946
946
  });
947
+
948
+ it('writes boolean values as unquoted YAML booleans', () => {
949
+ const out = setFrontmatterValues('# Body\n', { enabled: true, disabled: false });
950
+ expect(out).toContain('enabled: true');
951
+ expect(out).toContain('disabled: false');
952
+ expect(out).not.toContain('enabled: "true"');
953
+ expect(out).not.toContain('disabled: "false"');
954
+ expect(parseMarkdown(out).frontmatter).toEqual({ enabled: true, disabled: false });
955
+ });
947
956
  });
948
957
 
949
958
  describe('frontmatter in parseMarkdown', () => {
@@ -18,17 +18,19 @@ describe('markdown HTML sanitization', () => {
18
18
  });
19
19
 
20
20
  it('extraLinkSchemes allows host-app schemes but never executable ones', () => {
21
- expect(sanitizeUrl('gezel-nav:src%2Fa.ts', 'link')).toBeNull();
22
- expect(sanitizeUrl('gezel-nav:src%2Fa.ts', 'link', { extraLinkSchemes: ['gezel-nav'] })).toBe(
23
- 'gezel-nav:src%2Fa.ts',
24
- );
21
+ expect(sanitizeUrl('workspace-nav:src%2Fa.ts', 'link')).toBeNull();
22
+ expect(
23
+ sanitizeUrl('workspace-nav:src%2Fa.ts', 'link', { extraLinkSchemes: ['workspace-nav'] }),
24
+ ).toBe('workspace-nav:src%2Fa.ts');
25
25
  expect(
26
26
  sanitizeUrl('javascript:alert(1)', 'link', { extraLinkSchemes: ['javascript'] }),
27
27
  ).toBeNull();
28
28
  expect(sanitizeUrl('data:text/html,x', 'link', { extraLinkSchemes: ['data'] })).toBeNull();
29
29
  // media URLs ignore the option entirely
30
30
  expect(
31
- sanitizeUrl('gezel-nav:src%2Fa.ts', 'media', { extraLinkSchemes: ['gezel-nav'] }),
31
+ sanitizeUrl('workspace-nav:src%2Fa.ts', 'media', {
32
+ extraLinkSchemes: ['workspace-nav'],
33
+ }),
32
34
  ).toBeNull();
33
35
  });
34
36
 
@@ -40,7 +40,7 @@ import type {
40
40
  MarkdownNode,
41
41
  HtmlNode,
42
42
  } from '../markdown/types.js';
43
- import { extractPlainText } from '../markdown/utils.js';
43
+ import { extractPlainText, readFrontmatterThemeId } from '../markdown/utils.js';
44
44
  import { coerceAnnotationValues, type CoercedBlockMeta } from '../markdown/annotationCoercion.js';
45
45
  import { estimateReadingTime } from '../timing/readingTime.js';
46
46
  import { resolveTemplateName, isContainerTemplate } from './templates/index.js';
@@ -600,6 +600,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
600
600
 
601
601
  const customTemplates = readCustomTemplatesFromFrontmatter(markdownDoc.frontmatter);
602
602
  const customThemes = readCustomThemesFromFrontmatter(markdownDoc.frontmatter);
603
+ const themeId = readFrontmatterThemeId(markdownDoc.frontmatter);
603
604
  const doc: Doc = {
604
605
  articleId,
605
606
  duration: currentTime,
@@ -609,6 +610,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
609
610
  },
610
611
  ...(captions ? { captions } : {}),
611
612
  ...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
613
+ ...(themeId ? { themeId } : {}),
612
614
  ...(customTemplates ? { customTemplates } : {}),
613
615
  ...(customThemes ? { customThemes } : {}),
614
616
  ...(diagnostics.length > 0 ? { diagnostics } : {}),
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createTemplateContext } from '../../../schemas/BlockTemplates.js';
3
+ import type { TextLayer } from '../../../schemas/Doc.js';
4
+ import { DEFAULT_THEME } from '../../../schemas/themeLibrary.js';
5
+ import { VIEWPORT_PRESETS } from '../../../schemas/Viewport.js';
6
+ import { listBlock } from '../listBlock.js';
7
+
8
+ const ITEMS = [
9
+ 'We launched the scripting platform for Minecraft, which was essential for launching new categories of content for Minecraft (add-ons)',
10
+ "I spec'ed and largely built the Minecraft Creator Tools suite, including a website for beginning to advanced creators and set of NPM commands and tools.",
11
+ 'I manage and craft the Minecraft samples and documentation suite',
12
+ 'I produce and create the Minecraft Creator Channel on YouTube',
13
+ ];
14
+
15
+ describe('listBlock', () => {
16
+ it('reserves vertical space for wrapped list items', () => {
17
+ const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
18
+ const layers = listBlock(
19
+ {
20
+ template: 'list',
21
+ id: 'minecraft-platform',
22
+ duration: 10,
23
+ audioSegment: 0,
24
+ title: 'Minecraft Platform',
25
+ items: ITEMS,
26
+ },
27
+ context,
28
+ );
29
+ const itemLayers = layers.filter(
30
+ (layer): layer is TextLayer => layer.type === 'text' && /^item-\d+$/.test(layer.id),
31
+ );
32
+ const markerLayers = layers.filter(
33
+ (layer): layer is TextLayer => layer.type === 'text' && /^item-\d+-marker$/.test(layer.id),
34
+ );
35
+ const yPositions = itemLayers.map((layer) => parseFloat(String(layer.position.y)));
36
+
37
+ expect(itemLayers).toHaveLength(4);
38
+ expect(markerLayers).toHaveLength(4);
39
+ expect(yPositions[0]).toBe(34);
40
+
41
+ // The marker has its own right-aligned column, while all body lines are
42
+ // rendered by one layer beginning after that column (a hanging indent).
43
+ expect(markerLayers[0]!.content.text).toBe('1.');
44
+ expect(markerLayers[0]!.content.style.textAlign).toBe('right');
45
+ expect(itemLayers[0]!.content.text).toBe(ITEMS[0]);
46
+ const markerRightPx =
47
+ (parseFloat(String(markerLayers[0]!.position.x)) / 100) * context.viewport.width +
48
+ Number(markerLayers[0]!.position.width);
49
+ const bodyLeftPx =
50
+ (parseFloat(String(itemLayers[0]!.position.x)) / 100) * context.viewport.width;
51
+ expect(bodyLeftPx).toBeGreaterThan(markerRightPx);
52
+
53
+ // The first two entries wrap to two lines. Their following baselines
54
+ // therefore advance by two line-heights plus the inter-item gap.
55
+ const fontSize = itemLayers[0]!.content.style.fontSize;
56
+ const twoLineAdvance = ((2 * fontSize * 1.2 + 18) / context.viewport.height) * 100;
57
+ expect(yPositions[1]! - yPositions[0]!).toBeCloseTo(twoLineAdvance);
58
+ expect(yPositions[2]! - yPositions[1]!).toBeCloseTo(twoLineAdvance);
59
+
60
+ // The third entry fits on one line, so the final advance is compact.
61
+ const oneLineAdvance = ((fontSize * 1.2 + 18) / context.viewport.height) * 100;
62
+ expect(yPositions[3]! - yPositions[2]!).toBeCloseTo(oneLineAdvance);
63
+ });
64
+ });
@@ -19,6 +19,43 @@ import {
19
19
  } from '../utils/themeUtils.js';
20
20
  import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
21
21
 
22
+ const LIST_ITEM_LINE_HEIGHT = 1.2;
23
+ const LIST_ITEM_GAP_PX = 18;
24
+
25
+ /**
26
+ * Mirror the renderer's character-based wrapping closely enough to reserve
27
+ * vertical space for each independently positioned text layer.
28
+ */
29
+ function estimateWrappedLineCount(text: string, fontSize: number, maxWidth: number): number {
30
+ if (!text.trim()) return 1;
31
+
32
+ const charsPerLine = Math.floor(maxWidth / (fontSize * 0.5));
33
+ if (charsPerLine <= 0) return 1;
34
+
35
+ let lineCount = 0;
36
+ let currentLineLength = 0;
37
+
38
+ for (const word of text.split(/\s+/)) {
39
+ const testLineLength = currentLineLength ? currentLineLength + 1 + word.length : word.length;
40
+
41
+ if (testLineLength <= charsPerLine) {
42
+ currentLineLength = testLineLength;
43
+ continue;
44
+ }
45
+
46
+ if (currentLineLength) lineCount += 1;
47
+
48
+ let remainingLength = word.length;
49
+ while (remainingLength > charsPerLine) {
50
+ lineCount += 1;
51
+ remainingLength -= charsPerLine;
52
+ }
53
+ currentLineLength = remainingLength;
54
+ }
55
+
56
+ return Math.max(1, lineCount + (currentLineLength ? 1 : 0));
57
+ }
58
+
22
59
  export function listBlock(input: ListBlockInput, context: TemplateContext): Layer[] {
23
60
  const { title, accentImage } = input;
24
61
  // `items` is required by the schema, but malformed / partially-authored
@@ -66,8 +103,9 @@ export function listBlock(input: ListBlockInput, context: TemplateContext): Laye
66
103
  // defensive against future edits to the accent-layout types).
67
104
  const centerX = parseFloat(accentLayout.textCenterX);
68
105
  const widthPct = parseFloat(accentLayout.textWidth);
69
- const leftX =
70
- Number.isFinite(centerX) && Number.isFinite(widthPct) ? `${centerX - widthPct / 2}%` : '8%';
106
+ const leftPct =
107
+ Number.isFinite(centerX) && Number.isFinite(widthPct) ? centerX - widthPct / 2 : 8;
108
+ const leftX = `${leftPct}%`;
71
109
 
72
110
  // Title if provided
73
111
  const startY = title ? 34 : 26;
@@ -95,28 +133,49 @@ export function listBlock(input: ListBlockInput, context: TemplateContext): Laye
95
133
  });
96
134
  }
97
135
 
98
- // Stack items with a fixed compact gap rather than stretching them
99
- // across the available band. Distributing items across (startY 80%)
100
- // left big vertical gaps for short lists and made the slide read as a
101
- // sparse menu instead of a tight enumeration; conversely a bare
102
- // line-height gap read as a cramped paragraph — 18px of air keeps each
103
- // entry its own line without breaking the group.
104
- //
105
- // Spacing = item line-height (34px base × 1.2) + 18px gap, expressed
106
- // as % of the 1080px design canvas (~5.4%). Wrapped items push the
107
- // next entry down via their own line-height, so this sets the
108
- // minimum baseline-to-baseline distance for unwrapped items.
109
- const LIST_ITEM_BASE_PX = 34;
110
- const LIST_ITEM_LINE_HEIGHT = 1.2;
111
- const LIST_ITEM_GAP_PX = 18;
112
- const DESIGN_HEIGHT_PX = 1080;
113
- const spacing =
114
- ((LIST_ITEM_BASE_PX * LIST_ITEM_LINE_HEIGHT + LIST_ITEM_GAP_PX) / DESIGN_HEIGHT_PX) * 100;
136
+ // Render the number and body in separate columns. The body is therefore a
137
+ // single text layer whose wrapped lines all share the same x coordinate,
138
+ // producing a hanging indent instead of wrapping underneath the number.
139
+ const textWidthPx = (Number.isFinite(widthPct) ? widthPct / 100 : 0.85) * context.viewport.width;
140
+ const markerText = `${items.length}.`;
141
+ const markerWidthPx = Math.max(itemFontSize, markerText.length * itemFontSize * 0.5);
142
+ const markerGapPx = itemFontSize * 0.35;
143
+ const bodyIndentPx = markerWidthPx + markerGapPx;
144
+ const bodyLeftX = `${leftPct + (bodyIndentPx / context.viewport.width) * 100}%`;
145
+ const bodyWidthPx = Math.max(itemFontSize, textWidthPx - bodyIndentPx);
146
+ let itemY = startY;
115
147
 
116
148
  // List items with staggered animation
117
149
  for (let i = 0; i < items.length; i++) {
118
- const y = startY + spacing * i;
119
- const itemText = `${i + 1}. ${items[i]}`;
150
+ const itemText = items[i]!;
151
+ const lineCount = estimateWrappedLineCount(itemText, itemFontSize, bodyWidthPx);
152
+ const animation = themedEntrance(context, 'text', {
153
+ type: 'fadeIn',
154
+ duration: 0.8,
155
+ delay: 0.3 + 0.3 * i,
156
+ });
157
+
158
+ layers.push({
159
+ type: 'text',
160
+ id: `item-${i}-marker`,
161
+ content: {
162
+ text: `${i + 1}.`,
163
+ style: {
164
+ fontSize: itemFontSize,
165
+ fontFamily: getThemeFont(context, 'body'),
166
+ color: theme.colors.text,
167
+ textAlign: 'right',
168
+ lineHeight: LIST_ITEM_LINE_HEIGHT,
169
+ shadow: shouldUseShadow(context),
170
+ },
171
+ },
172
+ position: {
173
+ x: leftX,
174
+ y: adjustY(`${itemY}%`, accentLayout),
175
+ width: markerWidthPx,
176
+ },
177
+ animation,
178
+ });
120
179
 
121
180
  layers.push({
122
181
  type: 'text',
@@ -128,21 +187,20 @@ export function listBlock(input: ListBlockInput, context: TemplateContext): Laye
128
187
  fontFamily: getThemeFont(context, 'body'),
129
188
  color: theme.colors.text,
130
189
  textAlign: 'left',
131
- lineHeight: 1.2,
190
+ lineHeight: LIST_ITEM_LINE_HEIGHT,
132
191
  shadow: shouldUseShadow(context),
133
192
  },
134
193
  },
135
194
  position: {
136
- x: leftX,
137
- y: adjustY(`${y}%`, accentLayout),
138
- width: accentLayout.textWidth,
195
+ x: bodyLeftX,
196
+ y: adjustY(`${itemY}%`, accentLayout),
197
+ width: bodyWidthPx,
139
198
  },
140
- animation: themedEntrance(context, 'text', {
141
- type: 'fadeIn',
142
- duration: 0.8,
143
- delay: 0.3 + 0.3 * i,
144
- }),
199
+ animation,
145
200
  });
201
+
202
+ const itemHeightPx = lineCount * itemFontSize * LIST_ITEM_LINE_HEIGHT;
203
+ itemY += ((itemHeightPx + LIST_ITEM_GAP_PX) / context.viewport.height) * 100;
146
204
  }
147
205
 
148
206
  return layers;
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": "1",
3
3
  "id": "gezellig",
4
4
  "name": "Gezellig",
5
- "description": "Warm sage + terracotta palette inspired by the Gezel desktop app. Subtle orange-tinted dark background, cream text, sage and terracotta accents.",
5
+ "description": "Cozy, warm-hued palette with a subtle orange-tinted dark background, cream text, and sage and terracotta accents.",
6
6
  "colors": {
7
7
  "primary": "#b0724c",
8
8
  "secondary": "#667f62",