@lutrin/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation of a deck without generating it: positioned diagnostics (line in
|
|
3
|
+
* the source file) consumed by `lutrin validate`, the VS Code extension
|
|
4
|
+
* (underlines in the editor) and agents (`--json`).
|
|
5
|
+
*
|
|
6
|
+
* Severities: `error` (the rendering will not be the expected one), `warning`
|
|
7
|
+
* (probably not intended), `info` (automatic behaviour worth knowing about).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
parseDeck,
|
|
14
|
+
ALERT_BLOCK_TYPES,
|
|
15
|
+
CONTAINERS,
|
|
16
|
+
CHART_TYPES,
|
|
17
|
+
ICON_COLORS,
|
|
18
|
+
ANIM_PRESETS,
|
|
19
|
+
ANIM_PRESET_ALIASES,
|
|
20
|
+
isKnownAnimateValue,
|
|
21
|
+
runsToText,
|
|
22
|
+
} from './parse.mjs';
|
|
23
|
+
import {
|
|
24
|
+
buildScenes,
|
|
25
|
+
blockHeight,
|
|
26
|
+
inferLayout,
|
|
27
|
+
LAYOUTS,
|
|
28
|
+
LAYOUT_SECTIONS,
|
|
29
|
+
layoutDef,
|
|
30
|
+
layoutParams,
|
|
31
|
+
layoutParamSchema,
|
|
32
|
+
officialLayouts,
|
|
33
|
+
userLayouts,
|
|
34
|
+
} from './layout.mjs';
|
|
35
|
+
import { hasLucideIcon, imageDims, imageWithinRoots, resolveImagePath } from './assets.mjs';
|
|
36
|
+
import { chartDataDiagnostics } from './chart.mjs';
|
|
37
|
+
import { LAYER_SHADES, PAGE, contentArea } from './tokens.mjs';
|
|
38
|
+
import { prepareDeckContext } from './context.mjs';
|
|
39
|
+
import { THEME_KEYS } from './theme.mjs';
|
|
40
|
+
import { closest } from './suggest.mjs';
|
|
41
|
+
|
|
42
|
+
/** Candidates for animation preset suggestions (names + French aliases). */
|
|
43
|
+
const ANIM_CANDIDATES = [...ANIM_PRESETS, ...ANIM_PRESET_ALIASES];
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Walking the blocks of the IR (the :::… callouts nest blocks)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function* walkBlocks(slide) {
|
|
50
|
+
function* rec(blocks) {
|
|
51
|
+
for (const b of blocks) {
|
|
52
|
+
yield b;
|
|
53
|
+
if (b.type === 'alert') {
|
|
54
|
+
// only descend into what the callout renders: the content it drops is
|
|
55
|
+
// already reported one block up by ALERT_CONTENT_DROPPED — re-flagging
|
|
56
|
+
// it in cascade (nested callout) would produce nothing but noise
|
|
57
|
+
yield* rec(b.blocks.filter((x) => ALERT_BLOCK_TYPES.has(x.type)));
|
|
58
|
+
} else if (b.blocks) {
|
|
59
|
+
yield* rec(b.blocks);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const s of slide.sections) yield* rec(s.blocks);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Validation
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} source the complete Markdown (DSL)
|
|
72
|
+
* @param {object} [opts] { baseDir } — the file's directory (resolution of
|
|
73
|
+
* images, theme and layouts/*.json); { themePath } —
|
|
74
|
+
* imposed kit (CLI flag --kit, wins over the
|
|
75
|
+
* frontmatter); { deck, scenes } — IR and scenes
|
|
76
|
+
* already computed by the host (extension worker) so
|
|
77
|
+
* parseDeck/buildScenes are not redone on every
|
|
78
|
+
* keystroke.
|
|
79
|
+
* @returns {Array<{severity:'error'|'warning'|'info', code:string, message:string, line:number, suggestion?:string}>}
|
|
80
|
+
*/
|
|
81
|
+
export function validateDeck(
|
|
82
|
+
source,
|
|
83
|
+
{
|
|
84
|
+
baseDir = process.cwd(),
|
|
85
|
+
themePath = null,
|
|
86
|
+
defaultTheme = null,
|
|
87
|
+
imageRoots = [],
|
|
88
|
+
deck = null,
|
|
89
|
+
scenes = null,
|
|
90
|
+
} = {},
|
|
91
|
+
) {
|
|
92
|
+
// trust roots for local images: the deck's directory + the project/vault
|
|
93
|
+
// roots declared by the host (containment — assets.mjs)
|
|
94
|
+
const imageTrustRoots = [baseDir, ...imageRoots];
|
|
95
|
+
const diags = [];
|
|
96
|
+
const push = (severity, code, message, line, suggestion) =>
|
|
97
|
+
diags.push({ severity, code, message, line: line ?? 1, ...(suggestion ? { suggestion } : {}) });
|
|
98
|
+
|
|
99
|
+
// ------ text scan: unknown directives -------------------------------------
|
|
100
|
+
// markdown-it-container silently ignores an unknown `:::name` (rendered as a
|
|
101
|
+
// paragraph) — only a scan of the source catches them. We skip the
|
|
102
|
+
// frontmatter and the inside of ```…``` blocks to avoid false positives.
|
|
103
|
+
// Leading BOM: `parseDeck` strips it on its side before parsing. This scan
|
|
104
|
+
// re-scans the SAME source; without the same stripping, a deck saved as
|
|
105
|
+
// UTF-8-BOM would make the two readings diverge (frontmatter and frontmatter
|
|
106
|
+
// keys shifted) and would position diagnostics beside the mark. The stripping
|
|
107
|
+
// happens AFTER the split, on the first line: a BOM only exists at the head
|
|
108
|
+
// of a file, and this scan must ask nothing of `source` other than `.split` —
|
|
109
|
+
// a hostile source would throw here, before the try/catch that turns an
|
|
110
|
+
// impossible parse into a diagnostic rather than a crash.
|
|
111
|
+
const lines = source.split(/\r?\n/);
|
|
112
|
+
if (typeof lines[0] === 'string') lines[0] = lines[0].replace(/^\uFEFF/, '');
|
|
113
|
+
let inFence = null;
|
|
114
|
+
let inFrontmatter = lines[0]?.trim() === '---' ? 'open' : null;
|
|
115
|
+
lines.forEach((raw, k) => {
|
|
116
|
+
const line = raw.trim();
|
|
117
|
+
if (inFrontmatter === 'open' && k > 0) {
|
|
118
|
+
if (line === '---') inFrontmatter = null;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (inFrontmatter === 'open' && k === 0) return;
|
|
122
|
+
const fence = line.match(/^(`{3,}|~{3,})/);
|
|
123
|
+
if (fence) {
|
|
124
|
+
if (!inFence) inFence = fence[1][0];
|
|
125
|
+
else if (fence[1][0] === inFence) inFence = null;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (inFence) return;
|
|
129
|
+
const dir = line.match(/^:{3,}\s*([A-Za-z][\w-]*)/);
|
|
130
|
+
// CASE-SENSITIVE comparison, like markdown-it-container when opening:
|
|
131
|
+
// `:::Info` opens no callout and renders as a literal paragraph.
|
|
132
|
+
// Normalizing here would judge as "known" what the engine ignores, and
|
|
133
|
+
// would neutralize this diagnostic exactly where it serves most — the
|
|
134
|
+
// casing mistake.
|
|
135
|
+
if (dir && !CONTAINERS.includes(dir[1])) {
|
|
136
|
+
// mermaid/math/chart are ``` fences in this DSL, not directives
|
|
137
|
+
const fenceLangs = ['mermaid', 'math', 'latex', 'chart'];
|
|
138
|
+
const fence = closest(dir[1], fenceLangs);
|
|
139
|
+
// only the case differs: name the cause, otherwise the author re-reads a
|
|
140
|
+
// correctly spelled word without seeing what is wrong
|
|
141
|
+
const cased = CONTAINERS.includes(dir[1].toLowerCase()) ? dir[1].toLowerCase() : null;
|
|
142
|
+
push(
|
|
143
|
+
'error',
|
|
144
|
+
'UNKNOWN_DIRECTIVE',
|
|
145
|
+
cased
|
|
146
|
+
? `Unknown directive ":::${dir[1]}": directives are written in lowercase — ":::${cased}".`
|
|
147
|
+
: fence
|
|
148
|
+
? `Unknown directive ":::${dir[1]}" — "${fence}" is written as a code block: \`\`\`${fence} … \`\`\`.`
|
|
149
|
+
: `Unknown directive ":::${dir[1]}" (directives: ${CONTAINERS.join(', ')}).`,
|
|
150
|
+
k + 1,
|
|
151
|
+
cased ?? (fence ? `\`\`\`${fence}` : closest(dir[1], CONTAINERS)),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ------ IR -----------------------------------------------------------------
|
|
157
|
+
// Validation must never throw: a deck that cannot be parsed becomes a
|
|
158
|
+
// diagnostic, not a crash of the editor or of the CLI.
|
|
159
|
+
if (!deck) {
|
|
160
|
+
try {
|
|
161
|
+
deck = parseDeck(source);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
push('error', 'PARSE_ERROR', `Could not parse the document: ${e?.message ?? e}`, 1);
|
|
164
|
+
return diags;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Directives the parser could not attach to any slide (end of file, or `---`
|
|
169
|
+
// before any content). They have no effect: saying so here is the only trace
|
|
170
|
+
// the author will ever get. Reported BEFORE the early return of EMPTY_DECK —
|
|
171
|
+
// an empty deck is precisely the case where every directive is orphaned.
|
|
172
|
+
for (const d of deck.orphanDirectives ?? []) {
|
|
173
|
+
push(
|
|
174
|
+
'warning',
|
|
175
|
+
'ORPHAN_DIRECTIVE',
|
|
176
|
+
`The <!-- ${d.key}: … --> directive governs no slide: none opens after it (a directive applies to the slide surrounding it, whether it precedes or follows its "# heading"). It will have no effect.`,
|
|
177
|
+
d.line,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!deck.slides.length && !deck.meta.title) {
|
|
182
|
+
push(
|
|
183
|
+
'warning',
|
|
184
|
+
'EMPTY_DECK',
|
|
185
|
+
'No slides: neither a frontmatter `title:`, nor a `# heading` in the body.',
|
|
186
|
+
1,
|
|
187
|
+
);
|
|
188
|
+
return diags;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Line (1-based) of a frontmatter key, to position a diagnostic. */
|
|
192
|
+
const metaLine = (key) => {
|
|
193
|
+
if (lines[0]?.trim() !== '---') return 1;
|
|
194
|
+
for (let k = 1; k < lines.length && lines[k].trim() !== '---'; k++) {
|
|
195
|
+
if (new RegExp(`^${key}\\s*:`).test(lines[k])) return k + 1;
|
|
196
|
+
}
|
|
197
|
+
return 1;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// `animate:` in the frontmatter (whole deck) — the same check as the slide
|
|
201
|
+
// comment, positioned on the frontmatter line
|
|
202
|
+
if (deck.meta.animate != null && !isKnownAnimateValue(deck.meta.animate)) {
|
|
203
|
+
push(
|
|
204
|
+
'warning',
|
|
205
|
+
'UNKNOWN_ANIMATE',
|
|
206
|
+
`Unknown value "${deck.meta.animate}" for animate: (presets: ${ANIM_PRESETS.join(', ')} — or true/none).`,
|
|
207
|
+
metaLine('animate'),
|
|
208
|
+
closest(String(deck.meta.animate), ANIM_CANDIDATES) ?? undefined,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ------ theme + user layouts -----------------------------------------------
|
|
213
|
+
// Loads layouts/*.json and applies the theme BEFORE the slide loop
|
|
214
|
+
// (UNKNOWN_LAYOUT reads the live registry) and before the geometric audits
|
|
215
|
+
// (the estimates must be the theme's). Scenes precomputed by the host were
|
|
216
|
+
// built in the same state by compileHtml.
|
|
217
|
+
const prep = prepareDeckContext(deck.meta, { baseDir, themePath, defaultTheme });
|
|
218
|
+
// anchor on the frontmatter line that DESIGNATES the kit — `kit:` today,
|
|
219
|
+
// `theme:` for decks written before. The KIT_* codes, like the THEME_* ones,
|
|
220
|
+
// all speak of that line: forgetting them would send the user back to line 1,
|
|
221
|
+
// where there is nothing to fix.
|
|
222
|
+
// metaLine returns 1 when the key is absent, and never 1 when it is present
|
|
223
|
+
// (line 1 is the opening "---"): 1 therefore means "no kit:"
|
|
224
|
+
const lineOfKit = metaLine('kit');
|
|
225
|
+
const kitLine = lineOfKit !== 1 ? lineOfKit : metaLine('theme');
|
|
226
|
+
for (const d of prep.diagnostics) {
|
|
227
|
+
const aboutKit = d.code.startsWith('THEME_') || d.code.startsWith('KIT_');
|
|
228
|
+
push(d.severity, d.code, d.message, aboutKit ? kitLine : 1, d.suggestion);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const slide of deck.slides) {
|
|
232
|
+
if (slide.layout && !LAYOUTS.includes(slide.layout)) {
|
|
233
|
+
push(
|
|
234
|
+
'error',
|
|
235
|
+
'UNKNOWN_LAYOUT',
|
|
236
|
+
`Unknown layout "${slide.layout}" (layouts: ${LAYOUTS.join(', ')}).`,
|
|
237
|
+
slide.layoutLine ?? slide.line,
|
|
238
|
+
closest(slide.layout, LAYOUTS),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
// structured layouts: the number of ## sections must fit — name comparisons
|
|
242
|
+
// are made on the resolved BASE layout, so that a user alias
|
|
243
|
+
// (layouts/*.json) inherits the same exceptions
|
|
244
|
+
const expect = slide.layout && LAYOUT_SECTIONS[slide.layout];
|
|
245
|
+
const layoutBase = slide.layout ? (layoutDef(slide.layout)?.base ?? slide.layout) : null;
|
|
246
|
+
if (expect) {
|
|
247
|
+
const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
|
|
248
|
+
// LEAD: in columns, what precedes the first "##" does not occupy a
|
|
249
|
+
// column — layout.mjs flows it full width above (see the `lead` of
|
|
250
|
+
// two-columns/three-columns, SAME condition here). Counting it made us
|
|
251
|
+
// announce "4 sections found: the surplus will be ignored" where nothing
|
|
252
|
+
// is ignored: a lying warning, worse than none.
|
|
253
|
+
const lead =
|
|
254
|
+
(layoutBase === 'two-columns' || layoutBase === 'three-columns') &&
|
|
255
|
+
secs.length &&
|
|
256
|
+
!secs[0].heading &&
|
|
257
|
+
secs.some((s) => s.heading);
|
|
258
|
+
const nSecs = secs.length - (lead ? 1 : 0);
|
|
259
|
+
const bulletLayers =
|
|
260
|
+
layoutBase === 'layers' &&
|
|
261
|
+
!slide.sections.some((s) => s.heading) &&
|
|
262
|
+
slide.sections.flatMap((s) => s.blocks).every((b) => b.type === 'bullets');
|
|
263
|
+
if (!bulletLayers && (nSecs < expect.min || nSecs > expect.max)) {
|
|
264
|
+
push(
|
|
265
|
+
'warning',
|
|
266
|
+
'LAYOUT_SECTIONS',
|
|
267
|
+
`The "${slide.layout}" layout expects ${
|
|
268
|
+
expect.min === expect.max ? expect.min : `${expect.min} to ${expect.max}`
|
|
269
|
+
} "##" sections (${nSecs} found${
|
|
270
|
+
layoutBase === 'layers' ? ', or a single bullet list — one item per layer' : ''
|
|
271
|
+
}): the surplus will be ignored, the shortfall will leave gaps.`,
|
|
272
|
+
slide.layoutLine ?? slide.line,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (slide.animateUnknown != null) {
|
|
277
|
+
push(
|
|
278
|
+
'warning',
|
|
279
|
+
'UNKNOWN_ANIMATE',
|
|
280
|
+
`Unknown value "${slide.animateUnknown}" for <!-- animate: … --> (presets: ${ANIM_PRESETS.join(', ')} — or true/none).`,
|
|
281
|
+
slide.animateLine ?? slide.line,
|
|
282
|
+
closest(slide.animateUnknown, ANIM_CANDIDATES) ?? undefined,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
// quote layout: layout.mjs renders an EMPTY scene when the slide has no
|
|
286
|
+
// block to quote (rather than crashing the renderers). It places, it does
|
|
287
|
+
// not speak: without this diagnostic, the author sees a bare slide and
|
|
288
|
+
// never learns why.
|
|
289
|
+
if (layoutBase === 'quote' && ![...walkBlocks(slide)].length) {
|
|
290
|
+
push(
|
|
291
|
+
'warning',
|
|
292
|
+
'QUOTE_EMPTY',
|
|
293
|
+
`The "${slide.layout}" layout features the FIRST block on the slide, but this slide has nothing to quote: the slide will be empty — add the quotation under the title.`,
|
|
294
|
+
slide.layoutLine ?? slide.line,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// layers layout: the `shades` parameter indexes the kit's shades
|
|
298
|
+
// (LAYER_SHADES). An index beyond them is CLAMPED to the lightest by
|
|
299
|
+
// layout.mjs — two layers then end up the same tint, which shows without
|
|
300
|
+
// explaining itself: it is the kit that lacks shades, not the deck.
|
|
301
|
+
if (layoutBase === 'layers' && LAYER_SHADES.length) {
|
|
302
|
+
const shades = layoutParams(slide.layout).shades;
|
|
303
|
+
const over = Array.isArray(shades)
|
|
304
|
+
? [...new Set(shades.filter((s) => s > LAYER_SHADES.length - 1))]
|
|
305
|
+
: [];
|
|
306
|
+
if (over.length) {
|
|
307
|
+
push(
|
|
308
|
+
'warning',
|
|
309
|
+
'LAYERS_SHADE_MISSING',
|
|
310
|
+
`The "${slide.layout}" layout asks for shade ${over.join(', ')} (parameter "shades"), but the kit only provides ${LAYER_SHADES.length} layer shades — indices 0 to ${LAYER_SHADES.length - 1}: the layers concerned will fall back to the lightest.`,
|
|
311
|
+
slide.layoutLine ?? slide.line,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// metrics layout: past the cap (parameter `max`, 4 by default), the surplus
|
|
316
|
+
// is dropped without a trace — compare the resolved BASE layout (user
|
|
317
|
+
// aliases included), the effective cap of the alias
|
|
318
|
+
const nMetrics = [...walkBlocks(slide)].filter((b) => b.type === 'metric').length;
|
|
319
|
+
const effLayout = slide.layout ?? inferLayout(slide, 1);
|
|
320
|
+
if ((layoutDef(effLayout)?.base ?? effLayout) === 'metrics') {
|
|
321
|
+
const maxCards = layoutParams(effLayout).max ?? 4;
|
|
322
|
+
if (nMetrics > maxCards) {
|
|
323
|
+
push(
|
|
324
|
+
'warning',
|
|
325
|
+
'METRICS_DROPPED',
|
|
326
|
+
`${nMetrics} :::metric cards — the "${effLayout}" layout only displays ${maxCards}: ${
|
|
327
|
+
nMetrics - maxCards === 1
|
|
328
|
+
? 'the last one will be dropped'
|
|
329
|
+
: `the last ${nMetrics - maxCards} will be dropped`
|
|
330
|
+
}. Spread them over two slides.`,
|
|
331
|
+
slide.line,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const b of walkBlocks(slide)) {
|
|
336
|
+
if (b.type === 'image' && !/^https?:/.test(b.src)) {
|
|
337
|
+
const file = resolveImagePath(baseDir, b.src);
|
|
338
|
+
if (!imageWithinRoots(file, imageTrustRoots)) {
|
|
339
|
+
push(
|
|
340
|
+
'error',
|
|
341
|
+
'IMAGE_PATH_ESCAPE',
|
|
342
|
+
`Image outside the deck's directory: ${b.src} — refused (it will not be embedded). An image must sit under the deck's directory or under a project/vault directory allowed by the editor.`,
|
|
343
|
+
b.line,
|
|
344
|
+
);
|
|
345
|
+
} else if (!fs.existsSync(file)) {
|
|
346
|
+
push(
|
|
347
|
+
'warning',
|
|
348
|
+
'MISSING_IMAGE',
|
|
349
|
+
`Image not found: ${b.src} (a placeholder will be displayed).`,
|
|
350
|
+
b.line,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (b.type === 'icon' && hasLucideIcon(b.name) === false) {
|
|
355
|
+
push(
|
|
356
|
+
'warning',
|
|
357
|
+
'UNKNOWN_ICON',
|
|
358
|
+
`Lucide icon "${b.name}" not found — check the name on lucide.dev.`,
|
|
359
|
+
b.line,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (b.type === 'code' && b.invalidChart) {
|
|
363
|
+
push(
|
|
364
|
+
'warning',
|
|
365
|
+
'INVALID_CHART',
|
|
366
|
+
`The \`chart\` specification could not be parsed — the block will be displayed as code. Expected: "type: ${[...CHART_TYPES].join('|')}", "categories: a, b", then "Series: v1, v2".`,
|
|
367
|
+
b.line,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
// callouts: the renderers only render paragraphs and bullet lists — any
|
|
371
|
+
// other block is ignored, and the author must know it
|
|
372
|
+
if (b.type === 'alert') {
|
|
373
|
+
for (const inner of b.blocks) {
|
|
374
|
+
if (!ALERT_BLOCK_TYPES.has(inner.type)) {
|
|
375
|
+
push(
|
|
376
|
+
'warning',
|
|
377
|
+
'ALERT_CONTENT_DROPPED',
|
|
378
|
+
`The :::${b.kind} callout only renders paragraphs and bullet lists: the "${inner.type}" block will be ignored — move it out of the callout.`,
|
|
379
|
+
inner.line ?? b.line,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// quotation: only the text of paragraphs is kept — a list, a table or an
|
|
385
|
+
// image written inside are dropped by the parser, as for the callouts,
|
|
386
|
+
// and that must be said (parse.mjs sets `dropped`)
|
|
387
|
+
if (b.type === 'quote' && b.dropped?.length) {
|
|
388
|
+
for (const t of b.dropped) {
|
|
389
|
+
push(
|
|
390
|
+
'warning',
|
|
391
|
+
'QUOTE_CONTENT_DROPPED',
|
|
392
|
+
`A quotation only renders text: the "${t}" block it contains will be ignored — move it out of the quotation.`,
|
|
393
|
+
b.line,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// cartesian and radar: chart.mjs truncates each series to the number of
|
|
398
|
+
// categories BEFORE computing its scale (otherwise the surplus, never
|
|
399
|
+
// plotted, crushes the visible plot). What it rules out, it says here —
|
|
400
|
+
// the engine's rule: rendering places, validation speaks.
|
|
401
|
+
for (const d of chartDataDiagnostics(b))
|
|
402
|
+
push(d.severity, d.code, d.message, d.line ?? b.line);
|
|
403
|
+
// pie/doughnut: a single series of positive shares, truncated to the
|
|
404
|
+
// number of categories — the rest of the data is dropped at render time,
|
|
405
|
+
// with no trace other than this diagnostic (same windows as chart.mjs:
|
|
406
|
+
// the truncation applies BEFORE the clamp on negatives)
|
|
407
|
+
if (b.type === 'chart' && (b.chartType === 'pie' || b.chartType === 'doughnut')) {
|
|
408
|
+
if (b.series.length > 1) {
|
|
409
|
+
push(
|
|
410
|
+
'warning',
|
|
411
|
+
'CHART_DATA_IGNORED',
|
|
412
|
+
`Chart "${b.chartType}": ${b.series.length} series — only the first ("${b.series[0].name}") will be displayed. To compare series, use bar, line or radar.`,
|
|
413
|
+
b.line,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const extra = b.series[0].values.length - b.categories.length;
|
|
417
|
+
if (extra > 0) {
|
|
418
|
+
push(
|
|
419
|
+
'warning',
|
|
420
|
+
'CHART_DATA_IGNORED',
|
|
421
|
+
`Chart "${b.chartType}": the series "${b.series[0].name}" carries ${b.series[0].values.length} values for ${b.categories.length} categories — ${extra === 1 ? 'the last one will be dropped' : `the last ${extra} will be dropped`}.`,
|
|
422
|
+
b.line,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
// a series shorter than the categories: the rendering no longer invents
|
|
426
|
+
// a 0 share for the categories without a value, but the author must
|
|
427
|
+
// know they will not appear (same windows as the truncation)
|
|
428
|
+
const missing = b.categories.length - b.series[0].values.length;
|
|
429
|
+
if (missing > 0) {
|
|
430
|
+
const orphans = b.categories.slice(b.series[0].values.length);
|
|
431
|
+
push(
|
|
432
|
+
'warning',
|
|
433
|
+
'CHART_DATA_IGNORED',
|
|
434
|
+
`Chart "${b.chartType}": ${b.categories.length} categories, but the series stops at value ${b.series[0].values.length} — ${missing === 1 ? `the category "${orphans[0]}" will have no share` : `the categories ${orphans.map((c) => `"${c}"`).join(', ')} will have no share`}.`,
|
|
435
|
+
b.line,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const shown = b.series[0].values.slice(0, b.categories.length);
|
|
439
|
+
const neg = shown.filter((v) => v < 0).length;
|
|
440
|
+
if (neg) {
|
|
441
|
+
push(
|
|
442
|
+
'warning',
|
|
443
|
+
'CHART_DATA_IGNORED',
|
|
444
|
+
`Chart "${b.chartType}": negative values are displayed as 0 (${neg} of them) — this type only represents positive shares; use bar or barh.`,
|
|
445
|
+
b.line,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ------ art direction: suggested structured layout -------------------------
|
|
453
|
+
// reverse inference — structured layouts are never inferred, but some content
|
|
454
|
+
// betrays the intent (SWOT, before/after, dated milestones)
|
|
455
|
+
// NFD separates the letter from its diacritic, the character class removes
|
|
456
|
+
// the diacritics: "résumé" and "resume" then compare as identical.
|
|
457
|
+
// Targeting combining marks is the intention here, not a mistake.
|
|
458
|
+
const norm = (runs) =>
|
|
459
|
+
runsToText(runs)
|
|
460
|
+
.toLowerCase()
|
|
461
|
+
.normalize('NFD')
|
|
462
|
+
// biome-ignore lint/suspicious/noMisleadingCharacterClass: removal of diacritics after NFD
|
|
463
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
464
|
+
for (const slide of deck.slides) {
|
|
465
|
+
if (slide.layout) continue;
|
|
466
|
+
const heads = slide.sections.filter((s) => s.heading).map((s) => norm(s.heading));
|
|
467
|
+
if (heads.length < 2) continue;
|
|
468
|
+
const has = (re) => heads.some((h) => re.test(h));
|
|
469
|
+
// milestones: the date/step must OPEN the section heading ("2024",
|
|
470
|
+
// "Q1 2026", "Phase 2") — "Review of 2025" is not a milestone
|
|
471
|
+
const dated = heads.filter((h) =>
|
|
472
|
+
/^((19|20)\d{2}\b|[tq][1-4]\b|phase\s*\d|step\s*\d|milestone|week\s*\d|quarter\s*\d)/.test(h),
|
|
473
|
+
).length;
|
|
474
|
+
let layout = null;
|
|
475
|
+
let why = null;
|
|
476
|
+
// swot: require the canonical order — the layout tints quadrants BY
|
|
477
|
+
// POSITION (success, danger, info, warning); suggesting swot on unordered
|
|
478
|
+
// sections would render "Threats" in green
|
|
479
|
+
const SWOT_ORDER = ['strengths', 'weaknesses', 'opportunities', 'threats'];
|
|
480
|
+
// the OFFICIAL layouts (design/layouts/) are suggested too — only if they
|
|
481
|
+
// are properly loaded in the registry (catalog intact)
|
|
482
|
+
const official = (name) => (LAYOUTS.includes(name) ? name : null);
|
|
483
|
+
if (heads.length === 4 && SWOT_ORDER.every((k, i) => heads[i].includes(k))) {
|
|
484
|
+
layout = 'swot';
|
|
485
|
+
why = 'a strengths / weaknesses / opportunities / threats matrix';
|
|
486
|
+
} else if (
|
|
487
|
+
heads.length === 2 &&
|
|
488
|
+
has(/^pros?\b/) &&
|
|
489
|
+
has(/^cons?\b(?![-–])/) &&
|
|
490
|
+
// "Pros for whom?" (a question) or "Con-artists" (hyphenated) are not a decision to weigh
|
|
491
|
+
!heads.some((h) => h.includes('?')) &&
|
|
492
|
+
official('pros-cons')
|
|
493
|
+
) {
|
|
494
|
+
layout = 'pros-cons';
|
|
495
|
+
why = 'a decision to weigh (pros / cons)';
|
|
496
|
+
} else if (heads.length === 2 && has(/^before\b/) && has(/^after\b/)) {
|
|
497
|
+
layout = 'comparison';
|
|
498
|
+
why = 'a before / after comparison';
|
|
499
|
+
} else if (heads.length === 2 && has(/current|today/) && has(/target|goal|tomorrow|future/)) {
|
|
500
|
+
layout = 'comparison';
|
|
501
|
+
why = 'a current-state / target comparison';
|
|
502
|
+
} else if (
|
|
503
|
+
heads.length === 4 &&
|
|
504
|
+
heads.filter((h) => /probabilit|severity|likelihood/.test(h)).length >= 2 &&
|
|
505
|
+
// the layout tints BY POSITION (green → red): require ascending order —
|
|
506
|
+
// first quadrant benign, last critical — as for the canonical order of
|
|
507
|
+
// the SWOT
|
|
508
|
+
/low|minor/.test(heads[0]) &&
|
|
509
|
+
/critical|major|high|severe/.test(heads[3]) &&
|
|
510
|
+
official('risk-map')
|
|
511
|
+
) {
|
|
512
|
+
layout = 'risk-map';
|
|
513
|
+
why = 'a risk map (probability / severity)';
|
|
514
|
+
} else if (dated >= Math.max(2, heads.length - 1)) {
|
|
515
|
+
layout = 'timeline';
|
|
516
|
+
why = 'dated milestones';
|
|
517
|
+
}
|
|
518
|
+
if (layout) {
|
|
519
|
+
push(
|
|
520
|
+
'info',
|
|
521
|
+
'LAYOUT_SUGGESTION',
|
|
522
|
+
`The "##" sections of this slide express ${why}: adding <!-- layout: ${layout} --> will display it with the dedicated layout.`,
|
|
523
|
+
slide.line,
|
|
524
|
+
layout,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ------ geometric audits on the scenes --------------------------------------
|
|
530
|
+
try {
|
|
531
|
+
const allScenes = scenes ?? buildScenes(deck);
|
|
532
|
+
|
|
533
|
+
// pagination (density info)
|
|
534
|
+
const paginated = new Set();
|
|
535
|
+
for (const scene of allScenes) {
|
|
536
|
+
if (scene.continued && !paginated.has(scene.sourceLine)) {
|
|
537
|
+
paginated.add(scene.sourceLine);
|
|
538
|
+
push(
|
|
539
|
+
'info',
|
|
540
|
+
'SLIDE_PAGINATED',
|
|
541
|
+
`The slide "${String(scene.title ?? '').replace(/ \(cont\.\)$/, '')}" overflows: its content is split into "(cont.)" slides.`,
|
|
542
|
+
scene.sourceLine,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// overflow: estimated height vs region (unpaginated layouts: columns,
|
|
548
|
+
// panels, split — the author can only fix it by knowing about it).
|
|
549
|
+
// The lower bound is the bottom of the CONTENT AREA (672 px): that is where
|
|
550
|
+
// panels and columns end — not the footer (688 px).
|
|
551
|
+
const AUDITED = new Set([
|
|
552
|
+
'para',
|
|
553
|
+
'bullets',
|
|
554
|
+
'heading',
|
|
555
|
+
'code',
|
|
556
|
+
'table',
|
|
557
|
+
'alert',
|
|
558
|
+
'quote',
|
|
559
|
+
'math',
|
|
560
|
+
]);
|
|
561
|
+
const ADVICE = {
|
|
562
|
+
table: 'split the table or remove columns',
|
|
563
|
+
bullets: 'shorten the bullets or spread them over two slides',
|
|
564
|
+
};
|
|
565
|
+
const area = contentArea();
|
|
566
|
+
const areaBottom = area.y + area.h;
|
|
567
|
+
for (const scene of allScenes) {
|
|
568
|
+
if (scene.master === 'cover' || scene.master === 'section') continue;
|
|
569
|
+
let flagged = 0;
|
|
570
|
+
for (const el of scene.elements) {
|
|
571
|
+
if (flagged >= 3 || !AUDITED.has(el.block.type)) continue;
|
|
572
|
+
const needed = blockHeight(el.block, el.region.w);
|
|
573
|
+
const overflow = Math.round(
|
|
574
|
+
Math.max(needed - el.region.h, el.region.y + Math.max(needed, el.region.h) - areaBottom),
|
|
575
|
+
);
|
|
576
|
+
if (overflow > 12) {
|
|
577
|
+
flagged++;
|
|
578
|
+
push(
|
|
579
|
+
'warning',
|
|
580
|
+
'BLOCK_OVERFLOW',
|
|
581
|
+
`The "${el.block.type}" block overflows its region by about ${overflow} px (${scene.layout} layout) — ${
|
|
582
|
+
ADVICE[el.block.type] ?? 'trim the content or switch layouts'
|
|
583
|
+
}.`,
|
|
584
|
+
el.block.line ?? scene.sourceLine,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// resolution: a local image stretched beyond its native size
|
|
590
|
+
const images = scene.elements
|
|
591
|
+
.filter((el) => el.block.type === 'image')
|
|
592
|
+
.concat(scene.image ? [{ block: scene.image, region: { w: PAGE.width } }] : []);
|
|
593
|
+
for (const el of images) {
|
|
594
|
+
const b = el.block;
|
|
595
|
+
if (/^https?:/.test(b.src)) continue;
|
|
596
|
+
const file = resolveImagePath(baseDir, b.src);
|
|
597
|
+
// an image that escapes: refused for embedding (IMAGE_PATH_ESCAPE
|
|
598
|
+
// already emitted) — nothing to audit about its resolution
|
|
599
|
+
if (!imageWithinRoots(file, imageTrustRoots)) continue;
|
|
600
|
+
if (!fs.existsSync(file)) continue;
|
|
601
|
+
const dims = imageDims(file);
|
|
602
|
+
if (!dims?.w) continue;
|
|
603
|
+
// actual displayed width: the renderer frames with "contain" (ratio
|
|
604
|
+
// preserved) — except the cover/background roles, stretched over the
|
|
605
|
+
// region
|
|
606
|
+
const cover = b.role === 'cover' || b.role === 'background';
|
|
607
|
+
const displayed =
|
|
608
|
+
cover || !dims.h || !el.region.h
|
|
609
|
+
? el.region.w
|
|
610
|
+
: Math.min(el.region.w, (el.region.h * dims.w) / dims.h);
|
|
611
|
+
if (displayed > dims.w * 1.3) {
|
|
612
|
+
push(
|
|
613
|
+
'info',
|
|
614
|
+
'IMAGE_UPSCALED',
|
|
615
|
+
`Image "${b.src}", ${dims.w} px wide, displayed at about ${Math.round(displayed)} px — risk of blur: supply a larger image.`,
|
|
616
|
+
b.line ?? scene.sourceLine,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} catch (e) {
|
|
622
|
+
push('error', 'LAYOUT_ERROR', `Could not lay out the document: ${e?.message ?? e}`, 1);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return diags.sort((a, b) => a.line - b.line);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
// Engine capabilities (for agents and autocompletion)
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
export function capabilities() {
|
|
633
|
+
// deep copy on output: the registry and the schemas are live module state —
|
|
634
|
+
// a host that mutated the result would corrupt every subsequent deck of the
|
|
635
|
+
// warm worker
|
|
636
|
+
return structuredClone({
|
|
637
|
+
layouts: LAYOUTS,
|
|
638
|
+
// official layouts of the design/layouts/ catalog (base + parameters +
|
|
639
|
+
// description) — never inferred, to be asked for by <!-- layout: … -->
|
|
640
|
+
officialLayouts: officialLayouts(),
|
|
641
|
+
// user layouts of the last prepared deck (layouts/*.json) — their full
|
|
642
|
+
// definition, so that the agent knows where each alias comes from
|
|
643
|
+
userLayouts: userLayouts(),
|
|
644
|
+
layoutSections: Object.fromEntries(
|
|
645
|
+
LAYOUTS.filter((l) => layoutDef(l)?.sections).map((l) => [l, layoutDef(l).sections]),
|
|
646
|
+
),
|
|
647
|
+
// parameters of the built-in generators (review §3.3, step 3): to be set at
|
|
648
|
+
// the top level of a layouts/*.json — types, domains and defaults published
|
|
649
|
+
// so that agents discover them instead of inventing them
|
|
650
|
+
layoutParams: Object.fromEntries(
|
|
651
|
+
LAYOUTS.filter(
|
|
652
|
+
(l) => layoutDef(l)?.builtin && Object.keys(layoutParamSchema(l) ?? {}).length,
|
|
653
|
+
).map((l) => [l, layoutParamSchema(l)]),
|
|
654
|
+
),
|
|
655
|
+
directives: CONTAINERS,
|
|
656
|
+
chartTypes: [...CHART_TYPES],
|
|
657
|
+
iconColors: [...ICON_COLORS],
|
|
658
|
+
codeFences: ['mermaid', 'math', 'latex', 'tex', 'chart'],
|
|
659
|
+
comments: ['notes', 'layout', 'animate'],
|
|
660
|
+
animatePresets: [...ANIM_PRESETS],
|
|
661
|
+
frontmatter: ['title', 'subtitle', 'author', 'date', 'footer', 'animate', 'kit', 'assets'],
|
|
662
|
+
outputs: ['pptx', 'html'],
|
|
663
|
+
remoteImages:
|
|
664
|
+
'`` images are downloaded then embedded in the deliverable (the presentation has no ' +
|
|
665
|
+
'network dependency). They land in the user cache ~/.cache/lutrin/remote/, shared between projects — ' +
|
|
666
|
+
"compiling writes nothing into the deck's directory. `assets: vendor` (frontmatter) or --vendor-assets " +
|
|
667
|
+
'(CLI, which wins) copies them into assets/remote/ next to the .md, for a self-contained directory ' +
|
|
668
|
+
'that can be archived, handed over or versioned.',
|
|
669
|
+
vendor:
|
|
670
|
+
'"lutrin vendor <deck.md>" freezes ALL external dependencies in the deck\'s directory: remote images ' +
|
|
671
|
+
'(assets/remote/), already rendered Mermaid diagrams (assets/mermaid/ — the deck then compiles ' +
|
|
672
|
+
'without @mermaid-js/mermaid-cli installed) and the resolved kit, fonts and logos included ' +
|
|
673
|
+
'(assets/kit/). The frontmatter is rewritten accordingly (assets: vendor, kit: ./assets/kit): the ' +
|
|
674
|
+
'declaration stays explicit, no hidden level is added to the precedence of kits. The directory then ' +
|
|
675
|
+
'compiles offline, on a machine with no kit installed — at the price of a freeze: updating the kit ' +
|
|
676
|
+
'means running vendor again.',
|
|
677
|
+
theme: {
|
|
678
|
+
frontmatter:
|
|
679
|
+
'kit: my-kit (name of an installed kit), kit: ./my-theme.json (file resolved relative to the ' +
|
|
680
|
+
"deck's directory) or kit: ./my-kit (directory carrying a kit.json); kit: none forces the default " +
|
|
681
|
+
'theme. The CLI flag --kit wins; otherwise the project default via "lutrin": { "kit": … } of the ' +
|
|
682
|
+
'nearest package.json. "theme:" is still accepted as a deprecated alias (diagnostic ' +
|
|
683
|
+
'KIT_DEPRECATED_KEY).',
|
|
684
|
+
precedence:
|
|
685
|
+
'--kit (CLI) > frontmatter kit: > project default (package.json "lutrin".kit) > ' +
|
|
686
|
+
'user default (config.json, see userConfig) > host default (extension) > generic theme.',
|
|
687
|
+
userConfig:
|
|
688
|
+
'kit shared between projects: the "kit" field of ~/.config/lutrin/config.json (overridable by ' +
|
|
689
|
+
'LUTRIN_CONFIG; XDG_CONFIG_HOME respected) — set with "config --kit <ref>". ' +
|
|
690
|
+
'Kits installed in ~/.config/lutrin/kits/<name>/ are referenced by name from any project.',
|
|
691
|
+
reference:
|
|
692
|
+
'design/themes/default.json (full mirror of the default theme, a template to copy)',
|
|
693
|
+
kit:
|
|
694
|
+
'a kit carries a kit.json { name, version?, theme?: "./theme.json", layouts?: "./layouts" } at its ' +
|
|
695
|
+
'root (defaults: ./theme.json and ./layouts if it exists) — fonts/logos resolved relative to ' +
|
|
696
|
+
'theme.json, inside the kit; distributed as a directory or as a .deckkit archive',
|
|
697
|
+
keys: THEME_KEYS,
|
|
698
|
+
},
|
|
699
|
+
layoutsDir:
|
|
700
|
+
'layouts/*.json next to the deck — { name, base (built-in or official layout), sections?: { min, max }, description?, …parameters of the base (see layoutParams) }',
|
|
701
|
+
diagnostics: [
|
|
702
|
+
'PARSE_ERROR',
|
|
703
|
+
'LAYOUT_ERROR',
|
|
704
|
+
'EMPTY_DECK',
|
|
705
|
+
'UNKNOWN_DIRECTIVE',
|
|
706
|
+
'ORPHAN_DIRECTIVE',
|
|
707
|
+
'UNKNOWN_LAYOUT',
|
|
708
|
+
'LAYOUT_SECTIONS',
|
|
709
|
+
'UNKNOWN_ANIMATE',
|
|
710
|
+
'METRICS_DROPPED',
|
|
711
|
+
'MISSING_IMAGE',
|
|
712
|
+
'UNKNOWN_ICON',
|
|
713
|
+
'INVALID_CHART',
|
|
714
|
+
'SLIDE_PAGINATED',
|
|
715
|
+
'BLOCK_OVERFLOW',
|
|
716
|
+
'LAYOUT_SUGGESTION',
|
|
717
|
+
'IMAGE_UPSCALED',
|
|
718
|
+
'ALERT_CONTENT_DROPPED',
|
|
719
|
+
'CHART_DATA_IGNORED',
|
|
720
|
+
'QUOTE_EMPTY',
|
|
721
|
+
'LAYERS_SHADE_MISSING',
|
|
722
|
+
'THEME_NOT_FOUND',
|
|
723
|
+
'THEME_INVALID',
|
|
724
|
+
'THEME_UNKNOWN_KEY',
|
|
725
|
+
'THEME_BAD_VALUE',
|
|
726
|
+
'THEME_CONTRAST',
|
|
727
|
+
'KIT_NOT_FOUND',
|
|
728
|
+
'KIT_INVALID',
|
|
729
|
+
'KIT_UNKNOWN_KEY',
|
|
730
|
+
'KIT_BAD_VALUE',
|
|
731
|
+
'KIT_DEPRECATED_KEY',
|
|
732
|
+
'USER_CONFIG_INVALID',
|
|
733
|
+
'LAYOUT_DEF_INVALID',
|
|
734
|
+
'LAYOUT_DEF_ADJUSTED',
|
|
735
|
+
],
|
|
736
|
+
});
|
|
737
|
+
}
|