diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 9eba2d0..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,26 +0,0 @@ -module.exports = { - root: true, - env: { - browser: true, - node: true, - }, - parserOptions: { - parser: 'babel-eslint', - }, - extends: [ - 'eslint:recommended', - // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention - // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules. - 'plugin:vue/recommended', - 'prettier', - // "plugin:prettier/recommended" - ], - // required to lint *.vue files - plugins: ['vue', 'prettier'], - rules: { - semi: [2, 'always'], - 'no-console': 'off', - 'vue/max-attributes-per-line': 'off', - 'prettier/prettier': ['error', { semi: true }], - }, -}; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..219c4e1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm test diff --git a/.gitignore b/.gitignore index e8f682b..131d7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,32 +13,17 @@ pids *.seed *.pid.lock -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - # Dependency directories node_modules/ jspm_packages/ -# TypeScript v1 declaration files +# TypeScript declaration files typings/ # Optional npm cache directory @@ -59,32 +44,18 @@ typings/ # dotenv environment variables file .env -# parcel-bundler cache (https://parceljs.org/) -.cache - -# next.js build output -.next - -# nuxt.js build output -.nuxt - -# Nuxt generate +# vitepress build output dist - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless +.vitepress/cache # IDE / Editor .idea -# Service worker -sw.* - # macOS .DS_Store # Vim swap files *.swp + +# Service worker (if any) +sw.* diff --git a/.nvmrc b/.nvmrc index f6610ca..d845d9d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.18.1 +24.14.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..b945198 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "printWidth": 120, + "sortPackageJson": false, + "sortImports": { + "groups": [ + "builtin", + "external", + ["internal", "parent", "sibling", "index"], + "type-builtin", + "type-external", + ["type-internal", "type-parent", "type-sibling", "type-index"] + ] + }, + "ignorePatterns": [], + "overrides": [ + { + "files": ["generated/**"], + "options": { + "printWidth": 80 + } + } + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..cc24e72 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,38 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "vue"], + "env": { + "node": true, + "browser": true + }, + "categories": { + "correctness": "error", + "perf": "error", + "suspicious": "error", + "pedantic": "warn", + "style": "warn", + "nursery": "warn" + }, + "globals": { + "defineProps": "readonly", + "defineEmits": "readonly" + }, + "rules": { + "id-length": "off", + "init-declarations": "off", + "max-depth": "off", + "max-lines-per-function": "off", + "max-params": "off", + "max-statements": "off", + "no-await-in-loop": "off", + "no-duplicate-imports": ["error", { "allowSeparateTypeImports": true }], + "no-magic-numbers": "off", + "no-ternary": "off", + "prefer-global-this": "off", + "sort-imports": "off", + "typescript/consistent-type-imports": ["error", { "prefer": "type-imports", "fixStyle": "separate-type-imports" }], + "unicorn/filename-case": "off", + "unicorn/no-null": "off", + "unicorn/prefer-node-protocol": "error" + } +} diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 544138b..0000000 --- a/.prettierrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "singleQuote": true -} diff --git a/.vitepress/config.ts b/.vitepress/config.ts new file mode 100644 index 0000000..bd247cf --- /dev/null +++ b/.vitepress/config.ts @@ -0,0 +1,152 @@ +import { defineConfig } from 'vitepress'; +import { groupIconMdPlugin, groupIconVitePlugin } from 'vitepress-plugin-group-icons'; + +import modulesData from '../generated/metadata/modules.json' with { type: 'json' }; +import joiInfo from '../generated/modules/joi/info.json' with { type: 'json' }; +import { formatVersion } from './utils.js'; + +const modulesItems = Object.keys(modulesData) + .filter((name) => name !== 'joi') + .map((name) => ({ + link: `/module/${name}/install`, + text: name, + })); + +const getModuleSidebar = (moduleName: string) => { + const moduleData = modulesData[moduleName as keyof typeof modulesData]; + return [ + { + items: [{ link: '/module/', text: 'All Modules' }, ...modulesItems], + text: 'Modules', + }, + { + items: [ + { link: `/module/${moduleName}/install`, text: 'Installation' }, + { + items: moduleData.versions.map((version) => ({ + link: `/module/${moduleName}/api/${formatVersion(version.name)}`, + text: formatVersion(version.name), + })), + link: `/module/${moduleName}/api/${formatVersion(moduleData.versions[0].name)}`, + text: 'API', + }, + { link: `/module/${moduleName}/changelog`, text: 'Changelog' }, + ], + text: moduleName, + }, + ]; +}; + +const moduleSidebars = Object.fromEntries( + Object.keys(modulesData) + .filter((name) => name !== 'joi') + .map((name) => [`/module/${name}/`, getModuleSidebar(name)]), +); + +export default defineConfig({ + appearance: true, + cleanUrls: true, + description: 'The most powerful data validation library for JS', + head: [['link', { href: '/favicon2.png', rel: 'icon' }]], + markdown: { + config(md) { + md.use(groupIconMdPlugin); + }, + lineNumbers: true, + theme: { + dark: 'vitesse-dark', + light: 'vitesse-light', + }, + }, + outDir: 'dist', + srcDir: 'docs', + themeConfig: { + docFooter: { + next: false, + prev: false, + }, + footer: { + copyright: 'Copyright © 2012-present hapi.js team', + message: + 'Deploys by Netlify', + }, + logo: '/img/joiTransparent.png', + nav: [ + { link: '/', text: 'Home' }, + { activeMatch: '^/api/', link: `/api/${formatVersion(joiInfo.versionsArray[0])}`, text: 'API' }, + { + activeMatch: '^/resources/', + link: '/resources/changelog', + text: 'Resources', + }, + { activeMatch: '^/module/', link: '/module/', text: 'Modules' }, + { activeMatch: '^/policies/', link: '/policies/coc', text: 'Policies' }, + { activeMatch: '^/tester/', link: '/tester/', text: 'Sandbox' }, + ], + outline: { + label: 'On this page', + level: 'deep', + }, + sidebar: { + '/api/': [ + { + items: [ + { + items: joiInfo.versionsArray.map((version) => ({ + link: `/api/${formatVersion(version)}`, + text: formatVersion(version), + })), + link: `/api/${formatVersion(joiInfo.versionsArray[0])}`, + text: 'API', + }, + ], + text: 'joi', + }, + ], + '/module/': [ + { + items: [{ link: '/module/', text: 'All Modules' }, ...modulesItems], + text: 'Modules', + }, + ], + '/policies/': [ + { + items: [ + { link: '/policies/coc', text: 'Code of Conduct' }, + { link: '/policies/contributing', text: 'Contributing' }, + { link: '/policies/license', text: 'License' }, + { link: '/policies/security', text: 'Security' }, + { link: '/policies/styleguide', text: 'Style Guide' }, + { link: '/policies/support', text: 'Support' }, + ], + text: 'Policies', + }, + ], + '/resources/': [ + { + items: [ + { link: '/resources/changelog', text: 'Changelog' }, + { link: '/resources/status', text: 'Module Status' }, + ], + text: 'Resources', + }, + ], + '/tester/': [ + { + items: joiInfo.versionsArray.map((version) => ({ + link: `/tester/${formatVersion(version)}`, + text: formatVersion(version), + })), + text: 'Versions', + }, + ], + ...moduleSidebars, + }, + socialLinks: [{ icon: 'github', link: 'https://github.com/hapijs/joi' }], + }, + title: 'joi.dev', + titleTemplate: 'joi.dev - :title', + vite: { + plugins: [groupIconVitePlugin()], + }, +}); diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts new file mode 100644 index 0000000..f4ef4da --- /dev/null +++ b/.vitepress/theme/index.ts @@ -0,0 +1,42 @@ +import DefaultTheme from 'vitepress/theme'; +import { h } from 'vue'; + +import './variables.css'; +import './main.css'; +import 'virtual:group-icons.css'; +import CarbonAds from '../../components/CarbonAds.vue'; +import ModuleIndex from '../../components/ModuleIndex.vue'; +import StatusContent from '../../components/StatusContent.vue'; +import TesterContent from '../../components/TesterContent.vue'; +import { getRedirectPath } from './redirect.js'; + +import type { Theme } from 'vitepress'; + +export default { + Layout() { + return h(DefaultTheme.Layout, null, { + 'sidebar-nav-after': () => h(CarbonAds), + }); + }, + enhanceApp({ app, router }) { + app.component('ModuleIndex', ModuleIndex); + app.component('StatusContent', StatusContent); + app.component('TesterContent', TesterContent); + + if (typeof window !== 'undefined') { + router.onBeforeRouteChange = (to) => { + const target = getRedirectPath(to); + if (target) { + router.go(target); + return false; + } + }; + + const initialTarget = getRedirectPath(window.location.pathname); + if (initialTarget) { + router.go(initialTarget); + } + } + }, + extends: DefaultTheme, +} satisfies Theme; diff --git a/.vitepress/theme/main.css b/.vitepress/theme/main.css new file mode 100644 index 0000000..a84a2de --- /dev/null +++ b/.vitepress/theme/main.css @@ -0,0 +1,62 @@ +.VPSidebar .nav { + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--vp-nav-height) - 128px); +} + +.breaking-badge { + display: inline-flex; + align-items: center; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + height: 18px; + line-height: 18px; + border-radius: 4px; + background-color: var(--vp-c-brand-1); + color: var(--vp-c-white); + text-transform: uppercase; + vertical-align: middle; + margin-left: 8px; + box-sizing: border-box; +} + +.breaking-badge::after { + content: 'breaking changes'; +} + +.release-notes-link { + display: inline-flex; + align-items: center; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + height: 18px; + line-height: 18px; + border-radius: 4px; + background-color: var(--release-notes-bg); + color: var(--vp-c-black) !important; + text-transform: uppercase; + vertical-align: middle; + margin-left: 8px; + text-decoration: none !important; + box-sizing: border-box; +} + +.release-notes-link::after { + content: 'release notes'; + margin-left: 10px; +} + +.release-notes-link span { + margin-left: 10px; +} + +.release-notes-link:hover { + opacity: 0.8; +} + +.release-notes-img { + width: 12px; + height: 12px; +} diff --git a/.vitepress/theme/redirect.ts b/.vitepress/theme/redirect.ts new file mode 100644 index 0000000..b48fac6 --- /dev/null +++ b/.vitepress/theme/redirect.ts @@ -0,0 +1,51 @@ +import Semver from 'semver'; + +import joiInfo from '../../generated/modules/joi/info.json' with { type: 'json' }; +import { formatVersion } from '../utils.js'; + +export const getRedirectPath = (toPath: string) => { + const [urlPath, hash] = toPath.split('#'); + const path = urlPath.replace(/\/$/, '').replace(/\.html$/, '') || '/'; + const match = path.match(/^\/(api|tester)(?:\/([^/]+))?$/); + if (!match) { + return null; + } + + const [_, type, version] = match; + const suffix = hash ? `#${hash}` : ''; + + const [latestVersion] = joiInfo.versionsArray; + const latestMasked = formatVersion(latestVersion); + + if (!version) { + return `/${type}/${latestMasked}${suffix}`; + } + + const maskedVersions = joiInfo.versionsArray.map(formatVersion); + + if (maskedVersions.includes(version)) { + return null; + } + + let targetVersion = latestVersion; + try { + const coerced = Semver.coerce(version); + if (coerced) { + const major = Semver.major(coerced); + const sameMajor = joiInfo.versionsArray.find((v) => Semver.major(v) === major); + if (sameMajor) { + targetVersion = sameMajor; + } + } + } catch { + // Keep targetVersion as latest if coercion fails + } + + const finalTarget = formatVersion(targetVersion); + + if (finalTarget === version) { + return null; + } + + return `/${type}/${finalTarget}${suffix}`; +}; diff --git a/.vitepress/theme/variables.css b/.vitepress/theme/variables.css new file mode 100644 index 0000000..d8a13b8 --- /dev/null +++ b/.vitepress/theme/variables.css @@ -0,0 +1,22 @@ +:root { + /* Brand Colors */ + --vp-c-brand-1: #0080ff; + --vp-c-brand-2: #0066cc; + --vp-c-brand-3: #004da6; + --vp-c-brand-soft: rgba(0, 128, 255, 0.14); + + /* Tester Component */ + --tester-error: #ff6a6a; + --tester-success: #42b983; + + /* Release Notes Badge */ + --release-notes-bg: #fad8c7; + + /* CodeMirror Editor */ + --cm-selection-bg-dark: #3e4451; + --cm-selection-bg-light: #d7d4f0; + + /* Modal & Overlays */ + --overlay-bg: rgba(0, 0, 0, 0.5); + --modal-shadow: rgba(0, 0, 0, 0.3); +} diff --git a/.vitepress/utils.ts b/.vitepress/utils.ts new file mode 100644 index 0000000..0c481df --- /dev/null +++ b/.vitepress/utils.ts @@ -0,0 +1,4 @@ +export const formatVersion = (version: string) => { + const [major] = version.split('.'); + return `${major}.x.x`; +}; diff --git a/README.md b/README.md index a9e413b..d188061 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,29 @@ -# joiSite +# joi.dev ## Build Setup ```bash # install dependencies -$ npm install +$ pnpm install -# serve with hot reload at localhost:3000 -$ npm run dev +# serve with hot reload at localhost:5173 +$ pnpm dev -# build for production and launch server -$ npm run build -$ npm run start +# build for production +$ pnpm build -# generate static project -$ npm run generate +# preview production build +$ pnpm preview + +# generate data and build for production +$ pnpm generate + +# lint and format code +$ pnpm lint +$ pnpm fmt + +# run full check +$ pnpm test ``` -For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). +For more information on VitePress, check out [VitePress documentation](https://vitepress.dev). diff --git a/app/router.scrollBehavior.js b/app/router.scrollBehavior.js deleted file mode 100644 index f4763a7..0000000 --- a/app/router.scrollBehavior.js +++ /dev/null @@ -1,18 +0,0 @@ -export default function (to, from, savedPosition) { - if (savedPosition) { - return savedPosition; - } else { - let position = {}; - if (to.matched.length < 2) { - position = { x: 0, y: 0 }; - } else if ( - to.matched.some((r) => r.components.default.options.scrollToTop) - ) { - position = { x: 0, y: 0 }; - } - if (to.hash) { - position = { selector: to.hash }; - } - return position; - } -} diff --git a/assets/README.md b/assets/README.md deleted file mode 100644 index 34766f9..0000000 --- a/assets/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# ASSETS - -**This directory is not required, you can delete it if you don't want to use it.** - -This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. - -More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). diff --git a/assets/styles/api.css b/assets/styles/api.css deleted file mode 100644 index b11923f..0000000 --- a/assets/styles/api.css +++ /dev/null @@ -1,78 +0,0 @@ -h5 { - font-size: 1.1rem; -} - -h2, -h3, -h4, -h5 { - margin-top: 35px; -} - -.api-main-doc-header { - code { - font-weight: 700; - font-size: 1.55rem; - } -} - -.api-top-doc-header, -.api-top-doc-header code { - padding-top: 0; - margin-top: 1em; -} - -h2 code, -h3 .api-nav-code, -h4 .api-nav-code, -h3 code, -h4 code { - background: var(--white); - font-family: "Open Sans", sans-serif; - font-size: 1.55rem; - color: var(--black); - font-weight: 400; - -webkit-font-smoothing: antialiased; - padding: 5px 0; - margin: 0; -} - -h5 code { - background: var(--white); - font-family: "Open Sans", sans-serif; - font-size: 1.3rem; - color: var(--black); - font-weight: 400; - -webkit-font-smoothing: antialiased; - padding: 5px 0; -} - -h6 code { - background: var(--white); - font-family: "Open Sans", sans-serif; - font-size: 1.2rem; - color: var(--black); - font-weight: 400; - -webkit-font-smoothing: antialiased; - padding: 5px 0; -} - -@media (prefers-color-scheme: dark) { - h2 code, - h3 .api-nav-code, - h4 .api-nav-code, - h3 code, - h4 code { - background: var(--blacker); - color: var(--white); - } - h5 code { - background: var(--blacker); - color: var(--white); - } - h6 code { - background: var(--blacker); - color: var(--white); - } - -} diff --git a/assets/styles/main.css b/assets/styles/main.css deleted file mode 100644 index df5248d..0000000 --- a/assets/styles/main.css +++ /dev/null @@ -1,158 +0,0 @@ -html, -body { - margin: 0; - padding: 0; - font-size: 16px; - font-family: 'Lato', sans-serif; - color: var(--black); - text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.004); - overflow: hidden; - word-spacing: 1px; - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - box-sizing: border-box; - height: 100%; - overflow: hidden; -} - -*, -*:before, -*:after { - box-sizing: border-box; - margin: 0 auto; -} - -.hide { - display: none; -} - -.hapi-header { - margin: 10px 0; - border-top: none; - width: auto; - font-weight: 700; - font-size: 2.3rem; -} - -.hapi-family-header { - margin: 30px 0 10px 100px; - border-top: none; - width: auto; - font-weight: 700; - font-size: 2rem; - display: block; -} - -.nav-display { - display: block !important; -} - -.copy-clipboard { - display: inline-block; - width: var(--icon-size); - height: var(--icon-size); - opacity: 0.5; - cursor: pointer; - transition: all 0.2s; - - &:after { - content: '📋'; - } - - &-absolute { - position: absolute; - top: 5px; - right: 5px; - } - - &-relative { - position: relative; - margin: 0 0 0 5px; - } - - &:hover { - opacity: 0.7; - } - - &.clicked { - background-image: url('@/static/img/clipboardCheck.png'); - opacity: 0.7; - - &:after { - content: '✅'; - } - } -} - -.test-snippet { - position: absolute; - top: 5px; - right: 32px; - display: inline-block; - width: var(--icon-size); - height: var(--icon-size); - margin: 0 0 0 5px; - opacity: 0.5; - cursor: pointer; - transition: all 0.2s; - - &:after { - content: '🧪'; - } - - &:hover { - opacity: 0.7; - } -} - -.api-version-span { - font-weight: 400; - font-size: 1.4rem; -} - -@media screen and (max-width: 1500px) { - .hapi-family-header { - margin-left: 40px; - } -} - -@media screen and (max-width: 900px) { - html, - body { - font-size: 14px; - -webkit-overflow-scrolling: touch; - } - - :root { - font-size: 14px; - } - - .page-wrapper { - padding: 0 20px; - } - - .hapi-header { - font-size: 2rem; - } - - .hapi-family-header { - margin-top: 10px; - margin-left: 10px; - } -} - -@media (prefers-color-scheme: dark) { - html, - body { - color: var(--off-white); - background: var(--black); - z-index: -1; - } - - strong, - b { - color: var(--off-white); - } -} diff --git a/assets/styles/markdown.css b/assets/styles/markdown.css deleted file mode 100644 index d9d39f6..0000000 --- a/assets/styles/markdown.css +++ /dev/null @@ -1,223 +0,0 @@ -@import 'highlight.js/styles/github.css' screen; -@import 'highlight.js/styles/github-dark.css' screen and - (prefers-color-scheme: dark); - -.markdown-wrapper { - position: relative; - width: 100%; - box-sizing: border-box; - margin: 0; - padding: 20px 100px 10px 100px; - word-wrap: break-all; - - h1 { - font-size: 2rem; - } - - h2 { - font-size: 1.75rem; - } - - h3 { - font-size: 1.55rem; - } - - h4 { - font-size: 1.3rem; - } - - h5 { - font-size: 1.25rem; - } - - h2 a, - h3 a, - h4 a, - h5 a { - display: block; - position: relative; - top: -116px; - visibility: hidden; - } - - a:focus { - outline: none; - } - - h2 a, - h3 a { - color: var(--black); - cursor: auto; - } - - .code-snippet code { - font-family: 'inconsolata', menlo, consolas, monospace; - font-size: 1rem; - line-height: 1.5rem; - border: 0; - } - - a { - cursor: pointer; - color: var(--orange); - text-decoration: none; - } - - a:hover { - color: var(--orange); - text-decoration: underline; - } - - .code-snippet { - position: relative; - - > pre { - background-color: var(--quote-gray); - border: 1px solid var(--dark-white); - padding: 1.25rem 1.5rem; - - > code { - background-color: var(--quote-gray); - } - } - } - - p { - display: block; - margin-block-start: 1em; - margin-block-end: 1em; - margin-inline-start: 0; - margin-inline-end: 0; - color: var(--black); - line-height: 1.7rem; - } - - p code, - li code { - font-family: 'inconsolata', menlo, consolas, monospace; - padding: 0.2rem 0.33rem; - color: var(--gray); - background-color: var(--quote-gray); - border: 1px solid var(--dark-white); - } - - pre { - font-family: 'inconsolata', menlo, consolas, monospace; - font-size: 1rem; - } - - pre p code, - pre pre { - border: none; - } - - li { - margin-bottom: 20px; - line-height: 1.7rem; - list-style-type: disc; - } - - ul { - margin: 15px 0 15px 20px; - padding: 0; - } -} - -.family-markdown-wrapper { - padding: 0 100px 10px 100px; -} - -pre { - border: 1px solid var(--dark-white); - white-space: pre-wrap; /* Since CSS 2.1 */ - white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ - white-space: -pre-wrap; /* Opera 4-6 */ - white-space: -o-pre-wrap; /* Opera 7 */ - word-wrap: break-word; /* Internet Explorer 5.5+ */ -} - -@media screen and (max-width: 1500px) { - .markdown-wrapper { - padding: 20px 40px; - } - - .family-markdown-wrapper { - padding: 0 40px 10px 40px; - } -} - -@media screen and (max-width: 900px) { - .markdown-wrapper { - padding: 5px 10px; - - h2 a, - h3 a, - h4 a, - h5 a { - top: -60px; - } - - p, - li { - line-height: 2rem; - font-size: 16px; - } - - pre { - font-size: 15.25px; - } - } -} - -@media print { - .markdown-wrapper, - .family-markdown-wrapper { - padding: 0; - } -} - -@media (prefers-color-scheme: dark) { - .markdown-wrapper { - pre { - background: var(--blackest); - border-color: #000 !important; - color: var(--ash); - } - - h2 a, - h3 a { - color: var(--white); - } - - .code-snippet { - > pre { - background-color: var(--blacker) !important; - border: 1px solid var(--dark-white); - - > code { - background-color: var(--blacker) !important; - } - } - } - - p { - color: var(--off-white); - } - - p code, - li code { - color: var(--light-gray); - background-color: var(--blacker) !important; - border: 1px solid #000 !important; - } - - a:hover { - color: var(--gray); - } - - .hljs-attr, - .pl-en { - color: lighten(#6f42c1, 20%); - } - } -} diff --git a/assets/styles/sideNav.css b/assets/styles/sideNav.css deleted file mode 100644 index 66df44e..0000000 --- a/assets/styles/sideNav.css +++ /dev/null @@ -1,221 +0,0 @@ -.api-nav-wrapper { - display: flex; - flex-direction: column; - justify-content: space-between; - position: relative; - width: 100%; - height: auto; - min-height: calc(100vh - 122px); -} - -.api-nav-inner-wrapper { - display: flex; - flex: 0 0 auto; - flex-direction: column; - align-items: flex-start; - margin: 0; - position: relative; -} - -.side-nav-wrapper { - display: flex; - flex-direction: column; - justify-content: space-between; - position: relative; - width: 100%; - height: auto; - min-height: calc(100vh - 122px); -} - -.side-nav-inner-wrapper { - display: flex; - flex: 0 0 auto; - flex-direction: column; - align-items: flex-start; - margin: 0; -} - -.side-nav-title { - font-size: 1.5rem; - color: var(--black); - margin: 0; -} - -.lang-wrapper { - display: flex; - justify-content: flex-start; - align-items: center; - margin: 20px 0 0 0; - width: 100%; -} - -.lang-text { - margin: 0 33px 0 0; - font-size: 1.1em; -} - -.family-span { - font-size: 1rem; - color: #333; - font-weight: 400; -} - -.family-version-select { - width: 70px; - padding: 0px 5px 0px 5px; - border: none; - height: 30px; - font-size: 1em; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: url(/static/img/down.png) 96% / 15% no-repeat var(--off-white); - cursor: pointer; -} - -select { - width: 100px; - padding: 0px 5px 0px 5px; - border: 1px solid var(--dark-white); - height: 30px; - font-size: 0.9em; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: url(/static/img/down.png) 96% / 15% no-repeat var(--white); - cursor: pointer; -} - -select:focus { - outline: none; -} - -.api-side-nav-select-wrapper { - font-size: 1.1em; - color: var(--orange); - line-height: 30px; - width: 100%; - padding: 0 20px; -} - -.side-nav-select-wrapper { - margin: 20px 0 0 0; - font-size: 1.1em; - color: #ed7d31; - line-height: 30px; - width: 100%; - overflow-x: hidden; -} - -.family-nav-no-margin { - margin: 0; -} - -.family-nav-select-list { - margin-left: 30px; -} - -.side-nav-select-link { - cursor: pointer; - margin: 0 auto 0 0; - list-style-type: none; -} - -.side-nav-select-link:hover { - color: var(--orange); - text-decoration: underline; -} - -.side-nav-active { - font-weight: 900; -} - -.ads-wrapper { - width: 319px; - height: 142px; -} - -@media screen and (max-width: 900px) { - .side-nav-window, - .tutorial-nav-window { - flex-direction: row; - align-items: center; - position: relative; - top: 0; - padding: 10px 20px; - min-height: auto; - max-height: auto; - border-right: none; - border-bottom: 1px solid var(--dark-white); - width: 100%; - } - - .side-nav-inner-wrapper { - margin: 0; - } - - #carbonads { - max-width: none; - } - - .ads-wrapper { - margin: 0; - } - - .side-nav-wrapper { - min-height: auto; - } - - .side-nav-select-list { - display: inline-block; - } - - .side-nav-select-link { - display: inline-block; - } - - .side-nav-select-wrapper, - .api-nav-select-wrapper { - display: none; - } -} - -@media (prefers-color-scheme: dark) { - .side-nav-window, - .api-nav-window { - background: var(--blacker) !important; - border-color: var(--blackest) !important; - } - .side-nav-title { - color: var(--white); - } - .family-span { - color: var(--white); - } - .family-version-select, - select { - background: url(/static/img/down.png) 96% / 15% no-repeat var(--blackest); - color: var(--ash); - border-color: black; - } - .api-nav-window { - background: var(--blacker); - } - - .family-version-select, - .family-search-box, - .api-search-box { - background-color: var(--blackest) !important; - border-color: var(--blackest) !important; - color: var(--light-gray) !important; - } - - .family-top-wrapper, - .family-search-button, - .family-search-img, - .api-search-img { - background-color: var(--blackest) !important; - border-color: var(--blackest) !important; - color: var(--light-gray); - } -} diff --git a/assets/styles/variables.css b/assets/styles/variables.css deleted file mode 100644 index 0b582d5..0000000 --- a/assets/styles/variables.css +++ /dev/null @@ -1,17 +0,0 @@ -/* Colors */ -:root { - --orange: #0080ff; - --black: #333; - --blacker: #222; - --blackest: #111; - --off-white: #f8f8f8; - --dark-white: #ddd; - --light-gray: #ccc; - --ash: #aaa; - --quote-gray: #f3f3f3; - --quote-gray-dark: #3a3a3a; - --gray: #6f6f6f; - --gray-light: #c8c8c8; - --white: #ffffff; - --icon-size: 20px; -} diff --git a/cli/generateChangelog.js b/cli/generateChangelog.js deleted file mode 100644 index d57ffdc..0000000 --- a/cli/generateChangelog.js +++ /dev/null @@ -1,65 +0,0 @@ -const fs = require('fs/promises'); -const Semver = require('semver'); -const { getMilestones, getMilestoneIssues } = require('./gh'); -const { getModuleChangelogPath, getExisting } = require('./paths'); - -async function generateChangelog(moduleName) { - let foundNewMilestones = false; - - const changelog = []; - - const filePath = getModuleChangelogPath(moduleName); - - const existing = await getExisting(filePath); - const existingMap = existing - ? new Map(existing.map((c) => [c.version, c])) - : new Map(); - - console.info(`[changelog] Generating for ${moduleName}`); - - const milestones = await getMilestones(moduleName); - - for (const milestone of milestones) { - if (existingMap.has(milestone.title)) { - changelog.push(existingMap.get(milestone.title)); - console.info(`[changelog] Skipping ${moduleName}@${milestone.title}`); - continue; - } - - console.info( - `[changelog] Getting issues for ${moduleName}@${milestone.title}` - ); - - const issues = await getMilestoneIssues(moduleName, milestone.number); - - changelog.push({ - id: milestone.id, - number: milestone.number, - version: milestone.title, - date: milestone.closed_at, - url: milestone.html_url, - issues: issues.map((issue) => ({ - id: issue.id, - number: issue.number, - title: issue.title, - url: issue.html_url, - labels: issue.labels.map((label) => label.name), - })), - }); - - foundNewMilestones = true; - } - - if (foundNewMilestones) { - await fs.writeFile( - filePath, - JSON.stringify( - changelog.sort((a, b) => Semver.compare(b.version, a.version)), - null, - 2 - ) - ); - } -} - -exports.generateChangelog = generateChangelog; diff --git a/cli/generateChangelog.ts b/cli/generateChangelog.ts new file mode 100644 index 0000000..97ca941 --- /dev/null +++ b/cli/generateChangelog.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { Semaphore } from 'es-toolkit'; +import Semver from 'semver'; + +import { getMilestoneIssues, getMilestones } from './gh.js'; +import { getExisting, getModuleChangelogPath, getModuleMarkdownChangelogPath } from './paths.js'; + +import type { ChangelogItem } from './types.js'; + +export const generateChangelog = async (moduleName: string) => { + let foundNewMilestones = false; + + const filePath = getModuleChangelogPath(moduleName); + const existing = await getExisting(filePath); + const existingMap = new Map(existing?.map((c) => [c.version, c])); + + console.info(`[changelog] Generating for ${moduleName}`); + + const milestones = await getMilestones(moduleName); + + const changelog: ChangelogItem[] = []; + + const limit = new Semaphore(10); + const tasks = milestones.map(async (milestone) => { + if (existingMap.has(milestone.title)) { + console.info(`[changelog] Skipping ${moduleName}@${milestone.title}`); + return existingMap.get(milestone.title)!; + } + + await limit.acquire(); + try { + console.info(`[changelog] Getting issues for ${moduleName}@${milestone.title}`); + + const issues = await getMilestoneIssues(moduleName, milestone.number); + + foundNewMilestones = true; + + return { + date: milestone.closed_at, + id: milestone.id, + issues: issues.map((issue) => ({ + id: issue.id, + labels: issue.labels.map((label) => label.name), + number: issue.number, + title: issue.title, + url: issue.html_url, + })), + number: milestone.number, + url: milestone.html_url, + version: milestone.title, + }; + } finally { + limit.release(); + } + }); + + changelog.push(...(await Promise.all(tasks))); + + const sortedChangelog = changelog.toSorted((a, b) => Semver.compare(b.version, a.version)); + + if (foundNewMilestones) { + await fs.writeFile(filePath, JSON.stringify(sortedChangelog, null, 2)); + } + + await generateModuleMarkdownChangelog(moduleName, sortedChangelog); +}; + +const generateModuleMarkdownChangelog = async (moduleName: string, sortedChangelog: ChangelogItem[]) => { + const majorVersions = new Set(); + sortedChangelog.forEach((m) => { + const [major] = m.version.split('.'); + if (major) { + majorVersions.add(major); + } + }); + + const changelogPath = getModuleMarkdownChangelogPath(moduleName); + await fs.mkdir(path.dirname(changelogPath), { recursive: true }); + let content = ''; + + const sortedMajors = [...majorVersions].toSorted((a, b) => parseInt(b, 10) - parseInt(a, 10)); + + for (const major of sortedMajors) { + content += `## Version ${major} {#v${major}}\n\n`; + + const milestones = sortedChangelog.filter((m) => m.version.startsWith(`${major}.`)); + + for (const milestone of milestones) { + const hasBreaking = milestone.issues.some((issue) => issue.labels.some((label) => label === 'breaking changes')); + + const releaseNotesIssue = milestone.issues.find((issue) => + issue.labels.some((label) => label === 'release notes'), + ); + + content += `### [${milestone.version}](${milestone.url}) `; + + if (releaseNotesIssue) { + content += ` `; + } + + if (hasBreaking) { + content += ` `; + } + + content += `{#${milestone.version}} `; + + content += `\n\n`; + + for (const issue of milestone.issues) { + content += `- [#${issue.number}](${issue.url}) ${escapeHtml(issue.title)}\n`; + } + content += '\n'; + } + } + + await fs.writeFile(changelogPath, content); +}; + +const escapeHtml = (text: string) => text.replaceAll('<', '<').replaceAll('>', '>'); diff --git a/cli/getModuleInfo.js b/cli/getModuleInfo.js deleted file mode 100755 index 4712181..0000000 --- a/cli/getModuleInfo.js +++ /dev/null @@ -1,250 +0,0 @@ -const { execFileSync } = require('child_process'); -const fs = require('fs/promises'); -const _ = require('lodash'); -const Semver = require('semver'); - -const { modules } = require('./modules'); -const { slugger, marked } = require('./markdown'); -const { - getExisting, - getModuleStoragePath, - getModuleInfoPath, -} = require('./paths'); -const { getHtmlContent, getRawContent, getRepoInfo } = require('./gh'); -const { generateChangelog } = require('./generateChangelog'); - -function buildTree(headings) { - const rootNode = { id: null, text: null, children: [] }; - const stack = [{ node: rootNode, depth: -1 }]; - - for (const { text, depth } of headings) { - while (stack.at(-1).depth >= depth) { - stack.pop(); - } - const node = { - id: slugger.slug(text), - text: text.replace(/\(([^#*]+)\)/g, '()').replace(/(^`)|(`$)/g, ''), - }; - const parentPath = `${stack - .slice(1) - .map((item) => item.node.text) - .join('.')}.`; - if (node.text.startsWith(parentPath)) { - node.text = node.text.slice(parentPath.length); - } - node.text = marked.parseInline(node.text); - const parentNode = stack.at(-1).node; - if (parentNode.children) { - parentNode.children.push(node); - } else { - parentNode.children = [node]; - } - stack.push({ node, depth }); - } - - return rootNode; -} - -const partsToLookFor = [ - { part: 'Introduction', test: /^Introduction$/ }, - { part: 'Usage', test: /^(General )?Usage$/ }, - { part: 'Advanced', test: /^Advanced$/ }, - { part: 'Example', test: /^Example$/ }, - { part: 'F.A.Q', test: /^F.A.Q$/ }, -]; - -function getFilteredVersions(specs, versions) { - const compatibilityVersions = Object.keys(specs.compatibility); - const minVersion = _.min(compatibilityVersions); - const prefilteredVersions = _.filter(versions, (v) => - Semver.satisfies(v, `>=${minVersion}`) - ); - const publishedMajorVersions = _(prefilteredVersions) - .map((v) => Semver.major(v)) - .uniq() - .value(); - const semveredMajors = compatibilityVersions.map((v) => `${v}.0.0`); - - return publishedMajorVersions.map((major) => { - const closestMatchingVersion = Semver.maxSatisfying( - semveredMajors, - `<= ${major}` - ); - return { - nodeVersions: closestMatchingVersion - ? specs.compatibility[Semver.major(closestMatchingVersion)] - : [] ?? [], - fullVersion: Semver.maxSatisfying(prefilteredVersions, `^${major}`), - major, - }; - }); -} - -async function getInfo() { - const repos = {}; - for (const [moduleName, specs] of Object.entries(modules)) { - console.info(`Processing ${moduleName}`); - - await fs.mkdir(getModuleStoragePath(moduleName), { - recursive: true, - }); - - const filePath = getModuleInfoPath(moduleName); - - const existing = await getExisting(filePath); - - // Get published versions from npm - const versions = JSON.parse( - execFileSync('npm', ['view', specs.package, 'versions', '--json']) - ); - - const currentModule = { - name: moduleName, - versions: [], - versionsArray: [], - docs: {}, - api: false, - }; - repos[moduleName] = currentModule; - - // Keep only latest versions from majors listed in specs - const filteredVersions = getFilteredVersions(specs, versions); - - for (const { nodeVersions, fullVersion, major } of filteredVersions) { - if (existing?.docs[fullVersion]) { - console.info(`[docs] Skipping ${moduleName}@${major}`); - currentModule.versions.push( - existing.versions.find((v) => v.name === fullVersion) - ); - currentModule.versionsArray.push(fullVersion); - currentModule.docs[fullVersion] = existing.docs[fullVersion]; - currentModule.api = true; - continue; - } - - console.info(`[docs] Processing ${moduleName}@${fullVersion}`); - const tagName = `v${fullVersion}`; - - const api = await getRawContent(moduleName, 'API.md', tagName); - currentModule.api = true; - const chunks = marked.lexer(api.data); - const parts = new Map([['Other', []]]); - const headings = []; - - if (moduleName === 'joi') { - parts.get('Other').push(...chunks); - headings.push(...chunks.filter((chunk) => chunk.type === 'heading')); - } else { - let currentPart; - for (const chunk of chunks) { - if (chunk.type === 'heading') { - const matchingPart = partsToLookFor.find(({ test }) => - test.test(chunk.text) - ); - if (matchingPart) { - currentPart = matchingPart.part; - parts.set(currentPart, [chunk]); - continue; - } else { - headings.push(chunk); - currentPart = null; - } - } - - if (currentPart) { - parts.get(currentPart).push(chunk); - } else { - parts.get('Other').push(chunk); - } - } - } - const [intro, usage, advanced, example, faq] = partsToLookFor.map( - ({ part }) => { - if (parts.has(part)) { - return parts - .get(part) - .map((chunk) => chunk.raw) - .join(''); - } - return ''; - } - ); - const rendered = marked.parse( - parts - .get('Other') - .map((chunk) => chunk.raw) - .join('') - ); - currentModule.docs[fullVersion] = { - menu: buildTree(headings), - api: rendered, - intro: marked.parse(intro), - example: marked.parse(example), - usage: marked.parse(usage), - faq: marked.parse(faq), - advanced: marked.parse(advanced), - license: 'BSD', - }; - currentModule.versionsArray.push(fullVersion); - currentModule.versions.push({ - name: fullVersion, - branch: tagName, - license: 'BSD', - node: nodeVersions, - }); - } - currentModule.versions.sort((a, b) => Semver.rcompare(a.name, b.name)); - - const readme = await getRawContent(moduleName, 'README.md'); - const repoInfo = await getRepoInfo(moduleName); - currentModule.slogan = marked.parseInline( - readme.data.match(/####(.*)/gm) !== null - ? readme.data.match(/####(.*)/gm)[0].substring(5) - : 'Description coming soon...' - ); - currentModule.forks = repoInfo.data.forks_count; - currentModule.stars = repoInfo.data.stargazers_count; - currentModule.updated = repoInfo.data.pushed_at; - currentModule.link = repoInfo.data.html_url; - - await fs.writeFile(filePath, JSON.stringify(currentModule, null, 2)); - - await generateChangelog(moduleName); - - repos[moduleName] = { - slogan: currentModule.slogan, - link: currentModule.link, - stars: currentModule.stars, - forks: currentModule.forks, - updated: currentModule.updated, - versions: currentModule.versions, - }; - } - - const policies = [ - ['coc', 'CODE_OF_CONDUCT'], - ['contributing', 'CONTRIBUTING'], - ['license', 'LICENSE'], - ['security', 'SECURITY'], - ['styleguide', 'STYLE', 'assets'], - ['support', 'SUPPORT'], - ]; - - await Promise.all( - policies.map(async ([policy, fileName, repo]) => { - await fs.writeFile( - getModuleStoragePath(`${policy}.json`), - JSON.stringify({ - [policy]: await getHtmlContent(`${fileName}.md`, repo), - }) - ); - }) - ); - - await fs.writeFile( - getModuleStoragePath('modules.json'), - JSON.stringify(repos, null, 2) - ); -} - -getInfo(); diff --git a/cli/getModuleInfo.ts b/cli/getModuleInfo.ts new file mode 100644 index 0000000..bb2e257 --- /dev/null +++ b/cli/getModuleInfo.ts @@ -0,0 +1,248 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { Semaphore, uniq } from 'es-toolkit'; +import Semver from 'semver'; + +import { generateChangelog } from './generateChangelog.js'; +import { getRawContent, getRepoInfo } from './gh.js'; +import { modules } from './modules.js'; +import { + API_DIR, + METADATA_DIR, + MODULE_DIR, + POLICIES_GENERATED_DIR, + getExisting, + getModuleInfoPath, + getModuleMarkdownPath, + getModuleStoragePath, +} from './paths.js'; + +import type { ModuleInfo, ModuleSpec, VersionInfo } from './types.js'; + +const getFilteredVersions = (specs: ModuleSpec, versions: string[]): VersionInfo[] => { + const compatibilityVersions = Object.keys(specs.compatibility); + const minVersion = Math.min(...compatibilityVersions.map((v) => parseInt(v, 10))); + const prefilteredVersions = versions.filter((v) => Semver.satisfies(v, `>=${minVersion}`)); + const publishedMajorVersions = uniq(prefilteredVersions.map((v) => Semver.major(v))); + const semveredMajors = compatibilityVersions.map((v) => `${v}.0.0`); + + return publishedMajorVersions.map((major) => { + const closestMatchingVersion = Semver.maxSatisfying(semveredMajors, `<= ${major}`); + return { + fullVersion: Semver.maxSatisfying(prefilteredVersions, `^${major}`)!, + major, + nodeVersion: closestMatchingVersion ? specs.compatibility[Semver.major(closestMatchingVersion)] : '', + }; + }); +}; + +const repos: Record> = {}; + +const limit = new Semaphore(4); + +const processModule = async (moduleName: string, specs: ModuleSpec) => { + console.info(`Processing ${moduleName}`); + + await fs.mkdir(getModuleStoragePath(moduleName), { + recursive: true, + }); + + const filePath = getModuleInfoPath(moduleName); + + const existing = await getExisting(filePath); + + // Get published versions from npm + const versions = JSON.parse( + execFileSync('npm', ['view', specs.package, 'versions', '--json'], { + encoding: 'utf8', + }), + ) as string[]; + + const currentModule: ModuleInfo = { + api: false, + forks: 0, + link: '', + name: moduleName, + package: specs.package, + slogan: '', + stars: 0, + updated: '', + versions: [], + versionsArray: [], + }; + repos[moduleName] = currentModule; + + // Keep only latest versions from majors listed in specs + const filteredVersions = getFilteredVersions(specs, versions); + + currentModule.versionsArray = filteredVersions.map((v) => v.fullVersion).toSorted((a, b) => Semver.compare(b, a)); + + const versionLimit = new Semaphore(10); + const versionTasks = filteredVersions.map(async ({ nodeVersion, fullVersion, major }) => { + const apiPath = getModuleMarkdownPath(moduleName, major); + const apiExists = await fs + .access(apiPath) + .then(() => true) + .catch(() => false); + + const existingVersion = existing?.versions.find((v) => v.name === fullVersion); + + if (existingVersion && apiExists) { + console.info(`[docs] Skipping ${moduleName}@${major}`); + return { + apiExists: true, + branch: existingVersion.branch, + license: existingVersion.license, + name: existingVersion.name, + node: existingVersion.node, + }; + } + + await versionLimit.acquire(); + try { + console.info(`[docs] Processing ${moduleName}@${fullVersion}`); + const tagName = `v${fullVersion}`; + + const api = await getRawContent(moduleName, 'API.md', tagName); + + await fs.mkdir(path.dirname(apiPath), { recursive: true }); + + await fs.writeFile(apiPath, api.data); + console.info(`[docs] Wrote ${apiPath}`); + + return { + apiExists: true, + branch: tagName, + license: 'BSD', + name: fullVersion, + node: nodeVersion, + }; + } finally { + versionLimit.release(); + } + }); + + const versionResults = await Promise.all(versionTasks); + for (const result of versionResults) { + currentModule.versions.push({ + branch: result.branch, + license: result.license, + name: result.name, + node: result.node, + }); + if (result.apiExists) { + currentModule.api = true; + } + } + + currentModule.versions.sort((a, b) => Semver.compare(a.name, b.name)); + + const [readme, repoInfo] = await Promise.all([ + existing ? Promise.resolve({ data: '' }) : getRawContent(moduleName, 'README.md'), + existing + ? Promise.resolve({ + data: { + forks_count: existing.forks, + html_url: existing.link, + pushed_at: existing.updated, + stargazers_count: existing.stars, + }, + }) + : getRepoInfo(moduleName), + ]); + + const readmeMatch = readme.data.match(/####(.*)/gm); + const rawSlogan = readmeMatch === null ? (existing?.slogan ?? 'Description coming soon...') : readmeMatch[0].slice(5); + + currentModule.slogan = rawSlogan.trim(); + currentModule.forks = repoInfo.data.forks_count; + currentModule.stars = repoInfo.data.stargazers_count; + currentModule.updated = repoInfo.data.pushed_at; + currentModule.link = repoInfo.data.html_url; + + const moduleDir = getModuleStoragePath(moduleName); + await fs.mkdir(moduleDir, { recursive: true }); + + await fs.writeFile(filePath, JSON.stringify(currentModule, null, 2)); + + if (moduleName === 'joi') { + await fs.mkdir(API_DIR, { recursive: true }); + } + + await generateChangelog(moduleName); + + await fs.writeFile(filePath, JSON.stringify(currentModule, null, 2)); + + repos[moduleName] = { + forks: currentModule.forks, + link: currentModule.link, + package: currentModule.package, + slogan: currentModule.slogan, + stars: currentModule.stars, + updated: currentModule.updated, + versions: currentModule.versions, + versionsArray: currentModule.versionsArray, + }; +}; + +const moduleTasks = Object.entries(modules).map(async ([moduleName, specs]) => { + await limit.acquire(); + try { + await processModule(moduleName, specs); + } finally { + limit.release(); + } +}); + +await Promise.all(moduleTasks); + +const sortedRepos = Object.fromEntries(Object.keys(modules).map((name) => [name, repos[name]])); + +const policies: [string, string, string?][] = [ + ['coc', 'CODE_OF_CONDUCT'], + ['contributing', 'CONTRIBUTING'], + ['license', 'LICENSE'], + ['security', 'SECURITY'], + ['styleguide', 'STYLE', 'assets'], + ['support', 'SUPPORT'], +]; + +await fs.mkdir(POLICIES_GENERATED_DIR, { recursive: true }); +await Promise.all( + policies.map(async ([policy, fileName, repo]) => { + const policyPath = path.join(POLICIES_GENERATED_DIR, `${policy}.md`); + const existingPolicy = await fs.readFile(policyPath, 'utf8').catch(() => null); + if (existingPolicy) { + console.info(`[policy] Skipping ${policy}`); + return; + } + const { data } = await getRawContent(repo ?? '.github', `${fileName}.md`, 'master'); + await fs.writeFile(policyPath, data); + }), +); + +await fs.mkdir(METADATA_DIR, { recursive: true }); +await fs.writeFile(path.join(METADATA_DIR, 'modules.json'), JSON.stringify(sortedRepos, null, 2)); + +// Generate module/index.md +const moduleIndexMdPath = path.join(MODULE_DIR, 'index.md'); +const moduleIndexContent = `# Modules + +The joi ecosystem consists of several modules. + + +`; +await fs.mkdir(MODULE_DIR, { recursive: true }); +await fs.writeFile(moduleIndexMdPath, moduleIndexContent); + +console.info('Running oxfmt...'); +try { + execFileSync('oxfmt', ['./generated'], { stdio: 'inherit' }); + + // Apparently oxfmt sometimes needs a 2nd pass + execFileSync('oxfmt', ['./generated'], { stdio: 'inherit' }); +} catch (error: unknown) { + console.error('Failed to run oxfmt:', error instanceof Error ? error.message : error); +} diff --git a/cli/gh.js b/cli/gh.js deleted file mode 100644 index 7792925..0000000 --- a/cli/gh.js +++ /dev/null @@ -1,73 +0,0 @@ -const { Octokit } = require('@octokit/rest'); -const Semver = require('semver'); -const { marked } = require('./markdown'); - -const gh = new Octokit({ - auth: process.env.GITHUB_TOKEN, -}); - -async function getRawContent(moduleName, filePath, tagName) { - return await gh.repos.getContent({ - owner: 'hapijs', - repo: moduleName, - path: filePath, - ref: tagName, - mediaType: { - format: 'raw', - }, - }); -} - -async function getRepoInfo(moduleName) { - return await gh.repos.get({ - owner: 'hapijs', - repo: moduleName, - }); -} - -async function getMilestones(moduleName) { - const milestones = []; - for await (const response of gh.paginate.iterator(gh.issues.listMilestones, { - owner: 'hapijs', - repo: moduleName, - state: 'closed', - per_page: 100, - })) { - milestones.push(...response.data); - } - return milestones.sort((a, b) => Semver.compare(a.title, b.title)); -} - -async function getMilestoneIssues(moduleName, milestoneNumber) { - const issues = []; - for await (const response of gh.paginate.iterator(gh.issues.listForRepo, { - owner: 'hapijs', - repo: moduleName, - state: 'closed', - milestone: milestoneNumber, - per_page: 100, - })) { - issues.push(...response.data); - } - return issues; -} - -async function getHtmlContent(path, repo = '.github') { - const { data } = await gh.repos.getContent({ - owner: 'hapijs', - repo, - path, - mediaType: { - format: 'raw', - }, - }); - return marked.parse(data); -} - -module.exports = { - getHtmlContent, - getRawContent, - getRepoInfo, - getMilestones, - getMilestoneIssues, -}; diff --git a/cli/gh.ts b/cli/gh.ts new file mode 100644 index 0000000..23c827f --- /dev/null +++ b/cli/gh.ts @@ -0,0 +1,70 @@ +import { throttling } from '@octokit/plugin-throttling'; +import { Octokit } from '@octokit/rest'; +import Semver from 'semver'; + +import type { Issue, Milestone } from './types.js'; + +const MyOctokit = Octokit.plugin(throttling); + +const gh = new MyOctokit({ + auth: process.env.GITHUB_TOKEN, + throttle: { + onRateLimit: (retryAfter, options, octokit, retryCount) => { + octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`); + + if (retryCount < 1) { + // Only retries once + octokit.log.info(`Retrying after ${retryAfter} seconds!`); + return true; + } + }, + onSecondaryRateLimit: (retryAfter, options, octokit) => { + // Does not retry, only logs a warning + octokit.log.warn(`Secondary quota exhausted for request ${options.method} ${options.url}`); + }, + }, +}); + +export const getRawContent = async (repoName: string, filePath: string, tagName?: string) => + (await gh.repos.getContent({ + mediaType: { + format: 'raw', + }, + owner: 'hapijs', + path: filePath, + ref: tagName, + repo: repoName, + })) as unknown as { data: string }; + +export const getRepoInfo = async (moduleName: string) => + await gh.repos.get({ + owner: 'hapijs', + repo: moduleName, + }); + +export const getMilestones = async (moduleName: string) => { + const milestones: Milestone[] = []; + for await (const response of gh.paginate.iterator(gh.issues.listMilestones, { + owner: 'hapijs', + per_page: 100, + repo: moduleName, + state: 'closed', + })) { + milestones.push(...(response.data as Milestone[])); + } + return milestones.toSorted((a, b) => Semver.compare(a.title, b.title)); +}; + +export const getMilestoneIssues = async (moduleName: string, milestoneNumber: number) => { + const issues: Issue[] = []; + for await (const response of gh.paginate.iterator(gh.issues.listForRepo, { + milestone: milestoneNumber.toString(), + owner: 'hapijs', + per_page: 100, + repo: moduleName, + state: 'closed', + })) { + issues.push(...(response.data as Issue[])); + } + return issues; +}; diff --git a/cli/markdown.js b/cli/markdown.js deleted file mode 100644 index d6a1356..0000000 --- a/cli/markdown.js +++ /dev/null @@ -1,34 +0,0 @@ -const marked = require('marked'); -const { markedHighlight } = require('marked-highlight'); -const hljs = require('highlight.js'); -const slugger = new marked.Slugger(); - -const renderer = { - code(...args) { - const original = marked.Renderer.prototype.code.apply(this, args); - return `
${original}
`; - }, - heading(text, level, raw, slugger) { - const id = slugger.slug(raw); - return `${text}\n`; - }, -}; - -marked.use( - markedHighlight({ - highlight: function (code, lang) { - const language = hljs.getLanguage(lang) ? lang : 'plaintext'; - return hljs.highlight(code, { language }).value; - }, - langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class. - }), - { renderer } -); - -marked.setOptions({ - mangle: false, - headerIds: false, -}); - -exports.slugger = slugger; -exports.marked = marked; diff --git a/cli/modules.js b/cli/modules.ts similarity index 53% rename from cli/modules.js rename to cli/modules.ts index 0337b21..dc6d4e9 100644 --- a/cli/modules.js +++ b/cli/modules.ts @@ -1,42 +1,41 @@ -const modules = { - // Order is important here, so having joi first has an impact - joi: { - package: 'joi', - compatibility: { - 17: ['14', '16', '18', '20', '22'], - 18: ['20', '22', '24'], - }, - }, +import type { ModuleSpec } from './types.js'; + +export const modules: Record = { address: { - package: '@hapi/address', compatibility: { - 5: ['14', '16', '18', '20', '22'], + 5: '>= 14', }, + package: '@hapi/address', }, formula: { + compatibility: { + 3: '>= 14', + }, package: '@hapi/formula', + }, + joi: { compatibility: { - 3: ['14', '16', '18', '20', '22'], + 17: '>= 14', + 18: '>= 20', }, + package: 'joi', }, 'joi-date': { - package: '@joi/date', compatibility: { - 2: ['14', '16', '18', '20', '22'], + 2: '>= 14', }, + package: '@joi/date', }, pinpoint: { - package: '@hapi/pinpoint', compatibility: { - 2: ['14', '16', '18', '20', '22'], + 2: '>= 14', }, + package: '@hapi/pinpoint', }, tlds: { - package: '@hapi/tlds', compatibility: { - 1: ['14', '16', '18', '20', '22'], + 1: '>= 14', }, + package: '@hapi/tlds', }, }; - -exports.modules = modules; diff --git a/cli/paths.js b/cli/paths.js deleted file mode 100644 index 658e2ba..0000000 --- a/cli/paths.js +++ /dev/null @@ -1,30 +0,0 @@ -const fs = require('fs/promises'); -const path = require('path'); - -function getModuleStoragePath(moduleName) { - return path.join(__dirname, '../static/lib', moduleName); -} - -function getModuleInfoPath(moduleName) { - return path.join(getModuleStoragePath(moduleName), 'info.json'); -} - -function getModuleChangelogPath(moduleName) { - return path.join(getModuleStoragePath(moduleName), 'changelog.json'); -} - -async function getExisting(filePath) { - try { - const content = await fs.readFile(filePath); - return JSON.parse(content); - } catch { - // Ignore error - } -} - -module.exports = { - getModuleStoragePath, - getModuleInfoPath, - getModuleChangelogPath, - getExisting, -}; diff --git a/cli/paths.ts b/cli/paths.ts new file mode 100644 index 0000000..ef5cf60 --- /dev/null +++ b/cli/paths.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export const GENERATED_DIR = path.join(import.meta.dirname, '../generated'); +export const API_DIR = path.join(import.meta.dirname, '../docs/api'); +export const MODULE_DIR = path.join(import.meta.dirname, '../docs/module'); +export const MARKDOWN_DIR = path.join(GENERATED_DIR, 'markdown'); +export const POLICIES_GENERATED_DIR = path.join(MARKDOWN_DIR, 'policies'); +export const METADATA_DIR = path.join(GENERATED_DIR, 'metadata'); +export const MODULES_DIR = path.join(GENERATED_DIR, 'modules'); + +export const getModuleMarkdownPath = (moduleName: string, major: string | number) => + path.join(MARKDOWN_DIR, moduleName, major.toString(), 'api.md'); + +export const getModuleMarkdownChangelogPath = (moduleName: string) => + path.join(MARKDOWN_DIR, moduleName, 'changelog.md'); + +export const getModuleStoragePath = (moduleName: string) => path.join(MODULES_DIR, moduleName); + +export const getModuleInfoPath = (moduleName: string) => path.join(getModuleStoragePath(moduleName), 'info.json'); + +export const getModuleChangelogPath = (moduleName: string) => + path.join(getModuleStoragePath(moduleName), 'changelog.json'); + +export const getExisting = async (filePath: string): Promise => { + try { + const content = await fs.readFile(filePath, 'utf8'); + return JSON.parse(content) as T; + } catch { + // Ignore error + } +}; diff --git a/cli/types.ts b/cli/types.ts new file mode 100644 index 0000000..e002896 --- /dev/null +++ b/cli/types.ts @@ -0,0 +1,75 @@ +export interface ChangelogItem { + id: number; + number: number; + version: string; + date: string; + url: string; + issues: { + id: number; + number: number; + title: string; + url: string; + labels: string[]; + }[]; +} + +export interface Milestone { + id: number; + number: number; + title: string; + closed_at: string; + html_url: string; +} + +export interface Issue { + id: number; + number: number; + title: string; + html_url: string; + labels: { name: string }[]; +} + +export interface VersionInfo { + nodeVersion: string; + fullVersion: string; + major: number; +} + +export interface ModuleInfo { + name: string; + slogan: string; + forks: number; + stars: number; + updated: string; + link: string; + versions: { + name: string; + branch: string; + license: string; + node: string; + }[]; + versionsArray: string[]; + api: boolean; + package: string; +} + +export interface ModuleMetadata { + slogan: string; + link: string; + stars: number; + forks: number; + updated: string; + versionsArray: string[]; + versions: { + name: string; + branch: string; + license: string; + node: string; + }[]; + package: string; +} + +export interface ModuleSpec { + package: string; + compatibility: Record; +} diff --git a/components/Ads.vue b/components/Ads.vue deleted file mode 100644 index f49bbd4..0000000 --- a/components/Ads.vue +++ /dev/null @@ -1,105 +0,0 @@ -