From c2045aa13908560c51ab3ee6e1e71deacb453625 Mon Sep 17 00:00:00 2001 From: Alex Stephen <1325798+rambleraptor@users.noreply.github.com> Date: Sun, 22 Sep 2024 12:59:51 -0400 Subject: [PATCH] Refactor / Markdown rendering issues (#17) * wip * refactor and add collapse * add scripts * rest of markdown working properly --- astro.config.mjs | 7 ++ package-lock.json | 81 +++++++++++++++ package.json | 2 + scripts/generate.ts | 219 +--------------------------------------- scripts/src/config.ts | 30 +----- scripts/src/markdown.ts | 167 ++++++++++++++++++++++++++++++ scripts/src/sidebar.ts | 36 +++++++ scripts/src/types.ts | 71 +++++++++++++ 8 files changed, 370 insertions(+), 243 deletions(-) create mode 100644 scripts/src/markdown.ts create mode 100644 scripts/src/sidebar.ts create mode 100644 scripts/src/types.ts diff --git a/astro.config.mjs b/astro.config.mjs index 4ac96f9..3991223 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,10 @@ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; import * as fs from 'fs'; import tailwind from "@astrojs/tailwind"; +import { Graphviz } from "@hpcc-js/wasm"; +import rehypeGraphviz from "rehype-graphviz"; + + let sidebar = JSON.parse(fs.readFileSync("generated/sidebar.json")); let linter_sidebar = JSON.parse(fs.readFileSync("generated/linter_sidebar.json")); let redirects = JSON.parse(fs.readFileSync("generated/redirects.json")); @@ -12,6 +16,9 @@ let config = JSON.parse(fs.readFileSync("generated/config.json")); export default defineConfig({ site: 'https://beta.aep.dev', redirects: redirects, + markdown: { + rehypePlugins: [[rehypeGraphviz, { graphviz: await Graphviz.load() }]], + }, integrations: [starlight({ title: 'AEP', customCss: [ diff --git a/package-lock.json b/package-lock.json index e9e91f4..6f33bbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "@astrojs/starlight": "^0.26.1", "@astrojs/starlight-tailwind": "^2.0.3", "@astrojs/tailwind": "^5.1.0", + "@hpcc-js/wasm": "^2.22.1", "astro": "^4.10.2", + "rehype-graphviz": "^0.3.0", "sharp": "^0.32.5", "tailwindcss": "^3.4.10" }, @@ -1030,6 +1032,17 @@ "@expressive-code/core": "^0.35.6" } }, + "node_modules/@hpcc-js/wasm": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/@hpcc-js/wasm/-/wasm-2.22.1.tgz", + "integrity": "sha512-sqK8B5eMOC7T3eKBjYnstQw8UtSeKlcAu7iVraaSTmN8ZaaMSAYYFVYB1eOycm7qItF+WUlmjGCoZ8lCv6SEOg==", + "dependencies": { + "yargs": "17.7.2" + }, + "bin": { + "dot-wasm": "node ./node_modules/@hpcc-js/wasm-graphviz-cli/bin/index.js" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -3106,6 +3119,11 @@ "node": ">=4.0.0" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3749,6 +3767,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.0.tgz", + "integrity": "sha512-d6235voAp/XR3Hh5uy7aGLbM3S4KamdW0WEgOaU1YoewnuYw4HXb5eRtv9g65m/RFGEfUY1Mw4UqCc5Y8L4Stg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^8.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-dom/node_modules/hastscript": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-8.0.0.tgz", + "integrity": "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.1.tgz", @@ -3766,6 +3814,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.1.tgz", @@ -6588,6 +6651,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-graphviz": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/rehype-graphviz/-/rehype-graphviz-0.3.0.tgz", + "integrity": "sha512-0WwwAtZDzRYSOxYQciIPTdj6km8fTUYsYPZkx2hM99Ye2NILD/P7knYkLbdmUMEyQ9WYwpw/5cI5BEY4vPJ5KA==", + "dependencies": { + "defu": "^6.1.4", + "hast-util-from-html-isomorphic": "^2.0.0", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@hpcc-js/wasm": "^2.14.1", + "rehype": "^13.0.1", + "unified": "^11.0.4" + } + }, "node_modules/rehype-minify-whitespace": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.0.tgz", diff --git a/package.json b/package.json index 4f08653..67bcd06 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "@astrojs/starlight": "^0.26.1", "@astrojs/starlight-tailwind": "^2.0.3", "@astrojs/tailwind": "^5.1.0", + "@hpcc-js/wasm": "^2.22.1", "astro": "^4.10.2", + "rehype-graphviz": "^0.3.0", "sharp": "^0.32.5", "tailwindcss": "^3.4.10" }, diff --git a/scripts/generate.ts b/scripts/generate.ts index 7d92c0d..7d01005 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -3,65 +3,13 @@ import * as path from 'path'; import { load, dump } from "js-yaml"; import loadConfigFiles from './src/config'; - -interface AEP { - title: string; - id: string; - frontmatter: object; - contents: string; - category: string; - order: number; - slug: string; -} - -interface LinterRule { - title: string; - aep: string; - contents: string; - filename: string; - slug: string; -} - -interface ConsolidatedLinterRule { - aep: string; - contents: string; -} - -interface Markdown { - contents: string; - components: Set; -} - -interface GroupFile { - categories: Group[] -} - -interface Group { - code: string; - title: string; -} - +import { buildSidebar, buildLinterSidebar } from './src/sidebar'; +import type { AEP, ConsolidatedLinterRule, GroupFile, LinterRule, Markdown } from './src/types'; +import { buildMarkdown } from './src/markdown'; const AEP_LOC = process.env.AEP_LOCATION!; const AEP_LINTER_LOC = process.env.AEP_LINTER_LOC!; -const ASIDES = { - 'Important': { 'title': 'Important', 'type': 'caution' }, - 'Note': { 'title': 'Note', 'type': 'note' }, - 'TL;DR': { 'title:': 'TL;DR', 'type': 'tip' }, - 'Warning': { 'title': 'Warning', 'type': 'danger' }, - 'Summary': { 'type': 'tip', 'title': 'Summary' } -}; - -const RULE_COLORS = { - 'may': 'font-extrabold text-green-700', - 'may not': 'font-extrabold text-green-700', - 'should': 'font-extrabold text-yellow-700', - 'should not': 'font-extrabold text-yellow-700', - 'must': 'font-extrabold text-red-700', - 'must not': 'font-extrabold text-red-700' -} - async function getFolders(dirPath: string): Promise { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); @@ -116,74 +64,12 @@ function readAEP(dirPath: string): string[] { return [md_contents, yaml_text]; } -function readSample(dirPath: string, sample: string) { - const sample_path = path.join(dirPath, sample); - return fs.readFileSync(sample_path, "utf-8"); -} - function readGroupFile(dirPath: string): GroupFile { const group_path = path.join(dirPath, "aep/general/scope.yaml") const yaml_contents = fs.readFileSync(group_path, "utf-8"); return load(yaml_contents) as GroupFile; } -function buildMarkdown(contents: string, folder: string): Markdown { - let result = { - 'contents': contents, - 'components': new Set() - } - substituteSamples(result, folder); - substituteTabs(result); - substituteHTMLComments(result); - substituteEscapeCharacters(result); - substituteCallouts(result); - substituteRuleIdentifiers(result); - removeTitle(result); - substituteLinks(result); - return result; -} - -function substituteLinks(contents: Markdown) { - // Old site-generator expressed relative links as '[link]: ./0123'. - // These should be expressed as '[link]: /123' - - contents.contents = contents.contents.replaceAll(']: ./', ']: /') - contents.contents = contents.contents.replaceAll(']: /0', ']: /') -} - -function removeTitle(contents: Markdown) { - // Title should be removed because Starlight will add it for us. - contents.contents = contents.contents.replace(/# (.*)\n/, ''); -} - -function substituteRuleIdentifiers(contents: Markdown) { - var rule_regex = /\*\*(should(?: not)?|may(?: not)?|must(?: not)?)\*\*/g - var matches = contents.contents.matchAll(rule_regex); - for(var match of matches) { - var color = RULE_COLORS[match[1]]; - contents.contents = contents.contents.replace(match[0], `${match[1]}`); - } -} - -function substituteCallouts(contents: Markdown) { - var paragraph_regex = /(^|\n)\*\*(Note|Warning|Important|Summary|TL;DR):\*\*([\s\S]+?)(?=\n{2,}|$)/g; - var matches = contents.contents.matchAll(paragraph_regex); - for (var match of matches) { - const aside_info = ASIDES[match[2]]; - const formatted_results = ` -` - contents.contents = contents.contents.replace(match[0], formatted_results); - contents.components.add('Aside'); - } -} - -function substituteEscapeCharacters(contents: Markdown) { - contents.contents = contents.contents.replaceAll('<=', '\\<=') - .replaceAll('>=', '\\>='); -} - function getTitle(contents: string): string { var title_regex = /# (.*)\n/ const matches = contents.match(title_regex); @@ -218,70 +104,6 @@ ${contents.contents}` } } -function substituteHTMLComments(contents: Markdown) { - contents.contents = contents.contents.replaceAll("", " */}") -} - -function tabContents(contents: string): string { - return contents.split('\n').map((x) => ' ' + x).join('\n'); -} - -function substituteTabs(contents: Markdown) { - var tab_regex = /\{% tab proto -?%\}([\s\S]*?)\{% tab oas -?%\}([\s\S]*?)\{% endtabs -?%\}/g - let tabs = [] - - let matches = contents.contents.matchAll(tab_regex); - for (var match of matches) { - tabs.push({ - 'match': match[0], - 'proto': tabContents(match[1]), - 'oas': tabContents(match[2]), - }); - } - for (var tab of tabs) { - var new_tab = ` - - -${tab['proto']} - - -${tab['oas']} - - - ` - contents.contents = contents.contents.replace(tab.match, new_tab); - } -} - -function substituteSamples(contents: Markdown, folder: string) { - var sample_regex = /\{% sample '(.*)', '(.*)', '(.*)' %}/g - var sample2_regex = /\{% sample '(.*)', '(.*)' %}/g - - - let samples = [] - // TODO: Do actual sample parsing. - const matches = contents.contents.matchAll(sample_regex); - for (var match of matches) { - if (match[1].endsWith('proto') || match[1].endsWith('yaml')) { - samples.push({ 'match': match[0], 'filename': match[1], 'token1': match[2], 'token2': match[3] }) - } - } - - const matches2 = contents.contents.matchAll(sample2_regex); - for (var match of matches2) { - if (match[1].endsWith('proto') || match[1].endsWith('yaml')) { - samples.push({ 'match': match[0], 'filename': match[1], 'token1': match[2], 'token2': '' }) - } - } - - for (var sample of samples) { - let type = sample.filename.endsWith('proto') ? 'protobuf' : 'yml'; - let formatted_sample = `` - contents.contents = contents.contents.replace(sample.match, formatted_sample); - } -} - function writeMarkdown(aep: AEP) { const filePath = path.join("src/content/docs", `${aep.id}.mdx`) fs.writeFileSync(filePath, aep.contents, { flag: "w" }); @@ -373,39 +195,6 @@ function writeRule(rule: ConsolidatedLinterRule) { fs.writeFileSync(filePath, rule.contents, { flag: "w" }); } -function buildSidebar(aeps: AEP[]): object[] { - let response = []; - let groups = readGroupFile(AEP_LOC); - - for (var group of groups.categories) { - response.push({ - 'label': group.title, - 'items': aeps.filter((aep) => aep.category == group.code).sort((a1, a2) => a1.order > a2.order ? 1 : -1).map((aep) => aep.slug) - }) - } - return response; -} - -function buildLinterSidebar(rules: ConsolidatedLinterRule[]): object[] { - return [ - { - 'label': 'Tooling', - 'items': [ - { - 'label': 'Linter', - 'items': [ - 'tooling/linter', - { - 'label': 'Rules', - 'items': rules.map((x) => `tooling/linter/rules/${x.aep}`), - } - ] - } - ] - } - ]; -} - function buildFullAEPList(aeps: AEP[]) { let response = []; let groups = readGroupFile(AEP_LOC); @@ -458,7 +247,7 @@ writeSidebar(config, "config.json"); let aeps = await assembleAEPs(); // Build sidebar. -let sidebar = buildSidebar(aeps); +let sidebar = buildSidebar(aeps, readGroupFile(AEP_LOC)); writeSidebar(sidebar, "sidebar.json"); let full_aeps = buildFullAEPList(aeps); diff --git a/scripts/src/config.ts b/scripts/src/config.ts index 9251c6d..842cf08 100644 --- a/scripts/src/config.ts +++ b/scripts/src/config.ts @@ -1,36 +1,10 @@ import fs from 'fs'; import yaml from 'js-yaml'; import path from 'path'; -import { z } from "zod"; +import type { Config } from './types'; const AEP_LOC = process.env.AEP_LOCATION!; -const Config = z.object({ - hero: z.object({ - buttons: z.array(z.object({ - text: z.string(), - href: z.string(), - })), - shortcuts: z.array(z.object({ - title: z.string(), - description: z.string(), - button: z.object({ - text: z.string(), - href: z.string(), - }), - })), - }), - site: z.object({ - ga_tag: z.string(), - }), - urls: z.object({ - site: z.string(), - repo: z.string(), - }), -}); - -type Config = z.infer; - function loadConfigFiles(...fileNames: string[]): Config { const config = {}; @@ -45,7 +19,7 @@ function loadConfigFiles(...fileNames: string[]): Config { } }); - return Config.parse(config); + return config as Config; } export default loadConfigFiles; diff --git a/scripts/src/markdown.ts b/scripts/src/markdown.ts new file mode 100644 index 0000000..f086f9d --- /dev/null +++ b/scripts/src/markdown.ts @@ -0,0 +1,167 @@ +import * as path from 'path'; + +const ASIDES = { + 'Important': { 'title': 'Important', 'type': 'caution' }, + 'Note': { 'title': 'Note', 'type': 'note' }, + 'TL;DR': { 'title:': 'TL;DR', 'type': 'tip' }, + 'Warning': { 'title': 'Warning', 'type': 'danger' }, + 'Summary': { 'type': 'tip', 'title': 'Summary' } +}; + +const RULE_COLORS = { + 'may': 'font-extrabold text-green-700', + 'may not': 'font-extrabold text-green-700', + 'should': 'font-extrabold text-yellow-700', + 'should not': 'font-extrabold text-yellow-700', + 'must': 'font-extrabold text-red-700', + 'must not': 'font-extrabold text-red-700' +} + +class Markdown { + contents: string; + components: Set; + + constructor(contents: string) { + this.contents = contents; + this.components = new Set(); + } + + public substituteHTMLComments() { + this.contents = this.contents.replaceAll("", " */}") + return this; + } + + public substituteTabs() { + var tab_regex = /\{% tab proto -?%\}([\s\S]*?)\{% tab oas -?%\}([\s\S]*?)\{% endtabs -?%\}/g + let tabs = [] + + let matches = this.contents.matchAll(tab_regex); + for (var match of matches) { + tabs.push({ + 'match': match[0], + 'proto': tabContents(match[1]), + 'oas': tabContents(match[2]), + }); + } + for (var tab of tabs) { + var new_tab = ` + + +${tab['proto']} + + +${tab['oas']} + + + ` + this.contents = this.contents.replace(tab.match, new_tab); + } + return this; + } + public substituteSamples(folder: string) { + var sample_regex = /\{% sample '(.*)', '(.*)', '(.*)' %}/g + var sample2_regex = /\{% sample '(.*)', '(.*)' %}/g + + + let samples = [] + // TODO: Do actual sample parsing. + const matches = this.contents.matchAll(sample_regex); + for (var match of matches) { + if (match[1].endsWith('proto') || match[1].endsWith('yaml')) { + samples.push({ 'match': match[0], 'filename': match[1], 'token1': match[2], 'token2': match[3] }) + } + } + + const matches2 = this.contents.matchAll(sample2_regex); + for (var match of matches2) { + if (match[1].endsWith('proto') || match[1].endsWith('yaml')) { + samples.push({ 'match': match[0], 'filename': match[1], 'token1': match[2], 'token2': '' }) + } + } + + for (var sample of samples) { + let type = sample.filename.endsWith('proto') ? 'protobuf' : 'yml'; + let formatted_sample = `` + this.contents = this.contents.replace(sample.match, formatted_sample); + } + return this; + } + public substituteLinks() { + // Old site-generator expressed relative links as '[link]: ./0123'. + // These should be expressed as '[link]: /123' + + this.contents = this.contents.replaceAll(']: ./', ']: /') + this.contents = this.contents.replaceAll(']: /0', ']: /') + return this; + } + + public removeTitle() { + // Title should be removed because Starlight will add it for us. + this.contents = this.contents.replace(/# (.*)\n/, ''); + return this; + } + + public substituteRuleIdentifiers() { + var rule_regex = /\*\*(should(?: not)?|may(?: not)?|must(?: not)?)\*\*/g + var matches = this.contents.matchAll(rule_regex); + for (var match of matches) { + var color = RULE_COLORS[match[1]]; + this.contents = this.contents.replace(match[0], `${match[1]}`); + } + return this; + } + + public substituteCallouts() { + var paragraph_regex = /(^|\n)\*\*(Note|Warning|Important|Summary|TL;DR):\*\*([\s\S]+?)(?=\n{2,}|$)/g; + var matches = this.contents.matchAll(paragraph_regex); + for (var match of matches) { + const aside_info = ASIDES[match[2]]; + const formatted_results = ` +` + this.contents = this.contents.replace(match[0], formatted_results); + this.components.add('Aside'); + } + return this; + } + + public substituteEscapeCharacters() { + this.contents = this.contents.replaceAll('<=', '\\<=') + .replaceAll('>=', '\\>='); + return this; + } + + public substituteGraphviz() { + this.contents = this.contents.replaceAll('```graphviz', '```dot'); + return this; + } + + public substituteEBNF() { + this.contents = this.contents.replaceAll('```ebnf', '```'); + return this; + } + +} + +function buildMarkdown(contents: string, folder: string): Markdown { + let result = new Markdown(contents); + return result.substituteSamples(folder) + .substituteTabs() + .substituteHTMLComments() + .substituteEscapeCharacters() + .substituteCallouts() + .substituteRuleIdentifiers() + .removeTitle() + .substituteLinks() + .substituteGraphviz() + .substituteEBNF(); +} + +function tabContents(contents: string): string { + return contents.split('\n').map((x) => ' ' + x).join('\n'); +} + + +export { Markdown, buildMarkdown }; \ No newline at end of file diff --git a/scripts/src/sidebar.ts b/scripts/src/sidebar.ts new file mode 100644 index 0000000..23568b7 --- /dev/null +++ b/scripts/src/sidebar.ts @@ -0,0 +1,36 @@ +import type { Sidebar, AEP, ConsolidatedLinterRule } from './types'; + +function buildLinterSidebar(rules: ConsolidatedLinterRule[]): Sidebar { + return [ + { + label: 'Tooling', + items: [ + { + 'label': 'Linter', + 'items': [ + 'tooling/linter', + { + 'label': 'Rules', + 'collapsed': true, + 'items': rules.map((x) => `tooling/linter/rules/${x.aep}`), + } + ] + } + ] + } + ]; +} + +function buildSidebar(aeps: AEP[], groups: any): Sidebar { + let response = []; + + for (var group of groups.categories) { + response.push({ + 'label': group.title, + 'items': aeps.filter((aep) => aep.category == group.code).sort((a1, a2) => a1.order > a2.order ? 1 : -1).map((aep) => aep.slug) + }) + } + return response as Sidebar; +} + +export { buildSidebar, buildLinterSidebar }; \ No newline at end of file diff --git a/scripts/src/types.ts b/scripts/src/types.ts new file mode 100644 index 0000000..6bd50ac --- /dev/null +++ b/scripts/src/types.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +const Config = z.object({ + hero: z.object({ + buttons: z.array(z.object({ + text: z.string(), + href: z.string(), + })), + shortcuts: z.array(z.object({ + title: z.string(), + description: z.string(), + button: z.object({ + text: z.string(), + href: z.string(), + }), + })), + }), + site: z.object({ + ga_tag: z.string(), + }), + urls: z.object({ + site: z.string(), + repo: z.string(), + }), +}); + +type Config = z.infer; + +const SideBarItem = z.object({ + label: z.string(), + collapsed: z.boolean().optional(), + items: z.array(z.union([z.string(), z.lazy(() => SideBarItem)])) +}); + +const Sidebar = z.array(SideBarItem); + +type Sidebar = z.infer; + +interface AEP { + title: string; + id: string; + frontmatter: object; + contents: string; + category: string; + order: number; + slug: string; +} + +interface LinterRule { + title: string; + aep: string; + contents: string; + filename: string; + slug: string; +} + +interface ConsolidatedLinterRule { + aep: string; + contents: string; +} + +interface GroupFile { + categories: Group[] +} + +interface Group { + code: string; + title: string; +} + +export type { AEP, LinterRule, ConsolidatedLinterRule, Markdown, GroupFile, Group, Config, Sidebar }; \ No newline at end of file