diff --git a/Source/docs/Documentation/code-snippets.md b/Source/docs/Documentation/code-snippets.md index 9a1d1f0..ab831f9 100644 --- a/Source/docs/Documentation/code-snippets.md +++ b/Source/docs/Documentation/code-snippets.md @@ -101,6 +101,23 @@ The snippet will include: - The code with proper syntax highlighting - A link to the source file in GitHub with line numbers +## Multi-language Snippets + +To render language tabs from language-specific snippet files, use: + +```markdown +:::multilang title="Appending Events"::: +``` + +The title is converted to `appending-events.md`, and snippets are +resolved from a `snippets/` folder in the same hierarchy as the +markdown file. Language variants are discovered in predefined folders: + +- Current folder (C# tab) +- `.TypeScript` sibling folder (TypeScript tab) + +If a language file is missing, that language tab is omitted. + ## Naming Conventions Snippet names should: diff --git a/Source/package.json b/Source/package.json index c9bfc0d..98b0c8b 100644 --- a/Source/package.json +++ b/Source/package.json @@ -13,12 +13,13 @@ "build:storybooks": "NODE_OPTIONS=\"--loader ts-node/esm\" node build-storybooks.ts", "preprocess:storybooks": "NODE_OPTIONS=\"--loader ts-node/esm\" node preprocess-storybooks.ts", "postprocess:storybooks": "NODE_OPTIONS=\"--loader ts-node/esm\" node postprocess-storybooks.ts", - "build": "yarn build:storybooks && yarn build:ts && yarn extract-snippets && yarn insert-snippets && yarn preprocess:storybooks && dotnet build -t:BuildApiDependencies -c Release -p:TargetFramework=net10.0 && dotnet docfx && yarn postprocess:storybooks", - "build:articles": "yarn preprocess:storybooks && dotnet docfx build && yarn postprocess:storybooks", + "build": "yarn build:storybooks && yarn build:ts && yarn extract-snippets && yarn insert-snippets && yarn preprocess:multilang && yarn preprocess:storybooks && dotnet build -t:BuildApiDependencies -c Release -p:TargetFramework=net10.0 && dotnet docfx && yarn postprocess:storybooks", + "build:articles": "yarn preprocess:multilang && yarn preprocess:storybooks && dotnet docfx build && yarn postprocess:storybooks", "serve:landing": "yarn dlx browser-sync start --server . --startPath index.html --files index.html --port 8089 --no-ui --no-notify", "serve": "yarn http-server ./_site", "fixts": "NODE_OPTIONS=\"--loader ts-node/esm\" node fix-tsdocs.ts", "extract-snippets": "NODE_OPTIONS=\"--loader ts-node/esm\" node extract-code-snippets.ts", + "preprocess:multilang": "NODE_OPTIONS=\"--loader ts-node/esm\" node preprocess-multilang.ts", "insert-snippets": "NODE_OPTIONS=\"--loader ts-node/esm\" node insert-sample-snippets.ts", "postinstall": "yarn restore && yarn build" }, @@ -65,7 +66,7 @@ "@types/node": "22.10.2", "concurrently": "7.3.0", "cross-env": "7.0.3", - "glob": "11.0.0", + "glob": "11.1.0", "http-server": "14.1.1", "js-yaml": "4.1.0", "nodemon": "3.1.9", diff --git a/Source/preprocess-multilang.ts b/Source/preprocess-multilang.ts new file mode 100644 index 0000000..1f4e665 --- /dev/null +++ b/Source/preprocess-multilang.ts @@ -0,0 +1,120 @@ +import { glob } from 'glob'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface Language { + displayName: string; + tabId: string; + resolveRootFolder: (folderName: string) => string; +} + +const languages: Language[] = [ + { + displayName: 'C#', + tabId: 'csharp', + resolveRootFolder: folderName => folderName + }, + { + displayName: 'TypeScript', + tabId: 'typescript', + resolveRootFolder: folderName => `${folderName}.TypeScript` + } +]; + +const markdownFiles = await glob('**/*.md', { + ignore: [ + '**/node_modules/**', + '**/.yarn/**', + '**/_site/**', + '**/obj/**', + '**/bin/**' + ], + follow: true +}); + +const multilangRegex = /:::multilang\s+title="([^"]+)"\s*:::/g; + +const rootPath = process.cwd(); + +function getSnippetPathForLanguage(markdownFile: string, title: string, language: Language): string { + const markdownAbsolutePath = path.resolve(markdownFile); + const markdownDirectory = path.dirname(markdownAbsolutePath); + const fileName = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + '.md'; + const currentSnippetPath = path.join(markdownDirectory, 'snippets', fileName); + const relativePath = path.relative(rootPath, currentSnippetPath); + const pathParts = relativePath.split(path.sep); + + if (pathParts.length === 0) { + return currentSnippetPath; + } + + const languageRoot = language.resolveRootFolder(pathParts[0]); + const languageSnippetPath = path.join(rootPath, languageRoot, ...pathParts.slice(1)); + return languageSnippetPath; +} + +function buildMultilangTabs(markdownFile: string, title: string): string { + const tabs = languages + .map(language => { + const snippetPath = getSnippetPathForLanguage(markdownFile, title, language); + if (!fs.existsSync(snippetPath)) { + return null; + } + + const content = fs.readFileSync(snippetPath, 'utf-8').trim(); + if (content === '') { + return null; + } + + return `# [${language.displayName}](#tab/${language.tabId})\n\n${content}`; + }) + .filter((tab): tab is string => tab !== null); + + return tabs.join('\n\n---\n\n'); +} + +console.log('\n\nPreprocess multilang directives\n'); + +let count = 0; + +await Promise.all(markdownFiles.map(async file => { + const content = await fs.promises.readFile(file, 'utf-8'); + let replaced = false; + + const updatedContent = content.replace(multilangRegex, (match, title, offset) => { + const beforeMatch = content.substring(0, offset); + + const codeBlocksBefore = (beforeMatch.match(/```/g) || []).length; + if (codeBlocksBefore % 2 !== 0) { + return match; + } + + const closedCodeBlocksRegex = /```[\s\S]*?```/g; + const beforeMatchWithoutCodeBlocks = beforeMatch.replace(closedCodeBlocksRegex, ''); + const backticksBefore = (beforeMatchWithoutCodeBlocks.match(/`/g) || []).length; + if (backticksBefore % 2 !== 0) { + return match; + } + + const tabs = buildMultilangTabs(file, title); + if (tabs === '') { + console.warn(`No multilang snippets found for '${title}' in ${file}`); + return match; + } + + replaced = true; + count++; + process.stdout.write('.'); + return tabs; + }); + + if (replaced) { + await fs.promises.writeFile(file, updatedContent); + } +})); + +console.log(`\n\n${count} multilang directives inserted\n`); diff --git a/Source/styles/docfx.js b/Source/styles/docfx.js index 04b4bae..70c10a9 100644 --- a/Source/styles/docfx.js +++ b/Source/styles/docfx.js @@ -794,6 +794,7 @@ $(function () { name: 'data-bi-name', type: 'data-bi-type' }; + var tabsStorageKey = 'docfx-selected-tabs'; var Tab = (function () { function Tab(li, a, section) { @@ -855,8 +856,10 @@ $(function () { function initTabs(container) { var queryStringTabs = readTabsQueryStringParam(); + var savedTabs = readTabsFromLocalStorage(); + var initialTabs = queryStringTabs.length > 0 ? queryStringTabs : savedTabs; var elements = container.querySelectorAll('.tabGroup'); - var state = { groups: [], selectedTabs: [] }; + var state = { groups: [], selectedTabs: initialTabs.slice() }; for (var i = 0; i < elements.length; i++) { var group = initTabGroup(elements.item(i)); if (!group.independent) { @@ -868,8 +871,9 @@ $(function () { if (state.groups.length === 0) { return state; } - selectTabs(queryStringTabs, container); + selectTabs(initialTabs, container); updateTabsQueryStringParam(state); + updateTabsLocalStorage(state); notifyContentUpdated(); return state; } @@ -970,6 +974,7 @@ $(function () { updateVisibilityAndSelection(group_1, state); } updateTabsQueryStringParam(state); + updateTabsLocalStorage(state); } notifyContentUpdated(); var top = info.anchor.getBoundingClientRect().top; @@ -1008,6 +1013,34 @@ $(function () { history.replaceState({}, document.title, url); } + function readTabsFromLocalStorage() { + if (!window.localStorage) { + return []; + } + var tabs; + try { + tabs = window.localStorage.getItem(tabsStorageKey); + } + catch (_a) { + return []; + } + if (tabs === null || tabs === '') { + return []; + } + return tabs.split(','); + } + + function updateTabsLocalStorage(state) { + if (!window.localStorage) { + return; + } + try { + window.localStorage.setItem(tabsStorageKey, state.selectedTabs.join(',')); + } + catch (_a) { + } + } + function toQueryString(args) { var parts = []; for (var name_1 in args) {