diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml new file mode 100644 index 000000000..76da0cf10 --- /dev/null +++ b/.github/workflows/www.yaml @@ -0,0 +1,66 @@ +name: www +on: + workflow_dispatch: # Allows manual trigger + schedule: + - cron: "0 */8 * * *" # Runs every 8 hours + pull_request: + push: + branches: + - v4 + +jobs: + www: + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + id-token: write # Needed for auth with Deno Deploy + contents: read # Needed to clone the repository + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Serve Website + run: | + deno task dev & + until curl --output /dev/null --silent --head --fail http://127.0.0.1:8000; do + printf '.' + sleep 1 + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JSR_API: ${{ secrets.JSR_API }} + timeout-minutes: 5 + working-directory: ./www + + - name: Download Staticalize + run: | + wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-linux.tar.gz \ + -O /tmp/staticalize-linux.tar.gz + tar -xzf /tmp/staticalize-linux.tar.gz -C /usr/local/bin + chmod +x /usr/local/bin/staticalize-linux + + - name: Staticalize + run: | + staticalize-linux \ + --site=http://127.0.0.1:8000 \ + --output=www/built \ + --base=https://effection-www.deno.dev/ + + - run: npx pagefind --site built + working-directory: ./www + + - name: Upload to Deno Deploy + uses: denoland/deployctl@v1 + with: + project: effection + entrypoint: "jsr:@std/http/file-server" + root: www/built diff --git a/.gitignore b/.gitignore index 7883eb9be..26b892cd2 100755 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ # Local Netlify folder .netlify -/build/ \ No newline at end of file +/build/ +/node_modules/ diff --git a/deno.json b/deno.json index 824c1702f..b38a09a84 100644 --- a/deno.json +++ b/deno.json @@ -23,7 +23,12 @@ "ts-expect": "npm:ts-expect@1.3.0", "@std/expect": "jsr:@std/expect@1", "ctrlc-windows": "npm:ctrlc-windows@2.2.0", - "tinyexec": "npm:tinyexec@1.0.1" + "tinyexec": "npm:tinyexec@1.0.1", + "https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "npm:path-to-regexp@8.2.0" }, + "nodeModulesDir": "auto", + "workspace": [ + "./www" + ], "version": "4.0.0-alpha.9" } diff --git a/lib/main.ts b/lib/main.ts index 4d77d11d5..0bc54be41 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -180,6 +180,7 @@ function* withHost(op: HostOperation): Operation { // @see https://github.com/iliakan/detect-node/blob/master/index.js } else if ( Object.prototype.toString.call( + // @ts-expect-error we are just detecting the possibility, so type strictness not required typeof globalThis.process !== "undefined" ? globalThis.process : 0, ) === "[object process]" ) { diff --git a/tasks/built-test.ts b/tasks/built-test.ts index 989a6b183..dd9fa1a9a 100644 --- a/tasks/built-test.ts +++ b/tasks/built-test.ts @@ -15,6 +15,7 @@ await build({ deno: true, }, test: true, + testPattern: "test/**/*.test.ts", typeCheck: false, scriptModule: false, esModule: true, diff --git a/www/.gitignore b/www/.gitignore new file mode 100644 index 000000000..87b042e67 --- /dev/null +++ b/www/.gitignore @@ -0,0 +1,5 @@ +/node_modules +pagefind +build +built +tailwind diff --git a/www/README.md b/www/README.md new file mode 100644 index 000000000..dbb22c4c7 --- /dev/null +++ b/www/README.md @@ -0,0 +1,110 @@ +## Effection Website + +The Effection website shows documentation and packages that it pulls from GIT +repositories on GitHub. + +## About Git Integration + +The Effection website uses sophisticated GitHub integration to dynamically load +and display documentation and packages from Effection repositories. This +integration works through multiple layers: + +### Repository Access + +- **Dual Provider System**: Uses both Git commands (`git-provider.ts`) and + GitHub's Octokit API (`octokit-provider.ts`) for redundant access to + repository data +- **Dynamic Remote Management**: Automatically adds and fetches GitHub remotes + for repositories, enabling access to branches, tags, and file contents +- **Branch & Tag Detection**: Intelligently determines whether references are + branches or tags and normalizes them to proper Git reference formats + +### Documentation Loading + +- **Guides**: Pulls structured documentation from `docs/structure.json` and + loads MDX files from the `docs/` directory in the + [**thefrontside/effection**](https://github.com/thefrontside/effection) + repository +- **API Documentation**: Generates API documentation using Deno's documentation + generator from TypeScript source files in the + [**thefrontside/effection**](https://github.com/thefrontside/effection) + repository (supports both v3 and v4 via tags like `effection-v3.*` and + `effection-v4.*`) +- **README Integration**: Loads and renders README.md files from package + directories using MDX processing + +### Package Discovery + +- **Workspace Integration**: Reads `deno.json` files to discover packages and + their configurations within: + - [**thefrontside/effection**](https://github.com/thefrontside/effection) - + Core Effection library packages + - [**thefrontside/effectionx**](https://github.com/thefrontside/effectionx) - + Extended Effection ecosystem packages +- **Multi-Version Support**: Handles different versions of packages by working + with Git tags and branches from both repositories +- **Export Mapping**: Maps package exports to their corresponding source files + for direct GitHub links + +### Dynamic Content + +- **Live Repository Data**: Fetches star counts, default branches, and + repository metadata directly from GitHub +- **Content Versioning**: Supports loading content from specific Git references + (branches, tags, commits) +- **Badge Integration**: Generates badges for JSR packages, npm packages, bundle + sizes, and other metrics + +This integration ensures that the website always reflects the current state of +the Effection ecosystem by pulling data directly from the source repositories on +GitHub. + +## Deployment + +The website is deployed to Deno Deploy using a static site generation process +powered by the [Staticalize](https://github.com/thefrontside/staticalize) +utility: + +### Automated Deployment Process + +- **Trigger**: Runs automatically every 8 hours via scheduled GitHub Action, on + pushes to `main` branch, and can be manually triggered +- **Static Generation**: The live website is crawled and converted to static + files using Staticalize +- **Search Integration**: Pagefind indexes the static content to enable + client-side search functionality +- **Deno Deploy**: Static files are uploaded to + [Deno Deploy](https://deno.com/deploy) and served via their edge network + +### Deployment Pipeline + +1. **Server Startup**: Spins up the dynamic website locally using + `deno task dev` +2. **Content Crawling**: Staticalize crawls `http://127.0.0.1:8000` to generate + static HTML/CSS/JS +3. **Search Indexing**: Pagefind processes the static files to create search + indexes +4. **Upload**: Deploys the built static site to the `effection-www` project on + Deno Deploy + +This ensures the website stays current with repository changes while providing +fast global delivery through static hosting. + +## Troubleshooting + +### "Bad credentials" Error from GitHub API + +If you encounter an `HttpError: Bad credentials` error when running the website: + +1. **Verify your GITHUB_TOKEN**: Ensure the `GITHUB_TOKEN` environment variable + is set with a valid GitHub personal access token +2. **Clear the cache**: Old cached data may contain requests made with an + expired or invalid token. Run the clear-cache task: + ```bash + deno task clear-cache + ``` +3. **Restart the server**: After clearing the cache, restart the development + server + +This error typically occurs when the cache contains authenticated requests from +a previous token that is no longer valid. diff --git a/www/assets/external-link.svg b/www/assets/external-link.svg new file mode 100644 index 000000000..291aad217 --- /dev/null +++ b/www/assets/external-link.svg @@ -0,0 +1,20 @@ + diff --git a/www/assets/images/effection-logo.svg b/www/assets/images/effection-logo.svg new file mode 100644 index 000000000..2fb93d1a7 --- /dev/null +++ b/www/assets/images/effection-logo.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/assets/images/favicon-effection.png b/www/assets/images/favicon-effection.png new file mode 100644 index 000000000..e0c21d7f2 Binary files /dev/null and b/www/assets/images/favicon-effection.png differ diff --git a/www/assets/images/icon-effection.svg b/www/assets/images/icon-effection.svg new file mode 100644 index 000000000..7a63b9cbc --- /dev/null +++ b/www/assets/images/icon-effection.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + diff --git a/www/assets/images/jsr-logo.svg b/www/assets/images/jsr-logo.svg new file mode 100644 index 000000000..abd8f5fd6 --- /dev/null +++ b/www/assets/images/jsr-logo.svg @@ -0,0 +1,14 @@ + diff --git a/www/assets/images/meta-effection.png b/www/assets/images/meta-effection.png new file mode 100644 index 000000000..6268ca9f6 Binary files /dev/null and b/www/assets/images/meta-effection.png differ diff --git a/www/assets/images/overriding-context.svg b/www/assets/images/overriding-context.svg new file mode 100644 index 000000000..d253d5232 --- /dev/null +++ b/www/assets/images/overriding-context.svg @@ -0,0 +1,410 @@ + + + + + + + yield* MyContext.set('A')yield* MyContext.set('B');yield* MyContext;BByield* MyContext;yield* MyContextBAyield* MyContext.set('C');yield* MyContext;CCA + diff --git a/www/assets/prism-atom-one-dark.css b/www/assets/prism-atom-one-dark.css new file mode 100644 index 000000000..653e01920 --- /dev/null +++ b/www/assets/prism-atom-one-dark.css @@ -0,0 +1,543 @@ +/** + * Added to make line numbering and higlight selection work: + * https://github.com/timlrx/rehype-prism-plus#styling + */ +pre { + overflow-x: auto; +} + +/** + * Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/ + * 1. Make the element just wide enough to fit its content. + * 2. Always fill the visible space in .code-highlight. + */ +.code-highlight { + float: left; /* 1 */ + min-width: 100%; /* 2 */ +} + +.code-line { + display: block; + padding-left: 16px; + padding-right: 16px; + margin-left: -16px; + margin-right: -16px; + border-left: 4px solid + rgba( + 0, + 0, + 0, + 0 + ); /* Set placeholder for highlight accent border color to transparent */ +} + +.code-line.inserted { + background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */ +} + +.code-line.deleted { + background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */ +} + +.highlight-line { + margin-left: -16px; + margin-right: -16px; + background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */ + border-left: 4px solid + rgb(59, 130, 246); /* Set highlight accent border color */ +} + +.line-number::before { + display: inline-block; + width: 1rem; + text-align: right; + margin-right: 16px; + margin-left: -8px; + color: var(--prism-syntax-gutter); /* Line number color */ + content: attr(line); +} + +/** + * One Dark theme for prism.js + * Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax + */ + +@media (prefers-color-scheme: light) { + :root { + /* One Light colours */ + --prism-mono-1: hsl(230, 8%, 24%); + --prism-mono-2: hsl(230, 6%, 44%); + --prism-mono-3: hsl(230, 4%, 64%); + --prism-hue-1: hsl(198, 99%, 37%); + --prism-hue-2: hsl(221, 87%, 60%); + --prism-hue-3: hsl(301, 63%, 32%); + --prism-hue-4: hsl(119, 34%, 47%); + --prism-hue-5: hsl(5, 74%, 59%); + --prism-hue-5-2: hsl(344, 84%, 43%); + --prism-hue-6: hsl(35, 99%, 36%); + --prism-hue-6-2: hsl(35, 99%, 40%); + --prism-syntax-fg: hsl(230, 8%, 24%); + --prism-syntax-bg: hsl(230, 1%, 98%); + --prism-syntax-gutter: hsl(230, 1%, 62%); + --prism-syntax-guide: hsla(230, 8%, 24%, 0.2); + --prism-syntax-accent: hsl(230, 100%, 66%); + --prism-syntax-selection-color: hsl(230, 1%, 90%); + --prism-syntax-gutter-background-color-selected: hsl(230, 1%, 92%); + --prism-syntax-cursor-line: hsla(230, 8%, 24%, 0.04); + } + + /* Global code block overrides for light mode */ + pre.grid, + code.code-highlight { + color: var(--prism-syntax-fg) !important; + background: var(--prism-syntax-bg) !important; + } +} + +@media (prefers-color-scheme: dark) { + :root { + /* One Dark colours (accurate as of commit 8ae45ca on 6 Sep 2018) */ + --prism-mono-1: hsl(220, 14%, 71%); + --prism-mono-2: hsl(220, 9%, 55%); + --prism-mono-3: hsl(220, 10%, 40%); + --prism-hue-1: hsl(187, 47%, 55%); + --prism-hue-2: hsl(207, 82%, 66%); + --prism-hue-3: hsl(286, 60%, 67%); + --prism-hue-4: hsl(95, 38%, 62%); + --prism-hue-5: hsl(355, 65%, 65%); + --prism-hue-5-2: hsl(5, 48%, 51%); + --prism-hue-6: hsl(29, 54%, 61%); + --prism-hue-6-2: hsl(39, 67%, 69%); + --prism-syntax-fg: hsl(220, 14%, 71%); + --prism-syntax-bg: hsl(220, 13%, 18%); + --prism-syntax-gutter: hsl(220, 14%, 45%); + --prism-syntax-guide: hsla(220, 14%, 71%, 0.15); + --prism-syntax-accent: hsl(220, 100%, 66%); + --prism-syntax-selection-color: hsl(220, 13%, 28%); + --prism-syntax-gutter-background-color-selected: hsl(220, 13%, 26%); + --prism-syntax-cursor-line: hsla(220, 55%, 36%, 0.04); + } + + /* Global code block overrides for dark mode */ + code, + code::before, + code::after { + color: var(--prism-syntax-fg) !important; + } +} + +code[class*="language-"], +pre[class*="language-"] { + background: var(--prism-syntax-bg); + color: var(--prism-syntax-fg); + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: + "Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Selection */ +code[class*="language-"]::-moz-selection, +code[class*="language-"] *::-moz-selection, +pre[class*="language-"] *::-moz-selection { + background: var(--prism-syntax-selection-color); + color: inherit; + text-shadow: none; +} + +code[class*="language-"]::selection, +code[class*="language-"] *::selection, +pre[class*="language-"] *::selection { + background: var(--prism-syntax-selection-color); + color: inherit; + text-shadow: none; +} + +/* Code blocks */ +pre[class*="language-"] { + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.2em 0.3em; + border-radius: 0.3em; + white-space: normal; +} + +/* Print */ +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +.token.comment, +.token.prolog, +.token.cdata { + color: var(--prism-mono-3); +} + +.token.doctype, +.token.punctuation, +.token.entity { + color: var(--prism-mono-1); +} + +.token.attr-name, +.token.class-name, +.token.boolean, +.token.constant, +.token.number, +.token.atrule { + color: var(--prism-hue-6); +} + +.token.keyword { + color: var(--prism-hue-3); +} + +.token.property, +.token.tag, +.token.symbol, +.token.deleted, +.token.important { + color: var(--prism-hue-5); +} + +.token.selector, +.token.string, +.token.char, +.token.builtin, +.token.inserted, +.token.regex, +.token.attr-value, +.token.attr-value > .token.punctuation { + color: var(--prism-hue-4); +} + +.token.variable, +.token.operator, +.token.function { + color: var(--prism-hue-2); +} + +.token.url { + color: var(--prism-hue-1); +} + +/* HTML overrides */ +.token.attr-value > .token.punctuation.attr-equals, +.token.special-attr > .token.attr-value > .token.value.css { + color: var(--prism-mono-1); +} + +/* CSS overrides */ +.language-css .token.selector { + color: var(--prism-hue-5); +} + +.language-css .token.property { + color: var(--prism-mono-1); +} + +.language-css .token.function, +.language-css .token.url > .token.function { + color: var(--prism-hue-1); +} + +.language-css .token.url > .token.string.url { + color: var(--prism-hue-4); +} + +.language-css .token.important, +.language-css .token.atrule .token.rule { + color: var(--prism-hue-3); +} + +/* JS overrides */ +.language-javascript .token.operator { + color: var(--prism-hue-3); +} + +.language-javascript + .token.template-string + > .token.interpolation + > .token.interpolation-punctuation.punctuation { + color: var(--prism-hue-5-2); +} + +/* JSON overrides */ +.language-json .token.operator { + color: var(--prism-mono-1); +} + +.language-json .token.null.keyword { + color: var(--prism-hue-6); +} + +/* MD overrides */ +.language-markdown .token.url, +.language-markdown .token.url > .token.operator, +.language-markdown .token.url-reference.url > .token.string { + color: var(--prism-mono-1); +} + +.language-markdown .token.url > .token.content { + color: var(--prism-hue-2); +} + +.language-markdown .token.url > .token.url, +.language-markdown .token.url-reference.url { + color: var(--prism-hue-1); +} + +.language-markdown .token.blockquote.punctuation, +.language-markdown .token.hr.punctuation { + color: var(--prism-mono-3); + font-style: italic; +} + +.language-markdown .token.code-snippet { + color: var(--prism-hue-4); +} + +.language-markdown .token.bold .token.content { + color: var(--prism-hue-6); +} + +.language-markdown .token.italic .token.content { + color: var(--prism-hue-3); +} + +.language-markdown .token.strike .token.content, +.language-markdown .token.strike .token.punctuation, +.language-markdown .token.list.punctuation, +.language-markdown .token.title.important > .token.punctuation { + color: var(--prism-hue-5); +} + +/* General */ +.token.bold { + font-weight: bold; +} + +.token.comment, +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +.token.namespace { + opacity: 0.8; +} + +/* Plugin overrides */ +/* Selectors should have higher specificity than those in the plugins' default stylesheets */ + +/* Show Invisibles plugin overrides */ +.token.token.tab:not(:empty):before, +.token.token.cr:before, +.token.token.lf:before, +.token.token.space:before { + color: var(--prism-syntax-guide); + text-shadow: none; +} + +/* Toolbar plugin overrides */ +/* Space out all buttons and move them away from the right edge of the code block */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item { + margin-right: 0.4em; +} + +/* Styling the buttons */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span { + background: var(--prism-syntax-gutter-background-color-selected); + color: var(--prism-mono-2); + padding: 0.1em 0.4em; + border-radius: 0.3em; +} + +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus { + background: var(--prism-syntax-selection-color); + color: var(--prism-mono-1); +} + +/* Line Highlight plugin overrides */ +/* The highlighted line itself */ +.line-highlight.line-highlight { + background: var(--prism-syntax-cursor-line); +} + +/* Default line numbers in Line Highlight plugin */ +.line-highlight.line-highlight:before, +.line-highlight.line-highlight[data-end]:after { + background: var(--prism-syntax-gutter-background-color-selected); + color: var(--prism-mono-1); + padding: 0.1em 0.6em; + border-radius: 0.3em; + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */ +} + +/* Hovering over a linkable line number (in the gutter area) */ +/* Requires Line Numbers plugin as well */ +pre[id].linkable-line-numbers.linkable-line-numbers + span.line-numbers-rows + > span:hover:before { + background-color: var(--prism-syntax-cursor-line); +} + +/* Line Numbers and Command Line plugins overrides */ +/* Line separating gutter from coding area */ +.line-numbers.line-numbers .line-numbers-rows, +.command-line .command-line-prompt { + border-right-color: var(--prism-syntax-guide); +} + +/* Stuff in the gutter */ +.line-numbers .line-numbers-rows > span:before, +.command-line .command-line-prompt > span:before { + color: var(--prism-syntax-gutter); +} + +/* Match Braces plugin overrides */ +/* Note: Outline colour is inherited from the braces */ +.rainbow-braces .token.token.punctuation.brace-level-1, +.rainbow-braces .token.token.punctuation.brace-level-5, +.rainbow-braces .token.token.punctuation.brace-level-9 { + color: var(--prism-hue-5); +} + +.rainbow-braces .token.token.punctuation.brace-level-2, +.rainbow-braces .token.token.punctuation.brace-level-6, +.rainbow-braces .token.token.punctuation.brace-level-10 { + color: var(--prism-hue-4); +} + +.rainbow-braces .token.token.punctuation.brace-level-3, +.rainbow-braces .token.token.punctuation.brace-level-7, +.rainbow-braces .token.token.punctuation.brace-level-11 { + color: var(--prism-hue-2); +} + +.rainbow-braces .token.token.punctuation.brace-level-4, +.rainbow-braces .token.token.punctuation.brace-level-8, +.rainbow-braces .token.token.punctuation.brace-level-12 { + color: var(--prism-hue-3); +} + +/* Diff Highlight plugin overrides */ +/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */ +pre.diff-highlight > code .token.token.deleted:not(.prefix), +pre > code.diff-highlight .token.token.deleted:not(.prefix) { + background-color: hsla(353, 100%, 66%, 0.15); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix), +pre > code.diff-highlight .token.token.inserted:not(.prefix) { + background-color: hsla(137, 100%, 55%, 0.15); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +/* Previewers plugin overrides */ +/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */ +/* Border around popup */ +.prism-previewer.prism-previewer:before, +.prism-previewer-gradient.prism-previewer-gradient div { + border-color: hsl(224, 13%, 17%); +} + +/* Angle and time should remain as circles and are hence not included */ +.prism-previewer-color.prism-previewer-color:before, +.prism-previewer-gradient.prism-previewer-gradient div, +.prism-previewer-easing.prism-previewer-easing:before { + border-radius: 0.3em; +} + +/* Triangles pointing to the code */ +.prism-previewer.prism-previewer:after { + border-top-color: hsl(224, 13%, 17%); +} + +.prism-previewer-flipped.prism-previewer-flipped.after { + border-bottom-color: hsl(224, 13%, 17%); +} + +/* Background colour within the popup */ +.prism-previewer-angle.prism-previewer-angle:before, +.prism-previewer-time.prism-previewer-time:before, +.prism-previewer-easing.prism-previewer-easing { + background: hsl(219, 13%, 22%); +} + +/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */ +/* For time, this is the alternate colour */ +.prism-previewer-angle.prism-previewer-angle circle, +.prism-previewer-time.prism-previewer-time circle { + stroke: var(--prism-mono-1); + stroke-opacity: 1; +} + +/* Stroke colours of the handle, direction point, and vector itself */ +.prism-previewer-easing.prism-previewer-easing circle, +.prism-previewer-easing.prism-previewer-easing path, +.prism-previewer-easing.prism-previewer-easing line { + stroke: var(--prism-mono-1); +} + +/* Fill colour of the handle */ +.prism-previewer-easing.prism-previewer-easing circle { + fill: transparent; +} diff --git a/www/assets/search.js b/www/assets/search.js new file mode 100644 index 000000000..837e84938 --- /dev/null +++ b/www/assets/search.js @@ -0,0 +1,98 @@ +import { + all, + createChannel, + each, + main, + on, + resource, + sleep, + spawn, + // deno-lint-ignore no-import-prefix +} from "https://esm.sh/effection@4.0.0-beta.3"; + +await main(function* () { + const input = document.getElementById("search"); + if (!input) { + console.log(`Search could not be setup because input was not found.`); + return; + } + + const label = input.closest("label"); + if (!label) { + console.log( + `Search could not be setup because label element was not found.`, + ); + return; + } + + const button = input.nextElementSibling; + if (!button) { + console.log( + `Search could not be setup because button element was not found.`, + ); + return; + } + + const events = yield* join([ + on(input, "focus"), + on(button, "focus"), + on(input, "blur"), + on(button, "blur"), + ]); + + /** @type {Task} */ + let lastBlur; + yield* spawn(function* () { + for (const event of yield* each(events)) { + if (event.type === "blur") { + lastBlur = yield* spawn(function* () { + yield* sleep(15); + input.value = ""; + input.setAttribute("placeholder", "โŒ˜K"); + input.classList.remove("focused"); + }); + } else { + if (lastBlur) { + yield* lastBlur.halt(); + } + input.removeAttribute("placeholder"); + input.classList.add("focused"); + } + yield* each.next(); + } + }); + + for (const event of yield* each(on(document, "keydown"))) { + if (event.metaKey && event.key === "k") { + event.preventDefault(); + input.focus(); + } + if (event.key === "Escape") { + input.blur(); + } + yield* each.next(); + } +}); + +/** + * Combine multiple streams into a single stream + * @template {T} + * @param {Stream[]} streams + * @returns {Operation>} + */ +function join(streams) { + return resource(function* (provide) { + const channel = createChannel(); + + yield* spawn(function* () { + yield* all(streams.map(function* (stream) { + for (const event of yield* each(stream)) { + yield* channel.send(event); + yield* each.next(); + } + })); + }); + + yield* provide(channel); + }); +} diff --git a/www/components/alert.tsx b/www/components/alert.tsx new file mode 100644 index 000000000..ef1ad9164 --- /dev/null +++ b/www/components/alert.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck Property 'role' does not exist on type 'HTMLElement'.deno-ts(2322) +import { JSXChild } from "revolution/jsx-runtime"; + +const ALERT_LEVELS = { + warning: + "bg-orange-100 border-orange-500 text-orange-700 dark:bg-orange-900 dark:border-orange-300 dark:text-orange-200", + error: + "bg-red-100 border-red-400 text-red-700 dark:bg-red-900 dark:border-red-300 dark:text-red-200", + info: + "bg-blue-100 border-blue-500 text-blue-700 dark:bg-blue-900 dark:border-blue-300 dark:text-blue-200", +} as const; + +export function Alert({ + title, + children, + class: className, + level, +}: { + title?: string; + level: "info" | "warning" | "error"; + children: JSXChild; + class?: string; +}) { + return ( +
p]:my-1`} + role="alert" + > + {title ?

{title}

: <>} + {children} +
+ ); +} diff --git a/www/components/api/api-page.tsx b/www/components/api/api-page.tsx new file mode 100644 index 000000000..a8bfc9495 --- /dev/null +++ b/www/components/api/api-page.tsx @@ -0,0 +1,208 @@ +import type { JSXElement } from "revolution"; +import { DocPage } from "../../hooks/use-deno-doc.tsx"; +import { ResolveLinkFunction, useMarkdown } from "../../hooks/use-markdown.tsx"; +import { Package } from "../../lib/package.ts"; +import { major } from "../../lib/semver.ts"; +import { createSibling } from "../../routes/links-resolvers.ts"; +import { IconExternal } from "../icons/external.tsx"; +import { SourceCodeIcon } from "../icons/source-code.tsx"; +import { GithubPill } from "../package/source-link.tsx"; +import { Icon } from "../type/icon.tsx"; +import { Type } from "../type/jsx.tsx"; +import { Keyword } from "../type/tokens.tsx"; + +export function* ApiPage({ + pages, + current, + pkg, + externalLinkResolver, + banner, +}: { + current: string; + pages: DocPage[]; + pkg: Package; + banner?: JSXElement; + externalLinkResolver: ResolveLinkFunction; +}) { + const page = pages.find((node) => node.name === current); + + if (!page) throw new Error(`Could not find a doc page for ${current}`); + + const linkResolver: ResolveLinkFunction = function* resolve( + symbol, + connector, + method, + ) { + const target = pages && + pages.find((page) => page.name === symbol && page.kind !== "import"); + + if (target) { + return `[${ + [symbol, connector, method].join( + "", + ) + }](${yield* externalLinkResolver(symbol, connector, method)})`; + } else { + return symbol; + } + }; + + return ( + <> + {yield* ApiReference({ + pages, + current, + pkg, + content: ( + <> + <>{banner} + {yield* SymbolHeader({ pkg, page })} + {yield* ApiBody({ page, linkResolver })} + + ), + linkResolver: createSibling, + })} + + ); +} + +export function* ApiBody({ + page, + linkResolver, +}: { + page: DocPage; + linkResolver: ResolveLinkFunction; +}) { + const elements: JSXElement[] = []; + + for (const [i, section] of Object.entries(page.sections)) { + if (section.markdown) { + elements.push( +
+
+

+ {yield* Type({ node: section.node })} +

+ + + +
+
+ {yield* useMarkdown(section.markdown, { + linkResolver, + slugPrefix: section.id, + })} +
+
, + ); + } + } + + return <>{elements}; +} + +export function* ApiReference({ + pkg, + content, + current, + pages, + linkResolver, +}: { + pkg: Package; + content: JSXElement; + current: string; + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + return ( +
+ +
+ {content} +
+
+ ); +} + +export function* SymbolHeader({ page, pkg }: { page: DocPage; pkg: Package }) { + return ( +
+

+ + {page.kind === "typeAlias" ? "type alias " : page.kind} + {" "} + {page.name} +

+ {yield* GithubPill({ + url: pkg.ref.url, + text: pkg.ref.nameWithOwner, + // url: pkg.source.toString(), + // text: pkg.ref.repository.nameWithOwner, + })} +
+ ); +} + +function* Menu({ + pages, + current, + linkResolver, +}: { + current: string; + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + const elements = []; + for (const page of pages.sort((a, b) => a.name.localeCompare(b.name))) { + elements.push( +
  • + {current === page.name + ? ( + + + {page.name} + + ) + : ( + + + {page.name} + + )} +
  • , + ); + } + return {elements}; +} diff --git a/www/components/footer.tsx b/www/components/footer.tsx new file mode 100644 index 000000000..b5a89cc88 --- /dev/null +++ b/www/components/footer.tsx @@ -0,0 +1,57 @@ +import { IconExternal } from "./icons/external.tsx"; + +export function Footer(): JSX.Element { + return ( + + ); +} diff --git a/www/components/header.tsx b/www/components/header.tsx new file mode 100644 index 000000000..d4c3a2343 --- /dev/null +++ b/www/components/header.tsx @@ -0,0 +1,98 @@ +import { useWorkspace } from "../lib/workspace.ts"; +import { getStarCount } from "../lib/octokit.ts"; +import { IconDiscord } from "./icons/discord.tsx"; +import { IconGithub } from "./icons/github.tsx"; +import { StarIcon } from "./icons/star.tsx"; +import { Navburger } from "./navburger.tsx"; +import { SearchInput } from "./search-input.tsx"; + +export interface HeaderProps { + hasLeftSidebar?: boolean; +} + +export function* Header(props?: HeaderProps) { + let x = yield* useWorkspace("thefrontside/effectionx"); + + return ( +
    +
    +
    + + Effection Logo + +
    + +
    +
    + ); +} diff --git a/www/components/icons/cartouche.tsx b/www/components/icons/cartouche.tsx new file mode 100644 index 000000000..6123cc270 --- /dev/null +++ b/www/components/icons/cartouche.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconCartouche = () => ( + + + + + + + + + +); diff --git a/www/components/icons/discord.tsx b/www/components/icons/discord.tsx new file mode 100644 index 000000000..9960366d8 --- /dev/null +++ b/www/components/icons/discord.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconDiscord = () => ( + +); diff --git a/www/components/icons/external.tsx b/www/components/icons/external.tsx new file mode 100644 index 000000000..0c7e828ec --- /dev/null +++ b/www/components/icons/external.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +import { JSXComponentProps } from "revolution/jsx-runtime"; + +export const IconExternal = (props: JSXComponentProps = {}) => ( + +); diff --git a/www/components/icons/github.tsx b/www/components/icons/github.tsx new file mode 100644 index 000000000..163c88430 --- /dev/null +++ b/www/components/icons/github.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconGithub = () => ( + +); diff --git a/www/components/icons/info.tsx b/www/components/icons/info.tsx new file mode 100644 index 000000000..cd7a45e5b --- /dev/null +++ b/www/components/icons/info.tsx @@ -0,0 +1,15 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) + +export function InfoIcon() { + return ( + + ); +} diff --git a/www/components/icons/search.tsx b/www/components/icons/search.tsx new file mode 100644 index 000000000..ef94a2661 --- /dev/null +++ b/www/components/icons/search.tsx @@ -0,0 +1,19 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function SearchIcon(props) { + return ( + + + + + + ); +} diff --git a/www/components/icons/source-code.tsx b/www/components/icons/source-code.tsx new file mode 100644 index 000000000..70e08ba53 --- /dev/null +++ b/www/components/icons/source-code.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) + +export function SourceCodeIcon(props) { + return ( + + + + + + + ); +} diff --git a/www/components/icons/star.tsx b/www/components/icons/star.tsx new file mode 100644 index 000000000..4dc7ae494 --- /dev/null +++ b/www/components/icons/star.tsx @@ -0,0 +1,17 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function StarIcon(props) { + return ( + + ); +} diff --git a/www/components/icons/typescript.tsx b/www/components/icons/typescript.tsx new file mode 100644 index 000000000..930ca7a31 --- /dev/null +++ b/www/components/icons/typescript.tsx @@ -0,0 +1,21 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function IconTSLogo() { + return ( + + + + + + ); +} diff --git a/www/components/navburger.tsx b/www/components/navburger.tsx new file mode 100644 index 000000000..f61168583 --- /dev/null +++ b/www/components/navburger.tsx @@ -0,0 +1,13 @@ +//@ts-nocheck hastx does not currently typecheck correctly +export function Navburger() { + return ( + + Mobile menu + + + ); +} diff --git a/www/components/package/cicle-score.tsx b/www/components/package/cicle-score.tsx new file mode 100644 index 000000000..9757cf901 --- /dev/null +++ b/www/components/package/cicle-score.tsx @@ -0,0 +1,34 @@ +import { PackageDetailsResult } from "../../resources/jsr-client.ts"; + +export function CircleScore({ details }: { details: PackageDetailsResult }) { + return ( + <> +

    + JSR Logo + Score +

    +
    + + {details.score}% + +
    + + ); +} + +/** @src https://github.com/jsr-io/jsr/blob/34603e996f56eb38e811619f8aebc6e5c4ad9fa7/frontend/utils/score_ring_color.ts */ +export function getScoreBgColorClass(score: number): string { + if (score >= 90) { + return "bg-green-500"; + } else if (score >= 60) { + return "bg-yellow-500"; + } + return "bg-red-500"; +} diff --git a/www/components/package/cross.tsx b/www/components/package/cross.tsx new file mode 100644 index 000000000..2477f1287 --- /dev/null +++ b/www/components/package/cross.tsx @@ -0,0 +1,15 @@ +; diff --git a/www/components/package/exports.tsx b/www/components/package/exports.tsx new file mode 100644 index 000000000..54903e03d --- /dev/null +++ b/www/components/package/exports.tsx @@ -0,0 +1,103 @@ +import { join } from "@std/path"; + +import { Keyword, Punctuation } from "../type/tokens.tsx"; +import { DocPage, DocsPages } from "../../hooks/use-deno-doc.tsx"; +import { Operation } from "effection"; +import { JSXChild, JSXElement } from "revolution/jsx-runtime"; +import { ResolveLinkFunction } from "../../hooks/use-markdown.tsx"; + +interface PackageExportsParams { + packageName: string; + docs: DocsPages; + linkResolver: ResolveLinkFunction; +} + +export function* PackageExports({ + packageName, + docs, + linkResolver, +}: PackageExportsParams) { + const elements: JSXElement[] = []; + + for (const [exportName, docPages] of Object.entries(docs)) { + if (docPages.filter((page) => page.kind !== "import").length > 0) { + elements.push( + yield* PackageExport({ + linkResolver, + packageName, + exportName, + docPages, + }), + ); + } + } + + return elements; +} + +interface PackageExportOptions { + packageName: string; + exportName: string; + docPages: Array; + linkResolver: ResolveLinkFunction; +} + +function* PackageExport({ + packageName, + exportName, + docPages, + linkResolver, +}: PackageExportOptions): Operation { + const exports: JSXChild[] = []; + + for ( + const page of docPages + .flatMap((page) => (page.kind === "import" ? [] : [page])) + .sort((a, b) => a.name.localeCompare(b.name)) + ) { + exports.push( + ...[ + + {["enum", "typeAlias", "namespace", "interface"].includes( + page.kind, + ) + ? {"type "} + : ( + "" + )} + {page.name} + , + ", ", + ], + ); + } + + return ( +
    +      
    +        import
    +        {" { "}
    +        
      + {chunk(exports.slice(0, -1)).map((chunk) => ( +
    • {chunk}
    • + ))} +
    + {"} "} + {"from "} + "{join(packageName, exportName)}" +
    +
    + ); +} + +function chunk(array: T[], chunkSize = 2): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + const chunk = array.slice(i, i + chunkSize); + chunks.push(chunk); + } + return chunks; +} diff --git a/www/components/package/header.tsx b/www/components/package/header.tsx new file mode 100644 index 000000000..d846d8a96 --- /dev/null +++ b/www/components/package/header.tsx @@ -0,0 +1,35 @@ +import { Package } from "../../lib/package.ts"; +import { GithubPill } from "./source-link.tsx"; + +export function* PackageHeader(pkg: Package) { + return ( +
    +
    + + + @{pkg.scopeName} + / + {pkg.name.split("/")[1]} + + v{pkg.version ? pkg.version : ""} + + {yield* GithubPill({ + class: "mt-2 xl:mt-0", + url: pkg.ref.url, + text: pkg.ref.nameWithOwner, + })} +
    + +
    + ); +} diff --git a/www/components/package/icons.tsx b/www/components/package/icons.tsx new file mode 100644 index 000000000..d04b8d363 --- /dev/null +++ b/www/components/package/icons.tsx @@ -0,0 +1,715 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'. + +export interface IconProps { + class?: string; + height?: string; + width?: string; + style?: string; +} + +export function Check() { + return ( + + ); +} + +export function Cross() { + return ( + + ); +} + +export function BrowserIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function BunIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function CloudflareWorkersIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function DenoIcon(props: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function NodeIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function NPMIcon(props: IconProps) { + return ( + + + + + ); +} + +export function JSRIcon(props: IconProps) { + return ( + + + + + + + + + ); +} diff --git a/www/components/package/source-link.tsx b/www/components/package/source-link.tsx new file mode 100644 index 000000000..7911a23f1 --- /dev/null +++ b/www/components/package/source-link.tsx @@ -0,0 +1,25 @@ +import { IconExternal } from "../../components/icons/external.tsx"; +import { IconGithub } from "../../components/icons/github.tsx"; + +export function* GithubPill({ + url, + text, + ...props +}: { + url: string; + text: string; + class?: string; +}) { + return ( + + + {text} + + + ); +} diff --git a/www/components/project-select.tsx b/www/components/project-select.tsx new file mode 100644 index 000000000..b65d0f1c3 --- /dev/null +++ b/www/components/project-select.tsx @@ -0,0 +1,91 @@ +import { JSXComponentProps } from "revolution/jsx-runtime"; + +export function ProjectSelect(props: JSXComponentProps) { + let uuid = self.crypto.randomUUID(); + + let toggleId = `toggle-${uuid}`; + let openerId = `opener-${uuid}`; + let closerId = `closer-${uuid}`; + + return ( +
    + + + + +
    + ); +} + +const projects = [ + { + title: "Interactors", + description: "Page Objects for components libraries", + url: "https://frontside.com/interactors", + version: "v1", + img: + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmEgewogICAgICAgIGZpbGw6ICNmZmY7CiAgICAgIH0KCiAgICAgIC5hLCAuYiwgLmMgewogICAgICAgIHN0cm9rZTogIzE0MzA1YzsKICAgICAgICBzdHJva2UtbWl0ZXJsaW1pdDogMTA7CiAgICAgICAgc3Ryb2tlLXdpZHRoOiAycHg7CiAgICAgIH0KCiAgICAgIC5iIHsKICAgICAgICBmaWxsOiAjMjZhYmU4OwogICAgICB9CgogICAgICAuYyB7CiAgICAgICAgZmlsbDogI2Y3NGQ3YjsKICAgICAgfQogICAgPC9zdHlsZT4KICA8L2RlZnM+CiAgPGc+CiAgICA8ZWxsaXBzZSBjbGFzcz0iYSIgY3g9IjI4LjQ1IiBjeT0iNi45MSIgcng9IjQiIHJ5PSIzLjg5Ii8+CiAgICA8cGF0aCBjbGFzcz0iYiIgZD0iTTI4LjQ1LDEwLjhhMy45NCwzLjk0LDAsMCwxLTQtMy44OSw0LDQsMCwwLDEsLjI1LTEuMzQsNi40NSw2LjQ1LDAsMCwwLTEuMzMtLjE0QTYuMyw2LjMsMCwwLDAsMTcsMTEuNjRhNi4zOCw2LjM4LDAsMCwwLDEyLjc1LDAsNi45MSw2LjkxLDAsMCwwLS4xLTFBNC4zLDQuMywwLDAsMSwyOC40NSwxMC44WiIvPgogICAgPHBhdGggY2xhc3M9ImMiIGQ9Ik0xNywxMS42NGE2LjI2LDYuMjYsMCwwLDEsLjEtMSwxMS4xMSwxMS4xMSwwLDAsMC00LjY5LTEsMTAuODIsMTAuODIsMCwwLDAtMTEsMTAuNjhBMTAuODIsMTAuODIsMCwwLDAsMTIuNDEsMzFhMTAuODMsMTAuODMsMCwwLDAsMTEtMTAuNjgsMTAuNjYsMTAuNjYsMCwwLDAtLjMxLTIuNDhBNi4yNyw2LjI3LDAsMCwxLDE3LDExLjY0WiIvPgogIDwvZz4KPC9zdmc+Cg==", + }, + { + title: "Auth0 Simulator", + description: "Enabling testing and local development", + url: "https://github.com/thefrontside/simulacrum/tree/v0/packages/auth0", + version: "v0", + img: + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmEgewogICAgICAgIGZpbGw6ICMxNDMxNWQ7CiAgICAgIH0KCiAgICAgIC5iIHsKICAgICAgICBmaWxsOiAjMjZhYmU4OwogICAgICB9CgogICAgICAuYyB7CiAgICAgICAgZmlsbDogI2Y3NGQ3YjsKICAgICAgICBmaWxsLXJ1bGU6IGV2ZW5vZGQ7CiAgICAgIH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIDxnPgogICAgPHBhdGggY2xhc3M9ImEiIGQ9Ik0xNyw1LjE2bDEwLjMzLDUuOTVWMjIuODlMMTcsMjguODQsNi42NywyMi44OVYxMS4xMUwxNyw1LjE2TTE3LC4yOCwyLjQ1LDguNjZWMjUuMzRMMTcsMzMuNzJsMTQuNTUtOC4zOFY4LjY2TDE3LC4yOFoiLz4KICAgIDxwYXRoIGNsYXNzPSJiIiBkPSJNMTcsNy42OWw4LjEyLDQuNjd2OS4yOEwxNywyNi4zMSw4Ljg4LDIxLjY0VjEyLjM2TDE3LDcuNjltMC0zLjI1TDYuMDYsMTAuNzRWMjMuMjZMMTcsMjkuNTZsMTAuOTQtNi4zVjEwLjc0TDE3LDQuNDRaIi8+CiAgICA8cG9seWdvbiBjbGFzcz0iYyIgcG9pbnRzPSIxNyAxMy44OCAxNC4yOCAxNS40NCAxNC4yOCAxOC41NiAxNyAyMC4xMiAxOS43MiAxOC41NiAxOS43MiAxNS40NCAxNyAxMy44OCIvPgogIDwvZz4KPC9zdmc+Cg==", + }, +]; diff --git a/www/components/rehype.tsx b/www/components/rehype.tsx new file mode 100644 index 000000000..6b99e0810 --- /dev/null +++ b/www/components/rehype.tsx @@ -0,0 +1,23 @@ +import { type PluggableList, unified } from "unified"; + +export interface RehypeOptions { + children: JSX.Element; + plugins: PluggableList; +} + +export function Rehype(options: RehypeOptions): JSX.Element { + let { children, plugins } = options; + let pipeline = unified().use(plugins); + + let result = pipeline.runSync(children); + if ( + result.type === "text" || result.type === "element" || + result.type === "root" + ) { + return result as JSX.Element; + } else { + throw new Error( + `rehype plugin stack: {options.plugins} did not return a HAST Element`, + ); + } +} diff --git a/www/components/score-card.tsx b/www/components/score-card.tsx new file mode 100644 index 000000000..415c44a13 --- /dev/null +++ b/www/components/score-card.tsx @@ -0,0 +1,212 @@ +import { JSXElement } from "revolution/jsx-runtime"; +import { type PackageScoreResult } from "../resources/jsr-client.ts"; +import { + BrowserIcon, + BunIcon, + Check, + CloudflareWorkersIcon, + Cross, + DenoIcon, + IconProps, + JSRIcon, + NodeIcon, + NPMIcon, +} from "./package/icons.tsx"; +import { Package } from "../lib/package.ts"; + +export function* ScoreCard(pkg: Package) { + const [details, score] = yield* pkg.jsrPackageDetails(); + + const jsrScore = (details?.success && details.data && details.data.score) || + 0; + + return ( +
    +
    +
    + + +
    +
    +
    TypeScript
    + Yes +
    +
    + +
    +
    + + + + + +
    +
    +
    + +
    JSR Score
    +
    + {jsrScore}% +
    +
    +
    + {score?.success && score.data + ? + : <>} +
    + ); +} + +interface SupportedEnvironmentProps { + name: string; + enabled: boolean; + width: number; + height: number; + Icon: (props: IconProps) => JSXElement; +} + +function SupportedEnvironment(props: SupportedEnvironmentProps) { + return ( +
    + + {props.enabled ? <> : ( + + )} +
    + ); +} + +function ScoreDescription({ + score, + pkg, +}: { + score: PackageScoreResult; + pkg: Package; +}) { + const { + percentageDocumentedSymbols: _percentageDocumentedSymbols, + total: _total, + ...flags + } = score; + + const SCORE_MAP = { + hasReadme: "Has a readme or module doc", + hasReadmeExamples: "Has examples in the readme or module doc", + allEntrypointsDocs: "Has module docs in all entrypoints", + allFastCheck: ( + <> + No{" "} + + slow types + {" "} + are used + + ), + hasProvenance: "Has provenance", + hasDescription: ( + <> + Has a{" "} + + description + + + ), + atLeastOneRuntimeCompatible: "At least one runtime is marked as compatible", + multipleRuntimesCompatible: + "At least two runtimes are marked as compatible", + }; + + return ( +
    + + The JSR score is a measure of the overall quality of a package, expand + for more detail. + +
      + <> + {Object.entries(flags).map(([key, value]) => ( +
    • + {value ? : } + {SCORE_MAP[key as keyof typeof flags]} +
    • + ))} + +
    +
    + ); +} + +/** @src https://github.com/jsr-io/jsr/blob/34603e996f56eb38e811619f8aebc6e5c4ad9fa7/frontend/utils/score_ring_color.ts */ +export function getScoreTextColorClass(score: number): string { + if (score >= 90) { + return "text-green-600"; + } else if (score >= 60) { + return "text-yellow-700"; + } + return "text-red-500"; +} diff --git a/www/components/search-input.tsx b/www/components/search-input.tsx new file mode 100644 index 000000000..43724a05d --- /dev/null +++ b/www/components/search-input.tsx @@ -0,0 +1,24 @@ +import { SearchIcon } from "./icons/search.tsx"; + +export function SearchInput() { + return ( +
    + +
    + ); +} diff --git a/www/components/transform.tsx b/www/components/transform.tsx new file mode 100644 index 000000000..44478d05f --- /dev/null +++ b/www/components/transform.tsx @@ -0,0 +1,44 @@ +import type { JSXChild, JSXElement } from "revolution"; + +export interface Transformer { + (node: JSXElement): JSXElement; +} + +export interface TransformOptions { + fn: Transformer; + children: JSXChild | JSXChild[]; +} + +export function Transform(options: TransformOptions): JSX.Element { + let { children, fn } = options; + + if (Array.isArray(children)) { + return { + type: "root", + //@ts-expect-error dem hast types! + children: children.map((child) => transform(child, fn)), + }; + } else { + return transform(children, fn); + } +} + +export function transform(child: JSXChild, fn: Transformer): JSX.Element { + switch (typeof child) { + case "string": + case "number": + case "boolean": + return fn({ type: "text", value: String(child) }); + default: + switch (child.type) { + case "text": + case "element": + return fn(child); + default: { + let children = child.children as unknown as JSXElement[]; + //@ts-expect-error dem hast types! + return { type: "root", children: children.map(fn) }; + } + } + } +} diff --git a/www/components/type/icon.tsx b/www/components/type/icon.tsx new file mode 100644 index 000000000..9f8dd3ba6 --- /dev/null +++ b/www/components/type/icon.tsx @@ -0,0 +1,46 @@ +export function Icon(props: { kind: string; class?: string }) { + switch (props.kind) { + case "function": + return ( + + f + + ); + case "interface": + return ( + + I + + ); + case "typeAlias": + return ( + + T + + ); + case "variable": { + return ( + + v + + ); + } + } + return <>; +} diff --git a/www/components/type/jsx.tsx b/www/components/type/jsx.tsx new file mode 100644 index 000000000..2f5cff5a1 --- /dev/null +++ b/www/components/type/jsx.tsx @@ -0,0 +1,405 @@ +import { JSXElement } from "revolution/jsx-runtime"; +import { type Operation } from "effection"; +import type { + DocNode, + ParamDef, + TsTypeDef, + TsTypeParamDef, + TsTypeRefDef, + VariableDef, +} from "@deno/doc"; +import { + Builtin, + ClassName, + Keyword, + Operator, + Optional, + Punctuation, +} from "./tokens.tsx"; + +interface TypeProps { + node: DocNode; +} + +export function* Type(props: TypeProps): Operation { + const { node } = props; + + switch (node.kind) { + case "function": + return ( + + {node.functionDef.isAsync + ? {"async "} + : <>} + {node.kind} + {node.functionDef.isGenerator ? * : <>} + {" "} + {node.name} + {node.functionDef.typeParams.length > 0 + ? + : <>} + ( + + ): {node.functionDef.returnType + ? + : <>} + + ); + case "class": + return ( + + {node.kind} {node.name} + {node.classDef.extends + ? ( + <> + {" extends "} + {node.classDef.extends} + + ) + : <>} + {node.classDef.implements + ? ( + <> + {" implements "} + <> + {node.classDef.implements + .flatMap((typeDef) => [, ", "]) + .slice(0, -1)} + + + ) + : <>} + + ); + case "interface": + return ( + + {node.kind} {node.name} + {node.interfaceDef.typeParams.length > 0 + ? + : <>} + {node.interfaceDef.extends.length > 0 + ? ( + <> + {" extends "} + <> + {node.interfaceDef.extends + .flatMap((typeDef) => [, ", "]) + .slice(0, -1)} + + + ) + : <>} + + ); + case "variable": + return ( + + + + ); + case "typeAlias": + return ( + + {"type "} + {node.name} + {" = "} + + + ); + case "enum": + case "import": + case "moduleDoc": + case "namespace": + default: + console.log(" unimplemented", node.kind); + return ( + + {node.kind} {node.name} + + ); + } +} + +function TSVariableDef({ + variableDef, + name, +}: { + variableDef: VariableDef; + name: string; +}) { + return ( + <> + {variableDef.kind} {name} + :{" "} + {variableDef.tsType ? : <>} + + ); +} + +function FunctionParams({ params }: { params: ParamDef[] }) { + return ( + <> + {params + .flatMap((param) => [, ", "]) + .slice(0, -1)} + + ); +} + +function TSParam({ param }: { param: ParamDef }) { + switch (param.kind) { + case "identifier": { + return ( + <> + {param.name} + + {": "} + {param.tsType ? : <>} + + ); + } + case "rest": { + return ( + <> + + + {param.tsType ? : <>} + + ); + } + default: + console.log(" unimplemented:", param); + } + return <>; +} + +export function TypeDef({ typeDef }: { typeDef: TsTypeDef }) { + switch (typeDef.kind) { + case "literal": + switch (typeDef.literal.kind) { + case "string": + return "{typeDef.repr}"; + case "number": + return {typeDef.repr}; + case "boolean": + return {typeDef.repr}; + case "bigInt": + return {typeDef.repr}; + default: + // TODO(taras): implement template + return <>; + } + case "keyword": + if (["number", "string", "boolean", "bigint"].includes(typeDef.keyword)) { + return {typeDef.keyword}; + } else { + return {typeDef.keyword}; + } + case "typeRef": + return ; + case "union": + return ; + case "fnOrConstructor": + if (typeDef.fnOrConstructor.constructor) { + console.log(` unimplemeneted`, typeDef.fnOrConstructor); + // TODO(taras): implement + return <>; + } else { + return ( + <> + ( + + ) + {" => "} + + + ); + } + case "indexedAccess": + return ( + <> + + [ + + ] + + ); + case "tuple": + return ( + <> + [ + <> + {typeDef.tuple + .flatMap((tp) => [, ", "]) + .slice(0, -1)} + + ] + + ); + case "array": + return ( + <> + + [] + + ); + case "typeOperator": + return ( + <> + {typeDef.typeOperator.operator}{" "} + + + ); + case "parenthesized": { + return ( + <> + ( + + ) + + ); + } + case "intersection": { + return ( + <> + {typeDef.intersection + .flatMap((tp) => [ + , + {" & "}, + ]) + .slice(0, -1)} + + ); + } + case "typeLiteral": { + // todo(taras): this is incomplete + return ( + <> + { + } + + ); + } + case "conditional": { + return ( + <> + + {" extends "} + + {" ? "} + + {" : "} + + + ); + } + case "infer": { + return ( + <> + {"infer "} + {typeDef.infer.typeParam.name} + + ); + } + case "mapped": + return ( + <> + [ + {typeDef.mappedType.typeParam.name} + {` in `} + {typeDef.mappedType.typeParam.constraint + ? + : <>} + ] + {" : "} + {typeDef.mappedType.tsType + ? + : <>} + + ); + case "importType": + case "optional": + case "rest": + case "this": + case "typePredicate": + case "typeQuery": + console.log(" unimplemented", typeDef); + } + return <>; +} + +function TypeDefUnion({ union }: { union: TsTypeDef[] }) { + return ( + <> + {union.flatMap((typeDef, index) => ( + <> + + {index + 1 < union.length ? {" | "} : <>} + + ))} + + ); +} + +function TypeRef({ typeRef }: { typeRef: TsTypeRefDef }) { + return ( + <> + {typeRef.typeName} + {typeRef.typeParams + ? ( + <> + {"<"} + <> + {typeRef.typeParams + .flatMap((tp) => [, ", "]) + .slice(0, -1)} + + {">"} + + ) + : <>} + + ); +} + +function InterfaceTypeParams({ + typeParams, +}: { + typeParams: TsTypeParamDef[]; +}): JSXElement { + return ( + <> + {"<"} + <> + {typeParams + .flatMap((param) => { + return [ + <> + {param.name} + {param.constraint + ? ( + <> + {" extends "} + + + ) + : <>} + {param.default + ? ( + <> + {" = "} + + + ) + : <>} + , + ", ", + ]; + }) + .slice(0, -1)} + + {">"} + + ); +} diff --git a/www/components/type/markdown.tsx b/www/components/type/markdown.tsx new file mode 100644 index 000000000..3b1dda457 --- /dev/null +++ b/www/components/type/markdown.tsx @@ -0,0 +1,410 @@ +import { Operation } from "effection"; +import type { + ClassMethodDef, + DocNode, + ParamDef, + TsTypeDef, + TsTypeParamDef, +} from "@deno/doc"; +import { toHtml } from "hast-util-to-html"; +import { DocPage } from "../../hooks/use-deno-doc.tsx"; +import { Icon } from "./icon.tsx"; + +const NEW = + `new`; +const OPTIONAL = + `optional`; +const READONLY = + `readonly`; + +export const NO_DOCS_AVAILABLE = "*No documentation available.*"; + +export function* extract( + node: DocNode, +): Operation<{ markdown: string; ignore: boolean; pages: DocPage[] }> { + const lines = []; + const pages: DocPage[] = []; + + let ignore = false; + + if (node.jsDoc && node.jsDoc.doc) { + lines.push(node.jsDoc.doc); + } + + const deprecated = node.jsDoc && + node.jsDoc.tags?.flatMap((tag) => (tag.kind === "deprecated" ? [tag] : [])); + if (deprecated && deprecated.length > 0) { + lines.push(``); + for (const warning of deprecated) { + if (warning.doc) { + lines.push( + `
    + Deprecated + + ${warning.doc} + +
    + `, + ); + } + } + } + + const examples = node.jsDoc && + node.jsDoc.tags?.flatMap((tag) => (tag.kind === "example" ? [tag] : [])); + if (examples && examples?.length > 0) { + lines.push("### Examples"); + let i = 1; + for (const example of examples) { + lines.push(`#### Example ${i++}`, example.doc, "---"); + } + } + + if (node.kind === "class") { + if (node.classDef.constructors.length > 0) { + lines.push(`### Constructors`, "
    "); + for (const constructor of node.classDef.constructors) { + lines.push( + `
    ${NEW} **${node.name}**(${ + constructor.params + .map(Param) + .join(", ") + })
    `, + `
    `, + constructor.jsDoc, + `
    `, + ); + } + lines.push("
    "); + } + + const nonStatic = node.classDef.methods.filter( + (method) => !method.isStatic, + ); + if (nonStatic.length > 0) { + lines.push("### Methods", `
    `, ...methodList(nonStatic), "
    "); + } + + const staticMethods = node.classDef.methods.filter( + (method) => method.isStatic, + ); + if (staticMethods.length > 0) { + lines.push( + "### Static Methods", + "
    ", + ...methodList(staticMethods), + "
    ", + ); + } + } + + if (node.kind === "namespace") { + const variables = node.namespaceDef.elements.flatMap((node) => + node.kind === "variable" ? [node] : [] + ) ?? []; + if (variables.length > 0) { + lines.push("### Variables"); + lines.push("
    "); + for (const variable of variables) { + const name = `${node.name}.${variable.name}`; + const section = yield* extract(variable); + const description = variable.jsDoc?.doc || NO_DOCS_AVAILABLE; + pages.push({ + name, + kind: variable.kind, + description, + dependencies: [], + sections: [ + { + id: exportHash(variable, 0), + node: variable, + markdown: section.markdown, + ignore: section.ignore, + }, + ], + }); + lines.push( + `
    `, + toHtml(), + `[${name}](${name})`, + `
    `, + ); + lines.push(`
    `, description, `
    `); + } + lines.push("
    "); + } + } + + if (node.kind === "interface") { + if (node.name === "Completed") console.log(node); + + lines.push("\n", ...TypeParams(node.interfaceDef.typeParams, node)); + + if (node.interfaceDef.properties.length > 0) { + lines.push("### Properties", "
    "); + for (const property of node.interfaceDef.properties) { + const typeDef = property.tsType ? TypeDef(property.tsType) : ""; + const description = property.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `
    **${property.name}**${ + property.readonly ? READONLY : "" + }${property.optional ? OPTIONAL : ""}: ${typeDef}
    `, + `
    `, + description, + "
    ", + ); + } + lines.push("
    "); + } + + if (node.interfaceDef.methods.length > 0) { + lines.push("### Methods", "
    "); + for (const method of node.interfaceDef.methods) { + const typeParams = method.typeParams.map(TypeParam).join(", "); + const params = method.params.map(Param).join(", "); + const returnType = method.returnType ? TypeDef(method.returnType) : ""; + const description = method.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `

    ${method.name}

    ${ + typeParams ? `<${typeParams}>` : "" + }(${params}): ${returnType}
    `, + `
    `, + description, + "
    ", + ); + } + lines.push("
    "); + } + } + + if (node.kind === "typeAlias") { + lines.push("\n", ...TypeParams(node.typeAliasDef.typeParams, node)); + } + + if (node.kind === "function") { + lines.push(...TypeParams(node.functionDef.typeParams, node)); + + const { params } = node.functionDef; + if (params.length > 0) { + lines.push("### Parameters"); + const jsDocs = node.jsDoc?.tags?.flatMap((tag) => + tag.kind === "param" ? [tag] : [] + ) ?? []; + let i = 0; + for (const param of params) { + lines.push("\n", Param(param)); + if (jsDocs[i] && jsDocs[i].doc) { + lines.push("\n", jsDocs[i].doc); + } + i++; + } + } + + if (node.functionDef.returnType) { + lines.push("### Return Type", "\n", TypeDef(node.functionDef.returnType)); + const jsDocs = node.jsDoc?.tags?.find((tag) => tag.kind === "return"); + if (jsDocs && jsDocs.doc) { + lines.push("\n", jsDocs.doc); + } + } + } + + if (node.kind === "variable" && node.variableDef.tsType) { + lines.push("### Type", "\n", TypeDef(node.variableDef.tsType)); + } + + const see: string[] = []; + if (node.jsDoc && node.jsDoc.tags) { + for (const tag of node.jsDoc.tags) { + switch (tag.kind) { + case "ignore": { + ignore = true; + break; + } + case "see": { + see.push(tag.doc); + } + } + } + } + if (see.length > 0) { + lines.push("\n", "### See", ...see.map((item) => `* ${item}`)); + } + + const markdown = lines.join("\n"); + + return { + markdown, + ignore, + pages, + }; +} + +export function exportHash(node: DocNode, index: number): string { + return [node.kind, node.name, index].filter(Boolean).join("_"); +} + +export function TypeParams(typeParams: TsTypeParamDef[], node: DocNode) { + let lines = []; + if (typeParams.length > 0) { + lines.push("### Type Parameters"); + const jsDocs = node.jsDoc?.tags?.flatMap((tag) => + tag.kind === "template" ? [tag] : [] + ) ?? []; + let i = 0; + for (const typeParam of typeParams) { + lines.push(TypeParam(typeParam)); + if (jsDocs[i]) { + lines.push(jsDocs[i].doc); + } + lines.push("\n"); + i++; + } + } + return lines; +} + +export function TypeDef(typeDef: TsTypeDef): string { + switch (typeDef.kind) { + case "fnOrConstructor": { + const params = typeDef.fnOrConstructor.params.map(Param).join(", "); + const tparams = typeDef.fnOrConstructor.typeParams + .map(TypeParam) + .join(", "); + return `${tparams.length > 0 ? `<${tparams}>` : ""}(${params}) => ${ + TypeDef( + typeDef.fnOrConstructor.tsType, + ) + }`; + } + case "typeRef": { + const tparams = typeDef.typeRef.typeParams?.map(TypeDef).join(", "); + return `{@link ${typeDef.typeRef.typeName}}${ + tparams && tparams?.length > 0 ? `<${tparams}>` : "" + }`; + } + case "keyword": { + return typeDef.keyword; + } + case "union": { + return typeDef.union.map(TypeDef).join(" | "); + } + case "array": { + return `${TypeDef(typeDef.array)}[]`; + } + case "typeOperator": { + return `${typeDef.typeOperator.operator} ${ + TypeDef( + typeDef.typeOperator.tsType, + ) + }`; + } + case "tuple": { + return `[${typeDef.tuple.map(TypeDef).join(", ")}]`; + } + case "parenthesized": { + return TypeDef(typeDef.parenthesized); + } + case "intersection": { + return typeDef.intersection.map(TypeDef).join(" & "); + } + case "typeLiteral": { + // todo(taras): this is incomplete + return `{}`; + } + case "mapped": { + return `[${TypeParam(typeDef.mappedType.typeParam)}]: ${ + typeDef.mappedType.tsType ? TypeDef(typeDef.mappedType.tsType) : "" + }`; + } + case "conditional": { + return `${TypeDef(typeDef.conditionalType.checkType)} extends ${ + TypeDef( + typeDef.conditionalType.extendsType, + ) + } ? ${ + TypeDef( + typeDef.conditionalType.trueType, + ) + } : ${TypeDef(typeDef.conditionalType.falseType)}`; + } + case "indexedAccess": { + return `${TypeDef(typeDef.indexedAccess.objType)}[${ + TypeDef( + typeDef.indexedAccess.indexType, + ) + }]`; + } + case "literal": { + return `*${typeDef.repr}*`; + } + case "importType": + case "infer": + case "optional": + case "rest": + case "this": + case "typePredicate": + case "typeQuery": + console.log("TypeDef: unimplemented", typeDef); + } + return ""; +} + +function TypeParam(paramDef: TsTypeParamDef) { + let parts = [`{@link ${paramDef.name}}`]; + if (paramDef.constraint) { + if ( + paramDef.constraint.kind === "typeOperator" && + paramDef.constraint.typeOperator.operator === "keyof" + ) { + parts.push(`in ${TypeDef(paramDef.constraint)}`); + } else { + parts.push(`extends ${TypeDef(paramDef.constraint)}`); + } + } + if (paramDef.default) { + parts.push(`= ${TypeDef(paramDef.default)}`); + } + return parts.join(" "); +} + +function Param(paramDef: ParamDef): string { + switch (paramDef.kind) { + case "identifier": { + return `**${paramDef.name}**${paramDef.optional ? OPTIONAL : ""}: ${ + paramDef.tsType ? TypeDef(paramDef.tsType) : "" + }`; + } + case "rest": { + return `...${Param(paramDef.arg)} ${ + paramDef.tsType ? TypeDef(paramDef.tsType) : "" + }`; + } + case "assign": + case "array": + case "object": + console.log("Param: unimplemented", paramDef); + } + return ""; +} + +export function methodList(methods: ClassMethodDef[]) { + const lines = []; + for (const method of methods) { + const typeParams = method.functionDef.typeParams.map(TypeParam).join(", "); + const params = method.functionDef.params.map(Param).join(", "); + const returnType = method.functionDef.returnType + ? TypeDef(method.functionDef.returnType) + : ""; + const description = method.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `
    **${method.name}**${ + typeParams ? `<${typeParams}>` : "" + }(${params}): ${returnType}
    `, + `
    `, + description, + "
    ", + ); + } + return lines; +} diff --git a/www/components/type/tokens.tsx b/www/components/type/tokens.tsx new file mode 100644 index 000000000..bfad59672 --- /dev/null +++ b/www/components/type/tokens.tsx @@ -0,0 +1,37 @@ +import type { JSXChild, JSXElement } from "revolution"; + +export function ClassName({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Punctuation( + { children, classes, style }: { + children: JSXChild; + classes?: string; + style?: string; + }, +): JSXElement { + return ( + {children} + ); +} + +export function Operator({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Keyword({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Builtin({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Optional({ optional }: { optional: boolean }): JSXElement { + if (optional) { + return ?; + } else { + return <>; + } +} diff --git a/www/context/context-api.ts b/www/context/context-api.ts new file mode 100644 index 000000000..4b49ae08f --- /dev/null +++ b/www/context/context-api.ts @@ -0,0 +1,73 @@ +// deno-lint-ignore-file no-explicit-any ban-types +import { createContext, type Operation } from "effection"; + +export type Around = { + [K in keyof Operations]: A[K] extends + (...args: infer TArgs) => infer TReturn ? Middleware + : Middleware<[], A[K]>; +}; + +export interface Middleware { + (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; +} + +export interface Api { + operations: Operations; + around: (around: Partial>) => Operation; +} + +export type Operations = { + [K in keyof T]: T[K] extends ((...args: infer TArgs) => infer TReturn) + ? (...args: TArgs) => TReturn + : T[K] extends Operation ? Operation + : never; +}; + +export function createApi(name: string, handler: A): Api { + let fields = Object.keys(handler) as (keyof A)[]; + + let middleware: Around = fields.reduce((sum, field) => { + return Object.assign(sum, { + [field]: (args: any, next: any) => next(...args), + }); + }, {} as Around); + + let context = createContext>(`$api:${name}`, middleware); + + let operations = fields.reduce((api, field) => { + let handle = handler[field]; + if (typeof handle === "function") { + return Object.assign(api, { + [field]: function* (...args: any[]) { + let around = yield* context.expect(); + let middleware = around[field] as Function; + return yield* middleware(args, handle); + }, + }); + } else { + return Object.assign(api, { + [field]: { + *[Symbol.iterator]() { + let around = yield* context.expect(); + let middleware = around[field] as Function; + return yield* middleware([], () => handle); + }, + }, + }); + } + }, {} as Operations); + + function* around(around: Partial>): Operation { + let current = yield* context.expect(); + yield* context.set(fields.reduce((sum, field) => { + let prior = current[field] as Middleware; + let middleware = around[field] as Middleware; + return Object.assign(sum, { + [field]: (args: any, next: any) => + middleware(args, (...args) => prior(args, next)), + }); + }, Object.assign({}, current))); + } + + return { operations, around }; +} diff --git a/www/context/doc-page.ts b/www/context/doc-page.ts new file mode 100644 index 000000000..bffa404ae --- /dev/null +++ b/www/context/doc-page.ts @@ -0,0 +1,4 @@ +import { createContext } from "effection"; +import type { DocPage } from "../hooks/use-deno-doc.tsx"; + +export const DocPageContext = createContext("doc-page"); diff --git a/www/context/fetch.ts b/www/context/fetch.ts new file mode 100644 index 000000000..9adff6168 --- /dev/null +++ b/www/context/fetch.ts @@ -0,0 +1,76 @@ +import { Operation, until } from "effection"; +import { createApi } from "./context-api.ts"; +import { rewrite } from "./url-rewrite.ts"; +import { log } from "./logging.ts"; + +interface FetchApi { + fetch(input: RequestInfo | URL, init?: RequestInit): Operation; +} + +export const fetchApi = createApi("fetch", { + *fetch(input, init) { + return yield* until(globalThis.fetch(input, init)); + }, +}); + +export const { operations } = fetchApi; + +export function* initFetch() { + const cache = yield* until(caches.open("local-cache")); + + yield* fetchApi.around({ + *fetch([input, init], next) { + let request = input instanceof Request ? input : new Request(input, init); + if (request.method === "GET") { + const response = yield* until(cache.match(request)); + if (response) { + return response; + } else { + const response = yield* next(input, init); + yield* until(cache.put(request, response.clone())); + return response; + } + } + return yield* next(input, init); + }, + }); + + yield* fetchApi.around({ + *fetch([input, init], next) { + const url = input instanceof Request + ? new URL(input.url) + : new URL(input); + + if (url.protocol === "file:") { + yield* log.debug(`Reading file system file from ${url}`); + try { + const file = yield* until(Deno.open(url.pathname)); + return new Response(file.readable); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return new Response(`File not found ${url.pathname}`, { + status: 404, + }); + } + console.error(`Error reading file ${url.pathname}:`, error); + return new Response("Internal server error", { status: 500 }); + } + } else { + return yield* next(input, init); + } + }, + }); + + yield* fetchApi.around({ + *fetch([input, init], next) { + const url = input instanceof Request + ? new URL(input.url) + : new URL(input); + const newUrl = yield* rewrite(url, input, init); + if (url !== newUrl) { + yield* log.debug(`Rewrite ${url} to ${newUrl}`); + } + return yield* next(newUrl, init); + }, + }); +} diff --git a/www/context/jsr.ts b/www/context/jsr.ts new file mode 100644 index 000000000..86fa00065 --- /dev/null +++ b/www/context/jsr.ts @@ -0,0 +1,19 @@ +import { createContext, type Operation } from "effection"; +import { createJSRClient, JSRClient } from "../resources/jsr-client.ts"; + +const JSRClientContext = createContext("jsr-client"); + +export function* initJSRClient() { + const token = Deno.env.get("JSR_API") ?? ""; + if (token === "") { + console.log("Missing JSR API token; expect score card not to load."); + } + + let client = yield* createJSRClient(token); + + return yield* JSRClientContext.set(client); +} + +export function* useJSRClient(): Operation { + return yield* JSRClientContext.expect(); +} diff --git a/www/context/logging.ts b/www/context/logging.ts new file mode 100644 index 000000000..5dcf9beed --- /dev/null +++ b/www/context/logging.ts @@ -0,0 +1,91 @@ +import type { Operation } from "effection"; +import { createApi } from "./context-api.ts"; + +export interface Logger { + info: (message: string, ...args: unknown[]) => Operation; + debug: (message: string, ...args: unknown[]) => Operation; + warn: (message: string, ...args: unknown[]) => Operation; + error: (message: string, ...args: unknown[]) => Operation; +} + +export const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + gray: "\x1b[90m", + green: "\x1b[32m", +}; + +const consoleLogger: Logger = { + *info(message: string, ...args: unknown[]) { + console.log(`${colors.blue}[INFO]${colors.reset} ${message}`, ...args); + }, + *debug(message: string, ...args: unknown[]) { + console.log(`${colors.gray}[DEBUG]${colors.reset} ${message}`, ...args); + }, + *warn(message: string, ...args: unknown[]) { + console.warn(`${colors.yellow}[WARN]${colors.reset} ${message}`, ...args); + }, + *error(message: string, ...args: unknown[]) { + console.error(`${colors.red}[ERROR]${colors.reset} ${message}`, ...args); + }, +}; + +export const loggerApi = createApi("logger", consoleLogger); +export const log = loggerApi.operations; + +export function* verboseLogging(verbose: boolean) { + yield* loggerApi.around({ + *info(args, next) { + yield* next(...args); + }, + *warn(args, next) { + if (verbose) { + yield* next(...args); + } + }, + *debug(args, next) { + if (verbose) { + yield* next(...args); + } + }, + *error(args, next) { + yield* next(...args); + }, + }); +} + +export function* namespace(namespace: string) { + yield* loggerApi.around({ + *info(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *warn(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *debug(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *error(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + }); +} + +export function* indent() { + yield* loggerApi.around({ + *info(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *warn(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *debug(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *error(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + }); +} diff --git a/www/context/process.ts b/www/context/process.ts new file mode 100644 index 000000000..b53d5521b --- /dev/null +++ b/www/context/process.ts @@ -0,0 +1,160 @@ +import type { Operation, Stream } from "effection"; +import { each, spawn, until, withResolvers } from "effection"; +import md5 from "md5"; +import { regex } from "arktype"; +import { createApi } from "./context-api.ts"; +import { exec, ExecOptions, ProcessResult } from "@effectionx/process"; +import { log } from "./logging.ts"; +import { cwd, useCwd } from "./shell.ts"; +import { fileURLToPath } from "node:url"; + +export interface ProcessApi { + useProcess(command: string, options?: ExecOptions): Operation; +} + +export const processApi = createApi("process", { + *useProcess(command: string, options): Operation { + const cwd = yield* useCwd(); + return yield* exec(command, { + cwd, + ...options, + }).expect(); + }, +}); + +export const { useProcess } = processApi.operations; + +export function* drain(source: Stream): Operation { + const complete = withResolvers(); + yield* spawn(function* () { + let chunks = ""; + for (const chunk of yield* each(source)) { + chunks += chunk; + yield* each.next(); + } + complete.resolve(chunks); + }); + + return yield* complete.operation; +} + +export function urlFromCommand(command: string): URL { + return new URL(`https://cache.local/${md5(command)}`); +} + +export function* ProcessOutputCache(patterns: RegExp[]): Operation { + const cache = yield* until(caches.open("command-cache")); + + yield* processApi.around({ + *useProcess([command], next) { + // Check if command matches any of the patterns + const shouldCache = patterns.some((pattern) => pattern.test(command)); + + if (!shouldCache) { + return yield* next(command); + } + + const url = urlFromCommand(command); + + // Check if we have cached result + const cachedResponse = yield* until(cache.match(url)); + if (cachedResponse) { + // Return cached process with cached output + return yield* createCachedProcess(cachedResponse); + } + + // Execute the process normally + const process = yield* next(command); + + yield* until(cache.put(url, new Response(process.stdout))); + + // Fallback to original process if caching failed + return process; + }, + }); +} + +function* createCachedProcess( + cachedResponse: Response, +): Operation { + return { + stdout: yield* until(cachedResponse.text()), + stderr: "", + code: 0, + }; +} + +// Pattern for git show commands with named capture groups +export const gitShowPattern = regex( + "^git show (?[^/]+)/(?[^/]+)/(?[^:]+):(?.+)$", +); + +/** + * Check if the current HEAD is a descendant of a given branch/ref + */ +function* isDescendantOf(ref: string): Operation { + try { + const result = yield* exec( + `git merge-base --is-ancestor ${ref} HEAD`, + ).expect(); + return result.code === 0; + } catch { + return false; + } +} + +/** + * Middleware that intercepts git show commands and reads from filesystem instead + * when the current origin matches and HEAD descends from the target branch + */ +export function* ProcessFileSystemRead( + pattern: typeof gitShowPattern, +): Operation { + yield* processApi.around({ + *useProcess([command], next) { + const match = pattern.exec(command); + + if (!match) { + return yield* next(command); + } + + const { owner, repo, branch, path: filePath } = match.groups; + const repoPath = `${owner}/${repo}`; + const remote = `${owner}/${repo}/${branch}`; + + // Check if origin matches the repository + const originResult = yield* exec("git remote get-url origin").expect(); + const originUrl = originResult.stdout.trim(); + + if (!originUrl.includes(repoPath)) { + yield* log.debug( + `Origin ${originUrl} does not match repository ${repoPath}, executing command normally`, + ); + return yield* next(command); + } + + const isDescendant = yield* isDescendantOf(remote); + + if (!isDescendant) { + yield* log.debug( + `Current HEAD is not a descendant of ${remote}, executing command normally`, + ); + return yield* next(command); + } + + try { + yield* log.debug( + `Reading ${filePath} from filesystem instead of executing: ${command}`, + ); + const basePath = fileURLToPath(new URL("../../", import.meta.url)); + const [process] = yield* cwd(basePath, [next(`cat ${filePath}`)]); + return process; + } catch (error) { + yield* log.debug( + `Failed to read ${filePath}, falling back to command execution: ${error}`, + ); + return yield* next(command); + } + }, + }); +} diff --git a/www/context/request.ts b/www/context/request.ts new file mode 100644 index 000000000..32a657e32 --- /dev/null +++ b/www/context/request.ts @@ -0,0 +1,3 @@ +import { createContext } from "effection"; + +export const CurrentRequest = createContext("Request"); diff --git a/www/context/shell.ts b/www/context/shell.ts new file mode 100644 index 000000000..34c4eed81 --- /dev/null +++ b/www/context/shell.ts @@ -0,0 +1,47 @@ +import { createContext, type Operation, scoped, until } from "effection"; +import { ProcessResult } from "@effectionx/process"; +import { indent, log } from "./logging.ts"; +import { join } from "@std/path"; +import { useProcess } from "./process.ts"; + +const CwdContext = createContext("cwd", Deno.cwd()); + +export function useCwd() { + return CwdContext.expect(); +} + +export function* $(command: string): Operation { + yield* log.debug(`$ ${command}`); + return yield* useProcess(command); +} + +export function* cwd[]>( + directory: string, + ops: T, +): Operation<{ [K in keyof T]: T[K] extends Operation ? R : never }> { + return yield* scoped(function* () { + yield* log.debug(`cwd: ${directory}`); + const result = yield* CwdContext.with(directory, function* () { + yield* indent(); + const results = []; + for (const op of ops) { + results.push(yield* op); + } + // deno-lint-ignore no-explicit-any + return results as any; + }); + return result; + }); +} + +export function* $echo( + data: string | ReadableStream, + filename: string | URL, +): Operation { + const cwd = yield* CwdContext.expect(); + if (typeof filename === "string") { + yield* until(Deno.writeTextFile(join(cwd, filename), data)); + return; + } + yield* until(Deno.writeTextFile(new URL(filename, cwd), data)); +} diff --git a/www/context/url-rewrite.ts b/www/context/url-rewrite.ts new file mode 100644 index 000000000..dbaa782ef --- /dev/null +++ b/www/context/url-rewrite.ts @@ -0,0 +1,18 @@ +import { Operation } from "effection"; +import { createApi } from "./context-api.ts"; + +interface UrlRewrite { + rewrite( + url: URL, + input: RequestInfo | URL, + init?: RequestInit, + ): Operation; +} + +export const urlRewriteApi = createApi("url-rewrite", { + *rewrite(url) { + return url; + }, +}); + +export const { rewrite } = urlRewriteApi.operations; diff --git a/www/deno-deploy-patch.ts b/www/deno-deploy-patch.ts new file mode 100644 index 000000000..457411e89 --- /dev/null +++ b/www/deno-deploy-patch.ts @@ -0,0 +1,26 @@ +/** see https://github.com/denoland/deploy_feedback/issues/527#issuecomment-2510631720 */ +export function patchDenoPermissionsQuerySync() { + const permissions = { + run: "denied", + read: "granted", + write: "denied", + net: "granted", + env: "granted", + sys: "denied", + ffi: "denied", + } as const; + + Deno.permissions.querySync ??= ({ name }) => { + return { + // @ts-expect-error deno-ts(7053) + state: permissions[name], + onchange: null, + partial: false, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }; + }; +} diff --git a/www/deno.json b/www/deno.json new file mode 100644 index 000000000..fe0b21118 --- /dev/null +++ b/www/deno.json @@ -0,0 +1,81 @@ +{ + "tasks": { + "dev": "deno run -A @effectionx/watch deno run -A main.tsx", + "staticalize": "deno run -A jsr:@frontside/staticalize@0.2.1/cli --site http://localhost:8000 --output=built --base=http://localhost:8000", + "pagefind": "npx pagefind --site built", + "test": "deno test --allow-run --allow-write --allow-read" + }, + "lint": { + "exclude": ["docs/esm"], + "rules": { + "exclude": [ + "prefer-const", + "require-yield", + "jsx-curly-braces", + "jsx-key", + "jsx-no-useless-fragment" + ] + } + }, + "fmt": { + "exclude": ["docs/esm"] + }, + "compilerOptions": { + "lib": ["deno.ns", "dom.iterable", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "revolution" + }, + "imports": { + "@effectionx/watch": "jsr:@effectionx/watch@^0.3.1", + "@frontside/staticalize": "jsr:@frontside/staticalize@0.2.0", + "@jsdevtools/rehype-toc": "npm:@jsdevtools/rehype-toc@3.0.2", + "@libs/xml": "jsr:@libs/xml@^7.0.3", + "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/crypto": "jsr:@std/crypto@^1.0.5", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/http": "jsr:@std/http@^1.0.22", + "@types/hast": "npm:@types/hast@3.0.4", + "effection": "npm:effection@^3.6.1", + "@effectionx/deno-deploy": "npm:@effectionx/deno-deploy@^0.3.1", + "@effectionx/process": "npm:@effectionx/process@^0.6.2", + "hast": "npm:hast@^1.0.0", + "hast-util-shift-heading": "npm:hast-util-shift-heading@4.0.0", + "hast-util-to-html": "npm:hast-util-to-html@9.0.0", + "mdast": "npm:mdast@^3.0.0", + "mdx": "npm:mdx@^0.3.1", + "octokit": "npm:octokit@4.0.3", + "pagefind": "npm:pagefind@1.3.0", + "path-to-regexp": "npm:path-to-regexp@8.2.0", + "rehype-add-classes": "npm:rehype-add-classes@1.0.0", + "rehype-autolink-headings": "npm:rehype-autolink-headings@7.1.0", + "rehype-infer-description-meta": "npm:rehype-infer-description-meta@2.0.0", + "rehype-infer-title-meta": "npm:rehype-infer-title-meta@2.0.0", + "rehype-prism-plus": "npm:rehype-prism-plus@2.0.0", + "rehype-slug": "npm:rehype-slug@6.0.0", + "rehype-stringify": "npm:rehype-stringify@10.0.1", + "remark": "npm:remark@^15.0.1", + "remark-gfm": "npm:remark-gfm@4.0.0", + "remark-parse": "npm:remark-parse@11.0.0", + "remark-rehype": "npm:remark-rehype@11.1.1", + "unified": "npm:unified@11.0.4", + "revolution": "https://deno.land/x/revolution@0.6.1/mod.ts", + "revolution/jsx-runtime": "https://deno.land/x/revolution@0.6.1/jsx-runtime.ts", + "@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.16", + "tailwindcss": "npm:tailwindcss@^4.1.0", + "semver": "npm:semver@7.6.3", + "@deno/doc": "jsr:@deno/doc@0.188.0", + "@deno/graph": "jsr:@deno/graph@0.89.0", + "git-url-parse": "npm:git-url-parse@16.1.0", + "@std/fs": "jsr:@std/fs@1", + "@std/path": "jsr:@std/path@1", + "@std/testing": "jsr:@std/testing@1", + "md5": "npm:md5@^2.3.0", + "expect": "jsr:@std/expect@^1", + "arktype": "npm:arktype@^2", + "_posixNormalize": "https://deno.land/std@0.203.0/path/_normalize.ts", + "hast-util-select": "npm:hast-util-select@6.0.1", + "unist-util-visit": "npm:unist-util-visit@5.0.0", + "vfile": "npm:vfile@6.0.3", + "zod": "npm:zod@3.23.8" + } +} diff --git a/www/e4.ts b/www/e4.ts new file mode 100644 index 000000000..0f2d0cad6 --- /dev/null +++ b/www/e4.ts @@ -0,0 +1,111 @@ +import { + call, + type Operation, + race, + resource, + run, + sleep, + // deno-lint-ignore no-import-prefix +} from "npm:effection@4.0.0-alpha.4"; + +import { + close, + createIndex, + PagefindIndex, + PagefindServiceConfig, + SiteDirectory, + WriteOptions, +} from "pagefind"; +import { staticalize } from "@frontside/staticalize"; +import * as fs from "@std/fs"; + +function exists(path: string | URL, options?: fs.ExistsOptions) { + return call(() => fs.exists(path, options)); +} + +type GenerateOptions = { + host: URL; + publicDir: string; + pagefindDir: string; +} & PagefindServiceConfig; + +const log = (first: unknown, ...args: unknown[]) => + console.log(`๐Ÿ’ช: ${first}`, ...args); + +export function generate( + { host, publicDir, pagefindDir, ...indexOptions }: GenerateOptions, +) { + return async function () { + return await run(function* () { + const built = new URL(publicDir, import.meta.url); + + if (yield* exists(built, { isDirectory: true })) { + log(`Reusing existing staticalized ${built.pathname} directory`); + } else { + log(`Staticalizing: ${host} to ${built.pathname}`); + + yield* race([ + staticalize({ + host, + base: host, + dir: built.pathname, + }), + sleep(60000), + ]); + } + + log("Adding index"); + + const index = yield* createPagefindIndex(indexOptions); + + log(`Adding directory: ${built.pathname}`); + + const added = yield* index.addDirectory({ path: built.pathname }); + + log(`Addedd ${added} pages from ${built.pathname}`); + + log(`Writing files ${pagefindDir}`); + return yield* index.writeFiles({ outputPath: pagefindDir }); + }); + }; +} + +export class EPagefindIndex { + constructor(private readonly index: PagefindIndex) {} + + *addDirectory(path: SiteDirectory): Operation { + const response = yield* call(() => this.index.addDirectory(path)); + if (response.errors.length > 0) { + console.error( + `Encountered errors while adding ${path.path}: ${response.errors.join()}`, + ); + } + return response.page_count; + } + + *writeFiles(options?: WriteOptions): Operation { + const response = yield* call(() => this.index.writeFiles(options)); + if (response.errors.length > 0) { + console.error( + `Encountered errors while writing to ${options?.outputPath}: ${response.errors.join()}`, + ); + } + return response.outputPath; + } +} + +export function createPagefindIndex(config?: PagefindServiceConfig) { + return resource(function* (provide) { + const { errors, index } = yield* call(() => createIndex(config)); + + if (!index) { + throw new Error(`Failed to create an index: ${errors.join()}`); + } + + try { + yield* provide(new EPagefindIndex(index)); + } finally { + yield* call(() => close()); + } + }); +} diff --git a/www/hooks/use-deno-doc.tsx b/www/hooks/use-deno-doc.tsx new file mode 100644 index 000000000..f713a22d5 --- /dev/null +++ b/www/hooks/use-deno-doc.tsx @@ -0,0 +1,283 @@ +import { + CacheSetting, + doc, + type DocNode, + type DocOptions, + LoadResponse, +} from "@deno/doc"; +import { call, type Operation, until, useScope } from "effection"; +import { createGraph } from "@deno/graph"; + +import { exportHash, extract } from "../components/type/markdown.tsx"; +import { operations } from "../context/fetch.ts"; +import { DenoJsonSchema } from "../lib/deno-json.ts"; +import { useDescription } from "./use-description-parse.tsx"; + +export type { DocNode }; + +export function* useDenoDoc( + specifiers: string[], + docOptions?: DocOptions, +): Operation> { + let docs = yield* until(doc(specifiers, docOptions)); + return docs; +} + +export interface Dependency { + source: string; + name: string; + version: string; +} + +export interface DocPage { + name: string; + sections: DocPageSection[]; + description: string; + kind: DocNode["kind"]; + dependencies: Dependency[]; +} + +export interface DocPageSection { + id: string; + + node: DocNode; + + markdown?: string; + + ignore: boolean; +} + +export type DocsPages = Record; + +export function* useDocPages(specifier: string): Operation { + const scope = yield* useScope(); + + const loader = (specifier: string) => scope.run(docLoader(specifier)); + const imports = yield* extractImports( + new URL("./deno.json", specifier).toString(), + loader, + ); + + const resolve = imports + ? (specifier: string, referrer: string) => { + let resolved: string = specifier; + if (specifier in imports) { + resolved = imports[specifier]; + } else if (specifier.startsWith(".")) { + resolved = new URL(specifier, referrer).toString(); + } else if (specifier.startsWith("node:")) { + resolved = `npm:@types/node@^22.13.5`; + } + return resolved; + } + : undefined; + + const graph = yield* call(() => + createGraph([specifier], { + load: loader, + resolve, + }) + ); + + const externalDependencies: Dependency[] = graph.modules.flatMap((module) => { + if (module.kind === "external") { + const parts = module.specifier.match(/(.*):(.*)@(.*)/); + if (parts) { + const [, source, name, version] = parts; + return [ + { + source, + name, + version, + }, + ]; + } + } + return []; + }); + + const docs = yield* useDenoDoc([specifier], { + load: loader, + resolve, + }); + + const entrypoints: Record = {}; + + for (const [url, all] of Object.entries(docs)) { + const pages: DocPage[] = []; + for ( + const [symbol, nodes] of Object.entries( + Object.groupBy(all, (node) => node.name), + ) + ) { + if (nodes) { + const sections: DocPageSection[] = []; + for (const node of nodes) { + const { markdown, ignore, pages: _pages } = yield* extract(node); + sections.push({ + id: exportHash(node, sections.length), + node, + markdown, + ignore, + }); + pages.push( + ..._pages.map((page) => ({ + ...page, + dependencies: externalDependencies, + })), + ); + } + + const markdown = sections + .map((s) => s.markdown) + .filter((m) => m) + .join(""); + + const description = yield* useDescription(markdown); + + pages.push({ + name: symbol, + kind: nodes?.at(0)?.kind!, + description, + sections, + dependencies: externalDependencies, + }); + } + } + + entrypoints[url] = pages; + } + + return entrypoints; +} + +function docLoader( + specifier: string, + _isDynamic?: boolean, + _cacheSetting?: CacheSetting, + _checksum?: string, +): () => Operation { + return function* downloadDocModules() { + const url = URL.parse(specifier); + + if (url?.protocol.startsWith("file")) { + const content = yield* until(Deno.readTextFile(url.pathname)); + return { + kind: "module", + specifier, + content, + }; + } + + if (url?.host === "github.com") { + const response = yield* operations.fetch(specifier); + const content = yield* until(response.text()); + if (response.ok) { + return { + kind: "module", + specifier, + content, + }; + } else { + throw new Error(`Could not parse ${specifier} as Github URL`, { + cause: response, + }); + } + } + + if (url?.host === "jsr.io") { + console.log(`Ignoring ${url} while reading docs`); + } + }; +} + +export function isDocsPages(value: unknown): value is DocsPages { + if (typeof value !== "object" || value === null) { + return false; + } + + // Check if each key is a string and value is an array of DocPage objects + for (const key in value) { + if (typeof key !== "string") { + return false; + } + + const pages = (value as Record)[key]; + + if (!Array.isArray(pages)) { + return false; + } + + // Check if each item in the array is a valid DocPage + for (const page of pages) { + if (!isDocPage(page)) { + return false; + } + } + } + + return true; +} + +function isDocPage(value: unknown): value is DocPage { + if (typeof value !== "object" || value === null) { + return false; + } + + const page = value as DocPage; + + return ( + typeof page.name === "string" && + Array.isArray(page.sections) && + page.sections.every(isDocPageSection) && + typeof page.description === "string" && + typeof page.kind === "string" && + Array.isArray(page.dependencies) && + page.dependencies.every(isDependency) + ); +} + +function isDocPageSection(value: unknown): value is DocPageSection { + if (typeof value !== "object" || value === null) { + return false; + } + + const section = value as DocPageSection; + + return ( + typeof section.id === "string" && + typeof section.node === "object" && + section.node !== null && // You might need a guard for DocNode if it's complex + (typeof section.markdown === "undefined" || + typeof section.markdown === "string") && + typeof section.ignore === "boolean" + ); +} + +function isDependency(value: unknown): value is Dependency { + if (typeof value !== "object" || value === null) { + return false; + } + + const dependency = value as Dependency; + + return ( + typeof dependency.source === "string" && + typeof dependency.name === "string" && + typeof dependency.version === "string" + ); +} + +function* extractImports( + url: string, + loader: (specifier: string) => Operation, +) { + const module = yield* loader(url); + if (!module) return; + const content = module.kind === "module" + ? JSON.parse(`${module.content}`) + : undefined; + const { imports } = DenoJsonSchema.parse(content); + + return imports; +} diff --git a/www/hooks/use-description-parse.tsx b/www/hooks/use-description-parse.tsx new file mode 100644 index 000000000..e0a0a07ab --- /dev/null +++ b/www/hooks/use-description-parse.tsx @@ -0,0 +1,35 @@ +import { call, type Operation } from "effection"; +import { unified } from "unified"; +import type { VFile } from "vfile"; +import rehypeInferDescriptionMeta from "rehype-infer-description-meta"; +import rehypeInferTitleMeta from "rehype-infer-title-meta"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { trimAfterHR } from "../lib/trim-after-hr.ts"; + +export function* useDescription(markdown: string): Operation { + const file = yield* useMarkdownFile(markdown); + return file.data?.meta?.description ?? ""; +} + +export function* useTitle(markdown: string): Operation { + const file = yield* useMarkdownFile(markdown); + return file.data?.meta?.title ?? ""; +} + +export function* useMarkdownFile(markdown: string): Operation { + return yield* call(() => + unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeStringify) + .use(trimAfterHR) + .use(rehypeInferTitleMeta) + .use(rehypeInferDescriptionMeta, { + inferDescriptionHast: true, + truncateSize: 200, + }) + .process(markdown) + ); +} diff --git a/www/hooks/use-markdown.test.ts b/www/hooks/use-markdown.test.ts new file mode 100644 index 000000000..39f40ef95 --- /dev/null +++ b/www/hooks/use-markdown.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from "@std/assert"; +import { run } from "effection"; +import { createJsDocSanitizer } from "./use-markdown.tsx"; + +const sanitizer = createJsDocSanitizer(); + +function sanitizedEquals(a: string, b: string) { + Deno.test(`${a} => ${b}`, async function () { + const result = await run(function* () { + return yield* sanitizer(a); + }); + assertEquals(result, b); + }); +} + +sanitizedEquals("{@link Context}", "[Context](Context)"); +sanitizedEquals("@{link Scope}", "[Scope](Scope)"); +sanitizedEquals("{@link spawn()}", "[spawn](spawn)"); +sanitizedEquals("{@link Scope.run}", "[Scope.run](Scope.run)"); +sanitizedEquals("{@link Scope#run}", "[Scope#run](Scope#run)"); +sanitizedEquals( + "{@link * establish error boundaries https://frontside.com/effection/docs/errors | error boundaries}", + "", +); +sanitizedEquals( + "{@link Operation}<{@link T}>", + "[Operation](Operation)<[T](T)>", +); diff --git a/www/hooks/use-markdown.tsx b/www/hooks/use-markdown.tsx new file mode 100644 index 000000000..f71bbd6df --- /dev/null +++ b/www/hooks/use-markdown.tsx @@ -0,0 +1,118 @@ +import { call, type Operation } from "effection"; +import rehypeAddClasses from "rehype-add-classes"; +import rehypePrismPlus from "rehype-prism-plus"; +import rehypeSlug from "rehype-slug"; +import remarkGfm from "remark-gfm"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import { JSXElement } from "revolution/jsx-runtime"; +import { removeDescriptionHR } from "../lib/remove-description-hr.ts"; +import { replaceAll } from "../lib/replace-all.ts"; +import { useMDX, UseMDXOptions } from "./use-mdx.tsx"; + +export function* defaultLinkResolver( + symbol: string, + connector?: string, + method?: string, +) { + let parts = [symbol]; + if (symbol && connector && method) { + parts.push(connector, method); + } + const name = parts.filter(Boolean).join(""); + if (name) { + return `[${name}](${name})`; + } + return ""; +} + +export type ResolveLinkFunction = ( + symbol: string, + connector?: string, + method?: string, +) => Operation; + +export type UseMarkdownOptions = UseMDXOptions & { + linkResolver?: ResolveLinkFunction; + slugPrefix?: string; +}; + +export function* useMarkdown( + markdown: string, + options?: UseMarkdownOptions, +): Operation { + /** + * I'm doing this pre-processing here because MDX throws a parse error when it encounteres `{@link }`. + * I can't use a remark/rehype plugin to change this because they are applied after MDX parses is successful. + */ + const sanitize = createJsDocSanitizer( + options?.linkResolver ?? defaultLinkResolver, + ); + const sanitized = yield* sanitize(markdown); + + const mod = yield* useMDX(sanitized, { + remarkPlugins: [remarkGfm, ...(options?.remarkPlugins ?? [])], + rehypePlugins: [ + [removeDescriptionHR], + [ + rehypePrismPlus, + { + showLineNumbers: true, + }, + ], + [ + rehypeSlug, + { + prefix: options?.slugPrefix ? `${options.slugPrefix}-` : undefined, + }, + ], + [ + rehypeAutolinkHeadings, + { + behavior: "append", + properties: { + className: + "opacity-0 group-hover:opacity-100 after:content-['#'] after:ml-1.5 no-underline", + }, + }, + ], + [ + rehypeAddClasses, + { + "h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]": + "group scroll-mt-[100px]", + pre: "grid", + }, + ], + ...(options?.rehypePlugins ?? []), + ], + remarkRehypeOptions: options?.remarkRehypeOptions, + }); + + return yield* call(async () => { + try { + const result = await mod.default(); + return result; + } catch (e) { + console.error( + `Failed to convert markdown to JSXElement for ${markdown}`, + e, + ); + return <>; + } + }); +} + +export function createJsDocSanitizer( + resolver: ResolveLinkFunction = defaultLinkResolver, +) { + return function* sanitizeJsDoc(doc: string) { + return yield* replaceAll( + doc, + /@?{@?link\s*(\w*)([^\w}])?(\w*)?([^}]*)?}/gm, + function* (match) { + const [, symbol, connector, method] = match; + return yield* resolver(symbol, connector, method); + }, + ); + }; +} diff --git a/www/hooks/use-mdx.tsx b/www/hooks/use-mdx.tsx new file mode 100644 index 000000000..1fe75f5b1 --- /dev/null +++ b/www/hooks/use-mdx.tsx @@ -0,0 +1,45 @@ +import { call, type Operation } from "effection"; +// deno-lint-ignore no-import-prefix +import { evaluate } from "npm:@mdx-js/mdx@3.1.0"; +// deno-lint-ignore no-import-prefix +import type { MDXModule } from "npm:@types/mdx@2.0.13"; +import { Fragment, jsx, jsxs } from "revolution/jsx-runtime"; +import { PluggableList } from "unified"; +// deno-lint-ignore no-import-prefix +import type { Options as RemarkRehypeOptions } from "npm:remark-rehype@11.1.1"; + +export interface UseMDXOptions { + remarkPlugins?: PluggableList | null | undefined; + /** + * List of rehype plugins (optional). + */ + rehypePlugins?: PluggableList | null | undefined; + /** + * Options to pass through to `remark-rehype` (optional); + * the option `allowDangerousHtml` will always be set to `true` and the MDX + * nodes (see `nodeTypes`) are passed through; + * In particular, you might want to pass configuration for footnotes if your + * content is not in English. + */ + remarkRehypeOptions?: Readonly | null | undefined; +} + +export function* useMDX( + markdown: string, + options?: UseMDXOptions, +): Operation { + return yield* call(async function () { + try { + return await evaluate(markdown, { + jsx, + jsxs, + jsxDEV: jsx, + Fragment, + ...options, + }); + } catch (e) { + console.log(`Failed to convert markdown to MDX: ${markdown}`); + throw e; + } + }); +} diff --git a/www/lib/clones.ts b/www/lib/clones.ts new file mode 100644 index 000000000..2b5ef4ca4 --- /dev/null +++ b/www/lib/clones.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; +import { $ } from "../context/shell.ts"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const Clones = createContext("clones"); + +export function* initClones(path: string): Operation { + yield* $(`rm -rf ${path}`); + yield* $(`mkdir -p ${path}`); + yield* Clones.set(path); +} + +export function* useClone(nameWithOwner: string): Operation { + let basepath = yield* Clones.expect(); + let dirpath = resolve(`${basepath}/${nameWithOwner}`); + if (!existsSync(dirpath)) { + yield* $(`git clone https://github.com/${nameWithOwner} ${dirpath}`); + } + return dirpath; +} diff --git a/www/lib/command-parser.test.ts b/www/lib/command-parser.test.ts new file mode 100644 index 000000000..cd604da9f --- /dev/null +++ b/www/lib/command-parser.test.ts @@ -0,0 +1,158 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; +import { parseCommand, splitCommand } from "./command-parser.ts"; + +describe("parseCommand", () => { + // Test cases from minimist-string README + it("solves the main minimist-string problem", function* () { + const result = parseCommand('foo --bar "Hello world!"'); + expect(result).toEqual({ + _: ["foo"], + bar: "Hello world!", + }); + }); + + it("handles escaped quotes correctly", function* () { + const result = parseCommand('foo --bar "Hello \\"world\\"!"'); + expect(result).toEqual({ + _: ["foo"], + bar: 'Hello "world"!', + }); + }); + + it("handles simple quoted string", function* () { + const result = parseCommand('foo --bar "Hello!"'); + expect(result).toEqual({ + _: ["foo"], + bar: "Hello!", + }); + }); + + // Additional comprehensive tests + it("handles simple command without quotes", function* () { + const result = parseCommand("foo --bar hello"); + expect(result).toEqual({ + _: ["foo"], + bar: "hello", + }); + }); + + it("handles multiple arguments", function* () { + const result = parseCommand("command arg1 arg2 --flag --option value"); + expect(result).toEqual({ + _: ["command", "arg1", "arg2"], + flag: true, + option: "value", + }); + }); + + it("handles equals syntax for options", function* () { + const result = parseCommand("command --option=value --flag"); + expect(result).toEqual({ + _: ["command"], + option: "value", + flag: true, + }); + }); + + it("handles short flags", function* () { + const result = parseCommand("command -f -abc value"); + expect(result).toEqual({ + _: ["command"], + f: true, + a: true, + b: true, + c: "value", + }); + }); + + it("handles mixed quotes", function* () { + const result = parseCommand( + `command --single 'Hello world' --double "Hello world"`, + ); + expect(result).toEqual({ + _: ["command"], + single: "Hello world", + double: "Hello world", + }); + }); + + it("handles complex git command", function* () { + const result = parseCommand('git commit -m "Initial commit with spaces"'); + expect(result).toEqual({ + _: ["git", "commit"], + m: "Initial commit with spaces", + }); + }); + + it("handles empty quotes", function* () { + const result = parseCommand('command --empty ""'); + expect(result).toEqual({ + _: ["command"], + empty: "", + }); + }); + + it("handles boolean flags", function* () { + const result = parseCommand("command --verbose --quiet"); + expect(result).toEqual({ + _: ["command"], + verbose: true, + quiet: true, + }); + }); + + it("handles hyphenated options", function* () { + const result = parseCommand("command --dry-run --output-dir /tmp"); + expect(result).toEqual({ + _: ["command"], + "dry-run": true, + "output-dir": "/tmp", + }); + }); +}); + +describe("splitCommand", () => { + it("splits simple command without quotes", function* () { + const result = splitCommand("git status --porcelain"); + expect(result).toEqual(["git", "status", "--porcelain"]); + }); + + it("preserves quoted strings with spaces", function* () { + const result = splitCommand('git commit -m "Initial commit with spaces"'); + expect(result).toEqual([ + "git", + "commit", + "-m", + "Initial commit with spaces", + ]); + }); + + it("handles escaped quotes", function* () { + const result = splitCommand('echo "Hello \\"world\\""'); + expect(result).toEqual(["echo", 'Hello "world"']); + }); + + it("handles mixed quotes", function* () { + const result = splitCommand( + `command --single 'Hello world' --double "Hello world"`, + ); + expect(result).toEqual([ + "command", + "--single", + "Hello world", + "--double", + "Hello world", + ]); + }); + + it("handles empty quotes", function* () { + const result = splitCommand('command --empty ""'); + expect(result).toEqual(["command", "--empty", ""]); + }); + + it("handles multiple spaces", function* () { + const result = splitCommand(" command arg1 arg2 "); + expect(result).toEqual(["command", "arg1", "arg2"]); + }); +}); diff --git a/www/lib/command-parser.ts b/www/lib/command-parser.ts new file mode 100644 index 000000000..30b8782fe --- /dev/null +++ b/www/lib/command-parser.ts @@ -0,0 +1,211 @@ +/** + * Parses a command string into arguments and options + * Modern JavaScript version of minimist-string without external dependencies + */ +export interface ParsedCommand { + _: string[]; + [key: string]: string | true | string[]; +} + +/** + * Splits a command string into an array of arguments, preserving quoted strings + * @param input The command string to split + * @returns Array of command arguments + */ +export function splitCommand(input: string): string[] { + if (!input.includes('"') && !input.includes("'")) { + return input.trim().split(/\s+/); + } + + const wrongPieces = input.split(" "); + let goodPieces = solveQuotes(wrongPieces, '"'); + goodPieces = solveQuotes(goodPieces, "'"); + + // Remove outer quotes but preserve escaped quotes + const regexQuotes = /["']/g; + for (let i = 0; i < goodPieces.length; i++) { + goodPieces[i] = goodPieces[i].replace(/(\\\')/g, "%%%SINGLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(/(\\\")/g, "%%%DOUBLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(regexQuotes, ""); + goodPieces[i] = goodPieces[i].replace(/(%%%SINGLEQUOTE%%%)/g, "'"); + goodPieces[i] = goodPieces[i].replace(/(%%%DOUBLEQUOTE%%%)/g, '"'); + } + + return goodPieces; +} + +/** + * Parses a command string into arguments and options + * @param input The command string to parse + * @returns Parsed command with arguments and options + */ +export function parseCommand(input: string): ParsedCommand { + if (!input.includes('"') && !input.includes("'")) { + // Simple case - no quotes, just split by spaces + return parseSimple(input); + } + + // Complex case - handle quotes + return parseWithQuotes(input); +} + +function parseSimple(input: string): ParsedCommand { + const pieces = input.trim().split(/\s+/); + return parseTokens(pieces); +} + +function parseWithQuotes(input: string): ParsedCommand { + const wrongPieces = input.split(" "); + + let goodPieces = solveQuotes(wrongPieces, '"'); + goodPieces = solveQuotes(goodPieces, "'"); + + // Remove outer quotes but preserve escaped quotes + const regexQuotes = /["']/g; + for (let i = 0; i < goodPieces.length; i++) { + goodPieces[i] = goodPieces[i].replace(/(\\\')/g, "%%%SINGLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(/(\\\")/g, "%%%DOUBLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(regexQuotes, ""); + goodPieces[i] = goodPieces[i].replace(/(%%%SINGLEQUOTE%%%)/g, "'"); + goodPieces[i] = goodPieces[i].replace(/(%%%DOUBLEQUOTE%%%)/g, '"'); + } + + return parseTokens(goodPieces); +} + +function countQuotes(piece: string, quoteChar: string): number { + const regex = new RegExp(`[^${quoteChar}\\\\]`, "g"); + const replaced = piece.replace(regex, ""); + return replaced + .replace(new RegExp(`(\\\\${quoteChar})`, "g"), "") + .replace(/\\/g, "").length; +} + +function hasQuote(piece: string, quoteChar: string): boolean { + return countQuotes(piece, quoteChar) > 0; +} + +function getFirstQuote(piece: string, quoteChar: string, position = 0): number { + let i = position - 1; + do { + i = piece.indexOf(quoteChar, i + 1); + } while (piece.charAt(i - 1) === "\\"); + return i; +} + +function splitPiece(piece: string, quoteChar: string): [string, string] { + const firstQIndex = getFirstQuote(piece, quoteChar); + const secondQIndex = getFirstQuote(piece, quoteChar, firstQIndex + 1); + + const firstPart = piece.substring(0, secondQIndex + 1); + const secondPart = piece.substring(secondQIndex + 1); + + return [firstPart, secondPart]; +} + +function solveQuotes(pieces: string[], quoteChar: string): string[] { + let unclosedQuote = false; + const result: string[] = []; + + for (let i = 0; i < pieces.length; i++) { + if (unclosedQuote) { + if (hasQuote(pieces[i], quoteChar)) { + const qIndex = getFirstQuote(pieces[i], quoteChar); + if (qIndex !== pieces[i].length - 1) { + // Closing quote is not the last character + pieces[i + 1] = pieces[i].substring(qIndex + 1) + + (pieces[i + 1] !== undefined ? pieces[i + 1] : ""); + pieces[i] = pieces[i].substring(0, qIndex + 1); + } + + result[result.length - 1] = result[result.length - 1] + " " + pieces[i]; + unclosedQuote = false; + } else { + result[result.length - 1] = result[result.length - 1] + " " + pieces[i]; + } + } else { + if (hasQuote(pieces[i], quoteChar)) { + const quoteCount = countQuotes(pieces[i], quoteChar); + + if (quoteCount === 1) { + result.push(pieces[i]); + unclosedQuote = true; + } else if (quoteCount === 2) { + const split = splitPiece(pieces[i], quoteChar); + result.push(split[0]); + if (split[1] !== "") result.push(split[1]); + } else { + let next = pieces[i]; + do { + const split = splitPiece(next, quoteChar); + result.push(split[0]); + next = split[1]; + } while (countQuotes(next, quoteChar) > 2); + + if (countQuotes(next, quoteChar) === 1) { + result.push(next); + unclosedQuote = true; + } else if (countQuotes(next, quoteChar) === 2) { + result.push(next); + } else { + throw new Error( + "Unexpected behavior in command parsing. Please report this bug.", + ); + } + } + } else { + result.push(pieces[i]); + } + } + } + return result; +} + +function parseTokens(tokens: string[]): ParsedCommand { + const result: ParsedCommand = { _: [] }; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.startsWith("--")) { + // Long option + const equalIndex = token.indexOf("="); + if (equalIndex !== -1) { + const key = token.substring(2, equalIndex); + const value = token.substring(equalIndex + 1); + result[key] = value; + } else { + const key = token.substring(2); + // Check if next token is a value + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) { + result[key] = tokens[++i]; + } else { + result[key] = true; + } + } + } else if (token.startsWith("-") && token.length > 1) { + // Short option(s) + const flags = token.substring(1); + + for (let j = 0; j < flags.length; j++) { + const flag = flags[j]; + + if (j === flags.length - 1) { + // Last flag - might have a value + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) { + result[flag] = tokens[++i]; + } else { + result[flag] = true; + } + } else { + result[flag] = true; + } + } + } else { + // Regular argument + result._.push(token); + } + } + + return result; +} diff --git a/www/lib/deno-json.ts b/www/lib/deno-json.ts new file mode 100644 index 000000000..5bd2fada0 --- /dev/null +++ b/www/lib/deno-json.ts @@ -0,0 +1,21 @@ +import z from "zod"; +import { Operation, until } from "effection"; + +export const DenoJsonSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), + exports: z.union([z.record(z.string()), z.string()]).optional(), + license: z.string().optional(), + workspace: z.array(z.string()).optional(), + imports: z.record(z.string()).optional(), +}); + +export type DenoJson = z.infer; + +export function* useDenoJson(path: string): Operation { + const { default: json } = yield* until( + import(path, { with: { type: "json" } }), + ); + + return DenoJsonSchema.parse(json); +} diff --git a/www/lib/ensure-trailing-slash.test.ts b/www/lib/ensure-trailing-slash.test.ts new file mode 100644 index 000000000..b6d00f62b --- /dev/null +++ b/www/lib/ensure-trailing-slash.test.ts @@ -0,0 +1,17 @@ +import { assertEquals } from "@std/assert"; +import { ensureTrailingSlash } from "./ensure-trailing-slash.ts"; + +Deno.test("ensureTrailingSlash adds trailing slash to each URL", () => { + assertEquals( + ensureTrailingSlash(new URL("http://example.com/dir")).toString(), + "http://example.com/dir/", + ); + assertEquals( + ensureTrailingSlash(new URL("http://example.com/dir/")).toString(), + "http://example.com/dir/", + ); + assertEquals( + ensureTrailingSlash(new URL("http://example.com/file.json")).toString(), + "http://example.com/file.json", + ); +}); diff --git a/www/lib/ensure-trailing-slash.ts b/www/lib/ensure-trailing-slash.ts new file mode 100644 index 000000000..295032b18 --- /dev/null +++ b/www/lib/ensure-trailing-slash.ts @@ -0,0 +1,7 @@ +export function ensureTrailingSlash(url: URL) { + const isFile = url.pathname.split("/").at(-1)?.includes("."); + if (isFile || url.pathname.endsWith("/")) { + return url; + } + return new URL(`${url.toString()}/`); +} diff --git a/www/lib/fs.ts b/www/lib/fs.ts new file mode 100644 index 000000000..f08cab7d9 --- /dev/null +++ b/www/lib/fs.ts @@ -0,0 +1,10 @@ +import { Operation, until } from "effection"; + +import * as fs from "@std/fs"; + +export function exists( + path: string | URL, + options?: fs.ExistsOptions, +): Operation { + return until(fs.exists(path, options)); +} diff --git a/www/lib/octokit.ts b/www/lib/octokit.ts new file mode 100644 index 000000000..46c94b1ca --- /dev/null +++ b/www/lib/octokit.ts @@ -0,0 +1,37 @@ +import { createContext, Operation, until, useScope } from "effection"; +import { Octokit } from "octokit"; +import { operations } from "../context/fetch.ts"; + +const OctokitContext = createContext("github-client"); + +export function* initOctokitContext() { + const token = Deno.env.get("GITHUB_TOKEN"); + + const scope = yield* useScope(); + + const octokit = new Octokit({ + auth: token, + request: { + fetch: (url: string, init?: RequestInit) => { + return scope.run(() => operations.fetch(url, init)); + }, + }, + }); + + return yield* OctokitContext.set(octokit); +} + +/** + * Get star count for a repository using Octokit + */ +export function* getStarCount(nameWithOwner: string): Operation { + const github = yield* OctokitContext.expect(); + const [owner, name] = nameWithOwner.split("/"); + const response = yield* until( + github.rest.repos.get({ + repo: name, + owner: owner, + }), + ); + return response.data.stargazers_count; +} diff --git a/www/lib/package.ts b/www/lib/package.ts new file mode 100644 index 000000000..2242eed8d --- /dev/null +++ b/www/lib/package.ts @@ -0,0 +1,214 @@ +import { all, Operation, until } from "effection"; +import { DocsPages, useDocPages } from "../hooks/use-deno-doc.tsx"; +import { createRepo, Ref } from "./repo.ts"; +import { extractVersion, findLatestSemverTag } from "./semver.ts"; +import { useWorktree } from "./worktrees.ts"; +import { DenoJson, useDenoJson } from "./deno-json.ts"; +import { + PackageDetailsResult, + PackageScoreResult, +} from "../resources/jsr-client.ts"; +import { useJSRClient } from "../context/jsr.ts"; +import z from "zod"; +import { useMDX } from "../hooks/use-mdx.tsx"; +import { useDescription, useTitle } from "../hooks/use-description-parse.tsx"; + +export type WorkTreePackageOptions = { + type: "worktree"; + series: "v3" | "v4"; +}; + +export type ClonePackageOptions = { + type: "clone"; + name?: string; + path: string; + workspacePath: string; + ref: Ref; +}; + +export type PackageOptions = WorkTreePackageOptions | ClonePackageOptions; + +export interface Package { + name: string; + scopeName: string; + version: string; + workspacePath: string; + ref: Ref; + exports: Record; + entrypoints: Record; + docs: () => Operation; + workspaces: string[]; + jsrPackageDetails: () => Operation< + [ + z.SafeParseReturnType | null, + z.SafeParseReturnType | null, + ] + >; + jsr: URL; + /** + * URL of the package on JSR + */ + jsrBadge: URL; + /** + * URL of package on npm + */ + npm: URL; + /** + * URL of badge for version published on npm + */ + npmVersionBadge: URL; + MDXContent: () => Operation; + title: () => Operation; + description: () => Operation; + readme(): Operation; +} + +//TODO: cache package +export function* usePackage(options: PackageOptions): Operation { + if (options.type === "worktree") { + let repo = createRepo({ name: "effection", owner: "thefrontside" }); + + let tags = yield* repo.tags( + new RegExp(`effection-${options.series}.*`), + ); + + let ref = findLatestSemverTag(tags); + + if (!ref) { + throw new Error(`unable to find package ref for ${options.series}`); + } + + let path = yield* useWorktree(ref.name); + + let denoJson = yield* useDenoJson(`${path}/deno.json`); + + let version = extractVersion(ref.name); + + return yield* initPackage("effection", path, ".", version, ref, denoJson); + } else { + let { path, workspacePath, ref } = options; + let denoJson = yield* useDenoJson(`${path}/deno.json`); + + let name = options.name || denoJson.name || "UNKNOWN_PACKAGE"; + + let version = denoJson.version ?? "main"; + + return yield* initPackage( + name, + path, + workspacePath, + version, + ref, + denoJson, + ); + } +} + +function* initPackage( + name: string, + path: string, + workspacePath: string, + version: string, + ref: Ref, + denoJson: DenoJson, +): Operation { + let [, scope] = denoJson?.name?.match(/@(.*)\/(.*)/) ?? []; + let pkg: Package = { + name, + scopeName: scope, + workspacePath, + version, + ref, + get exports() { + if (typeof denoJson.exports === "string") { + return { ["."]: denoJson.exports }; + } else if (denoJson.exports === undefined) { + return { ["."]: "./mod.ts" }; + } else { + return denoJson.exports; + } + }, + get entrypoints() { + let entrypoints: Record = {}; + for (let key of Object.keys(pkg.exports)) { + entrypoints[key] = new URL(pkg.exports[key], `file://${path}/`); + } + return entrypoints; + }, + *docs() { + let docs: DocsPages = {}; + + for (let [entrypoint, url] of Object.entries(pkg.entrypoints)) { + const pages = yield* useDocPages(`${url}`); + + docs[entrypoint] = pages[`${url}`]; + } + + return docs; + }, + get workspaces() { + return denoJson.workspace ?? []; + }, + jsr: new URL(`./${denoJson.name}/`, "https://jsr.io/"), + jsrBadge: new URL(`./${denoJson.name}`, "https://jsr.io/badges/"), + npm: new URL(`./${denoJson.name}`, "https://www.npmjs.com/package/"), + npmVersionBadge: new URL( + `./${denoJson.name}`, + "https://img.shields.io/npm/v/", + ), + *jsrPackageDetails(): Operation< + [ + z.SafeParseReturnType | null, + z.SafeParseReturnType | null, + ] + > { + let [, packageName] = name.split("/"); + const client = yield* useJSRClient(); + try { + const [details, score] = yield* all([ + client.getPackageDetails({ scope, package: packageName }), + client.getPackageScore({ scope, package: packageName }), + ]); + + if (!details.success) { + console.info( + `JSR package details response failed validation`, + details.error.format(), + ); + } + + if (!score.success) { + console.info( + `JSR score response failed validation`, + score.error.format(), + ); + } + + return [details, score]; + } catch (e) { + console.error(e); + } + + return [null, null]; + }, + + readme: () => until(Deno.readTextFile(`${path}/README.md`)), + + *MDXContent(): Operation { + let readme = yield* pkg.readme(); + let mod = yield* useMDX(readme); + + return mod.default({}); + }, + *title(): Operation { + let readme = yield* pkg.readme(); + return yield* useTitle(readme); + }, + *description(): Operation { + let readme = yield* pkg.readme(); + return yield* useDescription(readme); + }, + }; + + return pkg; +} diff --git a/www/lib/remove-description-hr.ts b/www/lib/remove-description-hr.ts new file mode 100644 index 000000000..7afbec890 --- /dev/null +++ b/www/lib/remove-description-hr.ts @@ -0,0 +1,35 @@ +import type { Nodes } from "hast"; +import { EXIT, visit } from "unist-util-visit"; + +/** + * Remove the HR element used to define the end of the description. + */ +export function removeDescriptionHR() { + return function (tree: Nodes) { + return visit(tree, (node, index, parent) => { + if ( + node.type === "element" && node.tagName === "hr" && + parent?.type === "root" + ) { + const beforeHR = parent.children + .slice(0, index) + .filter((node: Nodes) => + !(node.type === "text" && node.value === "\n") + ); + + // assume this hr is for a description if there are only two elements and + // second element is a paragraph. + if ( + beforeHR.length === 2 && beforeHR[1].type === "element" && + beforeHR[1].tagName === "p" + ) { + parent.children = parent.children.filter((child: Nodes) => + child !== node + ); + } + + return EXIT; + } + }); + }; +} diff --git a/www/lib/replace-all.ts b/www/lib/replace-all.ts new file mode 100644 index 000000000..4efc4694e --- /dev/null +++ b/www/lib/replace-all.ts @@ -0,0 +1,41 @@ +import { Operation } from "effection"; + +export function* replaceAll( + input: string, + regex: RegExp, + replacement: (match: RegExpMatchArray) => Operation, +): Operation { + // replace all implies global, so append if it is missing + const addGlobal = !regex.flags.includes("g"); + let flags = regex.flags; + if (addGlobal) flags += "g"; + + // get matches + let matcher = new RegExp(regex.source, flags); + const matches = Array.from(input.matchAll(matcher)); + + if (matches.length == 0) return input; + + // construct all replacements + let replacements: Array; + replacements = new Array(); + for (let m of matches) { + let r = yield* replacement(m); + replacements.push(r); + } + + // change capturing groups into non-capturing groups for split + // (because capturing groups are added to the parts array + let source = regex.source.replace(/(?; + latest(matching: RegExp): Operation; +} + +export interface Ref { + name: string; + nameWithOwner: string; + url: string; +} + +export interface RepoOptions { + name: string; + owner: string; +} +export function createRepo(options: RepoOptions): Repo { + let { name, owner } = options; + let repo: Repo = { + name, + owner, + *tags(matching) { + let result = yield* $(`git tag`); + let names = result.stdout.trim().split(/\s+/).filter((tag) => + matching.test(tag) + ); + return names.map((tagname) => ({ + name: tagname, + nameWithOwner: `${owner}/${name}`, + url: `https://github.com/${owner}/${name}/tree/${tagname}`, + })); + }, + *latest(matching) { + let tags = yield* repo.tags(matching); + let latest = findLatestSemverTag(tags); + + if (!latest) { + throw new Error(`Could not retrieve latest tag matching ${matching}`); + } + + return tags.find((tag) => tag.name === latest.name)!; + }, + }; + return repo; +} diff --git a/www/lib/semver.ts b/www/lib/semver.ts new file mode 100644 index 000000000..b1d20c3ec --- /dev/null +++ b/www/lib/semver.ts @@ -0,0 +1,27 @@ +export { compare, major, minor, rsort } from "semver"; + +import { rsort } from "semver"; + +export function extractVersion(input: string) { + const parts = input.match( + // @source: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, + ); + if (parts) { + return parts[0]; + } else { + return "0.0.0"; + } +} + +/** + * Find the latest Semver tag from an array of tags + * @param tags - Array of tag objects with name property + * @returns Latest semver tag if found, undefined otherwise + */ +export function findLatestSemverTag( + tags: T[], +): T | undefined { + const [latest] = rsort(tags.map((tag) => tag.name).map(extractVersion)); + return tags.find((tag) => tag.name.endsWith(latest)); +} diff --git a/www/lib/shift-headings.ts b/www/lib/shift-headings.ts new file mode 100644 index 000000000..35da87567 --- /dev/null +++ b/www/lib/shift-headings.ts @@ -0,0 +1,20 @@ +// deno-lint-ignore no-import-prefix +import type { Nodes, Root } from "npm:@types/mdast@4.0.4"; +import { visit } from "unist-util-visit"; + +export function shiftHeadings(amount: number) { + return (tree: Root) => { + visit(tree, (node: Nodes) => { + if (node.type === "heading") { + const depth = node.depth + amount; + if (depth < 1) { + node.depth = 1; + } else if (depth > 6) { + node.depth = 6; + } else { + node.depth = depth as 1 | 2 | 3 | 4 | 5 | 6; + } + } + }); + }; +} diff --git a/www/lib/toc.ts b/www/lib/toc.ts new file mode 100644 index 000000000..c535b71f5 --- /dev/null +++ b/www/lib/toc.ts @@ -0,0 +1,32 @@ +import { Options } from "@jsdevtools/rehype-toc"; +import { createTOC } from "@jsdevtools/rehype-toc/lib/create-toc.js"; +import { customizationHooks } from "@jsdevtools/rehype-toc/lib/customization-hooks.js"; +import { findHeadings } from "@jsdevtools/rehype-toc/lib/fiind-headings.js"; +import { findMainNode } from "@jsdevtools/rehype-toc/lib/find-main-node.js"; +import { NormalizedOptions } from "@jsdevtools/rehype-toc/lib/options.js"; +import type { Nodes } from "hast"; +import { JSXElement } from "revolution/jsx-runtime"; + +export function createToc(root: Nodes, options?: Options): JSXElement { + const _options = new NormalizedOptions( + options ?? { + cssClasses: { + toc: + "hidden text-sm font-light tracking-wide leading-loose lg:block relative pt-2", + link: "hover:underline hover:underline-offset-2", + }, + }, + ); + + // Find the
    or element + let [mainNode] = findMainNode(root); + + // Find all heading elements + let headings = findHeadings(mainNode, _options); + + // Create the table of contents + let tocNode = createTOC(headings, _options); + + // Allow the user to customize the table of contents before we add it to the page + return customizationHooks(tocNode, _options) as unknown as JSXElement; +} diff --git a/www/lib/trim-after-hr.ts b/www/lib/trim-after-hr.ts new file mode 100644 index 000000000..fb1b77ba9 --- /dev/null +++ b/www/lib/trim-after-hr.ts @@ -0,0 +1,24 @@ +import type { Nodes } from "hast"; +import { EXIT, visit } from "unist-util-visit"; + +/** + * Removes all content after
    in the root element. + * This is used to restrict the length of the description by eliminating everything after
    + * @returns + */ +export function trimAfterHR() { + return function (tree: Nodes) { + visit( + tree, + (node: Nodes, index: number | undefined, parent: Nodes | undefined) => { + if ( + node.type === "element" && node.tagName === "hr" && + parent?.type === "root" + ) { + parent.children = parent.children.slice(0, index); + return EXIT; + } + }, + ); + }; +} diff --git a/www/lib/workspace.ts b/www/lib/workspace.ts new file mode 100644 index 000000000..a949c8188 --- /dev/null +++ b/www/lib/workspace.ts @@ -0,0 +1,53 @@ +import { Operation } from "effection"; +import { useClone } from "./clones.ts"; +import { Package, usePackage } from "./package.ts"; + +export interface Workspace { + url: string; + nameWithOwner: string; + root: Package; + packages: Package[]; +} + +export function* useWorkspace(nameWithOwner: string): Operation { + let path = yield* useClone(nameWithOwner); + let [name] = nameWithOwner.split("/"); + + let url = `https://github.com/${nameWithOwner}`; + + let root = yield* usePackage({ + type: "clone", + name, + path, + workspacePath: ".", + ref: { + name: "main", + nameWithOwner, + url: `${url}}/tree/main`, + }, + }); + + let packages: Package[] = []; + + for (let workspacePath of root.workspaces) { + packages.push( + yield* usePackage({ + type: "clone", + path: `${path}/${workspacePath}`, + workspacePath, + ref: { + name: "main", + nameWithOwner, + url: `${url}/tree/main/${workspacePath}`, + }, + }), + ); + } + + return { + url, + nameWithOwner, + root, + packages, + }; +} diff --git a/www/lib/worktrees.ts b/www/lib/worktrees.ts new file mode 100644 index 000000000..9d38bda0a --- /dev/null +++ b/www/lib/worktrees.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; +import { $ } from "../context/shell.ts"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const Worktrees = createContext("worktrees"); + +export function* initWorktrees(path: string): Operation { + yield* $(`rm -rf ${path}`); + yield* $(`mkdir -p ${path}`); + yield* Worktrees.set(path); +} + +export function* useWorktree(refname: string): Operation { + let basepath = yield* Worktrees.expect(); + let checkout = resolve(`${basepath}/${refname}`); + if (!existsSync(checkout)) { + yield* $(`git worktree add --force ${checkout} ${refname}`); + } + return checkout; +} diff --git a/www/main.css b/www/main.css new file mode 100644 index 000000000..cf0725291 --- /dev/null +++ b/www/main.css @@ -0,0 +1,164 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +/* Tailwind-based Pagefind UI styling */ +@layer components { + .pagefind-ui { + @apply w-full text-gray-900 dark:text-gray-200; + } + + .pagefind-ui__drawer { + @apply flex flex-row; + } + + .pagefind-ui__form { + @apply relative; + } + + .pagefind-ui__form:before { + @apply absolute left-4 top-1/2 w-6 h-6 bg-current; + content: ""; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'%3E%3C/path%3E%3Cpath d='M21 21l-6 -6'%3E%3C/path%3E%3C/svg%3E"); + width: 18px; + height: 18px; + top: 23px; + left: 20px; + mask-repeat: no-repeat; + mask-size: 100%; + z-index: 9; + pointer-events: none; + display: block; + opacity: 0.7; + } + + .pagefind-ui__search-input { + @apply w-full h-16 px-14 py-4 bg-white dark:bg-gray-800 border-2 + border-gray-200 dark:border-gray-600 rounded-lg text-xl font-bold + placeholder:opacity-20 focus:outline-none focus:border-sky-500 + focus:ring-2 focus:ring-sky-500; + } + + .pagefind-ui__search-clear { + @apply absolute top-1 right-1 h-14 px-4 py-1 text-gray-900 + dark:text-gray-200 text-sm cursor-pointer bg-white dark:bg-gray-800 + rounded-lg; + } + + .pagefind-ui__results { + @apply pl-0 px-0! my-0; + padding-inline-start: 0 !important; + } + + .pagefind-ui__results-area { + @apply flex-1 mt-5 pl-5; + } + + .pagefind-ui__message { + @apply flex items-center h-6 py-2 text-base font-bold mt-0; + } + + .pagefind-ui__result { + @apply list-none flex items-start gap-10 border-t-1 border-gray-200 + dark:border-gray-600; + } + + .pagefind-ui__result:last-of-type { + @apply border-b-1 border-gray-200 dark:border-gray-600; + } + + .pagefind-ui__result-inner { + @apply flex-1 flex flex-col items-start mt-2 py-2; + } + + .pagefind-ui__result-title { + @apply font-bold text-xl my-0!; + } + + .pagefind-ui__result-nested { + @apply pl-5 mb-2; + } + + .pagefind-ui__result-nested:nth-of-type(1) { + @apply mt-2; + } + + .pagefind-ui__result-nested .pagefind-ui__result-title { + @apply text-base; + } + + .pagefind-ui__result-link { + @apply relative text-gray-900 dark:text-gray-200 no-underline! + hover:underline!; + } + + .pagefind-ui__result-nested .pagefind-ui__result-link:before { + content: "\2937 "; + position: absolute; + top: -2; + right: calc(100% + 0.1em); + } + + .pagefind-ui__result-excerpt { + @apply font-normal text-sm mt-1! mb-0!; + } + + .pagefind-ui__result-tags { + @apply list-none p-0 flex gap-5 flex-wrap mt-5; + } + + .pagefind-ui__result-tag { + @apply px-2 py-1 text-sm rounded bg-gray-200 dark:bg-gray-600; + } + + .pagefind-ui__filter-panel { + @apply flex-1 flex flex-col mt-5 max-w-60; + } + + .pagefind-ui__filter-panel-label { + @apply hidden; + } + + .pagefind-ui__filter-block { + @apply block border-b-1 border-gray-200 dark:border-gray-600 py-5; + } + + .pagefind-ui__filter-name { + @apply text-base relative flex items-center list-none font-bold + cursor-pointer h-6; + } + + .pagefind-ui__filter-group { + @apply flex flex-col gap-5 pt-8; + } + + .pagefind-ui__filter-group-label { + @apply hidden; + } + + .pagefind-ui__filter-value { + @apply relative flex items-center gap-2; + } + + .pagefind-ui__filter-checkbox { + @apply m-0 w-4 h-4 border border-gray-200 dark:border-gray-600 + appearance-none rounded bg-white dark:bg-gray-800 cursor-pointer + checked:bg-sky-500 checked:border-sky-500; + } + + .pagefind-ui__filter-label { + @apply cursor-pointer text-base font-normal; + } + + .pagefind-ui__button { + @apply mt-10 w-full h-12 px-3 text-base text-gray-900 dark:text-sky-500 + bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-600 + rounded font-bold cursor-pointer text-center hover:border-sky-500 + hover:text-sky-500; + } + + /* Loading skeleton styles */ + .pagefind-ui__loading { + @apply text-gray-900 dark:text-gray-200 bg-gray-900 dark:bg-gray-200 rounded + opacity-10 pointer-events-none; + } +} diff --git a/www/main.tsx b/www/main.tsx new file mode 100644 index 000000000..a4d6e228a --- /dev/null +++ b/www/main.tsx @@ -0,0 +1,96 @@ +import { initDenoDeploy } from "@effectionx/deno-deploy"; +import { main, suspend } from "effection"; +import { createRevolution, ServerInfo } from "revolution"; + +import { etagPlugin } from "./plugins/etag.ts"; +import { rebasePlugin } from "./plugins/rebase.ts"; +import { route, sitemapPlugin } from "./plugins/sitemap.ts"; +import { tailwindPlugin } from "./plugins/tailwind.ts"; + +import { apiReferenceRoute } from "./routes/api-reference-route.tsx"; +import { assetsRoute } from "./routes/assets-route.ts"; +import { firstPage, guidesRoute } from "./routes/guides-route.tsx"; +import { indexRoute } from "./routes/index-route.tsx"; +import { xIndexRedirect, xIndexRoute } from "./routes/x-index-route.tsx"; +import { xPackageRedirect, xPackageRoute } from "./routes/x-package-route.tsx"; + +import { initFetch } from "./context/fetch.ts"; +import { initJSRClient } from "./context/jsr.ts"; +import { patchDenoPermissionsQuerySync } from "./deno-deploy-patch.ts"; +import { initWorktrees } from "./lib/worktrees.ts"; +import { initGuides } from "./resources/guides.ts"; +import { apiIndexRoute } from "./routes/api-index-route.tsx"; +import { pagefindRoute } from "./routes/pagefind-route.ts"; +import { redirectDocsRoute } from "./routes/redirect-docs-route.tsx"; +import { redirectIndexRoute } from "./routes/redirect-index-route.tsx"; +import { searchRoute } from "./routes/search-route.tsx"; +import { initClones } from "./lib/clones.ts"; +import { initOctokitContext } from "./lib/octokit.ts"; + +// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts +if (import.meta.main) { + await main(function* () { + const denoDeploy = yield* initDenoDeploy(); + + // if (denoDeploy.isDenoDeploy) { + // patchDenoPermissionsQuerySync(); + // } + + yield* initClones("build/clones"); + yield* initWorktrees("build/worktrees"); + yield* initGuides({ + current: "v4", + worktrees: ["v3"], + }); + + yield* initJSRClient(); + yield* initFetch(); + + // configures Octokit client + yield* initOctokitContext(); + + let revolution = createRevolution({ + app: [ + route("/", indexRoute()), + route("/search", searchRoute()), + route("/docs", redirectIndexRoute(firstPage("v3"))), + route("/docs/:id", redirectDocsRoute("v3")), + route("/guides/v3", redirectIndexRoute(firstPage("v3"))), + route("/guides/v4", redirectIndexRoute(firstPage("v4"))), + route("/guides/:series/:id", guidesRoute({ search: true })), + route("/contrib", xIndexRedirect()), + route("/contrib/:workspacePath", xPackageRedirect()), + route("/x", xIndexRoute({ search: true })), + route("/x/:workspacePath", xPackageRoute({ search: true })), + route("/api", apiIndexRoute({ search: true })), + route("/api/v3/:symbol", apiReferenceRoute("v3", { search: true })), + route("/api/v4/:symbol", apiReferenceRoute("v4", { search: true })), + route( + "/pagefind{/*path}", + pagefindRoute({ pagefindDir: "pagefind", publicDir: "./built/" }), + ), + route("/assets/*path", assetsRoute("assets")), + ], + plugins: [ + yield* tailwindPlugin({ input: "main.css", outdir: "tailwind" }), + etagPlugin(), + rebasePlugin(), + sitemapPlugin(), + ], + }); + + let server = yield* revolution.start(); + console.log(`www -> ${urlFromServer(server)}`); + + yield* suspend(); + }); +} + +function urlFromServer(server: ServerInfo) { + return new URL( + "/", + `http://${ + server.hostname === "0.0.0.0" ? "localhost" : server.hostname + }:${server.port}`, + ); +} diff --git a/www/plugins/etag.ts b/www/plugins/etag.ts new file mode 100644 index 000000000..f00051621 --- /dev/null +++ b/www/plugins/etag.ts @@ -0,0 +1,40 @@ +import { RevolutionPlugin } from "revolution"; +import { encodeBase64 } from "@std/encoding/base64"; + +const DEPLOYMENT_ID = + // The same deployment will be shared by the many isolates that serve it + // but because pages do not change, we can use this id as the ETAG + Deno.env.get("DENO_DEPLOYMENT_ID") || + // For local development, just create a new id every time the module is + // reloaded i.e. whenever the dev server restarts. + crypto.randomUUID(); + +const DEPLOYMENT_ID_HASH = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(DEPLOYMENT_ID), +); + +const ETAG = `"${encodeBase64(DEPLOYMENT_ID_HASH)}"`; +const WEAK_ETAG = `W/"${encodeBase64(DEPLOYMENT_ID_HASH)}"`; + +export function etagPlugin(): RevolutionPlugin { + return { + *http(request, next) { + let ifNoneMatch = request.headers.get("if-none-match"); + if (ifNoneMatch === ETAG || ifNoneMatch === WEAK_ETAG) { + return new Response(null, { + status: 304, + statusText: "Not Modified", + }); + } else { + let response = yield* next(request); + if (!response.headers.get("etag")) { + let tagged = new Response(response.body, response); + tagged.headers.set("etag", ETAG); + return tagged; + } + return response; + } + }, + }; +} diff --git a/www/plugins/rebase.ts b/www/plugins/rebase.ts new file mode 100644 index 000000000..bb2f0fb36 --- /dev/null +++ b/www/plugins/rebase.ts @@ -0,0 +1,82 @@ +import type { RevolutionPlugin } from "revolution"; +import { createContext, type Operation } from "effection"; +import { posixNormalize } from "_posixNormalize"; +import { selectAll } from "hast-util-select"; +import { CurrentRequest } from "../context/request.ts"; + +const BaseUrl = createContext("baseUrl"); + +export function rebasePlugin(): RevolutionPlugin { + return { + *http(request, next) { + yield* CurrentRequest.set(request); + + let rebaseUrl = request.headers.get("X-Base-Url") ?? void 0; + if (rebaseUrl) { + yield* BaseUrl.set(new URL(rebaseUrl)); + } else { + let url = new URL(request.url); + url.pathname = "/"; + yield* BaseUrl.set(url); + } + + return yield* next(request); + }, + + /** + * Rebase an HTML document at a different URL. This replaces all `
    ` and + * `` attributes that contain an absolute path. Any path that is + * relative or contains a fully qualitfied URL will be left alone. + * + * @param tree - the HTML tree to transform + * @param baseUrl - a string representing a fully qualified url, e.g. + * http://frontside.com/effection + */ + *html(request, next) { + let tree = yield* next(request); + + let baseUrl = yield* BaseUrl.expect(); + let base = new URL(baseUrl); + let elements = selectAll('[href^="/"],[src^="/"]', tree); + + for (let element of elements) { + let properties = element.properties!; + + if (properties.href) { + properties.href = posixNormalize( + `${base.pathname}${properties.href}`, + ); + } + if (properties.src) { + properties.src = posixNormalize(`${base.pathname}${properties.src}`); + } + } + return tree; + }, + }; +} + +/** + * Convert a non fully qualified url into a fully qualified url, complete + * with protocol. + */ +export function* useAbsoluteUrl(path: string): Operation { + let absolute = yield* useAbsoluteUrlFactory(); + return absolute(path); +} + +export function* useAbsoluteUrlFactory(): Operation<(path: string) => string> { + let base = yield* BaseUrl.expect(); + let request = yield* CurrentRequest.expect(); + + return (path) => { + let normalizedPath = posixNormalize(path); + if (normalizedPath.startsWith("/")) { + let url = new URL(base); + url.pathname = posixNormalize(`${base.pathname}${path}`); + return url.toString(); + } else { + return new URL(path, request.url).toString(); + } + }; +} diff --git a/www/plugins/sitemap.ts b/www/plugins/sitemap.ts new file mode 100644 index 000000000..17ed9dc70 --- /dev/null +++ b/www/plugins/sitemap.ts @@ -0,0 +1,125 @@ +import type { Middleware, RevolutionPlugin } from "revolution"; +import { route as revolutionRoute, useRevolutionOptions } from "revolution"; +import type { Operation } from "effection"; +import { stringify } from "@libs/xml"; +import { compile } from "path-to-regexp"; +import { useAbsoluteUrlFactory } from "./rebase.ts"; + +export function sitemapPlugin(): RevolutionPlugin { + return { + *http(request, next) { + let options = yield* useRevolutionOptions(); + let url = new URL(request.url); + + if (url.pathname === "/sitemap.xml") { + let app = options.app ?? []; + let paths: RoutePath[] = []; + for (let middleware of app) { + let ext = middleware as SitemapExtension; + if (ext.sitemapExtension) { + paths = paths.concat(yield* ext.sitemapExtension(request)); + } + } + + let absolute = yield* useAbsoluteUrlFactory(); + + let xml = stringify({ + "@version": "1.0", + "@encoding": "UTF-8", + urlset: { + "@xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9", + url: paths.map((path) => { + let { pathname, ...entry } = path; + + return { + loc: absolute(pathname), + ...entry, + }; + }), + }, + }); + + return new Response(xml, { + status: 200, + headers: { + "Content-Type": "application/xml", + }, + }); + } + return yield* next(request); + }, + }; +} + +export interface SitemapExtension { + sitemapExtension?(request: Request): Operation; +} + +export interface RoutePath { + pathname: string; + lastmod?: string; + changefreq?: + | "always" + | "hourly" + | "daily" + | "weekly" + | "monthly" + | "yearly" + | "never"; + priority?: number; +} + +/** + * Just like a route, but generates a sitemap for all the urls + */ +export interface SitemapRoute { + /** + * The HTTP or HTML handler for this route + */ + handler: Middleware; + + /** + * Generate a list of paths for this route. It will be passed a function which + * will substitute in the parameters of the route to generate the path as a string. + * For example: + * + * ```ts + * // assuming a route pattern: "/users/:username" + * generate({ username: 'cowboyd' }) //=> "/users/cowboyd" + * ``` + * @param generate - a function to generate a single pathname + * @param request - the request for the sitemap + * @returns a list of `RoutePath` values + */ + routemap?( + generate: (params?: Record) => string, + request: Request, + ): Operation; +} + +export function route( + pattern: string, + middleware: Middleware | SitemapRoute, +): Middleware { + if (isSitemapRoute(middleware)) { + let handler = revolutionRoute(pattern, middleware.handler); + if (middleware.routemap) { + const { routemap } = middleware; + Object.defineProperty(handler, "sitemapExtension", { + value(request: Request) { + let generate = compile(pattern); + return routemap(generate, request); + }, + }); + } + return handler; + } else { + return revolutionRoute(pattern, middleware); + } +} + +function isSitemapRoute( + o: Middleware | SitemapRoute, +): o is SitemapRoute { + return !!(o as SitemapRoute).handler; +} diff --git a/www/plugins/tailwind.ts b/www/plugins/tailwind.ts new file mode 100644 index 000000000..0c3081ea2 --- /dev/null +++ b/www/plugins/tailwind.ts @@ -0,0 +1,77 @@ +import { crypto } from "@std/crypto"; +import { encodeHex } from "@std/encoding/hex"; +import { emptyDir, exists } from "@std/fs"; +import { serveFile } from "@std/http"; +import { join } from "@std/path"; +import { Operation, until } from "effection"; +import { select } from "hast-util-select"; +import type { RevolutionPlugin } from "revolution"; +import { $ } from "../context/shell.ts"; + +export interface TailwindOptions { + readonly input: string; + readonly outdir: string; +} + +export function* tailwindPlugin( + options: TailwindOptions, +): Operation { + yield* until(emptyDir(options.outdir)); + + let css = yield* compileCSS(options); + + return { + *html(request, next) { + let html = yield* next(request); + let head = select("head", html); + head?.children.push({ + type: "element", + tagName: "link", + properties: { rel: "stylesheet", href: css.href }, + children: [], + }); + return html; + }, + http(request, next) { + let url = new URL(request.url); + if (url.pathname === css.csspath) { + return until(serveFile(request, css.filepath)); + } else { + return next(request); + } + }, + }; +} + +interface CSS { + filepath: string; + csspath: string; + href: string; +} + +function* compileCSS(options: TailwindOptions): Operation { + let { input, outdir } = options; + let output = join(outdir, input); + + yield* $( + `deno run -A \ +--unstable-detect-cjs \ +npm:@tailwindcss/cli@^4.0.0 \ +--config tailwind.config.ts \ +--input ${input} \ +--output ${output}`, + ); + + if (yield* until(exists(output))) { + let content = yield* until(Deno.readFile(output)); + const buffer = yield* until(crypto.subtle.digest("SHA-256", content)); + const hash = encodeHex(buffer); + return { + filepath: output, + csspath: `/${output}`, + href: `/${output}?${hash}`, + }; + } + + throw new Error(`failed to generate ${output}`); +} diff --git a/www/resources/guides.ts b/www/resources/guides.ts new file mode 100644 index 000000000..5f614edf2 --- /dev/null +++ b/www/resources/guides.ts @@ -0,0 +1,168 @@ +import { basename } from "@std/path"; +import { + all, + createContext, + type Operation, + resource, + type Task, + until, + useScope, +} from "effection"; +import { JSXElement } from "revolution/jsx-runtime"; +import { z } from "zod"; + +import { useMarkdown } from "../hooks/use-markdown.tsx"; +import { createToc } from "../lib/toc.ts"; +import { useWorktree } from "../lib/worktrees.ts"; +import { $ } from "../context/shell.ts"; + +export interface DocModule { + default: () => JSX.Element; + frontmatter: { + id: string; + title: string; + }; +} + +export interface Guides { + all(): Operation; + get(id?: string): Operation; + first(): Operation; +} + +export interface Topic { + name: string; + items: GuidesMeta[]; +} + +export interface GuidesMeta { + id: string; + title: string; + filename: string; + topics: Topic[]; + next?: GuidesMeta; + prev?: GuidesMeta; +} + +export interface GuidesPage extends GuidesMeta { + content: JSXElement; + toc: JSXElement; + markdown: string; +} + +const Structure = z.record( + z.string(), + z.array(z.tuple([z.string(), z.string()])), +); + +export type StructureJson = z.infer; + +const GuidesContext = createContext>("guides"); + +export type GuidesOptions = { + current: string; + worktrees: string[]; +}; + +export function* initGuides(options: GuidesOptions): Operation { + const guides = new Map(); + + let path = yield* useGitRoot(); + guides.set(options.current, yield* loadGuides(path)); + + for (let series of options.worktrees) { + let path = yield* useWorktree(series); + guides.set(series, yield* loadGuides(path)); + } + + yield* GuidesContext.set(guides); +} + +export function* useGitRoot() { + let result = yield* $(`git rev-parse --show-toplevel`); + return result.stdout.trim(); +} + +export function* useGuides(series: string): Operation { + let guidesBySeries = yield* GuidesContext.expect(); + let guides = guidesBySeries.get(series); + if (!guides) { + throw new Error(`guides not found for series '${series}'`); + } + return guides; +} + +export function loadGuides(dirpath: string): Operation { + return resource(function* (provide) { + let scope = yield* useScope(); + let loaders = new Map>(); + + let structureModule = yield* until( + import(`${dirpath}/docs/structure.json`, { with: { type: "json" } }), + ); + + let structure = Structure.parse(structureModule.default); + + let entries = Object.entries(structure); + + let topics: Topic[] = []; + + for (let [name, contents] of entries) { + let topic: Topic = { name, items: [] }; + topics.push(topic); + + let current: GuidesMeta | undefined = void (0); + for (let i = 0; i < contents.length; i++) { + let prev: GuidesMeta | undefined = current; + let [filename, title] = contents[i]; + let meta: GuidesMeta = current = { + id: basename(filename, ".mdx"), + title, + filename: `docs/${filename}`, + topics, + prev, + }; + if (prev) { + prev.next = current; + } + topic.items.push(current); + + loaders.set( + meta.id, + scope.run(function* () { + let source = yield* until( + Deno.readTextFile(`${dirpath}/${meta.filename}`), + ); + + const content = yield* useMarkdown(source); + + return { + ...meta, + markdown: source, + content, + toc: createToc(content), + }; + }), + ); + } + } + + yield* provide({ + *first() { + const [[_id, task]] = loaders.entries(); + return yield* task; + }, + *all() { + return yield* all([...loaders.values()]); + }, + *get(id) { + if (id) { + let task = loaders.get(id); + if (task) { + return yield* task; + } + } + }, + }); + }); +} diff --git a/www/resources/jsr-client.ts b/www/resources/jsr-client.ts new file mode 100644 index 000000000..f7309b17a --- /dev/null +++ b/www/resources/jsr-client.ts @@ -0,0 +1,110 @@ +import { call, type Operation, resource } from "effection"; +import { z } from "zod"; + +interface GetPackageDetailsParams { + scope: string; + package: string; +} + +const PackageScore = z.object({ + hasReadme: z.boolean(), + hasReadmeExamples: z.boolean(), + allEntrypointsDocs: z.boolean(), + allFastCheck: z.boolean(), + hasProvenance: z.boolean(), + hasDescription: z.boolean(), + atLeastOneRuntimeCompatible: z.boolean(), + multipleRuntimesCompatible: z.boolean(), + percentageDocumentedSymbols: z.number().min(0).max(1), + total: z.number(), +}); + +const PackageDetails = z.object({ + scope: z.string(), + name: z.string(), + description: z.string(), + runtimeCompat: z.object({ + browser: z.boolean().optional(), + deno: z.boolean().optional(), + node: z.boolean().optional(), + bun: z.boolean().optional(), + workerd: z.boolean().optional(), + }), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + githubRepository: z.object({ + id: z.number(), + owner: z.string(), + name: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }).nullable(), + score: z.number().min(0).max(100).nullable(), +}); + +export type PackageScoreResult = z.infer; +export type PackageDetailsResult = z.infer; + +export interface JSRClient { + getPackageScore: ( + params: GetPackageDetailsParams, + ) => Operation>; + getPackageDetails: ( + params: GetPackageDetailsParams, + ) => Operation>; +} + +export function createJSRClient(token: string): Operation { + return resource(function* (provide) { + yield* provide({ + *getPackageScore(params) { + const response = yield* call(() => + fetch( + `https://api.jsr.io/scopes/${params.scope}/packages/${params.package}/score`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + ); + + if (response.ok) { + const json = yield* call(() => response.json()); + return yield* call(() => PackageScore.safeParseAsync(json)); + } + + throw new Error( + `Could not get package score for @${params.scope}/${params.package}`, + { + cause: `${response.status}: ${response.statusText}`, + }, + ); + }, + *getPackageDetails(params) { + const response = yield* call(() => + fetch( + `https://api.jsr.io/scopes/${params.scope}/packages/${params.package}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + ); + + if (response.ok) { + const json = yield* call(() => response.json()); + return yield* call(() => PackageDetails.safeParseAsync(json)); + } + + throw new Error( + `Could not get package details for @${params.scope}/${params.package}`, + { + cause: `${response.status}: ${response.statusText}`, + }, + ); + }, + }); + }); +} diff --git a/www/routes/api-index-route.tsx b/www/routes/api-index-route.tsx new file mode 100644 index 000000000..0fdcd9110 --- /dev/null +++ b/www/routes/api-index-route.tsx @@ -0,0 +1,106 @@ +import { type JSXElement } from "revolution"; + +import { Icon } from "../components/type/icon.tsx"; +import { DocPage } from "../hooks/use-deno-doc.tsx"; +import { ResolveLinkFunction } from "../hooks/use-markdown.tsx"; +import { usePackage } from "../lib/package.ts"; +import { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createChildURL } from "./links-resolvers.ts"; + +export function apiIndexRoute( + { search }: { search: boolean }, +): SitemapRoute { + return { + *routemap(gen) { + return [{ pathname: gen() }]; + }, + handler: function* () { + let v3 = yield* usePackage({ + type: "worktree", + series: "v3", + }); + + let v4 = yield* usePackage({ + type: "worktree", + series: "v4", + }); + + let docs = { + v3: yield* v3.docs(), + v4: yield* v4.docs(), + }; + + const AppHtml = yield* useAppHtml({ + title: `API Reference | Effection`, + description: `API Reference for Effection`, + }); + + return ( + +
    +

    API Reference

    +
    +

    + {v4.version} + + + +

    +
      + {yield* listPages({ + pages: docs.v4["."], + linkResolver: createChildURL("v4"), + })} +
    +
    +
    +
    +

    + {v3.version} + + + +

    +
      + {yield* listPages({ + pages: docs.v3["."], + linkResolver: createChildURL("v3"), + })} +
    +
    +
    + + ); + }, + }; +} + +function* listPages({ + pages, + linkResolver, +}: { + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + const elements = []; + + for (const page of pages.sort((a, b) => a.name.localeCompare(b.name))) { + const link = yield* linkResolver(page.name); + elements.push( +
  • + + + {page.name} + +
  • , + ); + } + return <>{elements}; +} diff --git a/www/routes/api-reference-route.tsx b/www/routes/api-reference-route.tsx new file mode 100644 index 000000000..8fc10aa3a --- /dev/null +++ b/www/routes/api-reference-route.tsx @@ -0,0 +1,67 @@ +import { type JSXElement, useParams } from "revolution"; + +import { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; + +import { ApiPage } from "../components/api/api-page.tsx"; +import { usePackage } from "../lib/package.ts"; +import { createSibling } from "./links-resolvers.ts"; + +export function apiReferenceRoute(series: "v3" | "v4", { + search, +}: { + search: boolean; +}): SitemapRoute { + return { + *routemap(generate) { + let pkg = yield* usePackage({ + type: "worktree", + series, + }); + + let docs = yield* pkg.docs(); + + return docs["."] + .map((node) => node.name) + .flatMap((symbol) => { + return [ + { + pathname: generate({ symbol }), + }, + ]; + }); + }, + handler: function* () { + let { symbol } = yield* useParams<{ symbol: string }>(); + + let pkg = yield* usePackage({ + type: "worktree", + series, + }); + + let docs = yield* pkg.docs(); + + const pages = docs["."]; + + const page = pages.find((node) => node.name === symbol); + + if (!page) throw new Error(`Could not find a doc page for ${symbol}`); + + const AppHtml = yield* useAppHtml({ + title: `${symbol} | API Reference | Effection`, + description: page.description, + }); + + return ( + + {yield* ApiPage({ + pages, + current: symbol, + pkg, + externalLinkResolver: createSibling, + })} + + ); + }, + }; +} diff --git a/www/routes/app.html.tsx b/www/routes/app.html.tsx new file mode 100644 index 000000000..8314890ce --- /dev/null +++ b/www/routes/app.html.tsx @@ -0,0 +1,83 @@ +import type { Operation } from "effection"; +import type { JSXChild } from "revolution"; + +import { Footer } from "../components/footer.tsx"; +import { Header, type HeaderProps } from "../components/header.tsx"; +import { useAbsoluteUrl } from "../plugins/rebase.ts"; +import { JSXElement } from "revolution/jsx-runtime"; + +export type Options = { + title: string; + description: string; + head?: JSXElement; +} & HeaderProps; + +export interface AppHtmlProps { + children: JSXChild; + search?: boolean; +} + +export function* useAppHtml({ + title, + description, + hasLeftSidebar, + head, +}: Options): Operation<({ children, search }: AppHtmlProps) => JSX.Element> { + let twitterImageURL = yield* useAbsoluteUrl( + "/assets/images/meta-effection.png", + ); + let homeURL = yield* useAbsoluteUrl("/"); + + const header = yield* Header({ hasLeftSidebar }); + + return ({ children, search }) => ( + + + + {title} + + + + + + + + + + + + + + + + {/* @ts-expect-error Custom element is-land from @11ty/is-land */} + + + {/* @ts-expect-error Custom element is-land from @11ty/is-land */} + + + + ); + }, + }; +} diff --git a/www/routes/x-index-route.tsx b/www/routes/x-index-route.tsx new file mode 100644 index 000000000..eb1721fcc --- /dev/null +++ b/www/routes/x-index-route.tsx @@ -0,0 +1,123 @@ +import { all } from "effection"; +import type { JSXElement } from "revolution"; +import { GithubPill } from "../components/package/source-link.tsx"; +import { useWorkspace } from "../lib/workspace.ts"; +import type { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createChildURL, createSibling } from "./links-resolvers.ts"; +import { softRedirect } from "./redirect.tsx"; + +export function xIndexRedirect(): SitemapRoute { + return { + *routemap(pathname) { + return [{ pathname: pathname() }]; + }, + *handler(req) { + return yield* softRedirect(req, yield* createSibling("x")); + }, + }; +} + +export function xIndexRoute({ + search, +}: { + search: boolean; +}): SitemapRoute { + return { + *routemap(gen) { + return [{ pathname: gen() }]; + }, + *handler() { + let workspace = yield* useWorkspace("thefrontside/effectionx"); + + const AppHTML = yield* useAppHtml({ + title: "Extensions | Effection", + description: + "List of community contributed modules that represent emerging consensus on how to do common JavaScript tasks with Effection.", + }); + + const makeChildUrl = createChildURL(); + + return ( + +
    +
    +

    + Effection Extensions +

    + {yield* GithubPill({ + url: workspace.url, + text: workspace.nameWithOwner, + class: + "flex flex-row w-fit h-10 items-center rounded-full bg-gray-200 dark:bg-gray-800 px-2 py-1 text-gray-900 dark:text-gray-100", + })} +
    +

    + A collection of reusable, community-created extensions - ranging + from small packages to complete frameworks - that show the best + practices for handling common JavaScript tasks with Effection. +

    +
    +

    + Frameworks +

    + +
    +
    +

    + Packages +

    +
      + {yield* all( + workspace.packages.map(function* (pkg) { + const [details] = yield* pkg.jsrPackageDetails(); + + let title; + let description; + if (details && details.success) { + title = `@${details.data.scope}/${details.data.name}`; + description = details.data.description; + } else { + title = pkg.workspacePath; + description = yield* pkg.description(); + } + + return ( +
    • + + + {title} + + + {description} + + +
    • + ); + }), + )} +
    +
    +
    +
    + ); + }, + }; +} diff --git a/www/routes/x-package-route.tsx b/www/routes/x-package-route.tsx new file mode 100644 index 000000000..8ef5b54c4 --- /dev/null +++ b/www/routes/x-package-route.tsx @@ -0,0 +1,241 @@ +import { call } from "effection"; +import { shiftHeading } from "hast-util-shift-heading"; +import type { Nodes } from "hast/"; +import { + type JSXElement, // @ts-types="revolution" + respondNotFound, + useParams, +} from "revolution"; + +import { select } from "hast-util-select"; +import { ApiBody } from "../components/api/api-page.tsx"; +import { PackageExports } from "../components/package/exports.tsx"; +import { PackageHeader } from "../components/package/header.tsx"; +import { ScoreCard } from "../components/score-card.tsx"; +import { Icon } from "../components/type/icon.tsx"; +import { DocPageContext } from "../context/doc-page.ts"; +import { useMarkdown } from "../hooks/use-markdown.tsx"; +import { createToc } from "../lib/toc.ts"; +import { useWorkspace } from "../lib/workspace.ts"; +import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createSibling } from "./links-resolvers.ts"; +import { softRedirect } from "./redirect.tsx"; + +interface XPackageRouteParams { + search: boolean; +} + +function routemap(): SitemapRoute["routemap"] { + return function* (pathname) { + let paths: RoutePath[] = []; + + let workspace = yield* useWorkspace("thefrontside/effectionx"); + + for (let workspacePath of workspace.root.workspaces) { + paths.push({ + pathname: pathname({ + workspacePath: workspacePath.replace(/^\.\//, ""), + }), + }); + } + + return paths; + }; +} + +export function xPackageRedirect(): SitemapRoute { + return { + routemap: routemap(), + *handler(req) { + const params = yield* useParams<{ workspacePath: string }>(); + return yield* softRedirect( + req, + yield* createSibling(params.workspacePath), + ); + }, + }; +} + +export function xPackageRoute({ + search, +}: XPackageRouteParams): SitemapRoute { + return { + routemap: routemap(), + *handler() { + let params = yield* useParams<{ workspacePath: string }>(); + + let workspace = yield* useWorkspace("thefrontside/effectionx"); + + let pkg = workspace.packages.find((pkg) => + pkg.workspacePath.replace("./", "") === params.workspacePath + ); + + if (!pkg) { + return yield* respondNotFound(); + } + + try { + const docs = yield* pkg.docs(); + + const AppHTML = yield* useAppHtml({ + title: `${pkg.name} | Extensions | Effection`, + description: yield* pkg.description(), + }); + + const linkResolver = function* ( + symbol: string, + connector?: string, + method?: string, + ) { + const internal = `#${symbol}_${method}`; + if (connector === "_") { + return internal; + } + const page = docs["."].find( + (page) => page.name === symbol && page.kind !== "import", + ); + + if (page) { + // get internal link + return `[${symbol}](#${page.kind}_${page.name})`; + } + + return symbol; + }; + + const apiReference = []; + + const entrypoints = Object.entries(docs); + + for (const [entrypoint, pages] of entrypoints) { + const sections = []; + for (const page of pages) { + const content = yield* call(function* () { + yield* DocPageContext.set(page); + return yield* ApiBody({ page, linkResolver }); + }); + sections.push(content); + } + if (entrypoint.length === 1 && entrypoint === ".") { + apiReference.push( +
    + <>{sections} +
    , + ); + } else if (pages.length > 0) { + apiReference.push( +
    +

    {entrypoint}

    + <>{sections} +
    , + ); + } + } + + apiReference.forEach((section) => shiftHeading(section, 1)); + + const content = ( + <> + {yield* useMarkdown(yield* pkg.readme(), { linkResolver })} +

    API Reference

    + <>{apiReference} + + ); + + const toc = createToc(content, { + headings: ["h2", "h3"], + cssClasses: { + toc: + "hidden text-sm font-light tracking-wide leading-loose lg:block relative", + link: "flex flex-row items-center", + }, + customizeTOCItem(item, heading) { + heading.properties.class = [ + heading.properties.class, + `group scroll-mt-[100px]`, + ] + .filter(Boolean) + .join(""); + + const ol = select("ol.toc-level-2, ol.toc-level-3", item as Nodes); + if (ol) { + ol.properties.className = `${ol.properties.className} ml-6`; + } + if ( + heading.properties["data-kind"] && + heading.properties["data-name"] + ) { + item.properties.className += " mb-1"; + const a = select("a", item as Nodes); + if (a) { + // deno-lint-ignore no-explicit-any + (a as any).children = [ + , + + {heading.properties["data-name"]} + , + ]; + } + } else { + const a = select("a", item as Nodes); + if (a) { + a.properties.className = + `hover:underline hover:underline-offset-2`; + } + } + return item; + }, + }); + + return ( + + <> +
    +
    + {yield* PackageHeader(pkg)} +
    +
    + {yield* PackageExports({ + packageName: pkg.name, + docs, + linkResolver, + })} +
    + {content} +
    +
    + +
    + +
    + ); + } catch (e) { + console.error(e); + const AppHTML = yield* useAppHtml({ + title: `${params.workspacePath} not found`, + description: `Failed to load ${params.workspacePath} due to error.`, + }); + return ( + +

    Failed to load {params.workspacePath} due to error.

    +
    + ); + } + }, + }; +} diff --git a/www/tailwind.config.ts b/www/tailwind.config.ts new file mode 100644 index 000000000..a7d299e0a --- /dev/null +++ b/www/tailwind.config.ts @@ -0,0 +1,26 @@ +import { Config } from "tailwindcss"; + +export default { + content: [ + "./src/**/*.{js,ts,jsx,tsx,mdx}", + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + fontFamily: { + sans: ["Proxima Nova", "proxima-nova", "sans-serif"], + inter: ["Inter", "inter", "san-serif"], + }, + extend: { + colors: { + "blue-primary": "#14315D", + "blue-secondary": "#26ABE8", + "pink-secondary": "#F74D7B", + "typescript-blue": "#3178c6", + }, + screens: { + sm: { max: "540px" }, + }, + }, + }, +} satisfies Config; diff --git a/www/testing.ts b/www/testing.ts new file mode 100644 index 000000000..02d16a0d0 --- /dev/null +++ b/www/testing.ts @@ -0,0 +1,62 @@ +import type { Operation } from "effection"; +import { + afterAll as $afterAll, + describe as $describe, + it as $it, +} from "@std/testing/bdd"; +import { createTestAdapter, type TestAdapter } from "./testing/adapter.ts"; + +let current: TestAdapter | undefined; + +export function describe(name: string, body: () => void) { + const isTop = !current; + const original = current; + try { + const child = current = createTestAdapter({ name, parent: original }); + if (isTop) { + // + } + + $describe(name, () => { + $afterAll(() => child.destroy()); + body(); + }); + } finally { + current = original; + } +} + +describe.skip = $describe.skip; +describe.only = $describe.only; + +export function beforeEach(body: () => Operation) { + current?.addSetup(body); +} + +export function it(desc: string, body?: () => Operation): void { + const adapter = current!; + if (!body) { + return $it.skip(desc, () => {}); + } + $it(desc, async () => { + const result = await adapter.runTest(body); + if (!result.ok) { + throw result.error; + } + }); +} + +it.skip = (...args: Parameters): ReturnType => { + const [desc] = args; + $it.skip(desc, () => {}); +}; + +it.only = (desc: string, body: () => Operation): void => { + const adapter = current!; + $it.only(desc, async () => { + const result = await adapter.runTest(body); + if (!result.ok) { + throw result.error; + } + }); +}; diff --git a/www/testing/adapter.ts b/www/testing/adapter.ts new file mode 100644 index 000000000..29c024d3f --- /dev/null +++ b/www/testing/adapter.ts @@ -0,0 +1,134 @@ +import type { Future, Operation, Result, Scope } from "effection"; +import { createScope, Err, Ok } from "effection"; + +export interface TestOperation { + (): Operation; +} + +export interface TestAdapter { + /** + * The parent of this adapter. All of the setup from this adapter will be + * run in addition to the setup of this adapter during `runTest()` + */ + readonly parent?: TestAdapter; + + /** + * The name of this adapter which is mostly useful for debugging purposes + */ + readonly name: string; + + /** + * A qualified name that contains not only the name of this adapter, but of all its + * ancestors. E.g. `All Tests > File System > write` + */ + readonly fullname: string; + + /** + * Every test adapter has its own Effection `Scope` which holds the resources necessary + * to run this test. + */ + readonly scope: Scope; + + /** + * A list of this test adapter and every adapter that it descends from. + */ + readonly lineage: Array; + + /** + * The setup operations that will be run by this test adapter. It only includes those + * setups that are associated with this adapter, not those of its ancestors. + */ + readonly setups: TestOperation[]; + + /** + * Add a setup operation to every test that is part of this adapter. In BDD integrations, + * this is usually called by `beforEach()` + */ + addSetup(op: TestOperation): void; + + /** + * Actually run a test. This evaluates all setup operations, and then after those have completed + * it runs the body of the test itself. + */ + runTest(body: TestOperation): Future>; + + /** + * Teardown this test adapter and all of the task and resources that are running inside it. + * This basically destroys the Effection `Scope` associated with this adapter. + */ + destroy(): Future; +} + +export interface TestAdapterOptions { + /** + * The name of this test adapter which is handy for debugging. + * Usually, you'll give this the same name as the current test + * context. For example, when integrating with BDD, this would be + * the same as + */ + name?: string; + /** + * The parent test adapter. All of the setup from this adapter will be + * run in addition to the setup of this adapter during `runTest()` + */ + parent?: TestAdapter; +} + +const anonymousNames: Iterator = (function* () { + let count = 1; + while (true) { + yield `anonymous test adapter ${count++}`; + } +})(); + +/** + * Create a new test adapter with the given options. + */ +export function createTestAdapter( + options: TestAdapterOptions = {}, +): TestAdapter { + const setups: TestOperation[] = []; + const { parent, name = anonymousNames.next().value } = options; + + const [scope, destroy] = createScope(parent?.scope); + + const adapter: TestAdapter = { + parent, + name, + scope, + setups, + get lineage() { + const lineage = [adapter]; + for (let current = parent; current; current = current.parent) { + lineage.unshift(current); + } + return lineage; + }, + get fullname() { + return adapter.lineage.map((adapter) => adapter.name).join(" > "); + }, + addSetup(op) { + setups.push(op); + }, + runTest(op) { + return scope.run(function* () { + const allSetups = adapter.lineage.reduce( + (all, adapter) => all.concat(adapter.setups), + [] as TestOperation[], + ); + try { + for (const setup of allSetups) { + yield* setup(); + } + yield* op(); + return Ok(void 0); + } catch (error) { + return Err(error as Error); + } + }); + }, + destroy, + }; + + return adapter; +} diff --git a/www/testing/helpers.ts b/www/testing/helpers.ts new file mode 100644 index 000000000..7d96ff6dc --- /dev/null +++ b/www/testing/helpers.ts @@ -0,0 +1,53 @@ +import { type Operation, until } from "effection"; +import { $ } from "../context/shell.ts"; +import * as fs from "@std/fs"; + +export function ensureDir(dir: string | URL) { + return until(fs.ensureDir(dir)); +} + +export function writeTextFile( + path: string | URL, + data: string | ReadableStream, + options?: Deno.WriteFileOptions, +) { + return until(Deno.writeTextFile(path, data, options)); +} + +export interface GitCommit { + sha: string; + message: string; + tags: string[]; +} + +/** + * Gets git commit history from current directory in chronological order with detailed info + * @returns Array of commits with sha, message, and tags in chronological order (oldest first) + */ +export function* getGitHistory(): Operation { + // Get commit history with hash and message + const historyResult = yield* $(`git log --format="%H|%s" --reverse`); + + const lines = historyResult.stdout.split("\n").filter((line) => + line.length > 0 + ); + const commits: GitCommit[] = []; + + for (const line of lines) { + const [sha, message] = line.split("|"); + + // Get tags for this commit using $ shell utility + try { + const tagsResult = yield* $(`git tag --points-at ${sha}`); + const tags = tagsResult.stdout.split("\n").filter((tag) => + tag.length > 0 + ); + commits.push({ sha, message, tags }); + } catch { + // No tags for this commit + commits.push({ sha, message, tags: [] }); + } + } + + return commits; +} diff --git a/www/testing/logging.ts b/www/testing/logging.ts new file mode 100644 index 000000000..3a3bf9a22 --- /dev/null +++ b/www/testing/logging.ts @@ -0,0 +1,62 @@ +import { createQueue, type Queue, resource } from "effection"; +import { loggerApi } from "../context/logging.ts"; + +const levels = ["info", "warn", "debug", "error"] as const; + +type LogEvent = + | { type: "info"; args: unknown[] } + | { type: "warn"; args: unknown[] } + | { type: "debug"; args: unknown[] } + | { type: "error"; args: unknown[] }; + +export function* setupLogging(level: (typeof levels)[number] | false) { + const queue = yield* resource>(function* (provide) { + const queue = createQueue(); + try { + yield* provide(queue); + } finally { + queue.close(); + } + }); + + if (level === false) { + yield* loggerApi.around({ + *info() {}, + *debug() {}, + *warn() {}, + *error() {}, + }); + return; + } + yield* loggerApi.around({ + *info(args, next) { + if (level === "info") { + queue.add({ type: "info", args }); + return yield* next(...args); + } + }, + *warn(args, next) { + if (level === "info" || level === "warn") { + queue.add({ type: "warn", args }); + return yield* next(...args); + } + }, + *debug(args, next) { + if (level === "info" || level === "warn" || level === "debug") { + queue.add({ type: "debug", args }); + return yield* next(...args); + } + }, + *error(args, next) { + if ( + level === "info" || level === "warn" || level === "debug" || + level === "error" + ) { + queue.add({ type: "error", args }); + return yield* next(...args); + } + }, + }); + + return queue; +} diff --git a/www/testing/temp-dir.ts b/www/testing/temp-dir.ts new file mode 100644 index 000000000..7138843f7 --- /dev/null +++ b/www/testing/temp-dir.ts @@ -0,0 +1,71 @@ +import { type Operation, resource, until } from "effection"; +import { ensureDir, ensureFile } from "@std/fs"; +import { join } from "@std/path"; + +function* writeFiles( + dir: string, + files: Record, +): Operation { + for (const [path, content] of Object.entries(files)) { + yield* until(ensureFile(join(dir, path))); + yield* until(Deno.writeTextFile(join(dir, path), content)); + } +} + +export interface TempDir { + withFiles(files: Record): Operation; + withWorkspace( + workspace: string, + files: Record, + ): Operation; + path: string; +} + +interface CreateTempDirParams { + autoClean?: boolean; + baseDir?: string; +} + +export function createTempDir( + params?: CreateTempDirParams, +): Operation { + return resource(function* (provide) { + const { + baseDir, + autoClean, + } = params || {}; + let dir: string; + + if (baseDir) { + // Create directory in specified base directory + yield* until(ensureDir(baseDir)); + const timestamp = Date.now().toString(36); + const randomSuffix = Math.random().toString(36).substring(2, 8); + const dirName = `${timestamp}-${randomSuffix}`; + dir = join(baseDir, dirName); + yield* until(ensureDir(dir)); + } else { + // Fall back to system temp directory + dir = yield* until(Deno.makeTempDir()); + } + + try { + yield* provide({ + get path() { + return dir; + }, + *withFiles(files: Record) { + yield* writeFiles(dir, files); + }, + *withWorkspace(workspace: string, files: Record) { + yield* writeFiles(join(dir, workspace), files); + }, + }); + } finally { + // Only remove if we created it (not if it's in a managed base directory) + if (autoClean) { + yield* until(Deno.remove(dir, { recursive: true })); + } + } + }); +}