@openvoxproject/voxblocks 0.14.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvoxproject/voxblocks",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Web components for OpenVox community web sites and apps",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,259 @@
1
+ /**
2
+ * A small, dependency-free syntax tokenizer. It trades completeness for
3
+ * being self-contained (no Prism/highlight.js/Shiki) and covers the
4
+ * languages that actually show up in OpenVox docs: shell, Puppet
5
+ * manifests, Hiera/YAML data, JSON, Ruby, plus the web basics.
6
+ *
7
+ * Each language is an ordered list of rules; the first rule whose regex
8
+ * matches at the current position wins, so more specific rules (e.g. "a
9
+ * quoted key before a colon") must be listed before the general-purpose
10
+ * ones they'd otherwise be shadowed by.
11
+ */
12
+
13
+ export type TokenType =
14
+ | 'plain'
15
+ | 'comment'
16
+ | 'keyword'
17
+ | 'string'
18
+ | 'number'
19
+ | 'function'
20
+ | 'property'
21
+ | 'tag';
22
+
23
+ export interface Token {
24
+ type: TokenType;
25
+ text: string;
26
+ }
27
+
28
+ type TypeResolver = (matchText: string, code: string, matchEnd: number) => TokenType;
29
+
30
+ interface Rule {
31
+ re: RegExp;
32
+ type: TokenType | TypeResolver;
33
+ }
34
+
35
+ function skipInlineSpace(code: string, from: number): number {
36
+ let j = from;
37
+ while (j < code.length && (code[j] === ' ' || code[j] === '\t')) j++;
38
+ return j;
39
+ }
40
+
41
+ /** Matches an identifier; resolves to `keyword` if it's in `keywords`, `function` if followed by `(`, else `plain`. */
42
+ function identifierRule(re: RegExp, keywords: Set<string>): Rule {
43
+ return {
44
+ re,
45
+ type: (text, code, end) => {
46
+ if (keywords.has(text)) return 'keyword';
47
+ return code[skipInlineSpace(code, end)] === '(' ? 'function' : 'plain';
48
+ },
49
+ };
50
+ }
51
+
52
+ /** Matches a quoted string; resolves to `property` if it's a key (followed by `:`), else `string`. */
53
+ function quotedPropertyRule(re: RegExp): Rule {
54
+ return {
55
+ re,
56
+ type: (_text, code, end) => (code[skipInlineSpace(code, end)] === ':' ? 'property' : 'string'),
57
+ };
58
+ }
59
+
60
+ const NUMBER = /-?\b\d+\.?\d*(?:[eE][+-]?\d+)?\b/y;
61
+ const DQ_STRING = /"(?:\\.|[^"\\])*"/y;
62
+ const SQ_STRING = /'(?:\\.|[^'\\])*'/y;
63
+ const HASH_COMMENT = /#.*/y;
64
+ const SLASH_COMMENT = /\/\/.*/y;
65
+ const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//y;
66
+
67
+ function pushToken(tokens: Token[], type: TokenType, text: string): void {
68
+ const last = tokens[tokens.length - 1];
69
+ if (last && last.type === type) {
70
+ last.text += text;
71
+ } else {
72
+ tokens.push({ type, text });
73
+ }
74
+ }
75
+
76
+ /** Runs `rules` over `code`, falling back to a single plain-text token for anything unmatched. */
77
+ export function tokenize(code: string, rules: Rule[]): Token[] {
78
+ const tokens: Token[] = [];
79
+ let i = 0;
80
+ while (i < code.length) {
81
+ let matched = false;
82
+ for (const rule of rules) {
83
+ rule.re.lastIndex = i;
84
+ const m = rule.re.exec(code);
85
+ if (m && m.index === i && m[0].length > 0) {
86
+ const text = m[0];
87
+ const type = typeof rule.type === 'function' ? rule.type(text, code, i + text.length) : rule.type;
88
+ pushToken(tokens, type, text);
89
+ i += text.length;
90
+ matched = true;
91
+ break;
92
+ }
93
+ }
94
+ if (!matched) {
95
+ pushToken(tokens, 'plain', code[i]);
96
+ i++;
97
+ }
98
+ }
99
+ return tokens;
100
+ }
101
+
102
+ const BASH_KEYWORDS = new Set([
103
+ 'if', 'then', 'elif', 'else', 'fi', 'for', 'while', 'until', 'do', 'done',
104
+ 'case', 'esac', 'in', 'function', 'select', 'time', 'return', 'exit',
105
+ 'break', 'continue', 'local', 'export', 'readonly', 'declare', 'unset',
106
+ 'shift', 'eval', 'exec', 'trap', 'set', 'source', 'alias', 'unalias', 'true', 'false',
107
+ ]);
108
+
109
+ const bash: Rule[] = [
110
+ { re: HASH_COMMENT, type: 'comment' },
111
+ { re: /\$\{[^}]*\}|\$[A-Za-z_]\w*|\$[0-9@#?$!*_-]/y, type: 'function' },
112
+ { re: DQ_STRING, type: 'string' },
113
+ { re: SQ_STRING, type: 'string' },
114
+ { re: NUMBER, type: 'number' },
115
+ identifierRule(/[A-Za-z_][\w-]*/y, BASH_KEYWORDS),
116
+ ];
117
+
118
+ const YAML_KEYWORDS = new Set([
119
+ 'true', 'false', 'yes', 'no', 'null', 'on', 'off',
120
+ 'True', 'False', 'Yes', 'No', 'Null', 'On', 'Off',
121
+ 'TRUE', 'FALSE', 'YES', 'NO', 'NULL', 'ON', 'OFF',
122
+ ]);
123
+
124
+ const yaml: Rule[] = [
125
+ { re: HASH_COMMENT, type: 'comment' },
126
+ quotedPropertyRule(DQ_STRING),
127
+ quotedPropertyRule(SQ_STRING),
128
+ { re: /[A-Za-z_][\w .-]*?(?=:(\s|$))/y, type: 'property' },
129
+ { re: /[&*!][A-Za-z_][\w:.]*/y, type: 'function' },
130
+ { re: NUMBER, type: 'number' },
131
+ identifierRule(/[A-Za-z_][\w-]*/y, YAML_KEYWORDS),
132
+ ];
133
+
134
+ const json: Rule[] = [
135
+ quotedPropertyRule(DQ_STRING),
136
+ { re: NUMBER, type: 'number' },
137
+ identifierRule(/[A-Za-z_]\w*/y, new Set(['true', 'false', 'null'])),
138
+ ];
139
+
140
+ const JS_KEYWORDS = new Set([
141
+ 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
142
+ 'do', 'switch', 'case', 'default', 'break', 'continue', 'class', 'extends',
143
+ 'super', 'new', 'this', 'import', 'export', 'from', 'as', 'async', 'await',
144
+ 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of',
145
+ 'yield', 'static', 'get', 'set', 'void', 'delete', 'null', 'undefined',
146
+ 'true', 'false', 'public', 'private', 'protected', 'readonly', 'interface',
147
+ 'type', 'enum', 'implements', 'namespace', 'declare', 'abstract', 'keyof',
148
+ 'satisfies',
149
+ ]);
150
+
151
+ const javascript: Rule[] = [
152
+ { re: SLASH_COMMENT, type: 'comment' },
153
+ { re: BLOCK_COMMENT, type: 'comment' },
154
+ { re: DQ_STRING, type: 'string' },
155
+ { re: SQ_STRING, type: 'string' },
156
+ { re: /`(?:\\.|[^`\\])*`/y, type: 'string' },
157
+ { re: /-?\b0[xXbBoO][0-9a-fA-F]+\b|-?\b\d+\.?\d*(?:[eE][+-]?\d+)?\b/y, type: 'number' },
158
+ identifierRule(/[A-Za-z_$][\w$]*/y, JS_KEYWORDS),
159
+ ];
160
+
161
+ const css: Rule[] = [
162
+ { re: BLOCK_COMMENT, type: 'comment' },
163
+ { re: DQ_STRING, type: 'string' },
164
+ { re: SQ_STRING, type: 'string' },
165
+ { re: /@[\w-]+/y, type: 'keyword' },
166
+ { re: /!important/y, type: 'keyword' },
167
+ { re: /#[0-9a-fA-F]{3,8}\b/y, type: 'number' },
168
+ { re: /-?\b\d+\.?\d*[a-zA-Z%]*\b/y, type: 'number' },
169
+ { re: /[a-zA-Z-]+(?=\s*:)/y, type: 'property' },
170
+ identifierRule(/[a-zA-Z-]+/y, new Set()),
171
+ ];
172
+
173
+ const htmlLang: Rule[] = [
174
+ { re: /<!--[\s\S]*?-->/y, type: 'comment' },
175
+ { re: /<\/?[a-zA-Z][\w:-]*/y, type: 'tag' },
176
+ { re: /[a-zA-Z-][\w-]*(?=\s*=)/y, type: 'property' },
177
+ { re: DQ_STRING, type: 'string' },
178
+ { re: SQ_STRING, type: 'string' },
179
+ { re: /&[\w#]+;/y, type: 'keyword' },
180
+ ];
181
+
182
+ const RUBY_KEYWORDS = new Set([
183
+ 'def', 'end', 'if', 'elsif', 'else', 'unless', 'while', 'until', 'for',
184
+ 'in', 'do', 'class', 'module', 'begin', 'rescue', 'ensure', 'raise',
185
+ 'return', 'yield', 'break', 'next', 'redo', 'retry', 'case', 'when',
186
+ 'then', 'and', 'or', 'not', 'nil', 'true', 'false', 'self', 'super',
187
+ 'require', 'require_relative', 'include', 'extend', 'attr_accessor',
188
+ 'attr_reader', 'attr_writer', 'private', 'protected', 'public', 'lambda',
189
+ 'proc', 'new',
190
+ ]);
191
+
192
+ const ruby: Rule[] = [
193
+ { re: HASH_COMMENT, type: 'comment' },
194
+ { re: /:[A-Za-z_]\w*[?!]?/y, type: 'string' },
195
+ { re: /@{1,2}[A-Za-z_]\w*|\$[A-Za-z_]\w*/y, type: 'function' },
196
+ { re: DQ_STRING, type: 'string' },
197
+ { re: SQ_STRING, type: 'string' },
198
+ { re: NUMBER, type: 'number' },
199
+ { re: /[A-Z]\w*/y, type: 'property' },
200
+ identifierRule(/[a-z_]\w*[?!]?/y, RUBY_KEYWORDS),
201
+ ];
202
+
203
+ const PUPPET_KEYWORDS = new Set([
204
+ 'class', 'define', 'node', 'inherits', 'if', 'elsif', 'else', 'unless',
205
+ 'case', 'and', 'or', 'in', 'undef', 'true', 'false', 'default', 'import',
206
+ 'include', 'require', 'contain', 'function', 'type', 'application',
207
+ 'produces', 'consumes', 'private', 'return', 'break', 'next', 'each',
208
+ 'map', 'filter', 'reduce', 'with',
209
+ 'String', 'Integer', 'Boolean', 'Array', 'Hash', 'Optional', 'Enum',
210
+ 'Variant', 'Numeric', 'Float', 'Undef', 'Any', 'Pattern', 'Regexp',
211
+ 'Sensitive', 'Struct', 'Tuple', 'Type', 'Callable', 'Data', 'Scalar',
212
+ ]);
213
+
214
+ const puppet: Rule[] = [
215
+ { re: HASH_COMMENT, type: 'comment' },
216
+ { re: DQ_STRING, type: 'string' },
217
+ { re: SQ_STRING, type: 'string' },
218
+ { re: /\$[\w:]+/y, type: 'function' },
219
+ { re: /[a-zA-Z_][\w:]*(?=\s*\{)/y, type: 'tag' },
220
+ { re: /[a-z_]\w*(?=\s*=>)/y, type: 'property' },
221
+ { re: NUMBER, type: 'number' },
222
+ identifierRule(/[A-Za-z_][\w:]*/y, PUPPET_KEYWORDS),
223
+ ];
224
+
225
+ const GRAMMARS: Record<string, Rule[]> = {
226
+ bash, sh: bash, shell: bash, zsh: bash,
227
+ yaml, yml: yaml,
228
+ json,
229
+ javascript, js: javascript,
230
+ typescript: javascript, ts: javascript,
231
+ css,
232
+ html: htmlLang, xml: htmlLang,
233
+ ruby, rb: ruby,
234
+ puppet, pp: puppet,
235
+ };
236
+
237
+ export const LANGUAGE_LABELS: Record<string, string> = {
238
+ bash: 'Bash', sh: 'Shell', shell: 'Shell', zsh: 'Zsh',
239
+ yaml: 'YAML', yml: 'YAML',
240
+ json: 'JSON',
241
+ javascript: 'JavaScript', js: 'JavaScript',
242
+ typescript: 'TypeScript', ts: 'TypeScript',
243
+ css: 'CSS',
244
+ html: 'HTML', xml: 'XML',
245
+ ruby: 'Ruby', rb: 'Ruby',
246
+ puppet: 'Puppet', pp: 'Puppet',
247
+ plaintext: 'Plain Text', text: 'Plain Text',
248
+ };
249
+
250
+ export type CodeBlockLanguage =
251
+ | 'plaintext' | 'bash' | 'sh' | 'shell' | 'zsh' | 'yaml' | 'yml' | 'json'
252
+ | 'javascript' | 'js' | 'typescript' | 'ts' | 'css' | 'html' | 'xml'
253
+ | 'ruby' | 'rb' | 'puppet' | 'pp';
254
+
255
+ /** Tokenizes `code` for `language`; unrecognized languages render as plain, unhighlighted text. */
256
+ export function highlight(code: string, language: string): Token[] {
257
+ const rules = GRAMMARS[language];
258
+ return rules ? tokenize(code, rules) : [{ type: 'plain', text: code }];
259
+ }
@@ -0,0 +1,302 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { customElement, property, state } from 'lit/decorators.js';
3
+ import { ICON_PATHS } from '../icon/icon-paths.js';
4
+ import { highlight, LANGUAGE_LABELS, type Token } from './languages.js';
5
+
6
+ export type { CodeBlockLanguage } from './languages.js';
7
+
8
+ /** Strips a shared leading indent and surrounding blank lines, so authored (and thus HTML-indented) code renders flush left. */
9
+ function dedent(text: string): string {
10
+ const lines = text.split('\n');
11
+ while (lines.length && lines[0].trim() === '') lines.shift();
12
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
13
+ const indents = lines.filter((l) => l.trim() !== '').map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
14
+ const minIndent = indents.length ? Math.min(...indents) : 0;
15
+ return lines.map((l) => l.slice(minIndent)).join('\n');
16
+ }
17
+
18
+ /** Splits tokens on embedded newlines into one array of tokens per source line. */
19
+ function toLines(tokens: Token[]): Token[][] {
20
+ const lines: Token[][] = [[]];
21
+ for (const token of tokens) {
22
+ token.text.split('\n').forEach((part, i) => {
23
+ if (i > 0) lines.push([]);
24
+ if (part) lines[lines.length - 1].push({ type: token.type, text: part });
25
+ });
26
+ }
27
+ return lines;
28
+ }
29
+
30
+ /**
31
+ * A syntax-highlighted code block with a copy button, no external
32
+ * highlighter required. Covers shell, Puppet, YAML, JSON, Ruby, JS/TS,
33
+ * CSS, and HTML; unrecognized languages render as plain text.
34
+ *
35
+ * @slot - The code, as plain text (escape `<` and `&` as you would in
36
+ * any HTML source, e.g. inside a `<code>` child).
37
+ */
38
+ @customElement('vox-code-block')
39
+ export class VoxCodeBlock extends LitElement {
40
+ @property() language = '';
41
+
42
+ /** Shown in the header instead of/alongside the language label. */
43
+ @property() filename = '';
44
+
45
+ @property({ type: Boolean, attribute: 'line-numbers', reflect: true }) lineNumbers = false;
46
+
47
+ @property({ type: Boolean, attribute: 'no-copy' }) noCopy = false;
48
+
49
+ /** Hides the whole header bar (filename, language label, and copy button) — for dense contexts like a table cell. */
50
+ @property({ type: Boolean, attribute: 'no-header' }) noHeader = false;
51
+
52
+ /** Drops the outer border — for dropping into a surface (e.g. a table cell) that already has its own edge. */
53
+ @property({ type: Boolean, attribute: 'no-border', reflect: true }) noBorder = false;
54
+
55
+ @state() private _code = '';
56
+ @state() private _copied = false;
57
+
58
+ private _copyResetTimer?: ReturnType<typeof setTimeout>;
59
+
60
+ static styles = css`
61
+ :host {
62
+ display: block;
63
+ font-family: var(--vox-font-family-base);
64
+ }
65
+
66
+ .block {
67
+ border: 1px solid var(--vox-color-border);
68
+ border-radius: var(--vox-radius-md);
69
+ background-color: var(--vox-color-bg-alt);
70
+ overflow: hidden;
71
+ }
72
+
73
+ :host([no-border]) .block {
74
+ border: none;
75
+ }
76
+
77
+ .header {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ gap: var(--vox-space-3);
82
+ padding: var(--vox-space-2) var(--vox-space-2) var(--vox-space-2) var(--vox-space-4);
83
+ border-bottom: 1px solid var(--vox-color-divider);
84
+ font-size: 12px;
85
+ }
86
+
87
+ .meta {
88
+ display: flex;
89
+ align-items: baseline;
90
+ gap: var(--vox-space-3);
91
+ min-width: 0;
92
+ overflow: hidden;
93
+ color: var(--vox-color-text-2);
94
+ }
95
+
96
+ .filename {
97
+ color: var(--vox-color-text-1);
98
+ font-family: var(--vox-font-family-mono);
99
+ font-weight: 600;
100
+ white-space: nowrap;
101
+ overflow: hidden;
102
+ text-overflow: ellipsis;
103
+ }
104
+
105
+ .lang {
106
+ flex: none;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.04em;
109
+ }
110
+
111
+ .copy {
112
+ flex: none;
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ width: 28px;
117
+ height: 28px;
118
+ padding: 0;
119
+ border: none;
120
+ border-radius: var(--vox-radius-sm);
121
+ background: none;
122
+ color: var(--vox-color-text-2);
123
+ cursor: pointer;
124
+ }
125
+
126
+ .copy:hover {
127
+ background-color: var(--vox-color-brand-soft);
128
+ color: var(--vox-color-brand-1);
129
+ }
130
+
131
+ .copy:focus-visible {
132
+ outline: 2px solid var(--vox-color-brand-1);
133
+ outline-offset: 2px;
134
+ }
135
+
136
+ .copy svg {
137
+ width: 15px;
138
+ height: 15px;
139
+ }
140
+
141
+ pre {
142
+ margin: 0;
143
+ padding: var(--vox-space-4);
144
+ overflow-x: auto;
145
+ white-space: pre;
146
+ tab-size: 2;
147
+ }
148
+
149
+ pre:focus-visible {
150
+ outline: 2px solid var(--vox-color-brand-1);
151
+ outline-offset: -2px;
152
+ }
153
+
154
+ code {
155
+ font-family: var(--vox-font-family-mono);
156
+ font-size: 13px;
157
+ line-height: 1.7;
158
+ color: var(--vox-color-text-1);
159
+ }
160
+
161
+ .line {
162
+ display: block;
163
+ }
164
+
165
+ :host([line-numbers]) code {
166
+ counter-reset: line;
167
+ }
168
+
169
+ :host([line-numbers]) .line {
170
+ padding-left: 3.5ch;
171
+ position: relative;
172
+ }
173
+
174
+ :host([line-numbers]) .line::before {
175
+ counter-increment: line;
176
+ content: counter(line);
177
+ position: absolute;
178
+ left: 0;
179
+ width: 2.5ch;
180
+ text-align: right;
181
+ color: var(--vox-color-text-3);
182
+ user-select: none;
183
+ }
184
+
185
+ .tok-comment {
186
+ color: var(--vox-code-comment);
187
+ font-style: italic;
188
+ }
189
+ .tok-keyword {
190
+ color: var(--vox-code-keyword);
191
+ }
192
+ .tok-string {
193
+ color: var(--vox-code-string);
194
+ }
195
+ .tok-number {
196
+ color: var(--vox-code-number);
197
+ }
198
+ .tok-function {
199
+ color: var(--vox-code-function);
200
+ }
201
+ .tok-property {
202
+ color: var(--vox-code-property);
203
+ }
204
+ .tok-tag {
205
+ color: var(--vox-code-tag);
206
+ }
207
+
208
+ slot {
209
+ display: none;
210
+ }
211
+
212
+ .visually-hidden {
213
+ position: absolute;
214
+ width: 1px;
215
+ height: 1px;
216
+ overflow: hidden;
217
+ clip: rect(0 0 0 0);
218
+ white-space: nowrap;
219
+ }
220
+ `;
221
+
222
+ disconnectedCallback() {
223
+ super.disconnectedCallback();
224
+ clearTimeout(this._copyResetTimer);
225
+ }
226
+
227
+ private _handleSlotChange(event: Event) {
228
+ const slot = event.target as HTMLSlotElement;
229
+ const text = slot
230
+ .assignedNodes({ flatten: true })
231
+ .map((node) => node.textContent ?? '')
232
+ .join('');
233
+ this._code = dedent(text);
234
+ }
235
+
236
+ private async _copy() {
237
+ try {
238
+ await navigator.clipboard.writeText(this._code);
239
+ } catch {
240
+ const textarea = document.createElement('textarea');
241
+ textarea.value = this._code;
242
+ textarea.style.position = 'fixed';
243
+ textarea.style.opacity = '0';
244
+ this.shadowRoot?.appendChild(textarea);
245
+ textarea.select();
246
+ document.execCommand('copy');
247
+ textarea.remove();
248
+ }
249
+ this._copied = true;
250
+ clearTimeout(this._copyResetTimer);
251
+ this._copyResetTimer = setTimeout(() => {
252
+ this._copied = false;
253
+ }, 1500);
254
+ }
255
+
256
+ render() {
257
+ const label = LANGUAGE_LABELS[this.language] ?? this.language;
258
+ const lines = toLines(highlight(this._code, this.language));
259
+
260
+ return html`
261
+ <div class="block">
262
+ ${this.noHeader
263
+ ? nothing
264
+ : html`
265
+ <div class="header">
266
+ <span class="meta">
267
+ ${this.filename ? html`<span class="filename">${this.filename}</span>` : nothing}
268
+ ${label ? html`<span class="lang">${label}</span>` : nothing}
269
+ </span>
270
+ ${this.noCopy
271
+ ? nothing
272
+ : html`
273
+ <button
274
+ class="copy"
275
+ type="button"
276
+ @click=${this._copy}
277
+ aria-label=${this._copied ? 'Copied' : 'Copy code'}
278
+ >
279
+ <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
280
+ ${ICON_PATHS[this._copied ? 'check' : 'copy']}
281
+ </svg>
282
+ </button>
283
+ `}
284
+ </div>
285
+ `}
286
+ <pre tabindex="0"><code>${lines.map(
287
+ (line) => html`<span class="line">${line.map((token) =>
288
+ token.type === 'plain' ? token.text : html`<span class="tok-${token.type}">${token.text}</span>`,
289
+ )}</span>`,
290
+ )}</code></pre>
291
+ <span class="visually-hidden" aria-live="polite">${this._copied ? 'Copied to clipboard' : ''}</span>
292
+ </div>
293
+ <slot @slotchange=${this._handleSlotChange}></slot>
294
+ `;
295
+ }
296
+ }
297
+
298
+ declare global {
299
+ interface HTMLElementTagNameMap {
300
+ 'vox-code-block': VoxCodeBlock;
301
+ }
302
+ }