diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 153265b1a8..32a416ac8e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,15 +2,16 @@ # We start with defining ownership globally and later on can get more granular. # General content -* @renejeglinsky +* @renejeglinsky @danjoa -node.js/ @smahati @renejeglinsky -java/ @smahati @renejeglinsky +node.js/ @smahati @renejeglinsky @danjoa +java/ @smahati @renejeglinsky @danjoa +tools/ @chgeo @swaldmann @renejeglinsky # Infra .github/ @chgeo @swaldmann -.vitepress/ @chgeo @swaldmann -public/ @chgeo @swaldmann +.vitepress/ @chgeo @swaldmann @danjoa +public/ @chgeo @swaldmann @danjoa # allow dependencies updates through renovate w/o code owners package.json diff --git a/.github/etc/blc.js b/.github/etc/blc.js new file mode 100755 index 0000000000..40aff135d0 --- /dev/null +++ b/.github/etc/blc.js @@ -0,0 +1,331 @@ +#!/usr/bin/env node + +import { parseDocument } from 'htmlparser2' +import { spawn } from 'child_process' +import { readdirSync, existsSync } from 'fs' +import { join } from 'path' +import { parseArgs } from 'node:util' + +const { Bright,Dim,Reset, foreground:{ + Red, Yellow, Green +}} = { + + Reset: "\x1b[0m", + Bright: "\x1b[1m", + Dim: "\x1b[2m", + Underscore: "\x1b[4m", + Blink: "\x1b[5m", + Reverse: "\x1b[7m", + Hidden: "\x1b[8m", + + foreground: { + Black: "\x1b[30m", + Red: "\x1b[31m", + Green: "\x1b[32m", + Yellow: "\x1b[33m", + Blue: "\x1b[34m", + Magenta: "\x1b[35m", + Cyan: "\x1b[36m", + White: "\x1b[37m", + }, + background: { + Black: "\x1b[40m", + Red: "\x1b[41m", + Green: "\x1b[42m", + Yellow: "\x1b[43m", + Blue: "\x1b[44m", + Magenta: "\x1b[45m", + Cyan: "\x1b[46m", + White: "\x1b[47m", + } +} + +const urlExcludesBase = [ + /\/java\/assets\/cds-maven-plugin-site\//, + /\/java\/custom-logic\//, + /\/releases\/changelog\//, + /\/releases\/latest/, + /\/releases\/current/, + /\/tools\/lint/, +] + +// extended set of excludes because public content refers to internal content in some places +const urlExcludesPublicRepo = [ + ...urlExcludesBase, + /\/guides\/security\//, + /\/releases/, + /\/resources/, + /cds\/compiler\/messages/, + /mcp/ +] + +const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + x: { type: 'boolean', short: 'x', default: false }, + public: { type: 'boolean', default: false }, + }, + allowPositionals: true, +}) + +const urlExcludes = values.public ? urlExcludesPublicRepo : urlExcludesBase + +let [base] = positionals + +// Build a set of URL paths that are directory-backed (have index.html) +// so we know when to add trailing slash for correct relative URL resolution. +const distDir = '.vitepress/dist' +const dirPages = new Set() +function scanDirs(dir, prefix) { + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const sub = join(dir, entry.name) + const subPrefix = prefix + entry.name + '/' + if (existsSync(join(sub, 'index.html'))) dirPages.add(subPrefix) + scanDirs(sub, subPrefix) + } + } + } catch {} +} +scanDirs(distDir, '/') + +let server +if (!base) { + // Auto-start a vitepress preview server + const port = 4173 + Math.floor(Math.random() * 1000) + base = `http://localhost:${port}/docs/` + server = spawn('npx', ['vitepress', 'preview', '.', '--port', port], { + stdio: 'ignore', + detached: true, + }) + // Wait for server to be ready + for (let i = 0; i < 30; i++) { + try { + const res = await fetch(base) + if (res.ok) break + } catch {} + await new Promise(r => setTimeout(r, 500)) + } +} + +try { + if (values.x) { console.log (`Checking external links on ${base}...`); await check ({ excludeInternalLinks:true }) } + else { console.log (`Checking internal links on ${base}...`); await check ({ excludeExternalLinks:true }) } +} finally { + if (server) { + process.kill(-server.pid) + await new Promise(r => server.on('close', r)) + } +} + +async function check (options={}) { + + let N=0, all=new Set, broken={}, errors=[], pages={} + const visited = new Set() + const failedUrls = new Set() + const queue = [base] + const incomingLinks = {} // url -> [{ from: page, original: href }] + + function record (link, reason, p) { + ++N + if (broken.page !== p) errors.push (broken = {page:p,links:[]}) + broken.links.push ({ link, reason, toString() { return reason +': '+ link } }) + } + + // Phase 1: Crawl all reachable internal pages + while (queue.length > 0) { + const batch = queue.splice(0, 10) + await Promise.all(batch.map(crawlPage)) + } + + async function crawlPage (url) { + let cleanUrl = url.split('#')[0] + // Normalize /index URLs to directory form (e.g. /releases/index -> /releases/) + if (cleanUrl.endsWith('/index')) cleanUrl = cleanUrl.slice(0, -5) + if (visited.has(cleanUrl)) return + visited.add(cleanUrl) + // Also mark the counterpart (with/without trailing slash) as visited + // to avoid crawling the same page twice with different URL resolution + if (cleanUrl.endsWith('/')) visited.add(cleanUrl.slice(0,-1)) + else visited.add(cleanUrl + '/') + + const path = cleanUrl.replace(base,'/') + if (path.startsWith('/assets')) return + if (urlExcludes.find(l => l.test(path))) return + + let html, finalUrl, resolveBase + try { + const res = await fetch(cleanUrl) + if (!res.ok) { + failedUrls.add(cleanUrl) + return + } + const ct = res.headers.get('content-type') || '' + if (!ct.includes('text/html')) return + finalUrl = res.url // after redirects + // Use filesystem knowledge to determine if this page is directory-backed. + // Directory pages (served from dir/index.html) need trailing slash for + // correct relative URL resolution (e.g. ./foo resolves within the directory). + const urlPath = path.endsWith('/') ? path : path + '/' + resolveBase = dirPages.has(urlPath) ? cleanUrl.replace(/\/?$/, '/') : finalUrl + html = await res.text() + } catch(e) { + failedUrls.add(cleanUrl) + console.error(`Error fetching ${cleanUrl}: ${e.message}`) + return + } + + const doc = parseDocument(html) + const p = { + url: cleanUrl, path, + doc, + anchors: {}, + hashed: [], + } + pages[path] = p + + console.log (Dim+path, Reset) + + for (let hash of fetchLocalIn(doc)) p.hashed.push ({ hash }) + + walkLinks(doc, (href) => { + if (!href || href.startsWith('mailto:') || href.startsWith('javascript:') || href.startsWith('data:') || href.startsWith('vbscript:') || href.startsWith('tel:')) return + let resolved + try { resolved = new URL(href, resolveBase).href } catch { return } + + all.add(resolved) + const [resolvedBase] = resolved.split('#') + const [,hash] = href.split('#') + const isInternal = resolvedBase.startsWith(base) + + if (hash && isInternal) { + p.hashed.push ({ url: resolvedBase.replace(base,'/'), hash }) + } + + if (isInternal && !options.excludeInternalLinks) { + if (!incomingLinks[resolvedBase]) incomingLinks[resolvedBase] = [] + incomingLinks[resolvedBase].push({ from: p, original: href }) + if (!visited.has(resolvedBase)) { + queue.push(resolvedBase) + } + } + }) + } + + // Phase 2: Check for broken internal links (pages that failed to load) + if (!options.excludeInternalLinks) { + for (const [url, links] of Object.entries(incomingLinks)) { + if (failedUrls.has(url)) { + const linkRel = url.replace(base,'/') + if (urlExcludes.find(l => l.test(linkRel))) continue + for (const { from, original } of links) { + record(original, 'Not found', from) + } + } + } + } + + // Phase 3: Check external links (if -x mode) + if (!options.excludeExternalLinks) { + const externalLinks = new Map() + for (const p of Object.values(pages)) { + walkLinks(p.doc, href => { + if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('javascript:') || href.startsWith('data:') || href.startsWith('vbscript:') || href.startsWith('tel:')) return + let resolved + try { resolved = new URL(href, p.url).href } catch { return } + if (!resolved.startsWith(base)) { + if (!externalLinks.has(resolved)) externalLinks.set(resolved, []) + externalLinks.get(resolved).push({ from: p, original: href }) + } + }) + } + + console.log(`\nChecking ${externalLinks.size} external links...`) + const entries = [...externalLinks.entries()] + for (let i = 0; i < entries.length; i += 10) { + await Promise.all(entries.slice(i, i + 10).map(async ([url, links]) => { + try { + const res = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(10000), + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LinkChecker)' }, + redirect: 'follow', + }) + if (!res.ok) { + for (const { from, original } of links) record(original, `HTTP ${res.status}`, from) + } + } catch (e) { + for (const { from, original } of links) record(original, e.message || 'Connection error', from) + } + })) + } + } + + // Phase 4: Check hash/anchor links across pages + for (let p of Object.values(pages)) { + for (let {url,hash} of p.hashed) try { + if (url) { + if (urlExcludes.find(l => l.test(url))) continue + const page = pages[url] || pages[url.replace(/\/$/, '')] + if (!page) continue + checkLocal (page.doc,hash) || record (url+' #'+hash, 'Unresolved hash link', p) + } + else if (hash) { + if (urlExcludes.find(l => l.test(p.path))) continue + checkLocal (p.doc,hash) || record ('#'+hash, 'Unresolved local link', p) + } + } catch(e) { record(url+' #'+hash, 'Unresolved hash link', p) } + } + + // Phase 5: Report results + console.log (`\n-----------------------------------------------------------------`) + if (Object.keys(pages).length === 0) { + console.log (Bright+Red+`Could not fetch any pages from ${base}\n`, Reset) + process.exitCode = 1 + } else if (broken.links) { + console.log (Bright+Red+`Found ${N} broken link(s) to internal targets in ${errors.length} source(s):`, Reset) + for (let broken of errors) { + console.log ('in:', broken.page.path) + for (let each of broken.links) console.log (Bright+Red+ each) + console.log (Reset) + } + if (N > 0) process.exitCode = 1 + } else { + console.log (Bright+Green+`It's all fine in ${all.size} links, no broken links found\n`, Reset) + } +} + +function checkLocal (doc, id) { + return doc._anchors?.[id] ?? ((doc._anchors ??= {})[id] = findById(doc, id)) +} + +function findById (node, id) { + for (let each of (node.children || [])) { + if (each.attribs?.id === id) return each + if (each.children) { + const found = findById (each, id) + if (found) return found + } + } +} + +function fetchLocalIn (node, all=new Set) { + for (let each of (node.children || [])) { + if (each.name === 'a') { + const href = each.attribs?.href + if (href && href[0]==='#') all.add (href.slice(1)) + } + if (each.children) fetchLocalIn (each,all) + } + return all +} + +function walkLinks (node, callback) { + for (let each of (node.children || [])) { + if (each.name === 'a' && each.attribs?.href) { + callback(each.attribs.href) + } + if (each.children) walkLinks(each, callback) + } +} diff --git a/.github/etc/create-review.cjs b/.github/etc/create-review.cjs deleted file mode 100644 index 8b0ec1ecd6..0000000000 --- a/.github/etc/create-review.cjs +++ /dev/null @@ -1,301 +0,0 @@ -const cspellRegExp = /^(.*\.md)(:\d+:?\d*)\s*- Unknown word \((.*?)\)\s+-- (.*?) Suggestions: (\[.*\])$/ -const markdownlintRegExp = /^(.*\.md)(:\d+:?\d*) ([^\s]+) (.*?)(\[.*?\])?( \[Context: .*\])?$/ - -const createSuggestionText = (suggestion) => '```suggestion\n' + suggestion + '\n```\n' -const createCspellSuggestionText = (suggestion, other) => createSuggestionText(suggestion) + `Or maybe one of these: ${other.map(el => `**${el}**`).join(', ')}?` -const createWordsWithoutSuggestionsText = (words) => `For the following words no suggestions could be found, consider adding them to the word list:\n${words.map(word => `* ${word}\n`).join('')}` -const createUnknownWordComment = (word) => `Fix the spelling mistake in "**${word}**" or add it to the **project-words.txt** list.` -const createMissingCodeFencesText = (lines) => -` -\`\`\`\`suggestion -${lines.join('\n')} -\`\`\`\` - -Please add a language tag. For plain text add \`txt\` as language tag. -` - -const getNoEmptyLinkText = () => 'No empty links. Please provide a link value.' -const getSpellingCorrectionTip = () => -` -Generally, for each spelling mistake there are 2 ways to fix it: -1. Fix the spelling mistake and commit it. -2. The word is incorrectly reported as misspelled → put the word on the **project-words.txt** list, located in the root project directory. -` - -const getInvalidUrlText = (text, link) => { - const updatedLink = link.replace('http', 'https') - return createSuggestionText(`${text}(${updatedLink})`) -} - -const escapeMarkdownlink = (link) => link.replace(/(\[|\(|\]|\))/g, "\\$1") -const createSuggestContainerTypeText = (suggestion) => createSuggestionText(suggestion) + 'You have to specify a container type. Possible values: **info**, **tip**, **warning**, **danger**, **details**, **code-group**, **raw**.' - -module.exports = async ({ github, require, exec, core }) => { - const { readFileSync, existsSync } = require('fs') - const { join, extname } = require('path') - const { BASE_DIR, PULL_NUMBER, HEAD_SHA, REPO, REPO_OWNER } = process.env - - const cspellLogFile = join(BASE_DIR, 'CSPELL.log') - const markdownlintLogFile = join(BASE_DIR, 'MARKDOWNLINT.log') - - const comments = [] - let body = '' - let lintErrorsText = '' - let spellingMistakesText = '' - - const result = await github.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { - owner: REPO_OWNER, - repo: REPO, - pull_number: PULL_NUMBER - }) - - const linterErrors = [] - const spellingMistakes = [] - - result.data - .filter(review => review.body.includes('')) - .forEach(review => { - spellingMistakes.push(...(review.body.match(/\*(.*) /g) || [])) - linterErrors.push(...(review.body.match(/\*(.*) /g) || [])) - }) - - const { data } = await github.request('GET /repos/{owner}/{repo}/pulls/{pull_number}/files', { - owner: REPO_OWNER, - repo: REPO, - pull_number: PULL_NUMBER, - headers: { - accept: 'application/vnd.github.diff' - } - }) - - const diffs = {} - data.filter(obj => extname(obj.filename) === '.md') - .forEach(obj => { - diffs[obj.filename.replace('./', '')] = obj.patch?.split('\n') ?? [] - }) - - if (existsSync(markdownlintLogFile)) { - const matches = readFileSync(markdownlintLogFile, 'utf-8') - .split('\n') - .filter(Boolean) - .map(line => line.replace(`${BASE_DIR}/`, '').match(markdownlintRegExp)) - - /* - test.md:15:1 MD011/no-reversed-links Reversed link syntax [(test)[link.de]] -> - - test.md:15:1 MD011/no-reversed-links Reversed link syntax [(test)[link.de]] - test.md - :15:1 - MD011/no-reversed-links - Reversed link syntax - [(test)[link.de]] - - */ - for (let [, path, pointer, rule, description, details, context] of matches) { - let contextText = '' - let comment - - if (!fileIsInDiff(path)) continue - - if (rule === 'MD011/no-reversed-links') { - const detailValue = details.slice(1, -1) - contextText = `[Context: "${detailValue}"]` - - const { line, position } = await findPositionInDiff(detailValue, path) - if (!line || position < 0) continue - - const [, link, text] = detailValue.match(/\((.*?)\)\[(.*?)\]/) - const suggestion = line.replace(detailValue, `[${text}](${link})`).replace('+', '') - const commentBody = createSuggestionText(suggestion) - comment = { path, position, body: commentBody } - } - - if (rule === 'MD042/no-empty-links') { - let link = context.match(/\[Context: "(\[.*?)"\]/)[1] - // if the context is too long, markdownlint-cli will truncate the string and append "..." at the end - if (link.endsWith('...')) { - link = link.substring(0, link.length - 3) - } - contextText = `[Context: "${escapeMarkdownlink(link)}"]` - const { position } = await findPositionInDiff(link, path) - if (position < 0) continue - comment = { path, position, body: getNoEmptyLinkText() } - } - - if (rule === 'MD040/fenced-code-language') { - contextText = '' - const codeBlockLines = findCodeBlock(path, +pointer.slice(1)) - const { start, end } = await findCodeBlockInDiff(codeBlockLines, path) - if (start < 0 || end < 0) continue - codeBlockLines[0] = codeBlockLines[0] + 'txt' - comment = { path, body: createMissingCodeFencesText(codeBlockLines), position: start } - } - - if (rule === 'search-replace') { - // [prefer-https-links: https links should be prefered] -> prefer-https-links - const ruleName = details.split(':')[0].slice(1) - if (ruleName === 'prefer-https-links') { - const [, text, link] = context.match(/\[Context:.*(\[.*\])(\(.*\)).*\]/) - description = 'https links should be preferred' - contextText = `[Context: "${escapeMarkdownlink(text + link)}"]` - const { line, position } = await findPositionInDiff(text + link, path) - if (!line || position < 0) continue - comment = { path, position, body: getInvalidUrlText(text, link.slice(1, -1)) } - } - - if (ruleName === 'custom-containers-requires-type') { - const [, row] = pointer.split(':') - const affectedLine = getLineFromFile(path, +row) - const containerType = suggestContainerType(affectedLine) || 'info' - const { line, position } = await findPositionInDiff(affectedLine, path) - - if (!line || position < 0) continue - const correctedLine = `::: ${containerType} ${affectedLine.split(':::').slice(1).join('').trim()}` - - description = 'container type should be specified' - contextText = `[Context: "${affectedLine}"]` - comment = { path, position, body: createSuggestContainerTypeText(correctedLine) } - } - } - - const text = `* **${path}**${pointer} ${description} ${contextText} ` - if (!linterErrors.find(el => el === text)) { - lintErrorsText += text + '\n' - comments.push(comment) - } - } - } - - if (existsSync(cspellLogFile)) { - let lines = readFileSync(cspellLogFile, 'utf-8').split('\n') - lines = Array.from({ length: lines.length / 2 }, (_el, idx) => lines[idx * 2] + lines[idx * 2 + 1].replace(/\t/g, '')) - - // we will create a review comment for each match - const matches = lines.map(line => line.replace(`${BASE_DIR}/`, '').match(cspellRegExp)) - const wordsWithoutSuggestions = [] - for (const [, path, pointer, word, context, suggestionString] of matches) { - if (!fileIsInDiff(path)) continue - const text = `* **${path}**${pointer} Unknown word "**${word}**" ` - if (spellingMistakes.find(el => el === text)) continue - // from "[s1, s2, s3]" to [ "s1", "s2", "s3" ] - const suggestions = suggestionString - .slice(1, -1) // remove brackets - .replace(/ /g, '') - .split(',') - .filter(Boolean) // remove empty strings - - const { line, position } = await findPositionInDiff(context, path) - if (!line || position < 0) continue - - // Github requires that no path starts with './', but cspell provides the paths exactly in this format - const properlyStructuredPath = path.replace(/^\.\//, '') - if (suggestions.length > 0) { - // replace word with first suggestions and remove first "+" sign - const suggestion = line.replace(word, suggestions[0]).replace('+', '') - const commentBody = createCspellSuggestionText(suggestion, suggestions.slice(1)) - comments.push({ path: properlyStructuredPath, position, body: commentBody }) - } else { - comments.push({ path: properlyStructuredPath, position, body: createUnknownWordComment(word) }) - wordsWithoutSuggestions.push(word) - } - spellingMistakesText += text + '\n' - } - - if (wordsWithoutSuggestions.length > 0 && comments.length > 0) { - spellingMistakesText += `\n${createWordsWithoutSuggestionsText(wordsWithoutSuggestions)}\n` - } - - if (matches.length > 0 && comments.length > 0) { - spellingMistakesText += `${getSpellingCorrectionTip()}\n` - } - } - - if (lintErrorsText) { - body += `Linting Errors\n---\n${lintErrorsText}` - } - - if (spellingMistakesText) { - body += `\nSpelling Mistakes\n---\n${spellingMistakesText}` - } - - if (body) { - body = '\n' + body - - await github.rest.pulls.createReview({ - owner: REPO_OWNER, - repo: REPO, - pull_number: PULL_NUMBER, - commit_id: HEAD_SHA, - body, - event: 'COMMENT', - comments - }) - } - - function fileIsInDiff(file) { - return typeof getDiff(file) !== 'undefined' - } - - function getDiff(file) { - const k = file.replace('./', '') - if (!(k in diffs)) throw new Error(`There is no diff for file ${file}. Diffs found for ${Object.keys(diffs).join('\n')}`) - return diffs[k] - } - - async function findPositionInDiff(context, file) { - const diff = getDiff(file) - - if (!diff) return { position: -1 } - - const idxToStartingCountingFrom = diff.findIndex(line => line.startsWith('@@') && !line.includes(' errors is in file with diff, but errors was not introduced with current PR - if (idxToStartingCountingFrom === -1 || idxOfLineToSearch === -1) { - return { position: -1 } - } - const position = idxOfLineToSearch - idxToStartingCountingFrom - return { line: diff[idxOfLineToSearch], position } - } - - async function findCodeBlockInDiff(lines, file) { - const diff = getDiff(file) - - if (!diff) return { position: -1 } - - let start = -1 - let end = -1 - for (let i = 0; i < diff.length; i++) { - for (let j = 0; j < lines.length; j++) { - if (diff[i + j].replace(/[-+]/, '') !== lines[j]) { - break - } - if (j === lines.length - 1) { - start = i - end = i + j - } - } - } - - if (start === -1 || end === -1) { - return { start: -1, end: -1 } - } - - const idxToStartingCoutingFrom = diff.findIndex(line => line.startsWith('@@')) - return { start: start - idxToStartingCoutingFrom, end: end - idxToStartingCoutingFrom } - } - - // startIdx starts at 1 - function findCodeBlock(file, startIdx) { - const lines = readFileSync(join(BASE_DIR, file), 'utf-8').split(/\n\r?/) - const endIdx = lines.findIndex((el, idx) => idx >= startIdx && /`{3,}/.test(el.trim())) - return lines.slice(startIdx - 1, endIdx + 1) - } - - function suggestContainerType(line) { - return (line.toLowerCase().match(/(info|tip|warning|danger|details|code-group|raw)/) || [])[0] - } - - function getLineFromFile(file, lineNumber) { - return readFileSync(join(BASE_DIR, file), 'utf-8').split(/\n\r?/)[lineNumber - 1] - } -} diff --git a/.github/java-snippet-checker/check-java-snippets.js b/.github/java-snippet-checker/check-java-snippets.js index fe5e4172ee..013461f425 100755 --- a/.github/java-snippet-checker/check-java-snippets.js +++ b/.github/java-snippet-checker/check-java-snippets.js @@ -81,6 +81,7 @@ for (const dir of baseDirs) { const variations = [ { content: snippet.content, error: null }, { content: snippetAsMethod(snippet.content), error: null }, + { content: snippetAsExpression(snippet.content), error: null }, { content: snippetAsCode(snippet.content), error: null }, ]; @@ -206,6 +207,17 @@ ${ indentLines(content.trim(), 4) } `; } +/** + * @param {string} content + */ +function snippetAsExpression(content) { + return `// Snippet Checker +Object o = +${ indentLines(content.trim(), 4) } +; +`; +} + /** * @param {string[]} files */ diff --git a/.github/renovate.json b/.github/renovate.json index 64415f15dd..89d09e9379 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -2,7 +2,8 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:base", - ":disableDependencyDashboard" + ":disableDependencyDashboard", + "security:minimumReleaseAgeNpm" ], "rebaseWhen": "conflicted", "includePaths": [ diff --git a/.github/workflows/PR-SAP.yml b/.github/workflows/PR-SAP.yml deleted file mode 100644 index cbec6b6f51..0000000000 --- a/.github/workflows/PR-SAP.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: PR Build (SAP) - -on: - pull_request: - merge_group: - -concurrency: - group: pr-sap-${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build-sap: - runs-on: ubuntu-latest - if: '! github.event.pull_request.head.repo.fork' - steps: - - name: Checkout SAP repo - run: | - git config --global credential.helper "cache --timeout=3600" - echo -e "url=https://user:${GH_TOKEN}@github.com\n" | git credential approve - echo -e "url=https://user:${GH_TOKEN_TOOLS_DOCS}@github.tools.sap\n" | git credential approve - git clone --depth 1 --no-single-branch https://github.tools.sap/cap/docs docs - cd docs - git checkout $GITHUB_HEAD_REF || git checkout main - git submodule update --init --recursive - cd @external - git checkout $GITHUB_HEAD_REF - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_TOKEN_TOOLS_DOCS: ${{ secrets.GH_TOKEN_TOOLS_DOCS }} - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'npm' - cache-dependency-path: docs/package-lock.json - - run: npm ci - working-directory: docs - - run: npm test - working-directory: docs - - run: npm run lint - working-directory: docs - - run: npm run docs:build - working-directory: docs - env: - NODE_OPTIONS: "--max-old-space-size=6144" - VITE_CAPIRE_CI_HOST: "github.com" - VITE_CAPIRE_EXTRA_ASSETS: true - MAVEN_HOST: https://common.repositories.cloud.sap/artifactory/build.releases - MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} - - name: Find broken anchor links - working-directory: docs - run: | - npm run docs:preview -- --port 5555 & - sleep 2 - .github/etc/blc.js http://localhost:5555/docs/ diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml index 2fc4968810..9cffe251e2 100644 --- a/.github/workflows/PR.yml +++ b/.github/workflows/PR.yml @@ -33,6 +33,6 @@ jobs: npm install npm run check - run: npm ci - - run: npm test - run: npm run docs:build + - run: npm test - run: npm run lint diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml deleted file mode 100644 index 934c649d84..0000000000 --- a/.github/workflows/ai-review.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: AI Review - -on: - pull_request: - branches: [main] - merge_group: - -concurrency: - group: pr-sap-${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - ai-review: - name: AI Review - if: ${{ contains(github.event.pull_request.labels.*.name, 'AI Review') }} - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: SAP/ai-assisted-github-actions/pr-review@v3 - with: - aicore-service-key: ${{ secrets.AICORE_SERVICE_KEY }} - model: anthropic--claude-4-sonnet - model-parameters: '{"temperature": 0.1}' - prompt: | - - As an AI bot reviewing documentation pull requests on GitHub, please focus on the following areas to ensure high-quality and effective documentation: - - Use U.S. English spelling and punctuation. - - Check for spelling errors and provide corrections. - - Identify and correct grammatical errors and incorrect punctuation. - - Provide suggestions for improving the clarity and conciseness of the text to make it more understandable. Use the comments to create real suggestions and include all proposals that target the same line into one suggestions. Do not create multiple suggestions for the same line or paragraph. - - Consider the guidelines that can be found in .github/workflows/assets/editor.md and apply them. - - Ensure that the tone is appropriate for technical documentation, maintaining a professional and informative style. - - Verify that the structure of the document is logical and that headings and subheadings are used effectively. - - Check for consistency in terminology and style throughout the document. - - Use active voice instead of passive voice - - Use present tense and **avoid future tense**! \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 1770a1baa9..0000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Lint - -on: - pull_request: - branches: [main] - paths: - - '**.md' - merge_group: - workflow_dispatch: - -jobs: - suggestions: - runs-on: ubuntu-latest - if: '! github.event.pull_request.head.repo.fork' - permissions: - pull-requests: write - steps: - - name: Checkout SAP repo - run: | - git config --global credential.helper "cache --timeout=3600" - echo -e "url=https://user:${GH_TOKEN}@github.com\n" | git credential approve - echo -e "url=https://user:${GH_TOKEN_TOOLS_DOCS}@github.tools.sap\n" | git credential approve - git clone --depth 1 --no-single-branch https://github.tools.sap/cap/docs docs - cd docs - git checkout $GITHUB_HEAD_REF || git checkout main - git submodule update --init --recursive - cd @external - git checkout $GITHUB_HEAD_REF - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_TOKEN_TOOLS_DOCS: ${{ secrets.GH_TOKEN_TOOLS_DOCS }} - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'npm' - cache-dependency-path: docs/package-lock.json - - run: npm ci - working-directory: docs - - run: git checkout ${{ github.head_ref }} - working-directory: docs/@external - - name: Get changes - id: changes - working-directory: docs/@external - run: | - echo "DIFF_FILES=$(git diff --name-only --diff-filter=ACMRT ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} -- '*.md' | xargs)" >> "$GITHUB_OUTPUT" - - name: Run cspell - if: steps.changes.outputs.DIFF_FILES != '' - id: cspell - continue-on-error: true - working-directory: docs/@external - run: | - npx cspell --no-progress --show-suggestions --show-context ${{ steps.changes.outputs.DIFF_FILES }} >> ${{ github.workspace }}/docs/@external/CSPELL.log - - name: Run markdownlint - if: steps.changes.outputs.DIFF_FILES != '' - id: markdownlint - continue-on-error: true - working-directory: docs/@external - run: | - npx markdownlint-cli --output ${{ github.workspace }}/docs/@external/MARKDOWNLINT.log -r markdownlint-rule-search-replace ${{ steps.changes.outputs.DIFF_FILES }} - - name: Create review - id: create_review - if: steps.cspell.outcome == 'failure' || steps.markdownlint.outcome == 'failure' - uses: actions/github-script@v6 - env: - SHA: ${{ github.event.pull_request.head.sha }} - BASE_DIR: ${{ github.workspace }}/docs/@external - BASE_SHA: ${{ github.event.pull_request.base.sha }} - PULL_NUMBER: ${{ github.event.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - REPO: 'docs' - REPO_OWNER: 'cap-js' - with: - script: | - const script = require('${{ github.workspace }}/docs/@external/.github/etc/create-review.cjs') - await script({github, context, core, require, exec}) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 12915d399e..b8a996c671 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,7 +34,6 @@ jobs: node-version: 22 cache: 'npm' - run: npm ci - - run: npm test - run: npm run lint - run: npm run docs:build env: @@ -42,8 +41,7 @@ jobs: SITE_HOSTNAME: https://cap.js.org VITE_CAPIRE_PREVIEW: true VITE_CAPIRE_EXTRA_ASSETS: true - MAVEN_HOST: https://common.repositories.cloud.sap/artifactory/build.releases - MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} + - run: npm test - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index f068315242..2f302316bd 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -62,9 +62,6 @@ jobs: run: | npm ci .github/java-properties/update-properties.js - env: - MAVEN_HOST: https://common.repositories.cloud.sap/artifactory/build.releases - MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} - name: Check for changes run: | diff --git a/.gitignore b/.gitignore index ac2f31bf8d..dfced3185e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ .DS_Store .idea *.iml +.claude/ diff --git a/.markdownlint.yaml b/.markdownlint.yaml deleted file mode 100644 index 48d690ad45..0000000000 --- a/.markdownlint.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# For all rules see https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md -default: false -fenced-code-language: true -no-reversed-links: true -code-fence-style: - style: backtick -# proper-names: -# names: -# - SQLite -# - VS Code -#no-bare-urls: true -no-empty-links: true -search-replace: - rules: - - name: prefer-https-links - message: https links should be prefered - searchPattern: /\[.*?\]\(http:\/\/(?!localhost).*?\)/g - - name: custom-containers-requires-type - searchPattern: "/(? { md.use(dl) } +// Add twoslash transformer to the markdown config (if requested as it slows down builds) +import { transformerTwoslash } from '@shikijs/vitepress-twoslash' +if (process.env.VITE_CAPIRE_EXTRA_ASSETS) { + config.markdown.codeTransformers = [transformerTwoslash({ + twoslashOptions: { compilerOptions: { paths: { "@sap/cds": [MdTypedModels.cdsTypesPath()] } } } + })], + config.markdown.languages.push('js', 'jsx', 'ts', 'tsx') +} + // Add custom buildEnd hook import { promises as fs } from 'node:fs' import * as cdsMavenSite from './lib/cds-maven-site' config.buildEnd = async ({ outDir, site }) => { const sitemapURL = new URL(config.themeConfig.capire.siteURL.href) - sitemapURL.pathname = path.join(sitemapURL.pathname, 'sitemap.xml') + sitemapURL.pathname = join(sitemapURL.pathname, 'sitemap.xml') console.debug('โœ“ writing robots.txt with sitemap URL', sitemapURL.href) // eslint-disable-line no-console - const robots = (await fs.readFile(path.resolve(__dirname, 'robots.txt'))).toString().replace('{{SITEMAP}}', sitemapURL.href) - await fs.writeFile(path.join(outDir, 'robots.txt'), robots) + const robots = (await fs.readFile(resolve(__dirname, 'robots.txt'))).toString().replace('{{SITEMAP}}', sitemapURL.href) + await fs.writeFile(join(outDir, 'robots.txt'), robots) // disabled by default to avoid online fetches during local build if (process.env.VITE_CAPIRE_EXTRA_ASSETS) { - await cdsMavenSite.copySiteAssets(path.join(outDir, 'java/assets/cds-maven-plugin-site'), site) + await cdsMavenSite.copySiteAssets(join(outDir, 'java/assets/cds-maven-plugin-site'), site) } } diff --git a/.vitepress/languages/index.ts b/.vitepress/languages/index.ts index bfca47efef..c422eb9049 100644 --- a/.vitepress/languages/index.ts +++ b/.vitepress/languages/index.ts @@ -1,12 +1,26 @@ -import cds from './cds.tmLanguage.json' with {type:'json'} -import csv from './csv.tmLanguage.json' with {type:'json'} -import log from './log.tmLanguage.json' with {type:'json'} -import scsv from './scsv.tmLanguage.json' with {type:'json'} +import { bundledLanguages } from 'shiki' +import cds from './cds.tmLanguage.json' with { type: 'json' } +import csv from './csv.tmLanguage.json' with { type: 'json' } +import log from './log.tmLanguage.json' with { type: 'json' } +import scsv from './scsv.tmLanguage.json' with { type: 'json' } import type { LanguageInput } from 'shiki' + export default [ + { ...cds, aliases:['cds','cdl','dcl','cql'] }, { ...csv, aliases:['csv','csvc'] }, { ...scsv, aliases:['csvs'] }, { ...log, aliases:['log','logs'] }, + () => langAlias('php', 'httpc'), + ] as LanguageInput[] + +async function langAlias(targetLang: keyof typeof bundledLanguages, alias: string) { + const grammars = (await bundledLanguages[targetLang]()).default + const targetScope = `source.${targetLang}` + return grammars.map(g => g.scopeName === targetScope + ? { ...g, aliases: [...(g.aliases ?? []), alias] } + : g, + ) +} diff --git a/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js new file mode 100644 index 0000000000..1268e3823b --- /dev/null +++ b/.vitepress/lib/code-groups/restoreCodeGroupPreferences.js @@ -0,0 +1,145 @@ +;(() => { + // Code Group Tab Synchronization - Early Execution Script + // This script loads preferences and applies them before Vue hydration to prevent flicker + // + // Features: + // - Syncs tabs with exact or fuzzy matching ("/" delimiter) + // - "macOS/Linux" matches "macOS/Linux", "macOS", and "Linux" + // - "macOS" matches "macOS" and "macOS/Linux" + // - Stores preferences by independent dimensions (runtime vs OS) + // - runtime: Node.js โ†” Java + // - os: macOS โ†” Windows โ†” Linux (+ combinations) + // - Storage format: { "runtime": "Java", "os": "macOS" } + // - First entry in each dimension array is the default + + // eslint-disable-next-line no-undef + __CODE_GROUP_SHARED__ + + // Clean up old localStorage entries from previous implementation + const cleanupOldEntries = () => { + try { + const keysToRemove = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && (key.startsWith('code-group-preference:') || key.startsWith('code-group-tab:'))) { + keysToRemove.push(key) + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)) + } catch { + // localStorage might not be available + } + } + + cleanupOldEntries() + + const activeTabs = getActiveTabsByDimension() // eslint-disable-line no-undef + window.__CODE_GROUP_ACTIVE_TABS__ = activeTabs + + const applyToCodeGroup = (element) => { + const tabElements = element.querySelectorAll('.tabs label') + const tabs = Array.from(tabElements).map((label) => + (label.textContent || '').trim() + ).filter(Boolean) + + if (tabs.length === 0) return + + const selectedTab = getBestTab(tabs, activeTabs) // eslint-disable-line no-undef + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex === -1) return + + setActiveTab(element, selectedIndex) // eslint-disable-line no-undef + } + + const getScrollOffset = () => 134 + + const scrollToHash = (hash) => { + try { + const target = document.getElementById(decodeURIComponent(hash).slice(1)) + if (target) { + const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) + const targetTop = window.scrollY + + target.getBoundingClientRect().top - + getScrollOffset() + + targetPadding + + window.scrollTo(0, targetTop) + } + } catch { /* ignore invalid hash */ } + } + + const applyToAllCodeGroups = () => { + const codeGroups = document.querySelectorAll('.vp-code-group') + codeGroups.forEach(applyToCodeGroup) + + return codeGroups.length + } + + const initialHash = window.location.hash + let hashScrollPending = false + + if (initialHash) { + history.replaceState(null, '', window.location.pathname + window.location.search) + hashScrollPending = true + } + + const restoreHashScroll = () => { + if (hashScrollPending) { + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } + } + + const initialCodeGroupCount = applyToAllCodeGroups() + + if (initialCodeGroupCount > 0) restoreHashScroll() + + let observer + const stopObserving = () => { + observer?.disconnect() + observer = null + } + + if (document.readyState === 'loading' || hashScrollPending) { + observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + if (node.classList?.contains('vp-code-group')) { + applyToCodeGroup(node) + restoreHashScroll() + } else if (node.querySelector) { + const codeGroups = node.querySelectorAll('.vp-code-group') + codeGroups.forEach(applyToCodeGroup) + + if (codeGroups.length > 0) { + restoreHashScroll() + } + } + } + } + } + }) + + if (document.documentElement) { + observer.observe(document.documentElement, { + childList: true, + subtree: true + }) + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + applyToAllCodeGroups() + restoreHashScroll() + stopObserving() + }) + } else if (!hashScrollPending) { + stopObserving() + } +})() \ No newline at end of file diff --git a/.vitepress/lib/code-groups/shared.js b/.vitepress/lib/code-groups/shared.js new file mode 100644 index 0000000000..c8d734c105 --- /dev/null +++ b/.vitepress/lib/code-groups/shared.js @@ -0,0 +1,156 @@ +/** + * Shared helpers for code-group preference matching and activation. + */ + +export const STORAGE_KEY = 'code-group-active-tabs' + +export const TAB_DIMENSIONS = { + runtime: ['Node.js', 'Java'], + os: ['macOS', 'Windows', 'Linux'], + 'cloud-runtime': ['Cloud Foundry', 'Kyma'] +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +export function isTabMap(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +/** + * @param {string} tab1 + * @param {string} tab2 + */ +export function tabsMatch(tab1, tab2) { + if (tab1 === tab2) return true + + const normalized1 = tab1.trim().toLowerCase() + const normalized2 = tab2.trim().toLowerCase() + + if (normalized1 && normalized2 && (normalized1.includes(normalized2) || normalized2.includes(normalized1))) { + return true + } + + const components1 = tab1.split('/').map(s => s.trim()) + const components2 = tab2.split('/').map(s => s.trim()) + + return components1.some(c1 => components2.includes(c1)) || + components2.some(c2 => components1.includes(c2)) +} + +/** + * @param {string} tabLabel + * @returns {string | null} + */ +export function getTabDimension(tabLabel) { + for (const [dimension, tabs] of Object.entries(TAB_DIMENSIONS)) { + for (const dimTab of tabs) { + if (tabsMatch(tabLabel, dimTab)) { + return dimension + } + } + } + + return null +} + +/** + * @param {Record | undefined} [seedTabs] + * @returns {Record} + */ +export function getActiveTabsByDimension(seedTabs) { + const activeTabs = {} + + if (isTabMap(seedTabs)) { + Object.assign(activeTabs, seedTabs) + } + + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + if (Array.isArray(parsed)) { + return activeTabs + } + if (isTabMap(parsed)) { + Object.assign(activeTabs, parsed) + } + } + } catch { + // localStorage might not be available or JSON parsing failed + } + + return activeTabs +} + +/** + * @param {Record} activeTabs + */ +export function saveActiveTabsByDimension(activeTabs) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(activeTabs)) + } catch { + // localStorage might not be available + } +} + +/** + * @param {string} tabLabel + */ +export function addActiveTab(tabLabel) { + const activeTabs = getActiveTabsByDimension() + const dimension = getTabDimension(tabLabel) + + if (dimension) { + activeTabs[dimension] = tabLabel + saveActiveTabsByDimension(activeTabs) + } + + return activeTabs +} + +/** + * @param {string[]} tabs + * @param {Record} [activeTabs] + */ +export function getBestTab(tabs, activeTabs = getActiveTabsByDimension()) { + for (const tab of tabs) { + const dimension = getTabDimension(tab) + if (dimension && activeTabs[dimension]) { + const activeTab = activeTabs[dimension] + if (tab === activeTab || tabsMatch(tab, activeTab)) { + return tab + } + } + } + + for (const tab of tabs) { + const dimension = getTabDimension(tab) + if (dimension && TAB_DIMENSIONS[dimension]) { + const defaultTab = TAB_DIMENSIONS[dimension][0] + if (tab === defaultTab || tabsMatch(tab, defaultTab)) { + return tab + } + } + } + + return tabs[0] +} + +/** + * @param {HTMLElement} element + * @param {number} activeIndex + */ +export function setActiveTab(element, activeIndex) { + const inputs = element.querySelectorAll('.tabs input') + const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') + + inputs.forEach((input, index) => { + input.checked = index === activeIndex + }) + + blocks.forEach((block, index) => { + block.classList.toggle('active', index === activeIndex) + }) +} \ No newline at end of file diff --git a/.vitepress/lib/code-groups/useCodeGroupSync.ts b/.vitepress/lib/code-groups/useCodeGroupSync.ts new file mode 100644 index 0000000000..8efdbbd3c7 --- /dev/null +++ b/.vitepress/lib/code-groups/useCodeGroupSync.ts @@ -0,0 +1,180 @@ +/** + * Code Group Tab Synchronization Composable + * + * Manages tab preferences for VitePress code groups: + * - Synchronizes tab selection across all code groups with exact or fuzzy matching + * - Fuzzy matching treats "/" as delimiter: "macOS/Linux" matches both "macOS" and "Linux" + * - Stores preferences by independent dimensions (runtime vs OS) + * - Selecting a tab only updates its own dimension in persistent storage + */ + +import { + addActiveTab, + getActiveTabsByDimension, + getBestTab, + setActiveTab, + tabsMatch +} from './shared.js' + +interface CodeGroupInfo { + element: HTMLElement + tabs: string[] +} + +let hasClickListener = false +let codeGroupObserver: MutationObserver | null = null + +function findCodeGroups(): CodeGroupInfo[] { + const codeGroups: CodeGroupInfo[] = [] + const elements = document.querySelectorAll('.vp-code-group') + + elements.forEach((element) => { + const tabElements = element.querySelectorAll('.tabs label') + const tabs = Array.from(tabElements).map((label) => + (label.textContent || '').trim() + ).filter(Boolean) + + if (tabs.length > 0) { + codeGroups.push({ + element: element as HTMLElement, + tabs + }) + } + }) + + return codeGroups +} + +function applyPreference(codeGroup: CodeGroupInfo): void { + const { element, tabs } = codeGroup + const selectedTab = getBestTab( + tabs, + getActiveTabsByDimension((window as any).__CODE_GROUP_ACTIVE_TABS__) + ) + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex !== -1) { + setActiveTab(element, selectedIndex) + } +} + +function syncTabs(selectedTab: string): void { + const codeGroups = findCodeGroups() + + codeGroups.forEach((codeGroup) => { + const matchingTab = codeGroup.tabs.find(tab => tabsMatch(tab, selectedTab)) + + if (matchingTab) { + const { element, tabs } = codeGroup + const tabIndex = tabs.indexOf(matchingTab) + + if (tabIndex !== -1) { + setActiveTab(element, tabIndex) + } + } + }) + + ;(window as any).__CODE_GROUP_ACTIVE_TABS__ = addActiveTab(selectedTab) +} + +function handleDocumentClick(event: Event): void { + const target = event.target as HTMLElement | null + const label = target?.closest('.vp-code-group .tabs label') as HTMLLabelElement | null + if (!label) return + + const codeGroup = target?.closest('.vp-code-group') as HTMLElement | null + if (!codeGroup) return + + const tabLabel = (label.textContent || '').trim() + if (!tabLabel) return + + const clickedRect = label.getBoundingClientRect() + + syncTabs(tabLabel) + + requestAnimationFrame(() => { + const newRect = label.getBoundingClientRect() + const scrollDelta = newRect.top - clickedRect.top + + if (scrollDelta !== 0) { + window.scrollTo({ + top: (window.pageYOffset || document.documentElement.scrollTop) + scrollDelta, + behavior: 'instant' + }) + } + }) +} + +function ensureClickListener(enabled: boolean): void { + if (enabled && !hasClickListener) { + document.addEventListener('click', handleDocumentClick) + hasClickListener = true + return + } + + if (!enabled && hasClickListener) { + document.removeEventListener('click', handleDocumentClick) + hasClickListener = false + } +} + +function startObservingCodeGroups(): void { + if (codeGroupObserver || !document.body) return + + codeGroupObserver = new MutationObserver((mutations) => { + let shouldReapply = false + + for (const mutation of mutations) { + if (mutation.addedNodes.length === 0) continue + + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement && + (node.classList?.contains('vp-code-group') || node.querySelector?.('.vp-code-group'))) { + shouldReapply = true + break + } + } + + if (shouldReapply) break + } + + if (shouldReapply) { + reinitCodeGroupSync() + } + }) + + codeGroupObserver.observe(document.body, { + childList: true, + subtree: true + }) +} + +function initCodeGroupSync(): void { + startObservingCodeGroups() + + const codeGroups = findCodeGroups() + codeGroups.forEach(applyPreference) + ensureClickListener(codeGroups.length > 0) +} + +function reinitCodeGroupSync(): void { + const codeGroups = findCodeGroups() + codeGroups.forEach(applyPreference) + ensureClickListener(codeGroups.length > 0) +} + +export function setupCodeGroupSync(): void { + if (typeof window === 'undefined') return + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initCodeGroupSync(), { once: true }) + } else { + initCodeGroupSync() + } +} + +export function onRouteChange(): void { + if (typeof window !== 'undefined') { + setTimeout(() => { reinitCodeGroupSync() }, 0) + } +} \ No newline at end of file diff --git a/.vitepress/lib/md-typed-models.ts b/.vitepress/lib/md-typed-models.ts index 5df9fcbbe9..83ccd19d02 100644 --- a/.vitepress/lib/md-typed-models.ts +++ b/.vitepress/lib/md-typed-models.ts @@ -1,5 +1,6 @@ import { MarkdownRenderer } from 'vitepress' import { execSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { dirname, join, relative, resolve } from 'node:path' import { existsSync } from 'node:fs' @@ -22,6 +23,7 @@ export function install(md: MarkdownRenderer) { const typedModels = env.frontmatter.typedModels as Record|undefined if (typedModels) { const mdDir = dirname(env.realPath ?? env.path) // realPath is only set if Vitepress path rewrites are in place + const cdsPath = cdsTypesPath() for (const modelKey in typedModels) { const modelPath = typedModels[modelKey] @@ -32,7 +34,9 @@ export function install(md: MarkdownRenderer) { const resPath = resolvedImportPath(srcDir, modelOut) // console.log(`๐Ÿ“š ${modelPath} -> ${resPath}`) - tokens[idx].content = tokens[idx].content.replaceAll(`%typedModels:${modelKey}:resolved%`, resPath) + tokens[idx].content = tokens[idx].content + .replaceAll(`%typedModels:${modelKey}:resolved%`, resPath) + .replaceAll(`%sap_cds:resolved%`, cdsPath) } } @@ -40,6 +44,11 @@ export function install(md: MarkdownRenderer) { } } +export function cdsTypesPath() { + const path = fileURLToPath(import.meta.resolve!('@cap-js/cds-types/package.json')) + return resolve(path, '..').replace(/\\/g, '/') +} + function resolvedImportPath(srcDir: string, modelOut: string) { // make resolved path relative - tsc seems to have problems with absolute Windows paths (C:\...) let resolvedPath = relative(process.cwd(), srcDir) diff --git a/.vitepress/rewrites.js b/.vitepress/rewrites.js index 3caf87b0d2..4ec676f0db 100644 --- a/.vitepress/rewrites.js +++ b/.vitepress/rewrites.js @@ -18,7 +18,6 @@ export class Rewrites { for (let line of md.split('\n')) { let [,from,to] = /^[^[]*\[(.*)\]\((.*)\)/.exec(line) || [] if (!from || !to) continue - if (from.at(-1) === '/') from = from.slice(0,-1) entries[from] = to } } catch {/* ignored */} diff --git a/.vitepress/theme/code.scss b/.vitepress/theme/code.scss index 20fce2e416..53408d455e 100644 --- a/.vitepress/theme/code.scss +++ b/.vitepress/theme/code.scss @@ -1,6 +1,9 @@ main .vp-doc { + div.vp-code-group .tabs label { + line-height: 40px; + } div.vp-code-group + div[class*='language-'] { margin-top: -8px !important; // override code group margin } @@ -11,8 +14,9 @@ main .vp-doc { pre { padding: 12px 0 !important; - code { + code { padding: 0 22px !important; + white-space: pre !important; } } div[class*='language-'] > button.copy { diff --git a/.vitepress/theme/components/ConfigInspect.vue b/.vitepress/theme/components/ConfigInspect.vue index c54d21571f..617720ef7b 100644 --- a/.vitepress/theme/components/ConfigInspect.vue +++ b/.vitepress/theme/components/ConfigInspect.vue @@ -4,7 +4,7 @@ > - {{ label }} +