From 8dc203924373149bccbf6daa4599f32dc06c1be4 Mon Sep 17 00:00:00 2001 From: "brian.mulier" Date: Mon, 17 Feb 2025 15:44:30 +0100 Subject: [PATCH] feat: new plugins & subgroups homepages closes #1503 --- app.vue | 4 + assets/styles/docs.scss | 1 + assets/styles/theme.scss | 1 + components/blueprints/ListCard.vue | 4 - components/plugins/PluginCard.vue | 8 +- components/stories/Card.vue | 2 +- components/stories/RowCard.vue | 2 +- middleware/redirect.global.js | 4 +- package-lock.json | 67 ++++---- package.json | 2 +- pages/plugins/[...slug].vue | 254 ++++++++++++++++++++-------- server/api/plugins.js | 262 ----------------------------- server/api/plugins.ts | 212 +++++++++++++++++++++++ server/api/sitemap.ts | 2 +- utils/plugins.ts | 23 +++ utils/url.js | 14 -- 16 files changed, 469 insertions(+), 393 deletions(-) delete mode 100644 server/api/plugins.js create mode 100644 server/api/plugins.ts create mode 100644 utils/plugins.ts delete mode 100644 utils/url.js diff --git a/app.vue b/app.vue index 644ea42f7e..16c72fe1a9 100644 --- a/app.vue +++ b/app.vue @@ -8,6 +8,10 @@ import DefaultLayout from "~/layouts/default.vue"; import "@kestra-io/ui-libs/style.css"; + String.prototype.capitalize = function () { + return this.charAt(0).toUpperCase() + this.slice(1) + } + useHead({ htmlAttrs: { lang: "en", diff --git a/assets/styles/docs.scss b/assets/styles/docs.scss index addce9f188..8d5d31d4c1 100644 --- a/assets/styles/docs.scss +++ b/assets/styles/docs.scss @@ -84,6 +84,7 @@ $bd-gutter-x: 3rem; h4 { padding-top: 1rem; + color: $white; font-size: $h5-font-size; } diff --git a/assets/styles/theme.scss b/assets/styles/theme.scss index f57400db92..17ba8296cc 100644 --- a/assets/styles/theme.scss +++ b/assets/styles/theme.scss @@ -2,6 +2,7 @@ #{--kestra-io-token-color-background-primary}: #111113; #{--kestra-io-token-color-background-hover-primary}: #404559; #{--kestra-io-token-color-background-secondary}: #161617; + #{--tokens-border-border-active}: #8405FF; #{--kestra-io-token-color-border-primary}: #3D3D3F; #{--kestra-io-token-color-border-secondary}: #252526; #{--kestra-io-token-color-white}: #FFFFFF; diff --git a/components/blueprints/ListCard.vue b/components/blueprints/ListCard.vue index 6302bf173b..910afc0e8d 100644 --- a/components/blueprints/ListCard.vue +++ b/components/blueprints/ListCard.vue @@ -20,10 +20,6 @@ - - \ No newline at end of file diff --git a/server/api/plugins.js b/server/api/plugins.js deleted file mode 100644 index f7dfb000d4..0000000000 --- a/server/api/plugins.js +++ /dev/null @@ -1,262 +0,0 @@ -import {parseMarkdown} from '@nuxtjs/mdc/runtime' -import url from "node:url"; -import {camelToKebabCase} from "~/utils/url.js"; - -function toNuxtContent(parsedMarkdown, type) { - return { - body: { - toc: parsedMarkdown.toc, - ...parsedMarkdown.body - }, - pluginType: type, - description: parsedMarkdown.data.description, - title: parsedMarkdown.data.title, - icon: `data:image/svg+xml;base64,${parsedMarkdown.data.icon}` ?? null - }; -} - -function toNuxtBlocks(data, type) { - return { - body: { - jsonSchema: data, - toc: { - links: navTocData(data) - }, - }, - pluginType: type, - description: data.properties.description, - title: data.properties.title - }; -} - -const generateNavTocChildren = (hrefPrefix = "", properties) => { - const children = []; - - const sortedKeys = Object.keys(properties).sort((a, b) => { - return (properties[b].$required || false) - (properties[a].$required || false); - }); - - for (const key of sortedKeys) { - children.push({ - id: hrefPrefix + key, - depth: 3, - text: key, - }); - } - - return children; -} - -const navTocData = (schema) => { - const links = []; - - if (schema.properties?.["$examples"]) { - links.push({ - id: 'examples', - depth: 2, - text: 'Examples' - }); - } - - if (schema.properties?.properties) { - links.push({ - id: 'properties', - depth: 2, - text: 'Properties', - children: generateNavTocChildren("properties_", schema.properties.properties) - }); - } - - if (schema.outputs?.properties) { - links.push({ - id: 'outputs', - depth: 2, - text: 'Outputs', - children: generateNavTocChildren("outputs_", schema.outputs.properties) - }); - } - - if (schema.definitions) { - links.push({ - id: 'definitions', - depth: 2, - text: 'Definitions', - children: generateNavTocChildren(undefined, schema.definitions) - }); - } - - return links; -}; - -function generateSubMenu(baseUrl, group, items) { - return generateSubMenuWithGroupProvider(baseUrl, () => group, items); -} - -function generateSubMenuWithGroupProvider(baseUrl, groupProviderFromItem, items) { - let itemsBySubmenu = items.reduce((m, item) => { - const subMenuSplitter = item.lastIndexOf("."); - if (subMenuSplitter === -1) { - m[item] = { - title: toNavTitle(item), - path: `${baseUrl}/${groupProviderFromItem(item)}.${item}`.toLowerCase(), - isPage: true, - } - } else { - let subMenuName = item.substring(0, subMenuSplitter); - if (!m[subMenuName]) { - const subGroup = `${groupProviderFromItem(item)}.${subMenuName}`; - m[subMenuName] = generateSubMenu( - `${baseUrl}/${subMenuName}`, - subGroup, - items.filter(i => i.startsWith(subMenuName + ".")) - .map(i => i.substring(subMenuName.length + 1)) - ); - } - } - return m; - }, {}); - - return Object.entries(itemsBySubmenu).map(([key, value]) => { - if (Array.isArray(value)) { - return { - title: toNavTitle(key), - path: `${baseUrl}/${key}`.toLowerCase(), - isPage: false, - children: value - } - } - return value; - }); -} - -function toNavTitle(title) { - let startCaseTitle = title; - if (title.match(/^[a-z]+[A-Z][a-z]/)) { - startCaseTitle = title.replace(/[A-Z][a-z]/, match => " " + match); - } - return startCaseTitle.split(".") - .map(string => string.charAt(0).toUpperCase() + string.slice(1)) - .join(""); -} - -const pluginCategories = ['tasks', 'triggers', 'conditions', 'controllers', 'storages', 'secrets', 'guides', 'taskRunners']; - -export default defineEventHandler(async (event) => { - try { - const requestUrl = new url.URL("http://localhost" + event.node.req.url); - const page = requestUrl.searchParams.get("page"); - const type = requestUrl.searchParams.get("type"); - const config = useRuntimeConfig(); - if (type === 'icon') { - let icon = await (await $fetch(`${config.public.apiUrl}/plugins/icons/${page}`)).text(); - return Buffer.from(icon.replaceAll("currentColor", "#CAC5DA")).toString('base64'); - } - if (type === 'definitions') { - let pageData = await $fetch(`${config.public.apiUrl}/plugins/definitions/${page}`); - - return toNuxtBlocks(pageData.schema, type); - } - if (type === 'plugin') { - let pageData = await $fetch(`${config.public.apiUrl}/plugins/${page}`); - - const parsedMarkdown = await parseMarkdown(pageData.body); - - return toNuxtContent(parsedMarkdown, type); - } - if (type === 'navigation') { - const plugins = await $fetch(`${config.public.apiUrl}/plugins`); - let sortedPluginsHierarchy = plugins.map(plugin => { - let rootPluginUrl = '/plugins/' + plugin.name; - const children = pluginCategories - .flatMap(category => { - let children; - let kebabCasedCategory = camelToKebabCase(category); - const taskList = (plugin[category] || []); - if (plugin.name === "core") { - if (category === "tasks") { - children = generateSubMenu( - `${rootPluginUrl}/${kebabCasedCategory}`, - plugin.group, - taskList.map(item => item.split(".").slice(-2).join(".")) - ); - } else { - const fqnByClassName = {}; - children = generateSubMenuWithGroupProvider( - `${rootPluginUrl}/${kebabCasedCategory}`, - (item) => { - const fqn = fqnByClassName[item]; - - return fqn.substring(0, fqn.lastIndexOf(".")); - }, - taskList.map(item => { - const className = item.substring(item.lastIndexOf(".") + 1); - fqnByClassName[className] = item; - return className; - }) - ); - } - } else { - const coreTaskByFqn = {}; - const coreTaskQualifier = "io.kestra.core.tasks"; - children = generateSubMenuWithGroupProvider( - `${rootPluginUrl}/${kebabCasedCategory}`, - (item) => { - let fqnTask = coreTaskByFqn[item]; - if (fqnTask !== undefined) { - return coreTaskQualifier; - } - return plugin.group; - }, - taskList.map(item => { - if (item.startsWith(coreTaskQualifier)) { - let relativeItem = item.substring(coreTaskQualifier.length + 1); - coreTaskByFqn[relativeItem] = item; - return relativeItem; - } - return item.substring(plugin.group.length + 1); - }) - ); - } - - if (children.length === 0) { - return []; - } - - return { - title: toNavTitle(category), - path: `${rootPluginUrl}/${kebabCasedCategory}`.toLowerCase(), - isPage: false, - children - } - }); - return { - title: toNavTitle(plugin.title), - path: rootPluginUrl.toLowerCase(), - children - }; - }).sort((a, b) => { - const nameA = a.title.toLowerCase(), - nameB = b.title.toLowerCase(); - - if (nameA === "core") { - return -1; - } - if (nameB === "core") { - return 1; - } - - return nameA === nameB ? 0 : nameA < nameB ? -1 : 1; - }); - return [{ - title: "Plugins", - path: "/plugins", - children: sortedPluginsHierarchy - }]; - } - } catch (error) { - return { - message: 'Failed to fetch or parse data', - error: error, - }; - } -}); \ No newline at end of file diff --git a/server/api/plugins.ts b/server/api/plugins.ts new file mode 100644 index 0000000000..01c9da90c5 --- /dev/null +++ b/server/api/plugins.ts @@ -0,0 +1,212 @@ +import url from "node:url"; +import type {JSONProperty, JSONSchema, Plugin} from "@kestra-io/ui-libs"; +import {isEntryAPluginElementPredicate, slugify, subGroupName} from "@kestra-io/ui-libs"; +import type {RuntimeConfig} from "nuxt/schema"; +import type {NitroRuntimeConfig} from "nitropack/types"; + +type PageType = "subGroupsIcons" | "elementsIcons" | "definitions" | "plugin" | "navigation"; + +function nuxtBlocksFromJsonSchema(jsonSchema: JSONSchema) { + return { + body: { + jsonSchema, + toc: { + links: tocFromJsonSchema(jsonSchema) + }, + }, + description: jsonSchema.properties.description, + title: jsonSchema.properties.title + }; +} + +function nuxtBlocksFromSubGroupsWrappers(subGroupsWrappers: Plugin[]) { + return { + body: { + plugins: subGroupsWrappers, + group: subGroupsWrappers?.[0]?.group, + } + }; +} + +const jsonSchemaPropertiesChildrenToc = (hrefPrefix = "", properties: Record) => { + const children = []; + + const sortedKeys = Object.keys(properties).sort((a, b) => { + return (properties[b].$required || false) - (properties[a].$required || false); + }); + + for (const key of sortedKeys) { + children.push({ + id: hrefPrefix + key, + depth: 3, + text: key, + }); + } + + return children; +} + +const tocFromJsonSchema = (schema: JSONSchema) => { + const links = []; + + if (schema.properties?.["$examples"]) { + links.push({ + id: 'examples', + depth: 2, + text: 'Examples' + }); + } + + if (schema.properties?.properties) { + links.push({ + id: 'properties', + depth: 2, + text: 'Properties', + children: jsonSchemaPropertiesChildrenToc("properties_", schema.properties.properties) + }); + } + + if (schema.outputs?.properties) { + links.push({ + id: 'outputs', + depth: 2, + text: 'Outputs', + children: jsonSchemaPropertiesChildrenToc("outputs_", schema.outputs.properties) + }); + } + + if (schema.definitions) { + links.push({ + id: 'definitions', + depth: 2, + text: 'Definitions', + children: jsonSchemaPropertiesChildrenToc(undefined, schema.definitions) + }); + } + + return links; +}; + +function toNavTitle(title: string) { + let startCaseTitle = title; + if (title.match(/^[a-z]+[A-Z][a-z]/)) { + startCaseTitle = title.replace(/[A-Z][a-z]/, match => " " + match); + } + return startCaseTitle.split(".") + .map(string => string.charAt(0).toUpperCase() + string.slice(1)) + .join(""); +} + +function subGroupWrapperNav(subGroupWrapper: Plugin, parentUrl: string) { + return Object.entries(subGroupWrapper).filter(([key, value]) => isEntryAPluginElementPredicate(key, value)) + .map(([key, value]) => { + return ({ + title: toNavTitle(key), + isPage: false, + path: parentUrl + "#" + slugify(key), + children: value.map(item => ({ + title: toNavTitle(item.substring(item.lastIndexOf('.') + 1)), + path: `${parentUrl}/${slugify(item)}` + })) + }); + }); +} + +async function generateNavigation(config: RuntimeConfig | NitroRuntimeConfig) { + const pluginsSubGroups = await $fetch(`${config.public.apiUrl}/plugins/subgroups`); + const subGroupsByGroup = pluginsSubGroups.reduce( + (result, subGroupWrapper) => { + if (!result[subGroupWrapper.group]) { + result[subGroupWrapper.group] = []; + } + result[subGroupWrapper.group].push(subGroupWrapper); + return result; + }, {} as Record); + let sortedPluginsHierarchy = Object.entries(subGroupsByGroup).map(([group, subGroupsWrappers]) => { + let plugin = subGroupsWrappers.find(subGroupWrapper => subGroupWrapper.subGroup === undefined); + let rootPluginUrl = "/plugins/" + slugify(plugin.name); + let pluginChildren; + if (subGroupsWrappers.length > 1) { + pluginChildren = subGroupsWrappers.filter(subGroupWrapper => subGroupWrapper.subGroup !== undefined).map(subGroupWrapper => { + const subGroupUrl = `${rootPluginUrl}/${slugify(subGroupName(subGroupWrapper))}`; + return { + title: toNavTitle(subGroupWrapper.title), + path: subGroupUrl, + children: subGroupWrapperNav(subGroupWrapper, subGroupUrl) + } + }); + } + // There is no subgroups, we skip that part and directly put plugin elements below + else { + pluginChildren = subGroupWrapperNav(subGroupsWrappers[0], rootPluginUrl); + } + return { + title: toNavTitle(plugin.title), + path: rootPluginUrl, + children: pluginChildren + } + }).sort((a, b) => { + const nameA = a.title.toLowerCase(), + nameB = b.title.toLowerCase(); + + if (nameA === "core") { + return -1; + } + if (nameB === "core") { + return 1; + } + + return nameA === nameB ? 0 : nameA < nameB ? -1 : 1; + }); + return [{ + title: "Plugins", + path: "/plugins", + children: sortedPluginsHierarchy + }]; +} + +export default defineEventHandler(async (event) => { + try { + const requestUrl = new url.URL("http://localhost" + event.node.req.url); + const page = requestUrl.searchParams.get("page"); + const type = requestUrl.searchParams.get("type") as PageType; + const config = useRuntimeConfig(); + + const colorFixedB64Icon = (b64Icon: string) => { + return Buffer.from(Buffer.from(b64Icon, 'base64').toString('utf-8').replaceAll("currentColor", "#CAC5DA")).toString('base64'); + } + + switch (type) { + case "subGroupsIcons": { + return Object.fromEntries(Object.entries( + await $fetch(`${config.public.apiUrl}/plugins/${page}/icons/subgroups`) + ).map(([subGroup, {icon}]) => [subGroup, colorFixedB64Icon(icon)])); + } + case "elementsIcons": { + return Object.fromEntries(Object.entries( + await $fetch(`${config.public.apiUrl}/plugins/${page}/icons`) + ).map(([pluginElement, {icon}]) => { + return [pluginElement, colorFixedB64Icon(icon)] + })); + } + case "definitions": { + let pageData = await $fetch(`${config.public.apiUrl}/plugins/definitions/${page}`); + + return nuxtBlocksFromJsonSchema(pageData.schema); + } + case "plugin": { + let subgroups = await $fetch(`${config.public.apiUrl}/plugins/${page}/subgroups`); + + return nuxtBlocksFromSubGroupsWrappers(subgroups); + } + case "navigation": { + return await generateNavigation(config); + } + } + } catch (error) { + return { + message: 'Failed to fetch or parse data', + error: error, + }; + } +}); \ No newline at end of file diff --git a/server/api/sitemap.ts b/server/api/sitemap.ts index acd0abe49d..94fb86cfe1 100644 --- a/server/api/sitemap.ts +++ b/server/api/sitemap.ts @@ -1,5 +1,5 @@ import {recursivePages} from "~/utils/navigation.js"; -import {slugify} from "~/utils/url.js"; +import {slugify} from "@kestra-io/ui-libs"; import type {SitemapUrlInput} from "@nuxtjs/sitemap/dist/runtime/types"; const generateDefaultSitemap = async () => { diff --git a/utils/plugins.ts b/utils/plugins.ts new file mode 100644 index 0000000000..e4a52314d1 --- /dev/null +++ b/utils/plugins.ts @@ -0,0 +1,23 @@ +export interface Plugin { + name: string; + title: string; + group: string; + subGroup?: string; + categories?: string[]; + controllers?: string[]; + storages?: string[]; + aliases?: string[]; + guides?: string[]; + [pluginElement: string]: string[]; +} + +export function isEntryAPluginElementPredicate(key: string, value: any) { + return Array.isArray(value) && + !["categories", "controllers", "storages", "aliases", "guides"].includes(key) && + ((value as any[]).length === 0 || + typeof value[0] === "string"); +} + +export function subGroupName(subGroupWrapper: Plugin) { + return (subGroupWrapper.title ?? subGroupWrapper.subGroup.split(subGroupWrapper.group)[1]).replaceAll(/\.([a-zA-Z])/g, (_, capture) => ` ${capture.toUpperCase()}`).capitalize(); +} \ No newline at end of file diff --git a/utils/url.js b/utils/url.js deleted file mode 100644 index a79bddcced..0000000000 --- a/utils/url.js +++ /dev/null @@ -1,14 +0,0 @@ -import slugifyLib from "slugify"; - -slugifyLib.extend({'(': '-', ')': ''}) - -export function slugify(text) { - return slugifyLib(text, { - lower: true, - locale: 'en', - }); -} - -export function camelToKebabCase(text) { - return text.replace(/([a-z])([A-Z][a-z])/, '$1-$2').toLowerCase(); -} \ No newline at end of file