Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const config = defineConfig({

head: [
['meta', { name: 'theme-color', content: '#db8b0b' }],
['meta', { 'http-equiv': 'Content-Security-Policy', content: "script-src 'self' https://www.capire-matomo.cloud.sap 'unsafe-inline' 'unsafe-eval'" }],
['meta', { 'http-equiv': 'Content-Security-Policy', content: "script-src 'self' https://www.capire-matomo.cloud.sap 'unsafe-inline' 'unsafe-eval'; worker-src 'self' blob:" }],
['link', { rel: 'icon', href: base+'favicon.ico' }],
['link', { rel: 'shortcut icon', href: base+'favicon.ico' }],
['link', { rel: 'apple-touch-icon', sizes: '180x180', href: base+'logos/cap.png' }],
Expand All @@ -98,6 +98,14 @@ const config = defineConfig({
build: {
chunkSizeWarningLimit: 6000, // chunk for local search index dominates
},
// cds-worker.js is constructed with `type: 'module'`; match that at build time so its
// dynamic import('@sap/cds') is emitted as native ESM instead of an iife require() shim
worker: {
format: 'es',
// Vite doesn't reuse the main `plugins` array for worker bundles; without vite-plugin-cds's
// node()/cap() here, the worker build misses their Node built-in shims (e.g. lazify's module.require)
plugins: () => [...playground.plugins()],
},
css: {
preprocessorOptions: {
scss: {
Expand Down
108 changes: 104 additions & 4 deletions .vitepress/lib/cds-playground/md-live-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { enabled } from '.'
* ```cds live
* select from Books { title }
* ```
*
*
* ```js live
* await INSERT.into('Books').entries(
* { ID: 2, author_ID: 150, title: 'Eldorado' }
Expand All @@ -20,14 +20,106 @@ import { enabled } from '.'
* example: ```cds live as cql
* - readonly: make the code block readonly
* example: ```cds live readonly
* - [ModelName]: run query against a named model defined elsewhere on the page
* example: ```cds live [FooBar]
*
* Named model definitions (static, non-live):
* - ```cds [FooBar] — defines a named model; rendered as a plain code block
* - ```cds [FooBarBoo: FooBar] — extends FooBar; combined source is resolved at render time
* - ```cds [FooBar, data: FooData] — attaches a named CSV data set to the model
*
* Named CSV data sets (static, non-live):
* - ```csv [FooData: data/Foo.csv] — defines a named data set; rendered as a plain code block
* - ```csv hidden [FooData: data/Foo.csv] — same, but suppressed from output (not rendered)
*
* CSV and model blocks may appear anywhere on the page — they are collected in a full token pass
* before any fence is rendered, so forward references work.
*/

const MODEL_ARG_RE = /^\[.+\]$/

interface ModelDef { source: string; csvs?: Record<string, string> }

function parseBracketKV(inner: string): { name: string; base?: string; data?: string } {
const commaIdx = inner.indexOf(',')
const namePart = commaIdx === -1 ? inner.trim() : inner.slice(0, commaIdx).trim()
const colonIdx = namePart.indexOf(':')
const name = colonIdx === -1 ? namePart : namePart.slice(0, colonIdx).trim()
const base = colonIdx === -1 ? undefined : namePart.slice(colonIdx + 1).trim()
let data: string | undefined
if (commaIdx !== -1) {
const dataMatch = inner.slice(commaIdx + 1).match(/\bdata\s*:\s*(\S+)/)
if (dataMatch) data = dataMatch[1]
}
return { name, base, data }
}

function buildDataMap(tokens: any[]): Record<string, Record<string, string>> {
const result: Record<string, Record<string, string>> = {}
for (const token of tokens) {
if (token.type !== 'fence') continue
const bracketMatch = token.info.match(/\[([^\]]+)\]/)
if (!bracketMatch) continue
const [lang] = token.info.slice(0, bracketMatch.index).trim().split(/\s+/)
if (lang !== 'csv') continue
const inner = bracketMatch[1]
const colonIdx = inner.indexOf(':')
if (colonIdx === -1) continue
const name = inner.slice(0, colonIdx).trim()
const path = inner.slice(colonIdx + 1).trim()
result[name] = { [path]: token.content.trim() }
}
return result
}

function buildModelMap(tokens: any[], dataMap: Record<string, Record<string, string>>): Record<string, ModelDef> {
const raw: Record<string, { source: string; base?: string; csvs?: Record<string, string> }> = {}
for (const token of tokens) {
if (token.type !== 'fence') continue
// Match the bracket first since its content may contain spaces (e.g. "[Foo: Bar]"),
// which would otherwise be broken apart by a naive split(' ').
const bracketMatch = token.info.match(/\[([^\]]+)\]/)
if (!bracketMatch) continue
const before = token.info.slice(0, bracketMatch.index).trim().split(/\s+/)
const [lang] = before
if (lang !== 'cds') continue
// Only pick up non-live model definition blocks
if (before.includes('live')) continue
const { name, base, data } = parseBracketKV(bracketMatch[1])
raw[name] = { source: token.content.trim(), base, csvs: data ? dataMap[data] : undefined }
}
const resolved: Record<string, ModelDef> = {}
function resolve(name: string): ModelDef {
if (name in resolved) return resolved[name]
const def = raw[name]
if (!def) return { source: '' }
const baseDef = def.base ? resolve(def.base) : null
const source = baseDef ? `${baseDef.source}\n${def.source}` : def.source
const csvs = def.csvs ?? baseDef?.csvs
return (resolved[name] = { source, csvs })
}
Object.keys(raw).forEach(resolve)
return resolved
}

export function install(md: MarkdownRenderer) {
if (!enabled) return
const fence = md.renderer.rules.fence
md.renderer.rules.fence = (tokens, idx, options, env: MarkdownEnv, ...args) => {
// Build the model map before any fence is rendered: VitePress's preWrapperPlugin
// strips "[...]" from token.info as a side effect of rendering (for code-group tab
// titles), so scanning tokens lazily would miss brackets on already-rendered fences.
if (!(env as any)._modelMap) {
const dataMap = buildDataMap(tokens)
;(env as any)._modelMap = buildModelMap(tokens, dataMap)
}

const { info } = tokens[idx]
const [language, live, ...rest] = info.split(' ')

// Suppress named CSV data blocks marked hidden — content is captured in the pre-pass and shown as a model tab.
if (language === 'csv' && live === 'hidden' && /\[[^\]]+:[^\]]+\]/.test(info)) return ''

if (live === 'live') {
const mdDir = dirname(env.realPath ?? env.path)
const filePath = './' + relative(mdDir, join(__dirname, '../../theme/components/cds-playground/LiveCode.vue'))
Expand All @@ -38,20 +130,28 @@ export function install(md: MarkdownRenderer) {
const idx = rest.findIndex(k => k === key)
return idx > -1 ? [key, rest.splice(idx+1, 1)[0]] : [];
}))
const props = {

const modelArg = rest.find((p: string) => MODEL_ARG_RE.test(p))
const modelName = modelArg ? modelArg.slice(1, -1) : null
const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined

const props: Record<string, string> = {
language: opts.as ?? language,
}
if (modelDef?.source) props.modelSource = md.utils.escapeHtml(modelDef.source)
if (modelDef?.csvs) props.modelData = md.utils.escapeHtml(JSON.stringify(modelDef.csvs))

const flags = ['readonly'].filter(k => rest.includes(k))

const content = tokens[idx].content.trim()
return `<LiveCode initialQuery="${md.utils.escapeHtml(content)}" ${Object.entries(props).map(([k, v]) => `${k}="${v}"`)} ${flags.join(' ')}></LiveCode>`
return `<LiveCode initialQuery="${md.utils.escapeHtml(content)}" ${Object.entries(props).map(([k, v]) => `${k}="${v}"`).join(' ')} ${flags.join(' ')}></LiveCode>`
}
return fence!(tokens, idx, options, env, ...args)
}
}

function insertScriptSetup(env: MarkdownEnv, imp: string) {
const sfcBlocks = env.sfcBlocks!
const sfcBlocks = env.sfcBlocks!
if (!sfcBlocks.scriptSetup) {
sfcBlocks.scriptSetup = {
content: '<script setup>\n</script>',
Expand Down
42 changes: 35 additions & 7 deletions .vitepress/theme/components/cds-playground/LiveCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<div v-else v-html="play"></div>
</button>
<button v-if="modelTabs.length" class="icon-button model-toggle-btn" @click="toggleModel"
:title="modelVisible ? 'Hide CDS model' : 'Show CDS model'" :aria-pressed="modelVisible">
:title="modelVisible ? 'Hide CDS model and sample data' : 'Show CDS model and sample data'" :aria-pressed="modelVisible">
<svg class="model-icon" viewBox="0 0 24 24" aria-hidden="true">
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6a8 3 0 0 0 16 0V5" />
Expand Down Expand Up @@ -74,7 +74,7 @@ import { computed, onMounted, ref, useId } from 'vue'
import MonacoEditor from './MonacoEditor.vue'
import { useData } from 'vitepress'
import play from '/icons/play.svg?url&raw'
import { runners } from './runners'
import { runners, runWithModel } from './runners'
import highlighter from './highlighter'
import templates from 'virtual:templates'

Expand All @@ -95,6 +95,14 @@ const props = defineProps({
type: String,
default: 'js'
},
modelSource: {
type: String,
default: ''
},
modelData: {
type: String,
default: ''
},
onEvaluate: {
type: Function
}
Expand All @@ -111,10 +119,25 @@ const evalStatus = ref(null)

// the model the query runs against, shown on demand so it doesn't clutter the snippet
const modelVisible = ref(false)
const modelTabs = computed(() => (templates.bookshop ?? [])
.filter(file => file.path.endsWith('.cds'))
.sort((f1, f2) => f1.path.localeCompare(f2.path))
.map(file => ({ key: `${uid}-model-${file.path}`, kind: 'cds', name: file.path, value: file.content })))
const modelTabs = computed(() => {
if (props.modelSource) {
const tabs = [{ key: `${uid}-model-custom`, kind: 'cds', name: 'schema.cds', value: props.modelSource }]
if (props.modelData) {
const csvs = JSON.parse(props.modelData)
for (const [path, content] of Object.entries(csvs)) {
tabs.push({ key: `${uid}-data-${path}`, kind: 'csv', name: path, value: content })
}
}
return tabs
}
return (templates.bookshop ?? [])
.filter(file => file.path.endsWith('.cds') || file.path.endsWith('.csv'))
.sort((f1, f2) => {
const kindOrder = (f) => f.path.endsWith('.csv') ? 1 : 0
return kindOrder(f1) - kindOrder(f2) || f1.path.localeCompare(f2.path)
})
.map(file => ({ key: `${uid}-model-${file.path}`, kind: file.path.endsWith('.csv') ? 'csv' : 'cds', name: file.path, value: file.content }))
})

// eval tabs (if any) come first, model tabs are appended at the end
const combinedTabs = computed(() => [
Expand Down Expand Up @@ -194,7 +217,8 @@ async function evaluate() {
}
queryResult.value = null
try {
const exec = props.onEvaluate ?? runners[props.language]
const exec = props.onEvaluate
?? (props.modelSource ? (q) => runWithModel(q, props.modelSource, props.modelData ? JSON.parse(props.modelData) : undefined) : runners[props.language])
if (!exec) throw new Error(`No runner found for language: ${props.language}. Available runners: ${Object.keys(runners).join(', ')}`)
const result = await exec(queryText.value)
tabs.value = formatTabs(result).filter(({ value }) => value)
Expand Down Expand Up @@ -444,6 +468,10 @@ onMounted(() => { metaKey.value = /(Mac|iPhone|iPad)/i.test(navigator?.userAgent
stroke-linejoin: round;
}

.interactive-query :deep(.vp-code-group .tabs) {
overflow-x: auto;
}

.vp-code-group.error {
border: 1px solid var(--vp-c-danger-2);
border-radius: 4px;
Expand Down
60 changes: 60 additions & 0 deletions .vitepress/theme/components/cds-playground/cds-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
function simpleSqlFormat(sql) {
return sql
.replace(/\b(select|from|where|group by|order by|having|limit|offset|join|left join|right join|inner join|outer join)\b/gi, "\n$1")
.replace(/\b(and|or)\b/gi, "\n $1")
.replace(/,\s*/g, ",\n ")
.replace(/\n{2,}/g, "\n")
.trim();
}

const sqlLog = [];

function injectLogger(sqlite) {
const { prototype } = sqlite().constructor;
const { prepare: original } = prototype;
prototype.prepare = function prepare(sql) {
sqlLog.push(sql);
return original.call(this, sql);
}
}

let cds;
let initialized = false;

async function init(modelSource, csvs) {
cds = (await import('@sap/cds')).default;
const sqlite = (await import('better-sqlite3')).default;

await sqlite.initialized;
injectLogger(sqlite);

const csn = cds.compile({ 'model.cds': modelSource });
cds.model = csn;

cds.db = await cds.connect.to('db');

await cds.deploy(csn, null, csvs ?? {}).to(cds.db);
initialized = true;
}

self.onmessage = async ({ data: { type, id, payload } }) => {
try {
if (type === 'init') {
await init(payload.modelSource, payload.csvs);
self.postMessage({ type: 'ready' });
} else if (type === 'query') {
if (!initialized) throw new Error('Worker not initialized');
sqlLog.length = 0;
const cqn = cds.ql(payload.query);
const result = await cds.db.run(cqn);
const formatted = sqlLog.map(simpleSqlFormat).join('\n\n-------\n');
self.postMessage({ type: 'result', id, result: [
{ value: result, kind: 'json', name: 'Result' },
{ value: formatted, kind: 'sql', name: 'SQL' },
{ value: cqn, kind: 'json', name: 'CQN' },
]});
}
} catch (err) {
self.postMessage({ type: 'error', id, error: err.message ?? String(err) });
}
};
36 changes: 36 additions & 0 deletions .vitepress/theme/components/cds-playground/runners.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,45 @@ async function cdsQL(query) {
];
}

// Worker pool: one worker per model source string, shared across all LiveCode instances
const workerPool = new Map();

function getOrCreateWorker(modelSource, csvs) {
const key = csvs ? `${modelSource}\0${JSON.stringify(csvs)}` : modelSource;
if (workerPool.has(key)) return workerPool.get(key);
const worker = new Worker(new URL('./cds-worker.js', import.meta.url), { type: 'module' });
const initPromise = new Promise((resolve, reject) => {
worker.addEventListener('message', function once(e) {
if (e.data.type !== 'ready' && e.data.type !== 'error') return;
worker.removeEventListener('message', once);
e.data.type === 'ready' ? resolve() : reject(new Error(e.data.error));
});
worker.postMessage({ type: 'init', payload: { modelSource, csvs } });
});
const entry = { worker, initPromise };
workerPool.set(key, entry);
return entry;
}

async function runWithModel(query, modelSource, csvs) {
const { worker, initPromise } = getOrCreateWorker(modelSource, csvs);
await initPromise;
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
function handler(e) {
if (e.data.id !== id) return;
worker.removeEventListener('message', handler);
e.data.type === 'error' ? reject(new Error(e.data.error)) : resolve(e.data.result);
}
worker.addEventListener('message', handler);
worker.postMessage({ type: 'query', id, payload: { query } });
});
}

export {
evalJS,
cdsQL,
runWithModel,
}

export const runners = {
Expand Down
Loading
Loading