mdv-live 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.11] - 2026-05-09
9
+
10
+ ### Fixed
11
+
12
+ - **Marp PDF export ENOENT in fresh install** (実は 0.5.5 から潜在):
13
+ - `src/api/pdf.js` が marp 実行ファイルを
14
+ `node_modules/mdv-live/node_modules/.bin/marp` で解決していたが、npm
15
+ hoisting により実体は top-level の `node_modules/.bin/marp` にある
16
+ - dev 環境 (mdv-live リポ内) では nested の方が存在するため気づかず、
17
+ `npm install mdv-live` した fresh install では ENOENT
18
+ - `require.resolve('@marp-team/marp-cli/package.json')` から bin スクリプト
19
+ を解決し `node` で実行する方式に変更 (hoist/nest 両対応)
20
+
21
+ ### Tests
22
+
23
+ - 242 → **243 件 (+1)**: marp-cli bin entry の実在チェック regression test
24
+
25
+ ## [0.5.10] - 2026-05-09
26
+
27
+ ### Fixed (UX revert)
28
+
29
+ - **Markdown PDF ボタンを OS 印刷ダイアログに戻す** (本来の UX 復元):
30
+ - 0.5.9 (実体は 2026-01-31 `e5526f9` から) で plain Markdown も server-side
31
+ md-to-pdf 経由の PDF DL に切り替わっていたが、本来の UX は `Cmd+P` 相当の
32
+ OS 印刷ダイアログ (`window.print()`) で「PDF として保存」を選ぶフロー
33
+ - `src/static/app.js`: `print()` の markdown 分岐を削除 (`else` 分岐の
34
+ `browserPrint()` に落ちる)
35
+ - Marp / HTML preview の挙動は変更なし (server-side marp-cli を維持)
36
+
37
+ ### Fixed (codex review)
38
+
39
+ - `/api/pdf/export` の `fs.readFile` を `try/catch` 外で実行していた問題を修正
40
+ (directory 指定や読み取り不可ファイルで Express デフォルトエラーに落ちて
41
+ controlled JSON が返らなかった)。stat による file 判定を追加
42
+
43
+ ### Removed
44
+
45
+ - `PdfStyleManager` UI モジュール (markdown が server PDF を使わなくなったため
46
+ orphan)、`pdfStyleToggle` / `pdfStylePanel` HTML、関連 CSS、`normalizeUserPath`
47
+ helper、`pdf-style-preview` クラス
48
+ - `md-to-pdf` runtime dependency。web UI で使わなくなったため削除。
49
+ `bin/mdv.js convert` は元々 `npx md-to-pdf` 経由で 0.5.9 以前と同じ挙動
50
+ - `/api/pdf/export` の markdown 分岐 (Marp 専用エンドポイントに整理)。
51
+ 非 Marp ファイルは 415 で拒否
52
+
53
+ ### Tests
54
+
55
+ - 241 → **242 件 (+1)**、全 PASS
56
+ - 追加: directory path → 404 controlled JSON (codex round 3 regression)
57
+ - 変更: plain markdown PDF テスト → 415 テストに置換
58
+
8
59
  ## [0.5.9] - 2026-05-09
9
60
 
10
61
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -51,7 +51,6 @@
51
51
  "highlight.js": "^11.10.0",
52
52
  "markdown-it": "^14.1.0",
53
53
  "markdown-it-task-lists": "^2.1.1",
54
- "md-to-pdf": "^5.2.5",
55
54
  "mime-types": "^2.1.35",
56
55
  "multer": "^1.4.5-lts.1",
57
56
  "open": "^10.1.0",
package/src/api/pdf.js CHANGED
@@ -1,36 +1,38 @@
1
1
  import { spawn } from 'child_process';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
- import { fileURLToPath } from 'url';
5
4
  import os from 'os';
6
5
  import { createRequire } from 'module';
7
6
  import { isMarp } from '../rendering/markdown.js';
8
- import { validatePath, validatePathReal } from '../utils/path.js';
9
- import { resolvePdfOptions } from '../styles/index.js';
7
+ import { validatePath } from '../utils/path.js';
10
8
 
11
9
  const require = createRequire(import.meta.url);
12
- const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
13
- const binDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'node_modules', '.bin');
14
- const marpBin = path.join(binDir, 'marp');
15
- const mdToPdfBin = path.join(binDir, 'md-to-pdf');
10
+ // marp-cli npm hoisting によりインストール先が変わる (top-level / nested)
11
+ // require.resolve でパッケージの実体を特定し、その bin スクリプトを node
12
+ // 直接実行する。`node_modules/.bin/marp` 直叩きは fresh install で nested
13
+ // パスが無い時に ENOENT する罠。
14
+ const marpEntry = (() => {
15
+ const pkgPath = require.resolve('@marp-team/marp-cli/package.json');
16
+ const pkg = require('@marp-team/marp-cli/package.json');
17
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.marp;
18
+ if (!binRel) {
19
+ throw new Error('@marp-team/marp-cli does not declare a "marp" bin entry');
20
+ }
21
+ return path.join(path.dirname(pkgPath), binRel);
22
+ })();
16
23
  const PDF_EXPORT_TIMEOUT_MS = 180000;
17
24
 
18
25
  /**
19
- * Spawn a PDF tool with stdin closed.
26
+ * Spawn marp-cli with stdin closed.
20
27
  *
21
- * 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf
22
- * 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
23
- * ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
28
+ * 注意: execFile は stdio オプションを受け付けない (Node 仕様) ため spawn を使う。
24
29
  */
25
- function runPdfTool(bin, args, { cwd } = {}) {
30
+ function runMarp(args) {
26
31
  return new Promise((resolve, reject) => {
27
- const child = spawn(bin, args, {
28
- cwd,
32
+ const child = spawn(process.execPath, [marpEntry, ...args], {
29
33
  stdio: ['ignore', 'pipe', 'pipe'],
30
34
  });
31
- let stdout = '';
32
35
  let stderr = '';
33
- child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
34
36
  child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
35
37
 
36
38
  const timer = setTimeout(() => {
@@ -44,12 +46,11 @@ function runPdfTool(bin, args, { cwd } = {}) {
44
46
  child.on('close', (code, signal) => {
45
47
  clearTimeout(timer);
46
48
  if (code === 0) {
47
- resolve({ stdout, stderr });
49
+ resolve();
48
50
  } else {
49
- const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
51
+ const err = new Error(`marp exited with code=${code} signal=${signal}`);
50
52
  err.code = code;
51
53
  err.signal = signal;
52
- err.stdout = stdout;
53
54
  err.stderr = stderr;
54
55
  reject(err);
55
56
  }
@@ -58,50 +59,12 @@ function runPdfTool(bin, args, { cwd } = {}) {
58
59
  }
59
60
 
60
61
  /**
61
- * Resolve an optional user-selected file path under the server root.
62
- * @param {string | undefined} relativePath - Path supplied by the web UI.
63
- * @param {string} rootDir - Server root directory.
64
- * @returns {Promise<string | null>} Absolute path or null.
65
- */
66
- async function resolveOptionalUserFile(relativePath, rootDir) {
67
- if (!relativePath) return null;
68
- if (!await validatePathReal(relativePath, rootDir)) {
69
- throw new Error(`Access denied: ${relativePath}`);
70
- }
71
- const fullPath = path.join(rootDir, relativePath);
72
- const stat = await fs.stat(fullPath);
73
- if (!stat.isFile()) {
74
- throw new Error(`Not a file: ${relativePath}`);
75
- }
76
- return fullPath;
77
- }
78
-
79
- /**
80
- * Export a regular markdown document with md-to-pdf.
81
- * @param {string} inputPath - Source markdown file.
82
- * @param {string} outputPath - Temporary output path.
83
- * @param {string | null} stylesheetPath - Optional custom CSS file.
84
- * @param {string | null} pdfOptionsPath - Optional PDF options JSON file.
85
- * @returns {Promise<void>}
86
- */
87
- async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
88
- const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
89
- const args = [inputPath, '--pdf-options', JSON.stringify(pdfOptions)];
90
-
91
- if (stylesheetPath) {
92
- args.push('--stylesheet', highlightStylesheet);
93
- args.push('--stylesheet', stylesheetPath);
94
- args.push('--highlight-style', 'atom-one-dark');
95
- }
96
-
97
- await runPdfTool(mdToPdfBin, args, { cwd: path.dirname(inputPath) });
98
-
99
- const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
100
- await fs.rename(generatedPdf, outputPath);
101
- }
102
-
103
- /**
104
- * Setup PDF export routes
62
+ * Setup PDF export routes.
63
+ *
64
+ * Web UI からは Marp ファイルのみがこの経路を使う。通常 Markdown
65
+ * クライアント側で window.print() (OS 印刷ダイアログ) に流す設計のため、
66
+ * このエンドポイントは Marp 以外を 415 で拒否する。
67
+ *
105
68
  * @param {Express} app - Express application
106
69
  * @returns {void}
107
70
  */
@@ -109,7 +72,7 @@ export function setupPdfRoutes(app) {
109
72
  const { rootDir } = app.locals;
110
73
 
111
74
  app.post('/api/pdf/export', async (req, res) => {
112
- const { filePath, stylePath, pdfOptionsPath } = req.body;
75
+ const { filePath } = req.body;
113
76
 
114
77
  if (!filePath) {
115
78
  return res.status(400).json({ error: 'filePath is required' });
@@ -120,29 +83,28 @@ export function setupPdfRoutes(app) {
120
83
  }
121
84
 
122
85
  const fullPath = path.join(rootDir, filePath);
123
-
124
- try {
125
- await fs.access(fullPath);
126
- } catch {
127
- return res.status(404).json({ error: 'File not found' });
128
- }
129
-
130
86
  const baseName = path.basename(fullPath, '.md');
131
87
  const outputPath = path.join(os.tmpdir(), `mdv-${Date.now()}-${baseName}.pdf`);
132
88
  const outputFileName = `${baseName}.pdf`;
133
89
 
134
90
  try {
91
+ let stat;
92
+ try {
93
+ stat = await fs.stat(fullPath);
94
+ } catch {
95
+ return res.status(404).json({ error: 'File not found' });
96
+ }
97
+ if (!stat.isFile()) {
98
+ return res.status(404).json({ error: 'File not found' });
99
+ }
100
+
135
101
  const content = await fs.readFile(fullPath, 'utf-8');
136
- if (isMarp(content)) {
137
- await runPdfTool(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
138
- } else {
139
- const [stylesheetPath, resolvedPdfOptionsPath] = await Promise.all([
140
- resolveOptionalUserFile(stylePath, rootDir),
141
- resolveOptionalUserFile(pdfOptionsPath, rootDir),
142
- ]);
143
- await exportMarkdownPdf(fullPath, outputPath, stylesheetPath, resolvedPdfOptionsPath);
102
+ if (!isMarp(content)) {
103
+ return res.status(415).json({ error: 'Server-side PDF export supports Marp files only. Use the browser print dialog for regular Markdown.' });
144
104
  }
145
105
 
106
+ await runMarp([fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
107
+
146
108
  res.download(outputPath, outputFileName, async (err) => {
147
109
  if (err) {
148
110
  console.error('Download error:', err);
package/src/static/app.js CHANGED
@@ -11,9 +11,7 @@
11
11
 
12
12
  const STORAGE_KEYS = {
13
13
  THEME: 'mdv-theme',
14
- SIDEBAR_WIDTH: 'mdv-sidebar-width',
15
- PDF_STYLE_PATH: 'mdv-pdf-style-path',
16
- PDF_OPTIONS_PATH: 'mdv-pdf-options-path'
14
+ SIDEBAR_WIDTH: 'mdv-sidebar-width'
17
15
  };
18
16
 
19
17
  const HLJS_THEMES = {
@@ -87,9 +85,7 @@
87
85
  isResizing: false,
88
86
  skipScrollRestore: false,
89
87
  uploadTargetPath: '',
90
- rootPath: '',
91
- pdfStylePath: localStorage.getItem(STORAGE_KEYS.PDF_STYLE_PATH) || '',
92
- pdfOptionsPath: localStorage.getItem(STORAGE_KEYS.PDF_OPTIONS_PATH) || ''
88
+ rootPath: ''
93
89
  };
94
90
 
95
91
  // ============================================================
@@ -125,12 +121,6 @@
125
121
  statusText: document.getElementById('statusText'),
126
122
  resizeHandle: document.getElementById('resizeHandle'),
127
123
  editToggle: document.getElementById('editToggle'),
128
- pdfStyleToggle: document.getElementById('pdfStyleToggle'),
129
- pdfStylePanel: document.getElementById('pdfStylePanel'),
130
- pdfStylePath: document.getElementById('pdfStylePath'),
131
- pdfOptionsPath: document.getElementById('pdfOptionsPath'),
132
- pdfStyleApply: document.getElementById('pdfStyleApply'),
133
- pdfStyleClear: document.getElementById('pdfStyleClear'),
134
124
  editLabel: document.getElementById('editLabel'),
135
125
  editorStatus: document.getElementById('editorStatus'),
136
126
  shutdownBtn: document.getElementById('shutdownBtn'),
@@ -173,10 +163,6 @@
173
163
  });
174
164
  }
175
165
 
176
- function normalizeUserPath(path) {
177
- return path.trim().replace(/^\/+/, '');
178
- }
179
-
180
166
  async function apiRequest(url, options = {}) {
181
167
  const response = await fetch(url, options);
182
168
  const data = await response.json();
@@ -249,101 +235,6 @@
249
235
  }
250
236
  };
251
237
 
252
- // ============================================================
253
- // PDF Style Preview
254
- // ============================================================
255
-
256
- const PdfStyleManager = {
257
- scopedCssId: 'pdf-style-preview-css',
258
-
259
- init() {
260
- elements.pdfStylePath.value = state.pdfStylePath;
261
- elements.pdfOptionsPath.value = state.pdfOptionsPath;
262
- elements.pdfStyleToggle.addEventListener('click', () => {
263
- elements.pdfStylePanel.classList.toggle('hidden');
264
- });
265
- elements.pdfStyleApply.addEventListener('click', () => this.applyFromInputs());
266
- elements.pdfStyleClear.addEventListener('click', () => this.clear());
267
- elements.pdfStylePath.addEventListener('keydown', (event) => {
268
- if (event.key === 'Enter') this.applyFromInputs();
269
- });
270
- elements.pdfOptionsPath.addEventListener('keydown', (event) => {
271
- if (event.key === 'Enter') this.applyFromInputs();
272
- });
273
- this.loadPreviewCss();
274
- },
275
-
276
- getExportOptions() {
277
- return {
278
- stylePath: normalizeUserPath(state.pdfStylePath),
279
- pdfOptionsPath: normalizeUserPath(state.pdfOptionsPath)
280
- };
281
- },
282
-
283
- async applyFromInputs() {
284
- state.pdfStylePath = normalizeUserPath(elements.pdfStylePath.value);
285
- state.pdfOptionsPath = normalizeUserPath(elements.pdfOptionsPath.value);
286
- elements.pdfStylePath.value = state.pdfStylePath;
287
- elements.pdfOptionsPath.value = state.pdfOptionsPath;
288
- localStorage.setItem(STORAGE_KEYS.PDF_STYLE_PATH, state.pdfStylePath);
289
- localStorage.setItem(STORAGE_KEYS.PDF_OPTIONS_PATH, state.pdfOptionsPath);
290
- await this.loadPreviewCss();
291
- TabManager.renderActive();
292
- },
293
-
294
- clear() {
295
- state.pdfStylePath = '';
296
- state.pdfOptionsPath = '';
297
- elements.pdfStylePath.value = '';
298
- elements.pdfOptionsPath.value = '';
299
- localStorage.removeItem(STORAGE_KEYS.PDF_STYLE_PATH);
300
- localStorage.removeItem(STORAGE_KEYS.PDF_OPTIONS_PATH);
301
- const oldStyle = document.getElementById(this.scopedCssId);
302
- if (oldStyle) oldStyle.remove();
303
- TabManager.renderActive();
304
- elements.statusText.textContent = 'PDF style cleared';
305
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
306
- },
307
-
308
- async loadPreviewCss() {
309
- const oldStyle = document.getElementById(this.scopedCssId);
310
- if (oldStyle) oldStyle.remove();
311
- if (!state.pdfStylePath) return;
312
-
313
- try {
314
- const response = await fetch(`/raw/${state.pdfStylePath}`);
315
- if (!response.ok) throw new Error('CSS file not found');
316
- const cssText = await response.text();
317
- const style = document.createElement('style');
318
- style.id = this.scopedCssId;
319
- style.textContent = this.scopeCss(cssText);
320
- document.head.appendChild(style);
321
- elements.statusText.textContent = 'PDF style applied';
322
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
323
- } catch (error) {
324
- console.error('PDF style preview error:', error);
325
- elements.statusText.textContent = 'PDF style failed';
326
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 2500);
327
- }
328
- },
329
-
330
- scopeCss(cssText) {
331
- const scope = '.markdown-body.pdf-style-preview';
332
- const withoutComments = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
333
- return withoutComments.replace(/([^{}]+)\{/g, (match, selectorText) => {
334
- const selectors = selectorText.trim();
335
- if (!selectors || selectors.startsWith('@')) return match;
336
- const scopedSelectors = selectors.split(',').map((selector) => {
337
- const trimmed = selector.trim();
338
- if (trimmed === ':root' || trimmed === 'body') return scope;
339
- if (trimmed.startsWith(scope)) return trimmed;
340
- return `${scope} ${trimmed}`;
341
- });
342
- return `${scopedSelectors.join(', ')} {`;
343
- });
344
- }
345
- };
346
-
347
238
  // ============================================================
348
239
  // Sidebar Management
349
240
  // ============================================================
@@ -880,9 +771,7 @@
880
771
  render(htmlContent, fileType) {
881
772
  const containerClass = fileType === 'code'
882
773
  ? 'markdown-body code-view-container'
883
- : fileType === 'markdown'
884
- ? 'markdown-body pdf-style-preview'
885
- : 'markdown-body';
774
+ : 'markdown-body';
886
775
  elements.content.innerHTML = `<div class="${containerClass}">${htmlContent}</div>`;
887
776
 
888
777
  elements.content.querySelectorAll('pre code').forEach(block => {
@@ -1709,8 +1598,6 @@
1709
1598
  await this.exportPdf(tab.path);
1710
1599
  } else if (this.isHtmlPreview()) {
1711
1600
  this.printHtmlPreview(tab.name);
1712
- } else if (tab.fileType === 'markdown') {
1713
- await this.exportPdf(tab.path);
1714
1601
  } else {
1715
1602
  this.browserPrint(tab.name);
1716
1603
  }
@@ -1738,9 +1625,8 @@
1738
1625
 
1739
1626
  try {
1740
1627
  statusText.textContent = 'Generating PDF...';
1741
- const exportOptions = PdfStyleManager.getExportOptions();
1742
1628
 
1743
- const response = await MDVApi.exportPdf({ filePath, ...exportOptions });
1629
+ const response = await MDVApi.exportPdf({ filePath });
1744
1630
 
1745
1631
  if (!response.ok) {
1746
1632
  const error = await response.json();
@@ -2366,7 +2252,6 @@
2366
2252
  async function init() {
2367
2253
  // Initialize all managers
2368
2254
  ThemeManager.init();
2369
- PdfStyleManager.init();
2370
2255
  SidebarManager.init();
2371
2256
  ResizeHandler.init();
2372
2257
  EditorManager.init();
@@ -95,13 +95,6 @@
95
95
  PDF
96
96
  </button>
97
97
 
98
- <button class="toolbar-btn" id="pdfStyleToggle" title="PDF style settings" aria-label="PDF style settings">
99
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
100
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
101
- </svg>
102
- Style
103
- </button>
104
-
105
98
  <div class="toolbar-spacer"></div>
106
99
 
107
100
  <span class="editor-status" id="editorStatus" style="display: none;">Ready</span>
@@ -117,19 +110,6 @@
117
110
  </div>
118
111
  </div>
119
112
 
120
- <div class="pdf-style-panel hidden" id="pdfStylePanel">
121
- <label>
122
- <span>CSS</span>
123
- <input type="text" id="pdfStylePath" placeholder="src/styles/report.example.css">
124
- </label>
125
- <label>
126
- <span>PDF options</span>
127
- <input type="text" id="pdfOptionsPath" placeholder="src/styles/report.pdf-options.example.json">
128
- </label>
129
- <button class="toolbar-btn" id="pdfStyleApply">Apply</button>
130
- <button class="toolbar-btn" id="pdfStyleClear">Clear</button>
131
- </div>
132
-
133
113
  <!-- Tab bar -->
134
114
  <div class="tab-bar" id="tabBar" role="tablist" aria-label="Open files"></div>
135
115
 
@@ -241,40 +241,6 @@ body {
241
241
 
242
242
  .toolbar-spacer { flex: 1; }
243
243
 
244
- .pdf-style-panel {
245
- display: flex;
246
- align-items: center;
247
- gap: 10px;
248
- padding: 8px 16px;
249
- border-bottom: 1px solid var(--border);
250
- background: var(--bg-secondary);
251
- }
252
-
253
- .pdf-style-panel label {
254
- display: flex;
255
- align-items: center;
256
- gap: 6px;
257
- min-width: 0;
258
- color: var(--text-secondary);
259
- font-size: 12px;
260
- }
261
-
262
- .pdf-style-panel input {
263
- width: min(34vw, 360px);
264
- min-width: 160px;
265
- padding: 6px 8px;
266
- background: var(--bg-primary);
267
- border: 1px solid var(--border);
268
- border-radius: 6px;
269
- color: var(--text-primary);
270
- font-size: 12px;
271
- }
272
-
273
- .pdf-style-panel input:focus {
274
- border-color: var(--accent);
275
- outline: none;
276
- }
277
-
278
244
  .status {
279
245
  display: flex;
280
246
  align-items: center;