orz-slides 0.2.0 → 0.4.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.
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js CHANGED
@@ -12,64 +12,19 @@
12
12
  * --inline embed the engine bundle + theme CSS in the file (default)
13
13
  * --cdn reference the engine + theme from jsDelivr (small files)
14
14
  * --title <text> document <title> (fallback; deck `title:` wins)
15
+ *
16
+ * The inline path (the default) is shared with the programmatic library entry
17
+ * `buildSlidesHtml` (src/lib.ts) — there is ONE composition path. The CDN path
18
+ * lives here.
15
19
  */
16
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync } from 'node:fs';
17
21
  import { basename, extname, dirname, resolve, join } from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
- import { createRequire } from 'node:module';
20
22
  import { randomUUID } from 'node:crypto';
21
23
  import { getBrowserRuntimeScript } from 'orz-markdown/runtime';
22
24
  import { PREVIEW_CDN } from 'orz-markdown/preview-frame';
23
25
  import { parseDeck } from './slide-parser.js';
24
26
  import { buildHtml } from './template.js';
25
- /** The seven shipped slide themes (served from the orz-slides package on CDN). */
26
- const THEME_DEFS = [
27
- { id: 'paper', name: 'Paper', scheme: 'light' },
28
- { id: 'architect', name: 'Architect', scheme: 'light' },
29
- { id: 'executive', name: 'Executive', scheme: 'light' },
30
- { id: 'sage', name: 'Sage', scheme: 'light' },
31
- { id: 'poppy', name: 'Poppy', scheme: 'light' },
32
- { id: 'neon', name: 'Neon', scheme: 'dark' },
33
- { id: 'chalk', name: 'Chalk', scheme: 'dark' },
34
- ];
35
- const require = createRequire(import.meta.url);
36
- const HERE = dirname(fileURLToPath(import.meta.url));
37
- function pkgVersion(name, fallback = '0.0.0') {
38
- try {
39
- let dir = dirname(require.resolve(name));
40
- while (!existsSync(join(dir, 'package.json')))
41
- dir = dirname(dir);
42
- return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version || fallback;
43
- }
44
- catch {
45
- return fallback;
46
- }
47
- }
48
- /** orz-slides' own version — pins the engine bundle + theme CDN + version check. */
49
- function selfVersion() {
50
- for (const p of [join(HERE, '..', 'package.json'), join(HERE, '..', '..', 'package.json')]) {
51
- try {
52
- const j = JSON.parse(readFileSync(p, 'utf8'));
53
- if (j.name === 'orz-slides' && j.version)
54
- return j.version;
55
- }
56
- catch { /* keep looking */ }
57
- }
58
- return '0.0.0';
59
- }
60
- /** assets/ sits next to dist/ when published, and next to src/ in dev. */
61
- function findAsset(rel) {
62
- for (const p of [join(HERE, '..', 'assets', rel), join(HERE, '..', '..', 'assets', rel)]) {
63
- if (existsSync(p))
64
- return p;
65
- }
66
- throw new Error(`asset not found: ${rel}`);
67
- }
68
- /** A theme's CSS without its `@import url('./base.css')` (base is inlined once). */
69
- function themeOnly(id) {
70
- const css = readFileSync(findAsset(`themes/theme-${id}.css`), 'utf8');
71
- return css.replace(/@import\s+url\(\s*['"]?\.\/base\.css['"]?\s*\)\s*;/, '');
72
- }
27
+ import { THEME_DEFS, selfVersion, findAsset, pkgVersion, buildSlidesHtmlWithDocId, } from './lib.js';
73
28
  function parseArgs(argv) {
74
29
  const a = { delivery: 'inline' };
75
30
  for (let i = 0; i < argv.length; i++) {
@@ -89,74 +44,26 @@ function parseArgs(argv) {
89
44
  }
90
45
  return a;
91
46
  }
92
- function main() {
93
- const args = parseArgs(process.argv.slice(2));
94
- if (!args.input) {
95
- console.error('Usage: orz-slides <input.md> [-o out] [--theme name] [--inline|--cdn]');
96
- process.exit(1);
97
- }
98
- const inputPath = resolve(args.input);
99
- const source = readFileSync(inputPath, 'utf8');
100
- const base = basename(inputPath, extname(inputPath)).replace(/\.slides$/, '');
101
- const outPath = args.out ? resolve(args.out) : join(dirname(inputPath), `${base}.slides.html`);
102
- // The deck's own config wins; CLI flags are fallbacks.
103
- const deck = parseDeck(source);
104
- const ver = selfVersion();
105
- const themeBase = `https://cdn.jsdelivr.net/npm/orz-slides@${ver}/assets/themes`;
106
- const themes = THEME_DEFS.map((t) => ({ ...t, href: `${themeBase}/theme-${t.id}.css` }));
107
- const wanted = deck.config.theme || args.theme || 'paper';
108
- const defaultTheme = themes.some((t) => t.id === wanted) ? wanted : themes[0].id;
109
- const ratio = deck.config.ratio || '16:9';
110
- const title = deck.config.title || args.title || base;
111
- // Engine + theme delivery.
112
- let renderer;
113
- let theme;
114
- if (args.delivery === 'inline') {
115
- const bundlePath = [
116
- join(HERE, 'orz-slides.browser.js'),
117
- join(HERE, '..', 'dist', 'orz-slides.browser.js'),
118
- ].find(existsSync);
119
- if (!bundlePath) {
120
- console.error('Inline mode needs the engine bundle. Run: npm run bundle');
121
- process.exit(1);
122
- }
123
- renderer = { mode: 'inline', js: readFileSync(bundlePath, 'utf8') };
124
- theme = {
125
- mode: 'inline',
126
- base: readFileSync(findAsset('themes/base.css'), 'utf8'),
127
- themes: THEME_DEFS.map((t) => ({ id: t.id, css: themeOnly(t.id) })),
128
- };
129
- }
130
- else {
131
- renderer = { mode: 'cdn', src: `https://cdn.jsdelivr.net/npm/orz-slides-browser@${ver}/orz-slides.browser.js` };
132
- theme = { mode: 'cdn' };
133
- }
47
+ /** The CDN delivery path (engine + themes referenced from jsDelivr). */
48
+ function buildCdnHtml(source, title, base, docId, ver, themes, defaultTheme, ratio) {
49
+ const renderer = {
50
+ mode: 'cdn',
51
+ src: `https://cdn.jsdelivr.net/npm/orz-slides-browser@${ver}/orz-slides.browser.js`,
52
+ };
53
+ const theme = { mode: 'cdn' };
134
54
  const revealVer = pkgVersion('reveal.js', '5.0.4');
135
- // reveal's reset.css + reveal.css contain only data: URIs (no external refs),
136
- // so --inline embeds them and the deck presents fully offline.
137
- let revealCss;
138
- if (args.delivery === 'inline') {
139
- const revealDist = dirname(require.resolve('reveal.js'));
140
- revealCss = {
141
- mode: 'inline',
142
- reset: readFileSync(join(revealDist, 'reset.css'), 'utf8'),
143
- core: readFileSync(join(revealDist, 'reveal.css'), 'utf8'),
144
- };
145
- }
146
- else {
147
- revealCss = {
148
- mode: 'cdn',
149
- resetUrl: `https://cdn.jsdelivr.net/npm/reveal.js@${revealVer}/dist/reset.css`,
150
- coreUrl: `https://cdn.jsdelivr.net/npm/reveal.js@${revealVer}/dist/reveal.css`,
151
- };
152
- }
55
+ const revealCss = {
56
+ mode: 'cdn',
57
+ resetUrl: `https://cdn.jsdelivr.net/npm/reveal.js@${revealVer}/dist/reset.css`,
58
+ coreUrl: `https://cdn.jsdelivr.net/npm/reveal.js@${revealVer}/dist/reveal.css`,
59
+ };
153
60
  const appJs = readFileSync(findAsset('app.js'), 'utf8');
154
61
  const CM = 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16';
155
- const html = buildHtml({
62
+ return buildHtml({
156
63
  source,
157
64
  title,
158
65
  filename: base,
159
- docId: randomUUID(),
66
+ docId,
160
67
  rendererVersion: ver,
161
68
  renderer,
162
69
  theme,
@@ -182,6 +89,39 @@ function main() {
182
89
  chartJs: PREVIEW_CDN.chartJs,
183
90
  },
184
91
  });
92
+ }
93
+ function main() {
94
+ const args = parseArgs(process.argv.slice(2));
95
+ if (!args.input) {
96
+ console.error('Usage: orz-slides <input.md> [-o out] [--theme name] [--inline|--cdn]');
97
+ process.exit(1);
98
+ }
99
+ const inputPath = resolve(args.input);
100
+ const source = readFileSync(inputPath, 'utf8');
101
+ const base = basename(inputPath, extname(inputPath)).replace(/\.slides$/, '');
102
+ const outPath = args.out ? resolve(args.out) : join(dirname(inputPath), `${base}.slides.html`);
103
+ const deck = parseDeck(source);
104
+ const docId = randomUUID();
105
+ // Resolve the effective (validated) theme once, for the log line and CDN path.
106
+ const wanted = deck.config.theme || args.theme || 'paper';
107
+ const defaultTheme = THEME_DEFS.some((t) => t.id === wanted) ? wanted : THEME_DEFS[0].id;
108
+ let html;
109
+ if (args.delivery === 'inline') {
110
+ // Shared inline composition — the library and the CLI produce byte-identical
111
+ // output for the same source (modulo docId). The deck-config precedence and
112
+ // the 'Untitled' fallbacks live in lib.ts; the CLI passes its filename-based
113
+ // title as the fallback so the deck's own title still wins.
114
+ html = buildSlidesHtmlWithDocId({ markdown: source, title: args.title ?? base, theme: args.theme }, docId);
115
+ }
116
+ else {
117
+ // The deck's own config wins; CLI flags are fallbacks.
118
+ const ver = selfVersion();
119
+ const themeBase = `https://cdn.jsdelivr.net/npm/orz-slides@${ver}/assets/themes`;
120
+ const themes = THEME_DEFS.map((t) => ({ ...t, href: `${themeBase}/theme-${t.id}.css` }));
121
+ const ratio = deck.config.ratio || '16:9';
122
+ const title = deck.config.title || args.title || base;
123
+ html = buildCdnHtml(source, title, base, docId, ver, themes, defaultTheme, ratio);
124
+ }
185
125
  writeFileSync(outPath, html, 'utf8');
186
126
  console.log(`Wrote ${outPath} (${args.delivery}, theme: ${defaultTheme}, ${deck.slides.length} slides)`);
187
127
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * WP2 — Layout engine. Layout tree → nested CSS-grid DOM.
3
+ *
4
+ * Takes a fully-expanded `LayoutNode` (the parser has already expanded any
5
+ * preset alias), so this module depends only on ./types — nothing else.
6
+ * Pure string building; no DOM API.
7
+ *
8
+ * See BUILD-PLAN.md WP2 and docs/dom-contract.md ("Layout grid (WP2 output)").
9
+ */
10
+ import type { LayoutNode, GridRender } from './types.js';
11
+ /**
12
+ * Render a layout tree to nested grid DOM with empty region cells.
13
+ * Returns the inner HTML for `.orz-content` and the region names in order.
14
+ */
15
+ export declare function renderLayout(node: LayoutNode): GridRender;
package/dist/lib.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { type ThemeEntry } from './template.js';
2
+ /** The seven shipped slide themes (served from the orz-slides package on CDN). */
3
+ export declare const THEME_DEFS: Array<Omit<ThemeEntry, 'href'>>;
4
+ export declare function pkgVersion(name: string, fallback?: string): string;
5
+ /** orz-slides' own version — pins the engine bundle + theme CDN + version check. */
6
+ export declare function selfVersion(): string;
7
+ /** assets/ sits next to dist/ when published, and next to src/ in dev. */
8
+ export declare function findAsset(rel: string): string;
9
+ /** A theme's CSS without its `@import url('./base.css')` (base is inlined once). */
10
+ export declare function themeOnly(id: string): string;
11
+ export interface BuildSlidesOptions {
12
+ /** The deck source (orz-markdown + slide layout syntax). */
13
+ markdown: string;
14
+ /** Document `<title>` fallback; the deck's own `title:` wins. */
15
+ title?: string;
16
+ /** Theme id fallback; the deck's own `theme:` wins. Validated against THEME_DEFS. */
17
+ theme?: string;
18
+ /** Renderer + theme + reveal CSS delivery: `inline` (default, offline) or
19
+ * `cdn` (small file — engine, themes, and reveal.css load from jsDelivr at
20
+ * view time; requires orz-slides-browser to be published at this version). */
21
+ delivery?: 'inline' | 'cdn';
22
+ }
23
+ /**
24
+ * Shared inline-composition path. Both {@link buildSlidesHtml} and the CLI's
25
+ * `--inline` branch call this; the ONLY difference between them is the `docId`
26
+ * they pass, so with the same `docId` the outputs are byte-identical.
27
+ *
28
+ * @internal — exported for tests / the CLI, not part of the primary API surface.
29
+ */
30
+ export declare function buildSlidesHtmlWithDocId(opts: BuildSlidesOptions, docId: string): string;
31
+ /**
32
+ * Generate a fully self-contained, presentable `.slides.html` document from a
33
+ * deck source string, in-process. Returns the FULL document string.
34
+ *
35
+ * The output is always fully-inline (engine + base/theme CSS + reveal CSS +
36
+ * embedded deck source), byte-identical to the CLI `--inline` output for the
37
+ * same markdown/title/theme (modulo the random docId).
38
+ *
39
+ * Deck-config precedence: the deck's own `title:`/`theme:`/`ratio:` always win;
40
+ * `opts.title`/`opts.theme` are fallbacks (title falls back to `'Untitled'`,
41
+ * theme to `'paper'`, validated against THEME_DEFS).
42
+ */
43
+ export declare function buildSlidesHtml(opts: BuildSlidesOptions): string;
package/dist/lib.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * orz-slides — programmatic library entry.
3
+ *
4
+ * Exposes {@link buildSlidesHtml}, which generates a fully self-contained,
5
+ * presentable `.slides.html` document IN-PROCESS from a deck source string
6
+ * (orz-markdown + the slide layout syntax) — no CLI, no shelling out, no
7
+ * filesystem input file.
8
+ *
9
+ * The output is ALWAYS fully-inline (inlined engine bundle + inlined base/theme
10
+ * CSS + inlined reveal.js CSS + embedded deck source; no CDN engine/theme pins),
11
+ * byte-identical to the CLI `--inline` path for the same markdown/title/theme
12
+ * (modulo the random docId).
13
+ *
14
+ * This module also owns the shared inline-composition helpers (`selfVersion`,
15
+ * `findAsset`, `themeOnly`, `THEME_DEFS`, reveal.js CSS reading, `pkgVersion`)
16
+ * so there is ONE composition path and the CLI imports them rather than
17
+ * duplicating the logic.
18
+ *
19
+ * Asset resolution is ALWAYS via `import.meta.url` (never `process.cwd()`), so
20
+ * the entry works when consumed from an installed npm package.
21
+ */
22
+ import { readFileSync, existsSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { createRequire } from 'node:module';
26
+ import { randomUUID } from 'node:crypto';
27
+ import { getBrowserRuntimeScript } from 'orz-markdown/runtime';
28
+ import { PREVIEW_CDN } from 'orz-markdown/preview-frame';
29
+ import { parseDeck } from './slide-parser.js';
30
+ import { buildHtml } from './template.js';
31
+ /** The seven shipped slide themes (served from the orz-slides package on CDN). */
32
+ export const THEME_DEFS = [
33
+ { id: 'paper', name: 'Paper', scheme: 'light' },
34
+ { id: 'architect', name: 'Architect', scheme: 'light' },
35
+ { id: 'executive', name: 'Executive', scheme: 'light' },
36
+ { id: 'sage', name: 'Sage', scheme: 'light' },
37
+ { id: 'poppy', name: 'Poppy', scheme: 'light' },
38
+ { id: 'neon', name: 'Neon', scheme: 'dark' },
39
+ { id: 'chalk', name: 'Chalk', scheme: 'dark' },
40
+ ];
41
+ const require = createRequire(import.meta.url);
42
+ // Asset resolution anchors on THIS module's location, never process.cwd().
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ export function pkgVersion(name, fallback = '0.0.0') {
45
+ try {
46
+ let dir = dirname(require.resolve(name));
47
+ while (!existsSync(join(dir, 'package.json')))
48
+ dir = dirname(dir);
49
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version || fallback;
50
+ }
51
+ catch {
52
+ return fallback;
53
+ }
54
+ }
55
+ /** orz-slides' own version — pins the engine bundle + theme CDN + version check. */
56
+ export function selfVersion() {
57
+ for (const p of [join(HERE, '..', 'package.json'), join(HERE, '..', '..', 'package.json')]) {
58
+ try {
59
+ const j = JSON.parse(readFileSync(p, 'utf8'));
60
+ if (j.name === 'orz-slides' && j.version)
61
+ return j.version;
62
+ }
63
+ catch { /* keep looking */ }
64
+ }
65
+ return '0.0.0';
66
+ }
67
+ /** assets/ sits next to dist/ when published, and next to src/ in dev. */
68
+ export function findAsset(rel) {
69
+ for (const p of [join(HERE, '..', 'assets', rel), join(HERE, '..', '..', 'assets', rel)]) {
70
+ if (existsSync(p))
71
+ return p;
72
+ }
73
+ throw new Error(`asset not found: ${rel}`);
74
+ }
75
+ /** A theme's CSS without its `@import url('./base.css')` (base is inlined once). */
76
+ export function themeOnly(id) {
77
+ const css = readFileSync(findAsset(`themes/theme-${id}.css`), 'utf8');
78
+ return css.replace(/@import\s+url\(\s*['"]?\.\/base\.css['"]?\s*\)\s*;/, '');
79
+ }
80
+ /** The inlined engine bundle (dist/orz-slides.browser.js), read from disk. */
81
+ function readEngineBundle() {
82
+ const bundlePath = [
83
+ join(HERE, 'orz-slides.browser.js'),
84
+ join(HERE, '..', 'dist', 'orz-slides.browser.js'),
85
+ ].find(existsSync);
86
+ if (!bundlePath) {
87
+ throw new Error('Inline mode needs the engine bundle. Run: npm run bundle');
88
+ }
89
+ return readFileSync(bundlePath, 'utf8');
90
+ }
91
+ /** reveal.js reset.css + reveal.css (both contain only data: URIs — offline-safe). */
92
+ function readRevealCss() {
93
+ const revealDist = dirname(require.resolve('reveal.js'));
94
+ return {
95
+ reset: readFileSync(join(revealDist, 'reset.css'), 'utf8'),
96
+ core: readFileSync(join(revealDist, 'reveal.css'), 'utf8'),
97
+ };
98
+ }
99
+ /**
100
+ * Shared inline-composition path. Both {@link buildSlidesHtml} and the CLI's
101
+ * `--inline` branch call this; the ONLY difference between them is the `docId`
102
+ * they pass, so with the same `docId` the outputs are byte-identical.
103
+ *
104
+ * @internal — exported for tests / the CLI, not part of the primary API surface.
105
+ */
106
+ export function buildSlidesHtmlWithDocId(opts, docId) {
107
+ // The deck's own config wins; the passed options are fallbacks. The library
108
+ // has no input filename, so 'Untitled' stands in where the CLI used `base`.
109
+ const deck = parseDeck(opts.markdown);
110
+ const ver = selfVersion();
111
+ const themeBase = `https://cdn.jsdelivr.net/npm/orz-slides@${ver}/assets/themes`;
112
+ const themes = THEME_DEFS.map((t) => ({ ...t, href: `${themeBase}/theme-${t.id}.css` }));
113
+ const wanted = deck.config.theme || opts.theme || 'paper';
114
+ const defaultTheme = themes.some((t) => t.id === wanted) ? wanted : themes[0].id;
115
+ const ratio = deck.config.ratio || '16:9';
116
+ const title = deck.config.title || opts.title || 'Untitled';
117
+ // Engine + theme + reveal CSS delivery: inline (default, offline) or CDN.
118
+ const cdn = opts.delivery === 'cdn';
119
+ const renderer = cdn
120
+ ? { mode: 'cdn', src: `https://cdn.jsdelivr.net/npm/orz-slides-browser@${ver}/orz-slides.browser.js` }
121
+ : { mode: 'inline', js: readEngineBundle() };
122
+ const theme = cdn
123
+ ? { mode: 'cdn' }
124
+ : {
125
+ mode: 'inline',
126
+ base: readFileSync(findAsset('themes/base.css'), 'utf8'),
127
+ themes: THEME_DEFS.map((t) => ({ id: t.id, css: themeOnly(t.id) })),
128
+ };
129
+ let revealCss;
130
+ if (cdn) {
131
+ const revealVer = pkgVersion('reveal.js', '5.0.4');
132
+ revealCss = {
133
+ mode: 'cdn',
134
+ resetUrl: `https://cdn.jsdelivr.net/npm/reveal.js@${revealVer}/dist/reset.css`,
135
+ coreUrl: `https://cdn.jsdelivr.net/npm/reveal.js@${revealVer}/dist/reveal.css`,
136
+ };
137
+ }
138
+ else {
139
+ const reveal = readRevealCss();
140
+ revealCss = { mode: 'inline', reset: reveal.reset, core: reveal.core };
141
+ }
142
+ const appJs = readFileSync(findAsset('app.js'), 'utf8');
143
+ const CM = 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16';
144
+ return buildHtml({
145
+ source: opts.markdown,
146
+ title,
147
+ filename: 'Untitled',
148
+ docId,
149
+ rendererVersion: ver,
150
+ renderer,
151
+ theme,
152
+ defaultTheme,
153
+ themes,
154
+ ratio,
155
+ versionManifest: 'https://data.jsdelivr.com/v1/packages/npm/orz-slides-browser/resolved',
156
+ appJs,
157
+ runtime: getBrowserRuntimeScript(),
158
+ editorLibs: {
159
+ codemirrorCss: `${CM}/codemirror.min.css`,
160
+ codemirrorLightThemeCss: `${CM}/theme/eclipse.min.css`,
161
+ codemirrorDarkThemeCss: `${CM}/theme/material-darker.min.css`,
162
+ codemirrorJs: `${CM}/codemirror.min.js`,
163
+ codemirrorMarkdownJs: `${CM}/mode/markdown/markdown.min.js`,
164
+ codemirrorContinuelistJs: `${CM}/addon/edit/continuelist.min.js`,
165
+ },
166
+ revealCss,
167
+ cdn: {
168
+ katexCss: PREVIEW_CDN.katexCss,
169
+ mermaidJs: PREVIEW_CDN.mermaidJs,
170
+ smilesJs: PREVIEW_CDN.smilesJs,
171
+ chartJs: PREVIEW_CDN.chartJs,
172
+ },
173
+ });
174
+ }
175
+ /**
176
+ * Generate a fully self-contained, presentable `.slides.html` document from a
177
+ * deck source string, in-process. Returns the FULL document string.
178
+ *
179
+ * The output is always fully-inline (engine + base/theme CSS + reveal CSS +
180
+ * embedded deck source), byte-identical to the CLI `--inline` output for the
181
+ * same markdown/title/theme (modulo the random docId).
182
+ *
183
+ * Deck-config precedence: the deck's own `title:`/`theme:`/`ratio:` always win;
184
+ * `opts.title`/`opts.theme` are fallbacks (title falls back to `'Untitled'`,
185
+ * theme to `'paper'`, validated against THEME_DEFS).
186
+ */
187
+ export function buildSlidesHtml(opts) {
188
+ return buildSlidesHtmlWithDocId(opts, randomUUID());
189
+ }