├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── README.md ├── bin └── blocks.js ├── package.json ├── scripts ├── build.js ├── config │ ├── env.js │ ├── paths.js │ └── vite.config.dev.js └── start.js ├── src ├── components │ ├── Block.tsx │ ├── BlockComponent.tsx │ ├── ErrorBoundary.tsx │ └── PageWrapper.tsx ├── favicon.svg ├── index.css ├── index.d.ts ├── index.html ├── init.tsx └── utils.ts ├── tsconfig.json ├── tsup.config.ts ├── utils ├── components │ └── block-picker.tsx ├── extensionToLanguage.json ├── index.ts ├── lib │ └── index.ts └── types │ └── index.ts ├── vite-env.d.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist-ssr 4 | *.local 5 | .yalc.lock 6 | .yalc 7 | dist -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blocks 2 | 3 | Welcome! This package supports local development of custom GitHub Blocks. 4 | 5 | # Scripts 6 | 7 | Using the `blocks` command, you can run the following commands: 8 | 9 | - `start` - Starts a local development environment and builds Blocks bundles. 10 | - `build` - Builds Blocks bundles. 11 | 12 | # Utility functions 13 | 14 | To reduce the cognitive load associated with writing file and folder block components, we've assembled a helper library that exposes interface definitions and a few helper functions. 15 | 16 | ## How to use 17 | 18 | `yarn add @githubnext/blocks` 19 | 20 | ```tsx 21 | import { 22 | FileBlockProps, 23 | FolderBlockProps, 24 | getLanguageFromFilename, 25 | getNestedFileTree, 26 | } from '@githubnext/blocks` 27 | ``` 28 | 29 | ## FileBlockProps 30 | 31 | ```tsx 32 | import { FileBlockProps } from '@githubnext/blocks'; 33 | 34 | export default function (props: FileBlockProps) { 35 | const { content, metadata, onUpdateMetadata } = props; 36 | ... 37 | } 38 | ``` 39 | 40 | ## FolderBlockProps 41 | 42 | ```tsx 43 | import { FolderBlockProps } from '@githubnext/blocks'; 44 | 45 | export default function (props: FileBlockProps) { 46 | const { tree, metadata, onUpdateMetadata, BlockComponent } = props; 47 | ... 48 | } 49 | ``` 50 | 51 | ## getLanguageFromFilename 52 | 53 | A helper function that returns the "language" of a file, given a valid file path with extension. 54 | 55 | ## getNestedFileTree 56 | 57 | A helper function to turn the flat folder `tree` array into a nested tree structure 58 | 59 | import { FolderBlockProps, getNestedFileTree, } from "@githubnext/blocks"; 60 | 61 | ```tsx 62 | export default function (props: FolderBlockProps) { 63 | const { tree, onNavigateToPath } = props; 64 | 65 | const data = useMemo(() => { 66 | const nestedTree = getNestedFileTree(tree)[0] 67 | return nestedTree 68 | }, [tree]) 69 | ... 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /bin/blocks.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // this is largely based on https://github.com/facebook/create-react-app/blob/main/packages/react-scripts/bin/react-scripts.js 4 | 5 | 'use strict'; 6 | 7 | // Makes the script crash on unhandled rejections instead of silently 8 | // ignoring them. In the future, promise rejections that are not handled will 9 | // terminate the Node.js process with a non-zero exit code. 10 | process.on('unhandledRejection', err => { 11 | throw err; 12 | }); 13 | 14 | const spawn = require('cross-spawn'); 15 | const args = process.argv.slice(2); 16 | 17 | const scriptNames = ['build', 'start'] 18 | 19 | const scriptIndex = args.findIndex( 20 | x => scriptNames.includes(x) 21 | ); 22 | const script = scriptIndex === -1 ? args[0] : args[scriptIndex]; 23 | const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : []; 24 | 25 | if (!scriptNames.includes(script)) { 26 | console.log('Unknown script "' + script + '".'); 27 | process.exit(result.status); 28 | } 29 | 30 | const result = spawn.sync( 31 | process.execPath, 32 | nodeArgs 33 | .concat(require.resolve('../scripts/' + script)) 34 | .concat(args.slice(scriptIndex + 1)), 35 | { stdio: 'inherit' } 36 | ); 37 | if (result.signal) { 38 | if (result.signal === 'SIGKILL') { 39 | console.log( 40 | 'The build failed because the process exited too early. ' + 41 | 'This probably means the system ran out of memory or someone called ' + 42 | '`kill -9` on the process.' 43 | ); 44 | } else if (result.signal === 'SIGTERM') { 45 | console.log( 46 | 'The build failed because the process exited too early. ' + 47 | 'Someone might have called `kill` or `killall`, or the system could ' + 48 | 'be shutting down.' 49 | ); 50 | } 51 | process.exit(1); 52 | } 53 | process.exit(result.status); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@githubnext/blocks", 3 | "version": "2.3.5", 4 | "license": "MIT", 5 | "engines": { 6 | "node": ">=14.0.0" 7 | }, 8 | "typings": "dist/index.d.ts", 9 | "main": "dist/index.js", 10 | "module": "dist/index.mjs", 11 | "files": [ 12 | "bin", 13 | "dist", 14 | "scripts", 15 | "src", 16 | "tsconfig.json" 17 | ], 18 | "scripts": { 19 | "build": "tsup --dts", 20 | "prepublish": "yarn build" 21 | }, 22 | "bin": { 23 | "blocks": "./bin/blocks.js" 24 | }, 25 | "lint-staged": { 26 | "**/*": "prettier --write --ignore-unknown" 27 | }, 28 | "dependencies": { 29 | "@loadable/component": "^5.15.0", 30 | "@octokit/openapi-types": "^12.11.0", 31 | "@octokit/types": "^6.0.0", 32 | "@primer/octicons-react": "^17.3.0", 33 | "@primer/react": "^35.15.1", 34 | "@vitejs/plugin-basic-ssl": "^1.0.1", 35 | "@vitejs/plugin-react": "^3.0.1", 36 | "chalk": "^4.1.2", 37 | "chokidar": "^3.5.3", 38 | "cross-spawn": "^7.0.3", 39 | "dotenv": "^16.0.1", 40 | "dotenv-expand": "^8.0.3", 41 | "esbuild": "0.14.54", 42 | "express": "^4.18.1", 43 | "lodash.uniqueid": "^4.0.1", 44 | "minimist": "^1.2.6", 45 | "parse-git-config": "^3.0.0", 46 | "picomatch-browser": "^2.2.6", 47 | "prettier": "^2.6.2", 48 | "react-error-boundary": "^3.1.4", 49 | "react-query": "^3.39.0", 50 | "styled-components": "^5.3.5", 51 | "twind": "^0.16.17", 52 | "vite": "^4.0.4" 53 | }, 54 | "devDependencies": { 55 | "@types/loadable__component": "^5.13.4", 56 | "@types/lodash.uniqueid": "^4.0.6", 57 | "@types/picomatch": "^2.3.0", 58 | "@types/react": "^18.0.15", 59 | "@types/react-dom": "^18.0.6", 60 | "react": "^18.1.0", 61 | "react-dom": "^18.1.0", 62 | "tsup": "^5.6.0", 63 | "typescript": "^4.4.4", 64 | "use-debounce": "^8.0.2" 65 | }, 66 | "browserslist": { 67 | "production": [ 68 | ">0.2%", 69 | "not dead", 70 | "not op_mini all" 71 | ], 72 | "development": [ 73 | "last 1 chrome version", 74 | "last 1 firefox version", 75 | "last 1 safari version" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild"); 2 | const path = require("path"); 3 | 4 | process.env.BABEL_ENV = 'production'; 5 | process.env.NODE_ENV = 'production'; 6 | 7 | require('./config/env'); 8 | 9 | const build = async () => { 10 | const blocksConfigPath = path.resolve(process.cwd(), "blocks.config.json"); 11 | const blocksConfig = require(blocksConfigPath); 12 | 13 | const blockBuildFuncs = blocksConfig.map((block) => { 14 | return esbuild.build({ 15 | entryPoints: [`./` + block.entry], 16 | bundle: true, 17 | outdir: `dist/${block.id}`, 18 | format: "iife", 19 | globalName: "BlockBundle", 20 | minify: true, 21 | external: ["fs", "path", "assert", "react", "react-dom", "@primer/react"], 22 | loader: { 23 | '.ttf': 'file', 24 | }, 25 | }); 26 | }); 27 | 28 | try { 29 | await Promise.all(blockBuildFuncs); 30 | } catch (e) { 31 | console.error("Error bundling blocks", e); 32 | } 33 | } 34 | build() 35 | 36 | module.exports = build; 37 | -------------------------------------------------------------------------------- /scripts/config/env.js: -------------------------------------------------------------------------------- 1 | // this is largely based off https://github.com/facebook/create-react-app/blob/main/packages/react-scripts/config/env.js 2 | 3 | 'use strict'; 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const paths = require('./paths'); 8 | 9 | // Make sure that including paths.js after env.js will read .env variables. 10 | delete require.cache[require.resolve('./paths')]; 11 | 12 | const NODE_ENV = process.env.NODE_ENV; 13 | if (!NODE_ENV) { 14 | throw new Error( 15 | 'The NODE_ENV environment variable is required but was not specified.' 16 | ); 17 | } 18 | 19 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 20 | const dotenvFiles = [ 21 | `${paths.userRoot}.env`, 22 | `${paths.userRoot}.env.local`, 23 | ] 24 | 25 | dotenvFiles.forEach(dotenvFile => { 26 | if (fs.existsSync(dotenvFile)) { 27 | require('dotenv').config({ 28 | path: dotenvFile, 29 | }) 30 | } 31 | }); 32 | 33 | // We support resolving modules according to `NODE_PATH`. 34 | // This lets you use absolute paths in imports inside large monorepos: 35 | // https://github.com/facebook/create-react-app/issues/253. 36 | // It works similar to `NODE_PATH` in Node itself: 37 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 38 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 39 | // Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. 40 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 41 | // We also resolve them to make sure all tools using them work consistently. 42 | const appDirectory = fs.realpathSync(process.cwd()); 43 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 44 | .split(path.delimiter) 45 | .filter(folder => folder && !path.isAbsolute(folder)) 46 | .map(folder => path.resolve(appDirectory, folder)) 47 | .join(path.delimiter); 48 | 49 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 50 | // injected into the application via DefinePlugin in webpack configuration. 51 | const REACT_APP = /^REACT_APP_/i; 52 | 53 | function getClientEnvironment(publicUrl) { 54 | const raw = Object.keys(process.env) 55 | .filter(key => REACT_APP.test(key)) 56 | .reduce( 57 | (env, key) => { 58 | env[key] = process.env[key]; 59 | return env; 60 | }, 61 | { 62 | // Useful for determining whether we’re running in production mode. 63 | // Most importantly, it switches React into the correct mode. 64 | NODE_ENV: process.env.NODE_ENV || 'development', 65 | // Useful for resolving the correct path to static assets in `public`. 66 | // For example, . 67 | // This should only be used as an escape hatch. Normally you would put 68 | // images into the `src` and `import` them in code to get their paths. 69 | PUBLIC_URL: publicUrl, 70 | // We support configuring the sockjs pathname during development. 71 | // These settings let a developer run multiple simultaneous projects. 72 | // They are used as the connection `hostname`, `pathname` and `port` 73 | // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` 74 | // and `sockPort` options in webpack-dev-server. 75 | WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, 76 | WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, 77 | WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, 78 | // Whether or not react-refresh is enabled. 79 | // It is defined here so it is available in the webpackHotDevClient. 80 | FAST_REFRESH: process.env.FAST_REFRESH !== 'false', 81 | } 82 | ); 83 | // Stringify all values so we can feed into webpack DefinePlugin 84 | const stringified = { 85 | 'process.env': Object.keys(raw).reduce((env, key) => { 86 | env[key] = JSON.stringify(raw[key]); 87 | return env; 88 | }, {}), 89 | }; 90 | 91 | return { raw, stringified }; 92 | } 93 | 94 | module.exports = getClientEnvironment; -------------------------------------------------------------------------------- /scripts/config/paths.js: -------------------------------------------------------------------------------- 1 | const paths = { 2 | blocks: "node_modules/@githubnext/blocks/", 3 | userRoot: "./", 4 | blocksFolder: "./blocks", 5 | }; 6 | 7 | module.exports = paths; -------------------------------------------------------------------------------- /scripts/config/vite.config.dev.js: -------------------------------------------------------------------------------- 1 | const { searchForWorkspaceRoot } = require("vite"); 2 | const react = require("@vitejs/plugin-react"); 3 | const basicSsl = require("@vitejs/plugin-basic-ssl"); 4 | const paths = require("./paths"); 5 | const fs = require("fs"); 6 | const parseGitConfig = require("parse-git-config"); 7 | 8 | function sendJson(res, json) { 9 | res.setHeader("Access-Control-Allow-Origin", "*"); 10 | res.setHeader("Content-Type", "application/json"); 11 | res.end(JSON.stringify(json)); 12 | } 13 | 14 | // https://vitejs.dev/config/ 15 | const getViteConfigDev = (port, https) => ({ 16 | root: paths.blocks + "/src", 17 | server: { 18 | port, 19 | https, 20 | hmr: { 21 | host: "localhost", 22 | }, 23 | fs: { 24 | allow: [searchForWorkspaceRoot(process.cwd())], 25 | }, 26 | }, 27 | resolve: { 28 | alias: { 29 | "@user": process.cwd(), 30 | "@utils": process.cwd() + "/node_modules/@githubnext/blocks/dist", 31 | }, 32 | }, 33 | optimizeDeps: { 34 | // what else can we do here? 35 | include: [ 36 | "react", 37 | "react-dom", 38 | "react-dom/client", 39 | "styled-components", 40 | "hoist-non-react-statics", 41 | "react-is", 42 | "lodash.uniqueid", 43 | "@primer/react", 44 | "picomatch-browser", 45 | ], 46 | }, 47 | build: { 48 | commonjsOptions: { 49 | include: /node_modules/, 50 | }, 51 | }, 52 | plugins: [ 53 | https ? basicSsl() : null, 54 | react(), 55 | { 56 | name: "configure-response-headers", 57 | configureServer: (server) => { 58 | server.middlewares.use((_req, res, next) => { 59 | res.setHeader("Access-Control-Allow-Private-Network", "true"); 60 | next(); 61 | }); 62 | }, 63 | }, 64 | { 65 | name: "dev-server-endpoints", 66 | configureServer: (server) => { 67 | server.middlewares.use("/blocks.config.json", (req, res) => { 68 | const json = fs.readFileSync("./blocks.config.json"); 69 | sendJson(res, JSON.parse(json)); 70 | }); 71 | 72 | server.middlewares.use("/git.config.json", (req, res) => { 73 | sendJson(res, parseGitConfig.sync()); 74 | }); 75 | }, 76 | }, 77 | ], 78 | }); 79 | 80 | module.exports = getViteConfigDev; 81 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | const chalk = require("chalk"); 2 | const { createServer } = require("vite"); 3 | const getViteConfigDev = require("./config/vite.config.dev"); 4 | const argv = require("minimist")(process.argv.slice(2)); 5 | 6 | process.env.BABEL_ENV = "development"; 7 | process.env.NODE_ENV = "development"; 8 | 9 | require("./config/env"); 10 | 11 | const main = async () => { 12 | const port = argv.port || process.env.PORT || 4000; 13 | const https = Boolean(argv.https || process.env.HTTPS); 14 | const devServer = await createServer(getViteConfigDev(port, https)); 15 | 16 | console.log( 17 | chalk.cyan( 18 | `Starting the development server at http${ 19 | https ? "s" : "" 20 | }://localhost:${port}` 21 | ) 22 | ); 23 | await devServer.listen(); 24 | 25 | if (process.env.CI !== "true") { 26 | // Gracefully exit when stdin ends 27 | process.stdin.on("end", function () { 28 | devServer.close(); 29 | process.exit(); 30 | }); 31 | } 32 | }; 33 | main(); 34 | -------------------------------------------------------------------------------- /src/components/Block.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | Block as BlockType, 3 | FileBlockProps, 4 | FolderBlockProps, 5 | } from "@utils"; 6 | import loadable from "@loadable/component"; 7 | import * as PrimerReact from "@primer/react"; 8 | import { BaseStyles, ThemeProvider } from "@primer/react"; 9 | import React, { useCallback, useEffect, useState } from "react"; 10 | import * as ReactJSXRuntime from "react/jsx-runtime"; 11 | import ReactDOM from "react-dom"; 12 | import ReactDOMClient from "react-dom/client"; 13 | import { 14 | callbackFunctions, 15 | callbackFunctionsInternal, 16 | useHandleCallbacks, 17 | } from "../utils"; 18 | import { BlockComponentProps, BlockComponent } from "./BlockComponent"; 19 | 20 | const Bundle = ({ bundle }: { bundle: Asset[] }) => { 21 | useEffect(() => { 22 | const elements: HTMLElement[] = []; 23 | 24 | bundle.forEach((asset) => { 25 | if (asset.name.endsWith(".js")) { 26 | const jsElement = document.createElement("script"); 27 | jsElement.textContent = ` 28 | var BlockBundle = ({ React, ReactJSXRuntime, ReactDOM, ReactDOMClient, PrimerReact }) => { 29 | function require(name) { 30 | switch (name) { 31 | case "react": 32 | return React; 33 | case "react/jsx-runtime": 34 | return ReactJSXRuntime; 35 | case "react-dom": 36 | return ReactDOM; 37 | case "react-dom/client": 38 | return ReactDOMClient; 39 | case "@primer/react": 40 | case "@primer/components": 41 | return PrimerReact; 42 | default: 43 | console.log("no module '" + name + "'"); 44 | return null; 45 | } 46 | } 47 | ${asset.content} 48 | return BlockBundle; 49 | };`; 50 | 51 | elements.push(jsElement); 52 | } else if (asset.name.endsWith(".css")) { 53 | const cssElement = document.createElement("style"); 54 | cssElement.textContent = asset.content; 55 | elements.push(cssElement); 56 | } 57 | }); 58 | 59 | for (const el of elements) { 60 | document.body.appendChild(el); 61 | } 62 | return () => { 63 | for (const el of elements) { 64 | document.body.removeChild(el); 65 | } 66 | }; 67 | }, [bundle]); 68 | 69 | return null; 70 | }; 71 | 72 | export const Block = ({ 73 | bundle, 74 | props, 75 | setProps, 76 | }: { 77 | bundle: Asset[]; 78 | props: FileBlockProps | FolderBlockProps; 79 | setProps: (props: FileBlockProps | FolderBlockProps) => void; 80 | }) => { 81 | const [Block, setBlock] = useState(undefined); 82 | 83 | useEffect(() => { 84 | if (bundle.length === 0) { 85 | const importPrefix = "../../../../../"; 86 | const imports = import.meta.glob("../../../../../blocks/**"); 87 | const importPath = importPrefix + props.block.entry; 88 | const importContent = imports[importPath]; 89 | // @ts-ignore 90 | const content = loadable(importContent); 91 | // @ts-ignore 92 | setBlock(content); 93 | } else { 94 | setBlock( 95 | () => 96 | window.BlockBundle({ 97 | React, 98 | ReactJSXRuntime, 99 | ReactDOM, 100 | ReactDOMClient, 101 | PrimerReact, 102 | }).default 103 | ); 104 | } 105 | }, []); 106 | 107 | useHandleCallbacks("*"); 108 | 109 | const onUpdateContent = useCallback( 110 | (content: string) => { 111 | // the app does not send async content updates back to the block that 112 | // originated them, to avoid overwriting subsequent changes; we update the 113 | // content locally so controlled components work. this doesn't overwrite 114 | // subsequent changes because it's synchronous. 115 | setProps({ ...props, content }); 116 | callbackFunctions["onUpdateContent"](content); 117 | }, 118 | [props, setProps] 119 | ); 120 | 121 | const WrappedBlockComponent = useCallback( 122 | (nestedProps: BlockComponentProps) => { 123 | let context = { 124 | ...props.context, 125 | ...nestedProps.context, 126 | }; 127 | 128 | // clear sha if viewing content from another repo 129 | const parentRepo = [props.context.owner, props.context.repo].join("/"); 130 | const childRepo = [context.owner, context.repo].join("/"); 131 | const isSameRepo = parentRepo === childRepo; 132 | if (!isSameRepo) { 133 | context.sha = nestedProps.context.sha || "HEAD"; 134 | } 135 | 136 | return ; 137 | }, 138 | // eslint-disable-next-line react-hooks/exhaustive-deps 139 | [JSON.stringify(props.context)] 140 | ); 141 | 142 | const isInternal = 143 | (props as unknown as { block: BlockType }).block.owner === "githubnext"; 144 | const filteredCallbackFunctions = isInternal 145 | ? callbackFunctionsInternal 146 | : callbackFunctions; 147 | 148 | return ( 149 | <> 150 | {bundle.length > 0 && } 151 | 152 | {Block && props && ( 153 | // @ts-ignore 154 | 155 | 161 | {/* @ts-ignore */} 162 | 168 | 169 | 170 | )} 171 | 172 | ); 173 | }; 174 | -------------------------------------------------------------------------------- /src/components/BlockComponent.tsx: -------------------------------------------------------------------------------- 1 | import type { Block, FileContext, FolderContext } from "@utils"; 2 | 3 | export type BlockComponentProps = { 4 | context: FileContext | FolderContext; 5 | block: Block; 6 | }; 7 | export const BlockComponent = ({ block, context }: BlockComponentProps) => { 8 | const { owner, repo, id, type } = block; 9 | const hash = encodeURIComponent( 10 | JSON.stringify({ block: { owner, repo, id, type }, context }) 11 | ); 12 | return ( 13 |