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
18 changes: 15 additions & 3 deletions .vitepress/lib/cds-playground/md-live-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { enabled } from '.'
* example: ```cds live readonly
* - [ModelName]: run query against a named model defined elsewhere on the page
* example: ```cds live [FooBar]
* - [result:<lang>]: format the result as the given language (e.g. sql) instead of JSON
* example: ```js live [result:sql]
*
* Named model definitions (static, non-live):
* - ```cds [FooBar] — defines a named model; rendered as a plain code block
Expand All @@ -37,6 +39,7 @@ import { enabled } from '.'
*/

const MODEL_ARG_RE = /^\[.+\]$/
const RESULT_KIND_RE = /^\[result:(\w+)\]$/

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

Expand Down Expand Up @@ -115,11 +118,15 @@ export function install(md: MarkdownRenderer) {
}

const { info } = tokens[idx]
const [language, live, ...rest] = info.split(' ')
const hlMatch = info.match(/\{[\d,\-]+\}/)
const highlightSpec = hlMatch?.[0] ?? ''
const infoNormalized = info.replace(/\s*\{[\d,\-]+\}/, '')
const [language, live, ...rawRest] = infoNormalized.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 ''

const rest = rawRest.map(flag => flag.replace(/^\[|\]$/g, '')) // e.g. "[async]" -> "async"
if (live === 'live') {
const mdDir = dirname(env.realPath ?? env.path)
const filePath = './' + relative(mdDir, join(__dirname, '../../theme/components/cds-playground/LiveCode.vue'))
Expand All @@ -131,17 +138,22 @@ export function install(md: MarkdownRenderer) {
return idx > -1 ? [key, rest.splice(idx+1, 1)[0]] : [];
}))

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

const resultArg = rawRest.find((p: string) => RESULT_KIND_RE.test(p))
const resultKind = resultArg ? RESULT_KIND_RE.exec(resultArg)![1] : null

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))
if (highlightSpec) props.highlightLines = highlightSpec
if (resultKind) props.resultKind = resultKind

const flags = ['readonly'].filter(k => rest.includes(k))
const flags = ['readonly', 'async'].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}"`).join(' ')} ${flags.join(' ')}></LiveCode>`
Expand Down
41 changes: 35 additions & 6 deletions .vitepress/theme/components/cds-playground/LiveCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="language-sh">
<button title="Copy Code" class="copy"></button>
<span class="lang">{{ props.language === 'cds'? 'cql' : props.language }}</span>
<span v-html="format?.({value: queryText, kind: props.language}, isDark)"></span>
<span v-html="format?.({value: queryText, kind: props.language}, isDark, props.highlightLines)"></span>
</div>
</div>
<div class="editor language-sh" :hidden="!loaded" v-if="!readonly">
Expand All @@ -14,6 +14,7 @@
<MonacoEditor
v-model="queryText"
:language="props.language"
:highlightLines="props.highlightLines"
@loaded="loaded = true"
@evaluate="evaluate"
/>
Expand Down Expand Up @@ -77,6 +78,7 @@ import play from '/icons/play.svg?url&raw'
import { runners, runWithModel } from './runners'
import highlighter from './highlighter'
import templates from 'virtual:templates'
import { transformerMetaHighlight } from '@shikijs/transformers'

const uid = useId()

Expand All @@ -91,6 +93,10 @@ const props = defineProps({
type: Boolean,
default: false
},
async: {
type: Boolean,
default: false
},
language: {
type: String,
default: 'js'
Expand All @@ -103,6 +109,14 @@ const props = defineProps({
type: String,
default: ''
},
highlightLines: {
type: String,
default: ''
},
resultKind: {
type: String,
default: ''
},
onEvaluate: {
type: Function
}
Expand Down Expand Up @@ -156,17 +170,32 @@ function toggleModel() {
}
}

function format({ value, kind }, dark) {
function format({ value, kind }, dark, highlightSpec = '') {
if (!highlighter.getLoadedLanguages().includes(kind)) {
kind = 'plaintext'
}
const html = highlighter.codeToHtml(
const opts = { lang: kind, theme: dark ? 'github-dark' : 'github-light', transformers: [], meta: undefined }
if (highlightSpec) {
opts.meta = { __raw: highlightSpec }
opts.transformers = [transformerMetaHighlight()]
}
return highlighter.codeToHtml(
typeof value === 'string' ? value : JSON.stringify(value, null, 2),
{ lang: kind, theme: dark ? 'github-dark' : 'github-light' })
return html
opts)
}

function formatTabs(result) {
if (props.resultKind) {
// evalJS wraps results in tab objects with a possibly JSON-stringified value; unwrap first.
// When resultTabs produces yaml+json tabs, take the json tab (not the yaml one at [0]).
const raw = Array.isArray(result) && result[0]?.kind !== undefined
? (result.find(t => t.kind === 'json') ?? result[0]).value
: result
let data = raw
if (typeof raw === 'string') try { data = JSON.parse(raw) } catch {}
const value = Array.isArray(data) ? data.join(';\n') : typeof data === 'string' ? data : JSON.stringify(data, null, 2)
return [{ key: `${uid}-Result`, kind: props.resultKind, name: `Result (as ${props.resultKind.toUpperCase()})`, value }]
}
if (result && result.kind && result.value) {
const { kind, name = 'Result', value } = result
return [
Expand Down Expand Up @@ -220,7 +249,7 @@ async function evaluate() {
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)
const result = await exec(queryText.value, props.async)
tabs.value = formatTabs(result).filter(({ value }) => value)

if (!tabs.value.map(tab => tab.key).includes(selectedTab.value)) selectedTab.value = tabs.value[0].key
Expand Down
19 changes: 19 additions & 0 deletions .vitepress/theme/components/cds-playground/MonacoEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const props = defineProps({
rows: {
type: Number,
default: 3
},
highlightLines: {
type: String,
default: ''
}
})

Expand Down Expand Up @@ -86,6 +90,17 @@ async function createEditor() { try {
const contentSizeDispose = editor.onDidContentSizeChange(() => updateHeight())
updateHeight()

if (props.highlightLines) {
const lines = props.highlightLines.replace(/^\{|\}$/g, '').split(',').flatMap(part => {
const [a, b] = part.trim().split('-').map(Number)
return b ? Array.from({ length: b - a + 1 }, (_, i) => a + i) : [a]
})
editor.createDecorationsCollection(lines.map(line => ({
range: new monaco.Range(line, 1, line, 1),
options: { isWholeLine: true, className: 'live-code-highlighted-line' }
})))
}

// Emit evaluate on Cmd/Ctrl+Enter
editor.addAction({
id: 'eval',
Expand Down Expand Up @@ -155,4 +170,8 @@ watch(() => isDark.value, (dark) => {
background-color: var(--vp-code-block-bg) !important;
font-family: var(--vp-font-family-mono) !important;
}

.live-code-highlighted-line {
background-color: var(--vp-code-line-highlight-color) !important;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import languages from '../../../languages'

const highlighter = await createHighlighter({
themes: ['github-dark', 'github-light'],
langs: ['javascript', 'js', 'sql', 'typescript', 'vue', ...languages],
langs: ['javascript', 'js', 'sql', 'typescript', 'vue', 'yaml', ...languages],
langAlias: Object.fromEntries(languages.flatMap(l => {
if (!l || typeof l !== 'object' || !Array.isArray(l.aliases) || !l.name) return []
return l.aliases.map(alias => [alias, l.name])
Expand Down
144 changes: 135 additions & 9 deletions .vitepress/theme/components/cds-playground/runners.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function injectLogger(sqlite) {
return sqlLog;
}


/** @returns {Promise<import('@sap/cds')>} */
async function initialize() {
const cds = (await import('@sap/cds')).default;
const express = (await import('express')).default;
Expand Down Expand Up @@ -61,22 +61,48 @@ async function initialize() {
return cds;
}

/** @type {ReturnType<typeof initialize>} */
let initialized;
if (!import.meta.env.SSR) {
// runs only in the browser
initialized = initialize();
}

const AsyncFunction = async function () {}.constructor;
async function evalJS(code) {
await initialized;
const fn = new AsyncFunction(code);
const { result, formatted } = await sql.trace(fn);
async function evalJS(code, isAsync) {
const cds = await initialized;
const source = compile(code);

function resultTabs(result, kind) {
if (kind === 'json') {
let yaml
try { yaml = cds.compile.to.yaml(result) } catch {/* ignore */}
if (yaml) return [
{ value: yaml, kind: 'yaml', name: 'Result (as yaml)' },
{ value: JSON.stringify(result, null, 2), kind: 'json', name: 'Result (raw)' },
]
}
return [{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' }]
}

if (isAsync) {
let fn;
try { fn = new AsyncFunction(source) }
catch { fn = new AsyncFunction(code) } // rewrite had a syntax error -> run the code unmodified
const { result, formatted } = await sql.trace(fn);
const kind = result? 'json' : 'plaintext'
return [
...resultTabs(result, kind),
{ value: formatted, kind: 'sql', name: 'SQL'}
];
}

let fn;
try { fn = new Function(source) }
catch { fn = new Function(code) } // rewrite had a syntax error -> run the code unmodified
const result = fn();
const kind = result? 'json' : 'plaintext'
return [
{ value: result ? typeof result !== 'string' ? JSON.stringify(result, null, 2) : result : "success", kind, name: 'Result' },
{ value: formatted, kind: 'sql', name: 'SQL'}
];
return resultTabs(result, kind);
}

async function cdsQL(query) {
Expand Down Expand Up @@ -138,3 +164,103 @@ export const runners = {
cql: cdsQL,
cds: cdsQL,
}

function compile(code) {
const stmts = splitTopLevelStatements(code)
if (!stmts.length) return code
const last = stmts[stmts.length - 1]

// last statement already returns, or is a control-flow/declaration keyword -> leave the code as is
if (/^(return|throw|if|for|while|function|class|import|export)\b/.test(last.text)) return code

// anchored right after the keyword so we don't match "=" occurring inside the initializer, e.g. in a template literal
const declRe = /^(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*=/
if (declRe.test(last.text)) {
// last statement declares a variable, e.g. "let result = 1+1" -> collect all top-level declarations in the
// snippet so earlier ones aren't silently dropped, e.g. comparing "let q = ...; let p = ..." side by side
const names = stmts.map(s => s.text.match(declRe)?.[1]).filter(Boolean)
return names.length > 1
? `${code}\nreturn { ${names.join(', ')} };`
: `${code}\nreturn ${names[0]};`
}

// last statement isn't a declaration -> treat it (possibly spanning multiple lines) as the expression to return
return `${code.slice(0, last.start)}\nreturn (\n${last.text.replace(/;\s*$/, '')}\n);`
}


// splits code into its top-level statements (ignoring newlines/semicolons nested inside brackets, strings,
// template literals or comments), so multi-line statements like object literals are kept intact as one unit
function splitTopLevelStatements(code) {
const scrubbed = blankComments(code) // same length as code, but with comments replaced by spaces
const stmts = []
let start = 0, depth = 0, i = 0
while (i < scrubbed.length) {
const c = scrubbed[i]
if (c === '"' || c === "'") { i = skipString(scrubbed, i, c); continue }
if (c === '`') { i = skipTemplate(scrubbed, i); continue }
if (c === '(' || c === '{' || c === '[') { depth++; i++; continue }
if (c === ')' || c === '}' || c === ']') { depth--; i++; continue }
if (depth <= 0 && (c === ';' || c === '\n')) {
const text = scrubbed.slice(start, i).trim()
if (text) stmts.push({ text, start })
i++; start = i; continue
}
i++
}
const text = scrubbed.slice(start).trim()
if (text) stmts.push({ text, start })
return stmts
}

// replaces line and block comments with spaces of the same length, so a trailing comment (e.g. after the last
// statement, or commented-out code on its own line) is never mistaken for code, while offsets stay unchanged
function blankComments(code) {
let out = ''
let i = 0
while (i < code.length) {
const c = code[i]
if (c === '/' && code[i + 1] === '/') { while (i < code.length && code[i] !== '\n') { out += ' '; i++ }; continue }
if (c === '/' && code[i + 1] === '*') {
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) { out += code[i] === '\n' ? '\n' : ' '; i++ }
out += ' '; i += 2; continue
}
if (c === '"' || c === "'") { const j = skipString(code, i, c); out += code.slice(i, j); i = j; continue }
if (c === '`') { const j = skipTemplate(code, i); out += code.slice(i, j); i = j; continue }
out += c; i++
}
return out
}

// skips a single- or double-quoted string starting at code[i], returning the index right after the closing quote
function skipString(code, i, quote) {
i++
while (i < code.length && code[i] !== quote) { if (code[i] === '\\') i++; i++ }
return i + 1
}

// skips a template literal starting at code[i] (the opening backtick), diving into ${...} interpolations
function skipTemplate(code, i) {
i++
while (i < code.length) {
if (code[i] === '\\') { i += 2; continue }
if (code[i] === '`') return i + 1
if (code[i] === '$' && code[i + 1] === '{') { i = skipBraces(code, i + 2); continue }
i++
}
return i
}

// skips forward to the '}' balancing the '${' whose contents start at code[i]
function skipBraces(code, i) {
let depth = 1
while (i < code.length && depth > 0) {
const c = code[i]
if (c === '"' || c === "'") { i = skipString(code, i, c); continue }
else if (c === '`') { i = skipTemplate(code, i); continue }
else if (c === '{') depth++
else if (c === '}') depth--
i++
}
return i
}
5 changes: 1 addition & 4 deletions cds/cdl.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,7 @@ Within those strings, escape sequences from JavaScript, such as `\t` or `\u0020`

Using directives allow to import definitions from other CDS models. As shown in line 3 below, you optionally can specify local aliases to be used subsequently. You can import single definitions as well as several ones with a common namespace prefix.

::: code-group

```cds
using foo.bar.scoped.Bar from './contexts';
using foo.bar.scoped.nested from './contexts';
using foo.bar.scoped.nested as animal from './contexts';
Expand All @@ -158,8 +157,6 @@ entity Moo : nested.Zoo {} //> : foo.bar.scoped.nested.Zoo
entity Zoo : animal.Zoo {} //> : foo.bar.scoped.nested.Zoo
```

:::

Multiple named imports through ES6-like deconstructors:

```cds
Expand Down
Loading
Loading