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
11 changes: 8 additions & 3 deletions .vitepress/lib/cds-playground/md-live-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,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,7 +135,7 @@ 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))
const modelName = modelArg ? modelArg.slice(1, -1) : null
const modelDef: ModelDef | undefined = modelName ? (env as any)._modelMap[modelName] : undefined

Expand All @@ -140,8 +144,9 @@ export function install(md: MarkdownRenderer) {
}
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

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
26 changes: 20 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,10 @@ const props = defineProps({
type: String,
default: ''
},
highlightLines: {
type: String,
default: ''
},
onEvaluate: {
type: Function
}
Expand Down Expand Up @@ -156,14 +166,18 @@ 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) {
Expand Down Expand Up @@ -220,7 +234,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
2 changes: 1 addition & 1 deletion cds/cxl.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The cds model initialized on this page is a slightly modified version of the [ca
All samples run on a single browser-local `cds` instance, you can access it via the dev tools
or run statements in the following code block:

```js live
```js live [async]
await INSERT.into('Books').entries(
{ ID: 2, author_ID: 150, title: 'Eldorado' }
)
Expand Down
Loading
Loading