diff --git a/.github/cds-snippet-checker/check-cds-snippets.js b/.github/cds-snippet-checker/check-cds-snippets.js index 54a6b1f82..eec82432a 100755 --- a/.github/cds-snippet-checker/check-cds-snippets.js +++ b/.github/cds-snippet-checker/check-cds-snippets.js @@ -296,7 +296,7 @@ function* extractSnippets(section) { // Code snippets may have a configuration in form of an HTML comment. // When a cds-mode comment exists, we ignore the language. if (snippets[1]) { - const modeRegEx = /cds-mode: ([^,]+)$/; + const modeRegEx = /cds-mode: ([^,;]+)/; const result = modeRegEx.exec(snippets[1]); if (result && result[1]) mode = validateMode(result[1].trim()); diff --git a/.github/java-snippet-checker/.gitignore b/.github/java-snippet-checker/.gitignore new file mode 100644 index 000000000..d8b83df9c --- /dev/null +++ b/.github/java-snippet-checker/.gitignore @@ -0,0 +1 @@ +package-lock.json diff --git a/.github/java-snippet-checker/check-java-snippets.js b/.github/java-snippet-checker/check-java-snippets.js new file mode 100755 index 000000000..fe5e4172e --- /dev/null +++ b/.github/java-snippet-checker/check-java-snippets.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node + +// Java Snippet Checker +// =================== +// +// Similar to the CDS snippet checker, check Java snippets for syntax errors. +// We use the "java-parser" NPM package for that. +// All code-blocks are extracted. If they were set to `java`, we extract the +// snippet and parse it. +// +// In case of errors, we try to wrap the snippet and parse it again. +// +// - First try to parser it again with a class surrounding the snippet. +// - If that fails, try the same with a method. +// - If that fails, mark snippet as invalid. +// +// Also, we run a few pre-processing steps and use heuristics: +// We remove `...` markers and `imports`, etc. +// +// You can disable checking of a snippet by prepending a `` +// comment right before the snippet. +// +// TODO: +// - [ ] combine code with the CDS snippet checker. + +'use strict'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseJava } from 'java-parser'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const projectDir = path.resolve(__dirname, '../..'); +const verbose = process.argv[2] === '--verbose'; + +// Get base directories +const excludedDirs = [ + '.git', + '.github', + 'node_modules', + '.reuse', + '.vitepress', + '.idea', + '.vscode', + '.devcontainer', +]; +const baseDirs = fs.readdirSync(projectDir) + .filter(file => fs.statSync(path.join(projectDir, file)).isDirectory() && !excludedDirs.includes(file)) + .map(file => path.join(projectDir, file)); + +const JAVA_MODE_SYNTAX = 'syntax'; +const JAVA_MODE_IGNORE = 'ignore'; +const javaModes = [ + JAVA_MODE_SYNTAX, + JAVA_MODE_IGNORE, +]; + +let counter = 0; +let hasAnySnippetErrors = false; + +// Logging should always go to stderr. Have some convenience functions to minimize verbosity. +const log = (...args) => { console.error(...args); }; +const error = (...args) => { console.error(...args); }; +const debug = (...args) => { verbose && console.error(...args); }; + +for (const dir of baseDirs) { + const files = getFilesInDirectory(dir, /[.]md/); + log(`Checking ${files.length} markdown documents in ${path.relative(projectDir, dir)}`); + + for (const snippet of extractSnippetsFromFiles(files)) { + ++counter; + + if (snippet.mode === JAVA_MODE_IGNORE) + continue; + + snippet.original = snippet.content; + snippet.content = prepareSnippet(snippet.content); + + const variations = [ + { content: snippet.content, error: null }, + { content: snippetAsMethod(snippet.content), error: null }, + { content: snippetAsCode(snippet.content), error: null }, + ]; + + // We assume that the snippet has an error. + // If any of the variations _passes_, then the snippet is ok. + let snippetHasError = true; + for (const variation of variations) { + variation.error = compileSnippet(variation.content); + if (!variation.error) { + snippetHasError = false; + break; // success + } + } + + if (snippetHasError) { + hasAnySnippetErrors = true; + printErrorForSnippet(snippet, variations); + + } else if (verbose) { + log(`Snippet ${counter}`); + log(snippet.content); + } + } + + log(''); +} + +log(`Checked ${counter} snippets.`); + +if (hasAnySnippetErrors) { + error('\nError! Found syntax errors!'); + process.exit(1); + +} else { + log('Success! Found no syntax errors.'); + process.exit(0); +} + +// ---------------------------------------------------------------------------- + +function printErrorForSnippet(snippet, variations) { + log('--------------------------------------------------------------------') + log(`Errors in file ./${path.relative(projectDir, snippet.file)}`) + log('In following snippet\n') + log(' ```java') + log(indentLines(snippet.original, 2)) + log(' ```') + log('') + + for (const variation of variations) { + log(`which was modified and compiled again as: + \`\`\`java +${indentLines(variation.content, 2)} + \`\`\` + +which then ended up with errors: + +${indentLines(variation.error.message, 2)} + `); + } + log('') +} + +/** + * @param {string} content + */ +function compileSnippet(content) { + try { + parseJava(content); + return null; + + } catch (e) { + // the Java parser uses this string in its error messages + if (!e.message.includes('sad panda')) + throw e; + + if (e.message.length > 200) { + // cut off message text; the original length is too large + e.message = e.message.slice(0, 200); + } + return e; + } +} + +function prepareSnippet(content) { + // Delete "import" statements, as they are mixed in with other code. + content = content.replace(/^import .*$/mug, ''); + // `= ...;` is replaced by `= null` + content = content.replace(/= *[.][.][.];/mug, '= null;'); + // `= ...` is replaced by `= null;` (additional semicolon) + content = content.replace(/= [.][.][.]/mug, '= null;'); + // `, ...` is removed + content = content.replace(/, ?[.][.][.]/mug, ''); + content = content.replace(/[.][.][.] ?,/mug, ''); + // And other remaining `...` are removed + content = content.replace(/[.][.][.]|…/g, ''); + // Sometimes `---` is used as a delimiter + content = content.replace(/^---+.*$/gm, ''); + return content; +} + +/** + * @param {string} content + */ +function snippetAsMethod(content) { + return `// Snippet Checker +class MyClass { +${ indentLines(content.trim(), 2) } +} +`; +} + +/** + * @param {string} content + */ +function snippetAsCode(content) { + return `// Snippet Checker +class SnippetCheckerClass { + void snippetCheckerMethod() { +${ indentLines(content.trim(), 4) } + } +} +`; +} + +/** + * @param {string[]} files + */ +function* extractSnippetsFromFiles(files) { + for (const filename of files) { + for (const section of extractSections(filename)) { + for (const snippet of extractSnippets(section.content)) { + yield { + file: filename, + ...snippet, + }; + } + } + } +} + +/** + * @param {string} file + */ +function* extractSections(file) { + const content = fs.readFileSync(file, 'utf-8'); + const sections = content.split(/^#/gm); + + for (const content of sections) { + // Skip empty parts + if (content.trim() === "") + continue; + + const heading = content.slice(0, content.indexOf('\n')); + yield { + heading, + content, + }; + } +} + +/** + * @param {string} section + */ +function* extractSnippets(section) { + // Note: [^] matches any character, including newlines + const re = /^(?:\s*\n)?```([a-zA-Z]+)\s*\n([^]*?)\n```\s*$/gm; + + let snippets; + while ((snippets = re.exec(section)) !== null) { + const language = snippets[2].toLowerCase(); + const content = snippets[3]; + let mode; + + if ('java' !== language) + continue; + + // Code snippets may have a configuration in form of an HTML comment. + // When a cds-mode comment exists, we ignore the language. + if (snippets[1]) { + const modeRegEx = /mode: ([^,;]+)/; + const result = modeRegEx.exec(snippets[1]); + if (result && result[1]) + mode = result[1].trim(); + } + + yield { mode, content }; + } +} + +/** + * @param {string} dir + * @param {RegExp} fileRegEx + * @returns {string[]} + */ +function getFilesInDirectory(dir, fileRegEx) { + let results = []; + const files = fs.readdirSync(dir); + + for (let file of files) { + file = path.resolve(dir, file); + const stat = fs.statSync(file); + if (stat && stat.isDirectory()) { + results = results.concat(getFilesInDirectory(file)); + } else { + if (!fileRegEx || file.match(fileRegEx)) + results.push(file); + } + } + return results; +} + +/** + * Indent the given string by `indent` whitespace characters. + * + * @param {string} str + * @param {number} indent + * @returns {string} + */ +function indentLines(str, indent) { + const indentStr = ' '.repeat(indent); + const lines = str.split(/\r\n?|\n/); + return lines.map(s => indentStr + s).join('\n'); +} + diff --git a/.github/java-snippet-checker/package.json b/.github/java-snippet-checker/package.json new file mode 100644 index 000000000..2ae6c2ff1 --- /dev/null +++ b/.github/java-snippet-checker/package.json @@ -0,0 +1,17 @@ +{ + "name": "java-snippet-checker", + "version": "0.0.1", + "description": "Markdown checker for Java snippets", + "type": "module", + "main": "check-java-snippets.js", + "author": "SAP SE (https://www.sap.com)", + "license": "SEE LICENSE IN LICENSE", + "repository": "cap-js/docs", + "homepage": "https://cap.cloud.sap/", + "scripts": { + "check": "node check-java-snippets.js" + }, + "dependencies": { + "java-parser": "^2.3.0" + } +} diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml index 4bf51d7e4..fcbfc7d15 100644 --- a/.github/workflows/PR.yml +++ b/.github/workflows/PR.yml @@ -18,10 +18,16 @@ jobs: with: node-version: 18.x cache: 'npm' - - run: | + - name: Run CDS snippet checker + run: | cd .github/cds-snippet-checker npm install npm run check + - name: Run Java snippet checker + run: | + cd .github/java-snippet-checker + npm install + npm run check - run: npm ci - run: npm test - run: npm run docs:build diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 456e6d145..c87995f95 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -23,8 +23,8 @@ if (!siteURL.pathname.endsWith('/')) siteURL.pathname += '/' const redirectLinks: Record = {} const latestVersions = { - java_services: '3.1.0', - java_cds4j: '3.1.0' + java_services: '3.2.0', + java_cds4j: '3.2.0' } const localSearchOptions = { @@ -93,8 +93,8 @@ const config:UserConfig = { // IMPORTANT: Don't use getters here, as they are called again and again! sidebar: menu, nav: [ - Object.assign(nav.find(i => i.text === 'Getting Started')!, {text:'Get Started'}), - Object.assign(nav.find(i => i.text === 'Cookbook')!, {text:'Guides'}), + { ...nav.find(i => i.text === 'Getting Started') ?? {}, text: 'Get Started' }, + { ...nav.find(i => i.text === 'Cookbook') ?? {}, text: 'Guides' }, nav.find(i => i.text === 'CDS'), nav.find(i => i.text === 'Node'), nav.find(i => i.text === 'Java'), diff --git a/.vitepress/theme/Layout.vue b/.vitepress/theme/Layout.vue index 16490bbe3..c0317d173 100644 --- a/.vitepress/theme/Layout.vue +++ b/.vitepress/theme/Layout.vue @@ -6,6 +6,7 @@ import ImplVariants from './components/implvariants/ImplVariants.vue' import NavScreenMenuItem from './components/implvariants/NavScreenMenuItem.vue' import NotFound from './components/NotFound.vue' import Ribbon from './components/Ribbon.vue' +import ScrollToTop from './components/ScrollToTop.vue' const isPreview = !!import.meta.env.VITE_CAPIRE_PREVIEW @@ -18,6 +19,7 @@ const { frontmatter } = useData()