diff --git a/plugins/markdown-preview/frontend/src/index.js b/plugins/markdown-preview/frontend/src/index.js
new file mode 100644
index 0000000..ddb350b
--- /dev/null
+++ b/plugins/markdown-preview/frontend/src/index.js
@@ -0,0 +1,173 @@
+/* ===========================================================
+ Markdown Preview Plugin — Verstak v2 Frontend Bundle
+ Contract: window.VerstakPluginRegister(id, { components })
+ =========================================================== */
+
+(function () {
+ 'use strict';
+
+ function injectStyles() {
+ if (document.getElementById('md-preview-style-injected')) return;
+ var style = document.createElement('style');
+ style.id = 'md-preview-style-injected';
+ style.textContent = STYLES;
+ document.head.appendChild(style);
+ }
+
+ var STYLES = [
+ '.mp-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#d8d8e8;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
+ '.mp-toolbar{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-shrink:0}',
+ '.mp-mode{font-size:.72rem;color:#4ecca3;background:#1a2a3a;border-radius:3px;padding:.14rem .45rem}',
+ '.mp-path{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
+ '.mp-body{flex:1;min-height:0;overflow:auto;padding:1rem 1.2rem;line-height:1.7;font-size:.93rem}',
+ '.mp-body h1,.mp-body h2,.mp-body h3,.mp-body h4{color:#f0f0ff;margin:1rem 0 .5rem}',
+ '.mp-body h1{font-size:1.55rem;border-bottom:1px solid #16213e;padding-bottom:.35rem}.mp-body h2{font-size:1.3rem;border-bottom:1px solid #16213e;padding-bottom:.25rem}.mp-body h3{font-size:1.12rem}',
+ '.mp-body p{margin:.55rem 0}.mp-body code{background:#1a1a2e;padding:.15rem .35rem;border-radius:3px;font-size:.87em;color:#4ecca3}',
+ '.mp-body pre{background:#1a1a2e;padding:.85rem;border-radius:4px;overflow:auto;margin:.8rem 0}.mp-body pre code{background:none;padding:0;color:#d8d8e8}',
+ '.mp-body ul,.mp-body ol{padding-left:1.5rem;margin:.55rem 0}.mp-body li{margin:.25rem 0}',
+ '.mp-body blockquote{border-left:3px solid #4ecca3;margin:.6rem 0;padding:.25rem .85rem;color:#aaa;background:#101028}',
+ '.mp-body a{color:#4ecca3;text-decoration:none}.mp-body a:hover{text-decoration:underline}',
+ '.mp-body table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.mp-body th,.mp-body td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.mp-body th{background:#1a1a2e}',
+ '.mp-loading,.mp-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.mp-error{color:#e74c3c;flex-direction:column;gap:.5rem}'
+ ].join('\n');
+
+ function el(tag, attrs, children) {
+ var elem = document.createElement(tag);
+ if (attrs) {
+ Object.keys(attrs).forEach(function (k) {
+ if (k === 'className') elem.className = attrs[k];
+ else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(elem.style, attrs[k]);
+ else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
+ else if (k === 'innerHTML') elem.innerHTML = attrs[k];
+ else if (k === 'textContent') elem.textContent = attrs[k];
+ else elem.setAttribute(k, attrs[k]);
+ });
+ }
+ if (children) {
+ (Array.isArray(children) ? children : [children]).forEach(function (c) {
+ if (c == null) return;
+ elem.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
+ });
+ }
+ return elem;
+ }
+
+ function escapeHtml(value) {
+ return String(value == null ? '' : value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function renderInline(text) {
+ var html = escapeHtml(text);
+ html = html.replace(/`([^`\n]+)`/g, '$1');
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+|mailto:[^)]+)\)/g, '$1');
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1');
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1');
+ html = html.replace(/\*(.+?)\*/g, '$1');
+ return html;
+ }
+
+ function renderMarkdown(markdown) {
+ var lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
+ var html = [];
+ var inCode = false;
+ var code = [];
+ var inList = false;
+
+ function closeList() {
+ if (inList) {
+ html.push('');
+ inList = false;
+ }
+ }
+ function closeCode() {
+ if (inCode) {
+ html.push('
' + escapeHtml(code.join('\n')) + '');
+ code = [];
+ inCode = false;
+ }
+ }
+
+ lines.forEach(function (line) {
+ if (/^```/.test(line)) {
+ if (inCode) closeCode();
+ else {
+ closeList();
+ inCode = true;
+ code = [];
+ }
+ return;
+ }
+ if (inCode) {
+ code.push(line);
+ return;
+ }
+ if (!line.trim()) {
+ closeList();
+ return;
+ }
+ var heading = /^(#{1,4})\s+(.+)$/.exec(line);
+ if (heading) {
+ closeList();
+ html.push('' + renderInline(quote[1]) + ''); + return; + } + var item = /^\s*[-*]\s+(.+)$/.exec(line); + if (item) { + if (!inList) { + html.push('
' + renderInline(line) + '
'); + }); + closeCode(); + closeList(); + return html.join('\n'); + } + + var MarkdownPreview = { + mount: function (containerEl, props, api) { + injectStyles(); + var request = props && props.request || {}; + var path = request.path || ''; + containerEl.innerHTML = ''; + containerEl.className = 'mp-root'; + containerEl.setAttribute('data-plugin-id', 'verstak.markdown-preview'); + containerEl.setAttribute('data-preview-path', path); + containerEl.appendChild(el('div', { className: 'mp-toolbar' }, [ + el('span', { className: 'mp-mode' }, ['Preview']), + el('span', { className: 'mp-path' }, [path]) + ])); + var body = el('div', { className: 'mp-loading' }, ['Loading...']); + containerEl.appendChild(body); + api.files.readText(path).then(function (text) { + body.className = 'mp-body'; + body.innerHTML = renderMarkdown(text); + }).catch(function (err) { + body.className = 'mp-error'; + body.textContent = 'Preview error: ' + (err && err.message ? err.message : String(err)); + }); + }, + unmount: function (containerEl) { + containerEl.innerHTML = ''; + } + }; + + window.VerstakPluginRegister('verstak.markdown-preview', { + components: { MarkdownPreview: MarkdownPreview } + }); +})(); diff --git a/plugins/markdown-preview/plugin.json b/plugins/markdown-preview/plugin.json new file mode 100644 index 0000000..57c89a1 --- /dev/null +++ b/plugins/markdown-preview/plugin.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "id": "verstak.markdown-preview", + "name": "Markdown Preview", + "version": "0.1.0", + "apiVersion": "0.1.0", + "description": "Read-only Markdown preview provider for vault Markdown files.", + "source": "official", + "icon": "eye", + "provides": [ + "verstak/markdown-preview/v1" + ], + "requires": [ + "verstak/core/files/v1", + "verstak/core/workbench/v1" + ], + "permissions": [ + "files.read" + ], + "frontend": { + "entry": "frontend/src/index.js" + }, + "contributes": { + "openProviders": [ + { + "id": "verstak.markdown-preview.generic", + "title": "Markdown Preview", + "priority": 80, + "component": "MarkdownPreview", + "supports": [ + { + "kind": "vault-file", + "extensions": [".md", ".markdown"], + "contexts": ["generic-markdown"], + "modes": ["view"] + } + ] + }, + { + "id": "verstak.markdown-preview.notes", + "title": "Notes Markdown Preview", + "priority": 80, + "component": "MarkdownPreview", + "supports": [ + { + "kind": "vault-file", + "extensions": [".md", ".markdown"], + "contexts": ["notes-markdown"], + "modes": ["view"] + } + ] + } + ] + } +} diff --git a/scripts/check.sh b/scripts/check.sh index 3a787a1..ee0656a 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -213,6 +213,8 @@ if command -v node &>/dev/null; then report "platform-test frontend components mount" $? node "$ROOT/scripts/smoke-notes-plugin.js" report "notes frontend behavior" $? + node "$ROOT/scripts/smoke-markdown-preview-plugin.js" + report "markdown-preview frontend behavior" $? node "$ROOT/scripts/smoke-files-plugin.js" report "files frontend behavior" $? node "$ROOT/scripts/smoke-browser-inbox-plugin.js" diff --git a/scripts/smoke-markdown-preview-plugin.js b/scripts/smoke-markdown-preview-plugin.js new file mode 100755 index 0000000..a66537e --- /dev/null +++ b/scripts/smoke-markdown-preview-plugin.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const sourcePath = path.join(root, 'plugins', 'markdown-preview', 'frontend', 'src', 'index.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); + +class FakeNode { + constructor(tagName) { + this.tagName = String(tagName || '').toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = {}; + this.style = {}; + this.className = ''; + this.id = ''; + this.parentNode = null; + this._innerHTML = ''; + this._textContent = ''; + } + + appendChild(node) { + if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode'); + this.children.push(node); + node.parentNode = this; + return node; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + } + + getAttribute(name) { + return this.attributes[name]; + } + + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + } + + set innerHTML(value) { + this._innerHTML = String(value || ''); + this.children = []; + } + + get innerHTML() { + return this._innerHTML + this.children.map((child) => child.innerHTML).join(''); + } + + set textContent(value) { + this._textContent = String(value || ''); + this.children = []; + } + + get textContent() { + if (this.tagName === '#TEXT') return this._textContent; + return this._textContent + this.children.map((child) => child.textContent).join(''); + } +} + +function makeDocument() { + return { + body: new FakeNode('body'), + head: new FakeNode('head'), + createElement(tagName) { + return new FakeNode(tagName); + }, + createTextNode(text) { + const node = new FakeNode('#text'); + node.textContent = text; + return node; + }, + getElementById() { + return null; + }, + }; +} + +function loadComponent(document) { + const registry = {}; + const sandbox = { + console, + document, + window: { + VerstakPluginRegister(pluginId, bundle) { + registry[pluginId] = bundle.components || {}; + }, + }, + }; + sandbox.window.window = sandbox.window; + sandbox.window.document = document; + vm.runInNewContext(source, sandbox, { filename: sourcePath }); + const component = registry['verstak.markdown-preview'] && registry['verstak.markdown-preview'].MarkdownPreview; + if (!component) throw new Error('MarkdownPreview was not registered'); + return component; +} + +async function flush() { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +(async () => { + const document = makeDocument(); + const component = loadComponent(document); + const container = new FakeNode('div'); + const readPaths = []; + component.mount(container, { + request: { path: 'Docs/readme.md', mode: 'view' }, + }, { + files: { + readText: async (relativePath) => { + readPaths.push(relativePath); + return '# Title\n\nParagraph with `code`.\n\n- one\n- two\n'; + }, + }, + }); + await flush(); + + if (container.getAttribute('data-plugin-id') !== 'verstak.markdown-preview') { + throw new Error('plugin id marker missing'); + } + if (readPaths[0] !== 'Docs/readme.md') { + throw new Error(`expected readText Docs/readme.md, got ${readPaths[0] || 'code')) throw new Error('inline code was not rendered');
+ if (!html.includes('