diff --git a/plugins/default-editor/frontend/src/index.js b/plugins/default-editor/frontend/src/index.js
index 9ec072e..ddcbcc1 100644
--- a/plugins/default-editor/frontend/src/index.js
+++ b/plugins/default-editor/frontend/src/index.js
@@ -107,12 +107,15 @@
return out + '.md';
}
- function renderInline(text, isNotesContext) {
+ function renderInline(text, isNotesContext, secretLinksAvailable) {
var html = escapeHtml(text);
// Internal wiki links [[Title]] — only render in notes context
if (isNotesContext) {
html = html.replace(/\[\[([^\]]+)\]\]/g, '$1');
}
+ if (secretLinksAvailable) {
+ html = html.replace(/\[([^\]]+)\]\(verstak-secret:\/\/([^)]+)\)/g, '$1');
+ }
html = html.replace(/`([^`\n]+)`/g, '$1');
html = html.replace(/!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, '');
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+|mailto:[^)]+)\)/g, '$1');
@@ -122,7 +125,7 @@
return html;
}
- function renderMarkdown(text, isNotesContext) {
+ function renderMarkdown(text, isNotesContext, secretLinksAvailable) {
var lines = String(text || '').split(/\r?\n/);
var out = [];
var inCode = false;
@@ -140,14 +143,14 @@
function closeTable() {
if (!table.length) return;
out.push('
| ' + renderInline(cell.trim(), isNotesContext) + ' | '; }).join('') + '
| ' + renderInline(cell.trim(), isNotesContext, secretLinksAvailable) + ' | '; }).join('') + '
' + renderInline(line, isNotesContext) + '
'); + if (line.trim()) out.push('' + renderInline(line, isNotesContext, secretLinksAvailable) + '
'); } lines.forEach(function (line) { @@ -181,7 +184,7 @@ if (heading) { closeList(); closeTable(); - out.push('' + renderInline(quote[1], isNotesContext) + ''); + out.push('
' + renderInline(quote[1], isNotesContext, secretLinksAvailable) + ''); return; } @@ -212,9 +215,9 @@ listType = desired; } if (task) { - out.push('
' + escapeHtml(currentContent) + ''; + if (previewEl) previewEl.innerHTML = isMarkdown ? renderMarkdown(currentContent, editorMode === 'notes-markdown', secretLinksAvailable) : '
' + escapeHtml(currentContent) + ''; + } + + function loadSecretProviderAvailability() { + if (!isMarkdown || !api.contributions || typeof api.contributions.list !== 'function') return; + api.contributions.list('openProviders').then(function (providers) { + if (disposed) return; + secretLinksAvailable = (providers || []).some(function (provider) { + return (provider.supports || []).some(function (support) { + return support.kind === 'secret'; + }); + }); + updatePreview(); + }).catch(function () { + secretLinksAvailable = false; + updatePreview(); + }); } function syncFromTextarea() { @@ -512,9 +532,29 @@ }); } + loadSecretProviderAvailability(); reloadFromDisk(); containerEl.addEventListener('click', function (event) { + var secretLink = event.target.closest('.secret-link'); + if (secretLink) { + event.preventDefault(); + if (!secretLinksAvailable) return; + var secretID = secretLink.getAttribute('data-secret-id'); + if (!secretID) return; + api.workbench.openResource({ + kind: 'secret', + path: decodeURIComponent(secretID), + mode: 'view', + context: { + sourcePluginId: 'verstak.default-editor', + sourceView: 'editor' + } + }).catch(function (err) { + console.error('[default-editor] open secret link:', err); + }); + return; + } var link = event.target.closest('.internal-link'); if (!link) return; event.preventDefault(); diff --git a/plugins/secrets/plugin.json b/plugins/secrets/plugin.json index aac1f06..400eaec 100644 --- a/plugins/secrets/plugin.json +++ b/plugins/secrets/plugin.json @@ -21,6 +21,20 @@ "entry": "frontend/src/index.js" }, "contributes": { + "openProviders": [ + { + "id": "verstak.secrets.secret", + "title": "Secrets", + "priority": 100, + "component": "SecretsView", + "supports": [ + { + "kind": "secret", + "modes": ["view"] + } + ] + } + ], "settingsPanels": [ { "id": "verstak.secrets.settings", diff --git a/scripts/check.sh b/scripts/check.sh index f36a176..350adc7 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -211,6 +211,8 @@ echo "[frontend smoke]" if command -v node &>/dev/null; then node "$ROOT/scripts/smoke-platform-frontend.js" report "platform-test frontend components mount" $? + node "$ROOT/scripts/smoke-default-editor-plugin.js" + report "default editor frontend behavior" $? node "$ROOT/scripts/smoke-notes-plugin.js" report "notes frontend behavior" $? node "$ROOT/scripts/smoke-file-preview-plugin.js" diff --git a/scripts/smoke-default-editor-plugin.js b/scripts/smoke-default-editor-plugin.js new file mode 100755 index 0000000..7f99f1c --- /dev/null +++ b/scripts/smoke-default-editor-plugin.js @@ -0,0 +1,189 @@ +#!/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', 'default-editor', '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.className = ''; + this.value = ''; + this.disabled = false; + this.parentNode = null; + this._textContent = ''; + this._innerHTML = ''; + } + + appendChild(node) { + this.children.push(node); + node.parentNode = this; + return node; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + } + + getAttribute(name) { + return this.attributes[name]; + } + + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + } + + dispatchEvent(type, event = {}) { + (this.listeners[type] || []).forEach((handler) => handler({ target: this, preventDefault() {}, stopPropagation() {}, ...event })); + } + + set textContent(value) { + this._textContent = String(value || ''); + this._innerHTML = ''; + this.children = []; + } + + get textContent() { + if (this.tagName === '#TEXT') return this._textContent; + return this._textContent + this._innerHTML.replace(/<[^>]*>/g, '') + this.children.map((child) => child.textContent).join(''); + } + + set innerHTML(value) { + this._innerHTML = String(value || ''); + this._textContent = ''; + this.children = []; + } + + get innerHTML() { + return this._innerHTML + this.children.map((child) => child.innerHTML).join(''); + } +} + +function walk(node, fn) { + if (fn(node)) return node; + for (const child of node.children) { + const found = walk(child, fn); + if (found) return found; + } + return null; +} + +function makeDocument() { + return { + head: new FakeNode('head'), + body: new FakeNode('body'), + 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 = {}; + vm.runInNewContext(source, { + console, + document, + window: { + confirm: () => true, + VerstakPluginRegister(pluginId, bundle) { + registry[pluginId] = bundle.components || {}; + }, + }, + Event: function Event() {}, + setTimeout, + clearTimeout, + }, { filename: sourcePath }); + const component = registry['verstak.default-editor'] && registry['verstak.default-editor'].DefaultEditor; + if (!component) throw new Error('DefaultEditor was not registered'); + return component; +} + +async function flush() { + for (let i = 0; i < 12; i++) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +async function mountEditor(secretProviderEnabled) { + const document = makeDocument(); + const component = loadComponent(document); + const opened = []; + const api = { + files: { + readText: async () => '[DB password](verstak-secret://client-a.db)\n', + writeText: async () => undefined, + }, + contributions: { + list: async (point) => { + if (point !== 'openProviders' || !secretProviderEnabled) return []; + return [{ + pluginId: 'verstak.secrets', + id: 'verstak.secrets.secret', + component: 'SecretsView', + supports: [{ kind: 'secret', modes: ['view'] }], + }]; + }, + }, + workbench: { + openResource: async (request) => { + opened.push(request); + return { status: 'opened', request }; + }, + }, + }; + const container = document.createElement('div'); + component.mount(container, { + request: { kind: 'vault-file', path: 'Project/Notes/Secret.md', extension: '.md', mode: 'view' }, + }, api); + await flush(); + return { container, opened }; +} + +(async () => { + const disabled = await mountEditor(false); + const disabledPreview = walk(disabled.container, (node) => node.className === 'de-preview'); + if (!disabledPreview) throw new Error('disabled preview missing'); + if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider'); + + const enabled = await mountEditor(true); + const preview = walk(enabled.container, (node) => node.className === 'de-preview'); + if (!preview) throw new Error('enabled preview missing'); + if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider'); + + enabled.container.dispatchEvent('click', { + target: { + closest(selector) { + if (selector === '.secret-link') { + return { getAttribute: (name) => name === 'data-secret-id' ? 'client-a.db' : '' }; + } + return null; + }, + }, + }); + await flush(); + + if (!enabled.opened.some((request) => request.kind === 'secret' && request.path === 'client-a.db')) { + throw new Error('secret link did not open through workbench'); + } + + console.log('default editor smoke passed'); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-secrets-plugin.js b/scripts/smoke-secrets-plugin.js index ba5469d..8f177f7 100755 --- a/scripts/smoke-secrets-plugin.js +++ b/scripts/smoke-secrets-plugin.js @@ -129,6 +129,7 @@ async function flush() { if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read'); if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write'); if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register'); + if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing'); if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing'); if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');