├── .atlassian └── OWNER ├── .github └── workflows │ └── node-lint.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── .storybook ├── main.ts ├── preview-head.html └── preview.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── aws ├── secured-headers │ └── index.js └── witch │ ├── nodejs │ └── package-lock.json │ └── witch.js ├── babel.config.js ├── index.html ├── jest.config.js ├── json-schema.draft-07.json ├── package.json ├── src ├── BrowserApp.tsx ├── Docs.tsx ├── ExpandibleList.tsx ├── LoadSchema.tsx ├── Parameter.tsx ├── ParameterMetadata.tsx ├── PrimaryDropdown.tsx ├── SchemaApp.tsx ├── SchemaEditor.tsx ├── SchemaExplorer.tsx ├── SchemaValidator.tsx ├── SchemaView.tsx ├── SideNavWithRouter.tsx ├── Start.tsx ├── Type.tsx ├── assets.d.ts ├── breakpoints.ts ├── code-block-with-copy │ └── index.tsx ├── discriminant.ts ├── docs │ ├── introduction.md │ └── usage.md ├── enum-extraction.ts ├── example-schemas.ts ├── example.ts ├── exhaustiveness-assertion.ts ├── index.ts ├── jsx-util.tsx ├── logo.svg ├── lookup │ └── index.ts ├── markdown │ ├── custom-renderers │ │ ├── Code.tsx │ │ └── Link.tsx │ └── index.tsx ├── monaco-helpers.ts ├── recently-viewed.ts ├── route-path.ts ├── search-preserving-link.tsx ├── side-nav-loader.ts ├── stage.ts ├── stories │ ├── Button.tsx │ ├── DebuggingMemoryRouter.ts │ ├── Header.tsx │ ├── Page.tsx │ ├── ParameterMetadata.stories.tsx │ ├── SchemaApp.stories.tsx │ ├── SchemaExplorer.stories.tsx │ ├── SchemaView.stories.tsx │ ├── SideNavWithRouter.stories.tsx │ ├── Type.stories.tsx │ ├── button.css │ ├── header.css │ ├── openapi.json.ts │ ├── package.json.ts │ ├── page.css │ └── schema-returner-lookup.ts ├── style.css ├── test │ └── markdown-renderer.test.tsx ├── title.ts └── type-inference.ts ├── templates ├── acm-certificate.yaml ├── cloudfront-site.yaml ├── custom-resource.yaml └── main.yaml ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js ├── webpack.prod.js └── yarn.lock /.atlassian/OWNER: -------------------------------------------------------------------------------- 1 | rmassaioli -------------------------------------------------------------------------------- /.github/workflows/node-lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, and run linting 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [16.x] 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'yarn' 31 | - run: yarn install --frozen-lockfile 32 | - run: yarn lint 33 | - run: yarn test 34 | - run: yarn build-prod 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Built files 5 | /dist 6 | /src/schema.ts 7 | *.zip 8 | /packaged.template 9 | 10 | # Editors 11 | .idea 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Use public registry instead of Atlassian internal 2 | registry=https://registry.yarnpkg.com 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import { StorybookConfig } from '@storybook/react-webpack5'; 2 | 3 | const config: StorybookConfig = { 4 | stories: [ 5 | '../src/**/*.stories.mdx', 6 | '../src/**/*.stories.@(js|jsx|ts|tsx)' 7 | ], 8 | addons: [ 9 | '@storybook/addon-links', 10 | '@storybook/addon-essentials' 11 | ], 12 | framework: { 13 | name: '@storybook/react-webpack5', 14 | options: {}, 15 | }, 16 | docs: { 17 | autodocs: true, 18 | }, 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Submitting contributions or comments that you know to violate the intellectual property or privacy rights of others 15 | * Other unethical or unprofessional conduct 16 | 17 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 18 | By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 19 | 20 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting a project maintainer. Complaints will result in a response and be reviewed and investigated in a way that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. 23 | 24 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available at [http://contributor-covenant.org/version/1/3/0/][version] 25 | 26 | [homepage]: http://contributor-covenant.org 27 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to json-schema-viewer 2 | 3 | Thank you for considering a contribution to json-schema-viewer! Pull requests, issues and comments are welcome. For pull requests, please: 4 | 5 | * Add tests for new features and bug fixes 6 | * Follow the existing style 7 | * Separate unrelated changes into multiple pull requests 8 | 9 | See the existing issues for things to start contributing. 10 | 11 | For bigger changes, please make sure you start a discussion first by creating an issue and explaining the intended change. 12 | 13 | Atlassian requires contributors to sign a Contributor License Agreement, known as a CLA. This serves as a record stating that the contributor is entitled to contribute the code/documentation/translation to the project and is willing to have it used in distributions and derivative works (or is willing to transfer ownership). 14 | 15 | Prior to accepting your contributions we ask that you please follow the appropriate link below to digitally sign the CLA. The Corporate CLA is for those who are contributing as a member of an organization and the individual CLA is for those contributing as an individual. 16 | 17 | * [CLA for corporate contributors](https://opensource.atlassian.com/corporate) 18 | * [CLA for individuals](https://opensource.atlassian.com/individual) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Atlassian Pty Ltd 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-schema-viewer 2 | 3 | [![Atlassian license](https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square)](LICENSE) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](CONTRIBUTING.md) 4 | 5 | Welcome to JSON Schema Viewer! 6 | 7 | Try it out today at: https://json-schema.app 8 | 9 | JSON Schema is awesome. It lets you define and validate the schema for your JSON object and is used to define many many JSON structures. 10 | For example schemas, please see: https://www.schemastore.org/json/ 11 | 12 | However, as great as the format is, without familiarity, it is often very difficult to read JSON Schema and understand exactly what JSON 13 | is allowed by the schema. Well, fear not! JSON Schema Viewer to the rescue: just paste a link to your JSON Schema and it will be 14 | rendered beautifully, comprehensively and with examples describing the JSON you should expect at evely level of the hierarchy. 15 | 16 | ## Usage 17 | 18 | To run this project locally: 19 | 20 | 1. Run `yarn` to install the dependencies. 21 | 1. (Optional, automatically runs on install) Run `yarn gen-schema` to generate source from schema. 22 | 1. Run `yarn start` to start Webpack Dev server 23 | 24 | Now open: http://localhost:8080 to see the site and develop it live. 25 | 26 | ## Deployment 27 | 28 | To publish new SPA website resources to this AWS stack (this is the most common operation you will perform): 29 | 30 | ``` shell 31 | nix-shell -p awscli2 32 | yarn build-and-upload 33 | ``` 34 | 35 | To deploy this project in a way that updates the AWS Configuration via Cloud Formation: 36 | 37 | ``` shell 38 | nix-shell -p awscli2 39 | yarn build-and-deploy 40 | ``` 41 | 42 | ## Documentation 43 | 44 | This project is a React SPA that is designed to be deployed to AWS CloudFront. It implements a Schema Explorer for JSON Schema and does not build an abstraction 45 | layer between JSON Schema and the UI layer. We currently support JSON Schema Draft 07 in the code. 46 | 47 | ## Tests 48 | 49 | There are currently no tests for this project. Instead, we use and browse the react storybooks to ensure that the schema is being rendered correctly. 50 | 51 | ## Contributions 52 | 53 | Contributions to json-schema-viewer are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. 54 | 55 | ## License 56 | 57 | Copyright (c) 2021 Atlassian and others. 58 | Apache 2.0 licensed, see [LICENSE](LICENSE) file. 59 | 60 |
61 | 62 | [![With ❤️ from Atlassian](https://raw.githubusercontent.com/atlassian-internal/oss-assets/master/banner-cheers.png)](https://www.atlassian.com) 63 | -------------------------------------------------------------------------------- /aws/secured-headers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | exports.handler = (event, context, callback) => { 3 | 4 | //Get contents of response 5 | const response = event.Records[0].cf.response; 6 | const headers = response.headers; 7 | 8 | //Set new headers 9 | headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubdomains; preload'}]; 10 | // headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'"}]; 11 | headers['x-content-type-options'] = [{key: 'X-Content-Type-Options', value: 'nosniff'}]; 12 | headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}]; 13 | headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}]; 14 | headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}]; 15 | 16 | //Return modified response 17 | callback(null, response); 18 | }; -------------------------------------------------------------------------------- /aws/witch/nodejs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "mime-db": { 6 | "version": "1.45.0", 7 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz", 8 | "integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==" 9 | }, 10 | "mime-types": { 11 | "version": "2.1.28", 12 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz", 13 | "integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==", 14 | "requires": { 15 | "mime-db": "1.45.0" 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /aws/witch/witch.js: -------------------------------------------------------------------------------- 1 | const aws = require("aws-sdk"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const mime = require("mime-types"); 5 | 6 | const s3 = new aws.S3(); 7 | 8 | const SUCCESS = "SUCCESS"; 9 | const FAILED = "FAILED"; 10 | 11 | const BUCKET = process.env.BUCKET; 12 | 13 | exports.staticHandler = function(event, context) { 14 | if (event.RequestType !== "Create" && event.RequestType !== "Update") { 15 | return respond(event, context, SUCCESS, {}); 16 | } 17 | 18 | Promise.all(walkSync("./").map(file => { 19 | var fileType = mime.lookup(file) || "application/octet-stream"; 20 | 21 | console.log(`${file} -> ${fileType}`); 22 | 23 | return s3.upload({ 24 | Body: fs.createReadStream(file), 25 | Bucket: BUCKET, 26 | ContentType: fileType, 27 | Key: file, 28 | ACL: "private", 29 | }).promise(); 30 | })).then((msg) => { 31 | respond(event, context, SUCCESS, {}); 32 | }).catch(err => { 33 | respond(event, context, FAILED, {Message: err}); 34 | }); 35 | }; 36 | 37 | // List all files in a directory in Node.js recursively in a synchronous fashion 38 | function walkSync(dir, filelist) { 39 | var files = fs.readdirSync(dir); 40 | filelist = filelist || []; 41 | 42 | files.forEach(function(file) { 43 | if (fs.statSync(path.join(dir, file)).isDirectory()) { 44 | filelist = walkSync(path.join(dir, file), filelist); 45 | } else { 46 | filelist.push(path.join(dir, file)); 47 | } 48 | }); 49 | 50 | return filelist; 51 | }; 52 | 53 | function respond(event, context, responseStatus, responseData, physicalResourceId, noEcho) { 54 | var responseBody = JSON.stringify({ 55 | Status: responseStatus, 56 | Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName, 57 | PhysicalResourceId: physicalResourceId || context.logStreamName, 58 | StackId: event.StackId, 59 | RequestId: event.RequestId, 60 | LogicalResourceId: event.LogicalResourceId, 61 | NoEcho: noEcho || false, 62 | Data: responseData 63 | }); 64 | 65 | console.log("Response body:\n", responseBody); 66 | 67 | var https = require("https"); 68 | var url = require("url"); 69 | 70 | var parsedUrl = url.parse(event.ResponseURL); 71 | var options = { 72 | hostname: parsedUrl.hostname, 73 | port: 443, 74 | path: parsedUrl.path, 75 | method: "PUT", 76 | headers: { 77 | "content-type": "", 78 | "content-length": responseBody.length 79 | } 80 | }; 81 | 82 | var request = https.request(options, function(response) { 83 | console.log("Status code: " + response.statusCode); 84 | console.log("Status message: " + response.statusMessage); 85 | context.done(); 86 | }); 87 | 88 | request.on("error", function(error) { 89 | console.log("send(..) failed executing https.request(..): " + error); 90 | context.done(); 91 | }); 92 | 93 | request.write(responseBody); 94 | request.end(); 95 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-typescript', 5 | '@babel/preset-react', 6 | ], 7 | plugins: [ 8 | '@babel/plugin-proposal-class-properties', 9 | '@compiled/babel-plugin', 10 | '@babel/transform-runtime', 11 | ], 12 | env: { 13 | test: { 14 | plugins: ['@babel/plugin-transform-modules-commonjs'], 15 | }, 16 | }, 17 | }; 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 7 | 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | testEnvironment: 'jsdom', 3 | transform: { 4 | '\\.[jt]sx?$': 'babel-jest', 5 | }, 6 | // https://stackoverflow.com/questions/49263429/jest-gives-an-error-syntaxerror-unexpected-token-export 7 | transformIgnorePatterns: ['node_modules/jest-runner'], 8 | }; 9 | module.exports = config; 10 | -------------------------------------------------------------------------------- /json-schema.draft-07.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "http://json-schema.org/draft-07/schema#", 4 | "title": "Json Schema", 5 | "definitions": { 6 | "JsonSchema": { 7 | "type": "object", 8 | "properties": { 9 | "$id": { 10 | "type": "string", 11 | "format": "uri-reference" 12 | }, 13 | "$schema": { 14 | "type": "string", 15 | "format": "uri" 16 | }, 17 | "$ref": { 18 | "type": "string", 19 | "format": "uri-reference" 20 | }, 21 | "$comment": { 22 | "type": "string" 23 | }, 24 | "title": { 25 | "type": "string" 26 | }, 27 | "description": { 28 | "type": "string" 29 | }, 30 | "default": {}, 31 | "readOnly": { 32 | "type": "boolean", 33 | "default": false 34 | }, 35 | "writeOnly": { 36 | "type": "boolean", 37 | "default": false 38 | }, 39 | "examples": { 40 | "type": "array", 41 | "items": {} 42 | }, 43 | "multipleOf": { 44 | "type": "number", 45 | "exclusiveMinimum": 0 46 | }, 47 | "maximum": { 48 | "type": "number" 49 | }, 50 | "exclusiveMaximum": { 51 | "type": ["number", "boolean"] 52 | }, 53 | "minimum": { 54 | "type": "number" 55 | }, 56 | "exclusiveMinimum": { 57 | "type": ["number", "boolean"] 58 | }, 59 | "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, 60 | "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, 61 | "pattern": { 62 | "type": "string", 63 | "format": "regex" 64 | }, 65 | "additionalItems": { "$ref": "#/definitions/JsonSchema" }, 66 | "items": { 67 | "anyOf": [ 68 | { "$ref": "#" }, 69 | { "$ref": "#/definitions/schemaArray" } 70 | ], 71 | "default": true 72 | }, 73 | "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, 74 | "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, 75 | "uniqueItems": { 76 | "type": "boolean", 77 | "default": false 78 | }, 79 | "contains": { "$ref": "#" }, 80 | "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, 81 | "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, 82 | "required": { "$ref": "#/definitions/stringArray" }, 83 | "additionalProperties": { "$ref": "#" }, 84 | "definitions": { 85 | "type": "object", 86 | "additionalProperties": { "$ref": "#" }, 87 | "default": {} 88 | }, 89 | "properties": { 90 | "type": "object", 91 | "additionalProperties": { "$ref": "#" }, 92 | "default": {} 93 | }, 94 | "patternProperties": { 95 | "type": "object", 96 | "additionalProperties": { "$ref": "#" }, 97 | "propertyNames": { "format": "regex" }, 98 | "default": {} 99 | }, 100 | "dependencies": { 101 | "type": "object", 102 | "additionalProperties": { 103 | "anyOf": [ 104 | { "$ref": "#" }, 105 | { "$ref": "#/definitions/stringArray" } 106 | ] 107 | } 108 | }, 109 | "propertyNames": { "$ref": "#" }, 110 | "const": true, 111 | "enum": { 112 | "type": "array", 113 | "items": {}, 114 | "minItems": 1, 115 | "uniqueItems": true 116 | }, 117 | "type": { 118 | "anyOf": [ 119 | { "$ref": "#/definitions/simpleTypes" }, 120 | { 121 | "type": "array", 122 | "items": { "$ref": "#/definitions/simpleTypes" }, 123 | "minItems": 1, 124 | "uniqueItems": true 125 | } 126 | ] 127 | }, 128 | "format": { "type": "string" }, 129 | "contentMediaType": { "type": "string" }, 130 | "contentEncoding": { "type": "string" }, 131 | "if": { "$ref": "#" }, 132 | "then": { "$ref": "#" }, 133 | "else": { "$ref": "#" }, 134 | "allOf": { "$ref": "#/definitions/schemaArray" }, 135 | "anyOf": { "$ref": "#/definitions/schemaArray" }, 136 | "oneOf": { "$ref": "#/definitions/schemaArray" }, 137 | "not": { "$ref": "#" } 138 | } 139 | }, 140 | "schemaArray": { 141 | "type": "array", 142 | "minItems": 1, 143 | "items": { "$ref": "#" } 144 | }, 145 | "nonNegativeInteger": { 146 | "type": "integer", 147 | "minimum": 0 148 | }, 149 | "nonNegativeIntegerDefault0": { 150 | "allOf": [ 151 | { "$ref": "#/definitions/nonNegativeInteger" }, 152 | { "default": 0 } 153 | ] 154 | }, 155 | "simpleTypes": { 156 | "enum": [ 157 | "array", 158 | "boolean", 159 | "integer", 160 | "null", 161 | "number", 162 | "object", 163 | "string" 164 | ] 165 | }, 166 | "stringArray": { 167 | "type": "array", 168 | "items": { "type": "string" }, 169 | "uniqueItems": true, 170 | "default": [] 171 | } 172 | }, 173 | "anyOf": [ 174 | { "type": "boolean" }, 175 | { "$ref": "#/definitions/JsonSchema" } 176 | ], 177 | "default": true 178 | } 179 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-schema-viewer", 3 | "version": "1.0.0", 4 | "description": "A website that allows for the viewing of JSON Schemas.", 5 | "author": "Robert Massaioli ", 6 | "license": "MIT", 7 | "private": true, 8 | "dependencies": { 9 | "@atlaskit/atlassian-navigation": "^0.12.5", 10 | "@atlaskit/breadcrumbs": "^10.1.0", 11 | "@atlaskit/button": "^15.1.4", 12 | "@atlaskit/code": "^13.1.1", 13 | "@atlaskit/css-reset": "^6.0.5", 14 | "@atlaskit/empty-state": "^7.1.5", 15 | "@atlaskit/icon": "^21.2.0", 16 | "@atlaskit/logo": "^13.0.7", 17 | "@atlaskit/lozenge": "^10.1.0", 18 | "@atlaskit/menu": "^0.7.0", 19 | "@atlaskit/popup": "^1.0.6", 20 | "@atlaskit/spinner": "^15.0.6", 21 | "@atlaskit/table": "^0.4.6", 22 | "@atlaskit/tabs": "^12.1.2", 23 | "@atlaskit/textfield": "^5.0.0", 24 | "@atlaskit/theme": "^11.0.2", 25 | "@atlaskit/tooltip": "^17.1.2", 26 | "@monaco-editor/react": "^4.5.1", 27 | "js-yaml": "^4.0.0", 28 | "jsonpointer": "^4.1.0", 29 | "monaco-editor": "^0.39.0", 30 | "react": "^16.8", 31 | "react-copy-to-clipboard": "^5.0.2", 32 | "react-dom": "^16.8", 33 | "react-markdown": "^8.0.7", 34 | "react-router-dom": "^5.2.0", 35 | "rehype-raw": "^6.1.1", 36 | "rehype-sanitize": "^5.0.1", 37 | "styled-components": "^3.2", 38 | "ts-is-present": "^1.2.1" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.22.5", 42 | "@babel/plugin-transform-modules-commonjs": "^7.22.5", 43 | "@babel/plugin-transform-runtime": "^7.22.5", 44 | "@babel/preset-env": "^7.22.5", 45 | "@babel/preset-react": "^7.22.5", 46 | "@babel/preset-typescript": "^7.22.5", 47 | "@compiled/babel-plugin": "^0.19.1", 48 | "@storybook/addon-actions": "^7.0.22", 49 | "@storybook/addon-essentials": "^7.0.22", 50 | "@storybook/addon-links": "^7.0.22", 51 | "@storybook/cli": "^7.0.22", 52 | "@storybook/react-webpack5": "^7.0.22", 53 | "@testing-library/jest-dom": "^5.16.5", 54 | "@testing-library/react": "^11.2.7", 55 | "@types/jest": "^29.5.2", 56 | "@types/js-yaml": "^4.0.0", 57 | "@types/react": "^16.8", 58 | "@types/react-copy-to-clipboard": "^5.0.0", 59 | "@types/react-dom": "^16.8", 60 | "@types/react-router-dom": "^5.1.7", 61 | "babel-jest": "^29.5.0", 62 | "babel-loader": "^8.2.2", 63 | "babel-plugin-transform-class-properties": "^6.24.1", 64 | "csp-html-webpack-plugin": "^5.1.0", 65 | "css-loader": "^5.0.1", 66 | "favicons": "^7.1.3", 67 | "favicons-webpack-plugin": "^6.0.0", 68 | "html-webpack-plugin": "^5.5.3", 69 | "jest": "^29.5.0", 70 | "jest-environment-jsdom": "^29.5.0", 71 | "json-schema-to-typescript": "^10.1.2", 72 | "npm-run-all": "^4.1.5", 73 | "prettier": "^2.8.8", 74 | "storybook": "^7.0.22", 75 | "style-loader": "^2.0.0", 76 | "typescript": "^5.1.3", 77 | "webpack": "^5.87.0", 78 | "webpack-cli": "^5.1.4", 79 | "webpack-dev-server": "^4.15.1", 80 | "webpack-merge": "^5.7.3" 81 | }, 82 | "scripts": { 83 | "start": "webpack serve -c webpack.dev.js", 84 | "lint": "tsc --noEmit", 85 | "test": "jest", 86 | "postinstall": "npm run gen-schema", 87 | "build-dev": "webpack -c webpack.dev.js", 88 | "build-prod": "webpack -c webpack.prod.js", 89 | "build": "rm -fr dist && webpack -c webpack.prod.js", 90 | "clean": "rm -rf *.zip source/witch/nodejs/node_modules/", 91 | "pack-witch": "cd aws/witch && npm install --prefix nodejs mime-types && cp witch.js nodejs/node_modules/ && zip -r ../../witch.zip nodejs", 92 | "pack-secured-headers": "cd aws/secured-headers/ && zip -r ../../s-headers.zip index.js", 93 | "pack-website": "cd dist && zip -r ../website.zip *", 94 | "package-all": "run-p pack-witch pack-secured-headers pack-website", 95 | "package-build": "run-s clean package-all", 96 | "aws-package": "aws cloudformation package --template-file templates/main.yaml --s3-bucket json-schema-viewer-cloudformation --output-template-file packaged.template", 97 | "aws-deploy": "aws --region us-east-1 cloudformation deploy --stack-name json-schema-viewer --template-file packaged.template --capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND --parameter-overrides DomainName=json-schema.app SubDomain=www CreateApex=yes", 98 | "build-and-deploy": "run-s build package-build aws-package aws-deploy", 99 | "upload": "aws s3 sync --profile AdministratorAccess-110055367801 --cache-control \"max-age=31536000\" --exclude 'index.html' --delete dist s3://json-schema-viewer-customresourcesta-s3bucketroot-vjmtja1bzua1 && aws s3 cp --profile AdministratorAccess-110055367801 --cache-control \"max-age=300\" dist/index.html s3://json-schema-viewer-customresourcesta-s3bucketroot-vjmtja1bzua1", 100 | "build-and-upload": "run-s build upload", 101 | "cloudfront-invalidate": "aws cloudfront create-invalidation --distribution-id E2LM6EIHUKJUXG --paths '/*' | cat", 102 | "check-invalidate": "aws cloudfront get-invalidation --distribution-id E2LM6EIHUKJUXG --id", 103 | "storybook": "storybook dev -p 6006", 104 | "build-storybook": "storybook build", 105 | "gen-schema": "json2ts json-schema.draft-07.json > src/schema.ts" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/BrowserApp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { SchemaApp } from './SchemaApp' 4 | 5 | export const App: React.FC = () => ( 6 | 7 | 8 | 9 | ) -------------------------------------------------------------------------------- /src/Docs.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from '@atlaskit/empty-state'; 2 | import React, { useEffect, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | import { useParams } from 'react-router-dom'; 5 | import introduction from './docs/introduction.md'; 6 | import usage from './docs/usage.md'; 7 | import { Markdown } from './markdown'; 8 | import Spinner from '@atlaskit/spinner'; 9 | 10 | type RouteParams = { 11 | id: string; 12 | } 13 | 14 | const docsMap: { [key: string]: string } = { 15 | introduction, 16 | usage 17 | }; 18 | 19 | type LoadResult = FileLoaded | FileLoadedFailed | undefined; 20 | 21 | type FileLoaded = { 22 | loadId: string; 23 | fileContents: string; 24 | }; 25 | 26 | type FileLoadedFailed = { 27 | loadId: string; 28 | message: string; 29 | }; 30 | 31 | function isLoadError(r: FileLoaded | FileLoadedFailed): r is FileLoadedFailed { 32 | return 'message' in r; 33 | } 34 | 35 | const Container = styled.div` 36 | margin: 24px 24px; 37 | padding: 0; 38 | `; 39 | 40 | export const Docs: React.FC = () => { 41 | let { id } = useParams(); 42 | 43 | const [loadResult, setLoadResult] = useState(undefined); 44 | 45 | const loadFileContents = async () => { 46 | const docsUrl = docsMap[id]; 47 | 48 | if (docsUrl === undefined) { 49 | setLoadResult({ message: 'There are no docs at this URL.', loadId: id }); 50 | } else { 51 | try { 52 | const fileContents = await fetch(docsUrl).then(r => r.text()); 53 | setLoadResult({ fileContents, loadId: id }); 54 | } catch (e) { 55 | setLoadResult({ message: e instanceof Error ? e.message : JSON.stringify(e), loadId: id }); 56 | } 57 | } 58 | }; 59 | 60 | useEffect(() => { 61 | if (loadResult === undefined || loadResult.loadId !== id) { 62 | loadFileContents(); 63 | } 64 | }); 65 | 66 | if (loadResult === undefined) { 67 | return ( 68 | 73 | )} 74 | /> 75 | ); 76 | } 77 | 78 | if (isLoadError(loadResult)) { 79 | return ( 80 | 84 | ) 85 | } 86 | 87 | const { fileContents } = loadResult; 88 | return ( 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/ExpandibleList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { intersperse } from './jsx-util'; 3 | import styled from 'styled-components'; 4 | import { Code } from '@atlaskit/code'; 5 | import Lozenge from '@atlaskit/lozenge'; 6 | import Tooltip from '@atlaskit/tooltip'; 7 | 8 | export type RenderElementProps = { 9 | text: string; 10 | }; 11 | 12 | export type RenderElement = React.ComponentClass; 13 | 14 | export type ElementAndTooltip = { 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | element: any; 18 | tooltip?: string; 19 | }; 20 | 21 | export type ExpandibleListProps = { 22 | elements: ElementAndTooltip[]; 23 | collapsedMaxLength: number; 24 | renderElement?: RenderElement; 25 | }; 26 | 27 | export type ExpandibleListState = { 28 | expanded: boolean; 29 | }; 30 | 31 | class InlineCodeRenderElement extends React.PureComponent { 32 | render() { 33 | return ; 34 | } 35 | } 36 | 37 | export class LozengeRenderElement extends React.PureComponent { 38 | render() { 39 | return {this.props.text}; 40 | } 41 | } 42 | 43 | type RendererProps = { 44 | RE: RenderElement; 45 | e: ElementAndTooltip; 46 | }; 47 | 48 | const Inline = styled.span` 49 | display: inline-block; 50 | `; 51 | 52 | const Renderer: React.FC = props => { 53 | if (props.e.tooltip === undefined) { 54 | return ; 55 | } else { 56 | return ; 57 | } 58 | }; 59 | 60 | export class ExpandibleList extends React.PureComponent { 61 | private static Link = styled.a` 62 | margin-left: 10px; 63 | `; 64 | 65 | UNSAFE_componentWillMount() { 66 | this.setState({ 67 | expanded: false 68 | }); 69 | } 70 | 71 | render() { 72 | const { elements, collapsedMaxLength, renderElement } = this.props; 73 | const { expanded } = this.state; 74 | 75 | const RE: RenderElement = renderElement || InlineCodeRenderElement; 76 | 77 | if (elements.length <= collapsedMaxLength) { 78 | const renderedElements = elements.map((e, i) => ); 79 | return {intersperse(renderedElements, ', ')}; 80 | } 81 | 82 | if (expanded) { 83 | const renderedElements = elements.map((e, i) => ); 84 | return ( 85 | 86 | {intersperse(renderedElements, ', ')} 87 | this.expand(e, false)}>(Show less) 88 | 89 | ); 90 | } else { 91 | const renderedElements = elements.slice(0, collapsedMaxLength) 92 | .map((e, i) => ); 93 | 94 | return ( 95 | 96 | {intersperse(renderedElements, ', ')} ... 97 | this.expand(e, true)}>(Show more) 98 | 99 | ); 100 | } 101 | } 102 | 103 | private expand(e: React.MouseEvent, expanded: boolean) { 104 | e.preventDefault(); 105 | this.setState({ 106 | expanded 107 | }); 108 | } 109 | } -------------------------------------------------------------------------------- /src/LoadSchema.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { RouteComponentProps, withRouter } from 'react-router-dom'; 3 | import { JsonSchema } from './schema'; 4 | import Spinner from '@atlaskit/spinner'; 5 | import EmptyState from '@atlaskit/empty-state'; 6 | import { addRecentlyViewedLink } from './recently-viewed'; 7 | 8 | export type LoadSchemaProps = RouteComponentProps & { 9 | children: (schema: JsonSchema) => ReactNode; 10 | }; 11 | 12 | export type LoadSchemaError = { 13 | message: string; 14 | }; 15 | 16 | export type LoadSchemaState = { 17 | result?: ResultState; 18 | }; 19 | 20 | export type ResultState = { 21 | currentUrl: string; 22 | schema: JsonSchema | LoadSchemaError; 23 | } 24 | 25 | function isLoadSchemaError(e: JsonSchema | LoadSchemaError): e is LoadSchemaError { 26 | return typeof e !== 'boolean' && 'message' in e; 27 | } 28 | 29 | class LoadSchemaWR extends React.PureComponent { 30 | state: LoadSchemaState = { 31 | 32 | }; 33 | 34 | componentDidUpdate(prevProps: LoadSchemaProps, prevState: LoadSchemaState) { 35 | const url = this.getUrlFromProps(); 36 | if (prevState.result !== undefined && prevState.result.currentUrl !== url && url !== null) { 37 | this.loadUrl(url); 38 | } 39 | } 40 | 41 | componentDidMount() { 42 | const url = this.getUrlFromProps(); 43 | if (url !== null) { 44 | this.loadUrl(url); 45 | } 46 | } 47 | 48 | private getUrlFromProps(): string | null { 49 | const urlToFetch = new URLSearchParams(this.props.location.search); 50 | return urlToFetch.get('url') ; 51 | } 52 | 53 | private loadUrl(url: string): void { 54 | fetch(url) 55 | .then(resp => resp.json()) 56 | .then(schema => this.setState({ result: { schema, currentUrl: url } })) 57 | .catch(e => this.setState({ result: { currentUrl: url, schema: { message: e.message }}})); 58 | } 59 | 60 | render() { 61 | const { result } = this.state; 62 | if (result === undefined) { 63 | return ( 64 | 69 | )} 70 | /> 71 | ); 72 | } 73 | 74 | if (isLoadSchemaError(result.schema)) { 75 | return ( 76 | Error: {result.schema.message}

81 | )} 82 | /> 83 | ); 84 | } 85 | 86 | const { children } = this.props; 87 | if (typeof children !== 'function') { 88 | throw new Error('The children of the LoadSchema must be a function to accept the schema.') 89 | } 90 | const linkTitle = typeof result.schema !== 'boolean' ? result.schema.title || result.currentUrl : result.currentUrl; 91 | addRecentlyViewedLink({ 92 | title: linkTitle, 93 | url: result.currentUrl 94 | }); 95 | return <>{children(result.schema)}; 96 | } 97 | } 98 | 99 | export const LoadSchema = withRouter(LoadSchemaWR); 100 | -------------------------------------------------------------------------------- /src/Parameter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Lozenge from '@atlaskit/lozenge'; 4 | import { colors } from '@atlaskit/theme'; 5 | import { JsonSchema } from './schema'; 6 | import { Lookup } from './lookup'; 7 | import { Markdown } from './markdown'; 8 | import { ParameterMetadata } from './ParameterMetadata'; 9 | import { ClickElement, Type } from './Type'; 10 | 11 | const Wrap = styled.span` 12 | padding-left: 10px; 13 | vertical-align: text-bottom; 14 | `; 15 | 16 | const Required = Required; 17 | 18 | const Deprecated = Deprecated; 19 | 20 | export type ParameterViewProps = { 21 | name: string; 22 | description?: string; 23 | required?: boolean; 24 | deprecated?: boolean; 25 | schema: JsonSchema | undefined; 26 | reference: string; 27 | lookup: Lookup; 28 | clickElement: ClickElement; 29 | }; 30 | 31 | const ParameterContainer = styled.section` 32 | margin-top: 16px; 33 | padding: 0; 34 | max-width: 100%; 35 | `; 36 | 37 | const ParameterTitle = styled.strong` 38 | color: ${colors.N800}; 39 | font-size: 14px; 40 | font-weight: 500; 41 | `; 42 | 43 | const Description = styled.div` 44 | margin: 8px 0 0 0; 45 | `; 46 | 47 | export const ParameterView: React.FC = (props) => ( 48 | 49 | {props.name} {props.required && Required} {props.deprecated && Deprecated} 50 | 56 | {props.description !== undefined && ( 57 | 58 | 59 | 60 | )} 61 | {props.schema && } 62 | 63 | ); 64 | 65 | // export type ParameterProps = { 66 | // parameterName: string; 67 | // p: JsonSchema; 68 | // required: boolean; 69 | // lookup: Lookup; 70 | // }; 71 | 72 | // export class Parameter extends React.PureComponent { 73 | // render() { 74 | // const { p, parameterName, required, lookup } = this.props; 75 | 76 | // const schema = lookup.getSchema(p); 77 | 78 | // if (schema === undefined) { 79 | // return ( 80 | // 88 | // ) 89 | // } 90 | 91 | // if (typeof schema === 'boolean') { 92 | // return ( 93 | // 101 | // ); 102 | // } 103 | 104 | 105 | // return ( 106 | // 114 | // ); 115 | // } 116 | // } -------------------------------------------------------------------------------- /src/ParameterMetadata.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { JsonSchema } from './schema'; 3 | import { Lookup } from './lookup'; 4 | import styled from 'styled-components'; 5 | import { intersperse } from './jsx-util'; 6 | import { ExpandibleList } from './ExpandibleList'; 7 | import { Code } from '@atlaskit/code'; 8 | import { extractEnum } from './enum-extraction'; 9 | 10 | const MetadataContainer = styled.div` 11 | margin: 8px 0 8px 0; 12 | `; 13 | 14 | export type ParameterMetadataProps = { 15 | schema: JsonSchema; 16 | lookup: Lookup; 17 | }; 18 | 19 | function turnEnumToValues(schema: JsonSchema, lookup: Lookup): JSX.Element | undefined { 20 | const potentialEnum = extractEnum(schema, lookup); 21 | 22 | if (potentialEnum === undefined) { 23 | return undefined; 24 | } 25 | 26 | return ( 27 |

28 | Valid values:  29 | ({ element }))} collapsedMaxLength={10} /> 30 |

31 | ); 32 | } 33 | 34 | export const ParameterMetadata: React.SFC = (props) => { 35 | const { schema, lookup } = props; 36 | 37 | const restrictions: JSX.Element[] = new Array(); 38 | const validValues = new Array(); 39 | 40 | function showBoolean(name: string, key: string, value: boolean) { 41 | return {name}: ; 42 | } 43 | 44 | function show(name: string, key: string, value: string | number) { 45 | const displayVal = typeof value === 'string' ? value : value.toString(); 46 | return {name}: ; 47 | } 48 | 49 | if (typeof schema !== 'boolean') { 50 | if (schema.default !== undefined) { 51 | const def = schema.default; 52 | if (typeof def === 'string' || typeof def === 'number') { 53 | restrictions.push(show('Default', 'default', def)); 54 | } else if (typeof def === 'boolean') { 55 | restrictions.push(showBoolean('Default', 'default', def)); 56 | } 57 | } 58 | 59 | if (schema.minItems !== undefined) { 60 | restrictions.push(show('Min items', 'min-items', schema.minItems)); 61 | } 62 | if (schema.maxItems !== undefined) { 63 | restrictions.push(show('Max items', 'max-items', schema.maxItems)); 64 | } 65 | if (typeof schema.uniqueItems !== 'undefined') { 66 | restrictions.push(showBoolean('Unique items', 'unique-items', schema.uniqueItems)); 67 | } 68 | if (schema.minimum !== undefined) { 69 | const isExclusive = typeof schema.exclusiveMinimum === 'boolean' && schema.exclusiveMinimum; 70 | restrictions.push(show(`${isExclusive ? 'Exclusive ' : ''}Minimum`, 'minimum', schema.minimum)); 71 | } 72 | if (schema.maximum !== undefined) { 73 | const isExclusive = typeof schema.exclusiveMaximum === 'boolean' && schema.exclusiveMaximum; 74 | restrictions.push(show(`${isExclusive ? 'Exclusive ' : ''}Maximum`, 'maximum', schema.maximum)); 75 | } 76 | if (typeof schema.exclusiveMinimum === 'number' && schema.minimum === undefined) { 77 | restrictions.push(show('Exclusive Minimum', 'minimum', schema.exclusiveMinimum)); 78 | } 79 | if (typeof schema.exclusiveMaximum === 'number' && schema.maximum === undefined) { 80 | restrictions.push(show('Exclusive Maximum', 'maximum', schema.exclusiveMaximum)); 81 | } 82 | if (schema.multipleOf !== undefined) { 83 | restrictions.push(show('Multiple of', 'multiple-of', schema.multipleOf)); 84 | } 85 | if (schema.minProperties !== undefined) { 86 | restrictions.push(show('Min properties', 'min-properties', schema.minProperties)); 87 | } 88 | if (schema.maxProperties !== undefined) { 89 | restrictions.push(show('Max properties', 'max-properties', schema.maxProperties)); 90 | } 91 | if (schema.minLength !== undefined) { 92 | restrictions.push(show('Min length', 'min-length', schema.minLength)); 93 | } 94 | if (schema.maxLength !== undefined) { 95 | restrictions.push(show('Max length', 'min-length', schema.maxLength)); 96 | } 97 | if (typeof schema.pattern !== 'undefined') { 98 | restrictions.push(show('Pattern', 'pattern', schema.pattern)); 99 | } 100 | if (typeof schema.format !== 'undefined') { 101 | restrictions.push(show('Format', 'format', schema.format)); 102 | } 103 | 104 | let potentialEnum = turnEnumToValues(schema, lookup); 105 | if (potentialEnum !== undefined) { 106 | validValues.push(potentialEnum); 107 | } 108 | } 109 | 110 | return {intersperse(restrictions, ', ')}{validValues}; 111 | }; -------------------------------------------------------------------------------- /src/PrimaryDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useOverflowStatus, PrimaryDropdownButton } from '@atlaskit/atlassian-navigation'; 3 | import Popup, { PopupProps, ContentProps } from '@atlaskit/popup'; 4 | import { ButtonItem } from '@atlaskit/menu'; 5 | 6 | 7 | export type PrimaryDropdownProps = { 8 | content: (props: ContentPropsWithClose) => JSX.Element; 9 | text: string; 10 | isHighlighted?: boolean; 11 | }; 12 | 13 | export type ContentPropsWithClose = ContentProps & { 14 | closePopup: () => void; 15 | } 16 | 17 | export const PrimaryDropdown: React.FC = (props) => { 18 | const { content, text, isHighlighted } = props; 19 | const { isVisible, closeOverflowMenu } = useOverflowStatus(); 20 | const [isOpen, setIsOpen] = useState(false); 21 | const Content = content; 22 | const onDropdownItemClick = () => { 23 | console.log( 24 | 'Programmatically closing the menu, even though the click happens inside the popup menu.', 25 | ); 26 | closeOverflowMenu(); 27 | }; 28 | 29 | if (!isVisible) { 30 | return ( 31 | 32 | {text} 33 | 34 | ); 35 | } 36 | 37 | const onClick = () => { 38 | setIsOpen(!isOpen); 39 | }; 40 | 41 | const onClose = () => { 42 | setIsOpen(false); 43 | }; 44 | 45 | const onKeyDown = (event: React.KeyboardEvent) => { 46 | if (event.key === 'ArrowDown') { 47 | setIsOpen(true); 48 | } 49 | }; 50 | 51 | return ( 52 | setIsOpen(false)} {...props} />} 54 | isOpen={isOpen} 55 | onClose={onClose} 56 | placement="bottom-start" 57 | testId={`${text}-popup`} 58 | trigger={triggerProps => ( 59 | 67 | {text} 68 | 69 | )} 70 | /> 71 | ); 72 | }; -------------------------------------------------------------------------------- /src/SchemaApp.tsx: -------------------------------------------------------------------------------- 1 | import { AtlassianNavigation, Create, ProductHome } from '@atlaskit/atlassian-navigation'; 2 | import { AtlassianIcon, AtlassianLogo } from '@atlaskit/logo'; 3 | import React from 'react'; 4 | import { Redirect, Route, RouteComponentProps, Switch, useHistory, withRouter } from 'react-router-dom'; 5 | import { LoadSchema } from './LoadSchema'; 6 | import { JsonSchema } from './schema'; 7 | import { SchemaView } from './SchemaView'; 8 | import { Start } from './Start'; 9 | import { PopupMenuGroup, Section, ButtonItem, LinkItem, LinkItemProps } from '@atlaskit/menu'; 10 | import { linkToRoot } from './route-path'; 11 | import { ContentPropsWithClose, PrimaryDropdown } from './PrimaryDropdown'; 12 | import { Docs } from './Docs'; 13 | import { getRecentlyViewedLinks, RecentlyViewedLink } from './recently-viewed'; 14 | import { exampleSchemas } from "./example-schemas"; 15 | 16 | const JsonSchemaHome = () => ( 17 | 18 | ); 19 | 20 | type NavigationButtonItemProps = { 21 | exampleUrl: string; 22 | onClick: () => void; 23 | }; 24 | 25 | const NavigationButtonItem: React.FC = (props) => { 26 | const history = useHistory(); 27 | const linkLocation = linkToRoot(['view'], props.exampleUrl); 28 | const onClick = (e: React.MouseEvent | React.KeyboardEvent) => { 29 | e.preventDefault(); 30 | history.push(linkLocation); 31 | props.onClick(); 32 | }; 33 | return {props.children} 34 | } 35 | 36 | const NewTabLinkItem: React.FC = (props) => ; 37 | 38 | type RecentlyViewedMenuProps = ContentPropsWithClose & { 39 | recentlyViewed: Array; 40 | }; 41 | 42 | const RecentlyViewedMenu: React.FC = (props) => { 43 | const recentlyViewed = getRecentlyViewedLinks() || []; 44 | 45 | return ( 46 | 47 |
48 | {recentlyViewed.map(link => ( 49 | {link.title} 50 | ))} 51 |
52 |
53 | ); 54 | }; 55 | 56 | const ExampleMenu: React.FC = (props) => ( 57 | 58 | {Array.from(Object.entries(exampleSchemas)).map(([title, links]) => ( 59 |
60 | {Array.from(Object.entries(links)).map(([linkTitle, url]) => ( 61 | {linkTitle} 62 | ))} 63 |
64 | ))} 65 |
66 | Schemastore Repository 67 |
68 |
69 | ); 70 | 71 | const HelpMenu: React.FC = (props) => { 72 | const history = useHistory(); 73 | 74 | const goTo = (location: string) => { 75 | return (e: React.MouseEvent | React.KeyboardEvent) => { 76 | e.preventDefault(); 77 | history.push(location); 78 | props.closePopup(); 79 | }; 80 | }; 81 | 82 | return ( 83 | 84 |
85 | Introduction 86 | Linking your schema 87 | Understanding JSON Schema 88 |
89 |
90 | Raise issue 91 | View source code 92 |
93 |
94 | ); 95 | }; 96 | 97 | const NewSchema: React.FC = () => { 98 | const history = useHistory(); 99 | const isStart = history.location.pathname === '/start'; 100 | if (isStart) { 101 | return <>; 102 | } 103 | 104 | return ( 105 | history.push('/start')} 110 | /> 111 | ); 112 | }; 113 | 114 | export type LoadedState = { 115 | schemaUrl: string; 116 | loadedSchema: JsonSchema; 117 | } 118 | 119 | export type SchemaAppState = { 120 | loadedState?: LoadedState; 121 | } 122 | 123 | class SchemaAppWR extends React.PureComponent { 124 | state: SchemaAppState = { 125 | 126 | }; 127 | 128 | render() { 129 | const primaryItems = [ 130 | } text="Examples" />, 131 | } text="Help" /> 132 | ]; 133 | 134 | const recentlyViewed = getRecentlyViewedLinks(); 135 | if (recentlyViewed !== undefined) { 136 | primaryItems.unshift( 137 | } text="Recently viewed" /> 138 | ); 139 | } 140 | 141 | return ( 142 |
143 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | {(schema) => ( 157 | 162 | )} 163 | 164 | 165 | 166 | 167 |
168 | ); 169 | } 170 | } 171 | 172 | export const SchemaApp = withRouter(SchemaAppWR); 173 | -------------------------------------------------------------------------------- /src/SchemaEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { JsonSchema } from './schema'; 3 | import Editor, { OnValidate, useMonaco } from '@monaco-editor/react'; 4 | import type { IRange } from 'monaco-editor'; 5 | import { ScrollType } from './monaco-helpers'; 6 | 7 | export type SchemaEditorProps = { 8 | initialContent: unknown; 9 | schema: JsonSchema; 10 | validationRange?: IRange; 11 | onValidate: OnValidate; 12 | }; 13 | 14 | /** 15 | * No more than 50 characters per line. 16 | */ 17 | const editorPreamble = ` 18 | // Copy-and-paste your JSON in here to live-edit 19 | // while reading the docs and also getting the 20 | // benefit of validation and autocompletion! 21 | `.trim(); 22 | 23 | export const SchemaEditor: React.FC = (props) => { 24 | const { initialContent, schema, validationRange, onValidate } = props; 25 | const monaco = useMonaco(); 26 | 27 | useEffect(() => { 28 | monaco?.languages.json.jsonDefaults.setDiagnosticsOptions({ 29 | validate: true, 30 | allowComments: true, 31 | schemas: [ 32 | { 33 | uri: 'https://json-schema.app/example.json', // id of the first schema 34 | fileMatch: ['a://b/example.json'], 35 | schema: schema, 36 | }, 37 | ], 38 | }); 39 | }, [monaco, schema]); 40 | useEffect(() => { 41 | if (!validationRange || !monaco) { 42 | return; 43 | } 44 | monaco.editor.getEditors().forEach((codeEditor) => { 45 | codeEditor.setSelection(validationRange); 46 | codeEditor.revealRangeAtTop(validationRange, ScrollType.Smooth); 47 | }); 48 | }, [monaco, validationRange]); 49 | 50 | return ( 51 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/SchemaValidator.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import type { editor, IRange } from 'monaco-editor'; 3 | import { colors } from '@atlaskit/theme'; 4 | import IconError from '@atlaskit/icon/glyph/error'; 5 | import IconInfo from '@atlaskit/icon/glyph/info'; 6 | import IconWarning from '@atlaskit/icon/glyph/warning'; 7 | import Table, { Cell, Row, SortableColumn, TBody, THead } from '@atlaskit/table'; 8 | import styled from 'styled-components'; 9 | import { MarkerSeverity } from './monaco-helpers'; 10 | import EmptyState from "@atlaskit/empty-state"; 11 | import EditorSuccessIcon from "@atlaskit/icon/glyph/editor/success"; 12 | 13 | type SchemaValidatorProps = { 14 | results: editor.IMarker[]; 15 | onSelectRange: (range: IRange) => void; 16 | }; 17 | 18 | const severityDefinitions = { 19 | [MarkerSeverity.Error]: { 20 | label: 'Error', 21 | icon: IconError, 22 | color: colors.R500, 23 | }, 24 | [MarkerSeverity.Warning]: { 25 | label: 'Warning', 26 | icon: IconWarning, 27 | color: colors.Y300, 28 | }, 29 | [MarkerSeverity.Info]: { 30 | label: 'Info', 31 | icon: IconInfo, 32 | color: colors.B300, 33 | }, 34 | [MarkerSeverity.Hint]: { 35 | label: 'Hint', 36 | icon: IconInfo, 37 | color: colors.B300, 38 | }, 39 | }; 40 | 41 | export const SchemaValidator: FC = ({ results, onSelectRange }) => { 42 | if (results.length === 0) { 43 | return ( 44 | } 47 | /> 48 | ); 49 | } 50 | const sortedByLineNumber = results.sort((a, b) => { 51 | if (a.startLineNumber !== b.startLineNumber) { 52 | return a.startLineNumber - b.startLineNumber; 53 | } 54 | return a.startColumn - b.startColumn; 55 | }); 56 | return ( 57 | 58 | 59 | Severity 60 | Message 61 | Location 62 | 63 | 64 | {(row) => { 65 | const { label, icon: Icon, color } = severityDefinitions[row.severity]; 66 | const { 67 | message, 68 | startColumn, 69 | startLineNumber, 70 | endColumn, 71 | endLineNumber, 72 | } = row; 73 | const locationString = `${startLineNumber}:${startColumn}-${endLineNumber}:${endColumn}` 74 | return ( 75 | 76 | 77 | 78 | 79 | {label} 80 | 81 | 82 | {message} 83 | 84 | { 87 | e.preventDefault() 88 | onSelectRange({ 89 | startColumn, 90 | startLineNumber, 91 | endColumn, 92 | endLineNumber, 93 | }); 94 | }} 95 | > 96 | {locationString} 97 | 98 | 99 | 100 | ); 101 | }} 102 | 103 |
104 | ); 105 | }; 106 | 107 | const Flex = styled.div` 108 | display: flex; 109 | align-items: center; 110 | `; 111 | -------------------------------------------------------------------------------- /src/SchemaView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { RouteComponentProps, withRouter } from 'react-router-dom'; 4 | import { getSchemaFromReference, InternalLookup, Lookup } from './lookup'; 5 | import { PathElement } from './route-path'; 6 | import { JsonSchema } from './schema'; 7 | import { SchemaExplorer } from './SchemaExplorer'; 8 | import { SideNavWithRouter } from './SideNavWithRouter'; 9 | import { Stage } from './stage'; 10 | import { extractLinks } from './side-nav-loader'; 11 | import { SchemaEditor } from './SchemaEditor'; 12 | import { generateJsonExampleFor, isErrors } from './example'; 13 | import { forSize } from './breakpoints'; 14 | import type { editor, IRange } from 'monaco-editor'; 15 | 16 | export type SchemaViewProps = RouteComponentProps & { 17 | basePathSegments: Array; 18 | schema: JsonSchema; 19 | stage: Stage; 20 | }; 21 | 22 | type SchemaViewState = { 23 | selectedValidationRange: IRange | undefined; 24 | validationResults: editor.IMarker[]; 25 | } 26 | 27 | // TODO we need to reverse engineer the schema explorer to show based on the path 28 | 29 | function getTitle(schema: JsonSchema | undefined): string { 30 | if (schema === undefined) { 31 | return ''; 32 | } 33 | 34 | if (typeof schema === 'boolean') { 35 | return ''; 36 | } 37 | 38 | return schema.title || 'object'; 39 | } 40 | 41 | function removeLeadingSlash(v: string): string { 42 | if (v.startsWith('/')) { 43 | return v.slice(1); 44 | } 45 | return v; 46 | } 47 | 48 | export class SchemaViewWR extends React.PureComponent { 49 | private static Container = styled.div` 50 | display: flex; 51 | `; 52 | 53 | private static EditorContainer = styled.div` 54 | min-width: 500px; 55 | max-width: 500px; 56 | 57 | display: none; 58 | position: relative; 59 | ${forSize('tablet-landscape-up', ` 60 | display: block; 61 | `)} 62 | 63 | section { 64 | position: fixed !important; 65 | padding: 0; 66 | margin: 0; 67 | } 68 | `; 69 | 70 | private static EditorContainerHeading = styled.h3` 71 | position: fixed; 72 | top: 0px; 73 | z-index: -100; 74 | `; 75 | 76 | constructor(props: SchemaViewProps) { 77 | super(props); 78 | this.state = { 79 | selectedValidationRange: undefined, 80 | validationResults: [] 81 | } 82 | } 83 | 84 | public render() { 85 | const { schema, basePathSegments } = this.props; 86 | 87 | const lookup = new InternalLookup(schema); 88 | const path = this.getPathFromRoute(lookup); 89 | 90 | if (path.length === 0) { 91 | return
Error: Could not work out what to load from the schema.
92 | } 93 | 94 | const currentPathElement = path[path.length - 1]; 95 | const currentSchema = getSchemaFromReference(currentPathElement.reference, lookup); 96 | 97 | if (currentSchema === undefined) { 98 | return
ERROR: Could not look up the schema that was requested in the URL.
; 99 | } 100 | 101 | if (typeof currentSchema === 'boolean') { 102 | return
TODO: Implement anything or nothing schema once clicked on.
103 | } 104 | 105 | const generatedExample = generateJsonExampleFor(schema, lookup, 'both'); 106 | const fullExample: unknown = isErrors(generatedExample) ? {} : generatedExample.value; 107 | 108 | return ( 109 | 110 | 111 | this.setState({ selectedValidationRange: range })} 118 | validationResults={this.state.validationResults} 119 | /> 120 | 121 | this.setState({ validationResults: results })} 126 | /> 127 | Editor and Validator 128 | 129 | 130 | ); 131 | } 132 | 133 | private getPathFromRoute(lookup: Lookup): Array { 134 | const { basePathSegments } = this.props; 135 | const { pathname } = this.props.location; 136 | const pathSegments = removeLeadingSlash(pathname).split('/'); 137 | let iterator = 0; 138 | while (pathSegments[iterator] !== undefined && basePathSegments[iterator] !== undefined && basePathSegments[iterator] === pathSegments[iterator]) { 139 | iterator++; 140 | } 141 | 142 | if (iterator === pathSegments.length) { 143 | const reference = '#'; 144 | const title = getTitle(getSchemaFromReference(reference, lookup)); 145 | return [{ 146 | title, 147 | reference 148 | }]; 149 | } 150 | 151 | return pathSegments.slice(iterator).map(decodeURIComponent).map(userProvidedReference => { 152 | const reference = userProvidedReference.startsWith('#') ? userProvidedReference : '#/invalid-reference'; 153 | const title = getTitle(getSchemaFromReference(reference, lookup)); 154 | return { 155 | title, 156 | reference 157 | }; 158 | }); 159 | } 160 | } 161 | 162 | export const SchemaView = withRouter(SchemaViewWR); 163 | -------------------------------------------------------------------------------- /src/SideNavWithRouter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ChevronDownIcon from '@atlaskit/icon/glyph/chevron-down'; 3 | import ChevronRightIcon from '@atlaskit/icon/glyph/chevron-right'; 4 | import styled from 'styled-components'; 5 | import { forSize } from './breakpoints'; 6 | import { NavLink } from 'react-router-dom'; 7 | import { linkTo } from './route-path'; 8 | import { NavLinkPreservingSearch } from './search-preserving-link'; 9 | 10 | export type SideNavLink = SingleSideNavLink | GroupSideNavLink | SideNavSpace; 11 | 12 | export type SingleSideNavLink = { 13 | title: string; 14 | reference: string; 15 | }; 16 | 17 | export type GroupSideNavLink = { 18 | title: string; 19 | reference: string | undefined; 20 | children: SingleSideNavLink[]; 21 | }; 22 | 23 | export type SideNavSpace = { type: 'space' }; 24 | 25 | export const Spacer: SideNavSpace = { type: 'space' }; 26 | 27 | function isGroupSideNavLink(l: SideNavLink): l is GroupSideNavLink { 28 | return 'children' in l; 29 | } 30 | 31 | function isSideNavSpace(l: SideNavLink): l is SideNavSpace { 32 | return 'type' in l && l.type === 'space'; 33 | } 34 | 35 | interface NavItemProps { 36 | indent?: boolean; 37 | } 38 | 39 | /* 40 | ${({ selected }) => ` 41 | list-style-type: ${selected ? 'disc' : 'none'}; 42 | color: ${selected ? '#0057d8' : 'rgb(37, 56, 88)'}; 43 | `} 44 | */ 45 | 46 | const NavItem = styled('li')` 47 | margin: 0; 48 | font-size: 11px; 49 | 50 | a { 51 | color: inherit; 52 | font-size: 14px; 53 | text-decoration: none; 54 | 55 | line-height: 18px; 56 | padding: ${({ indent }) => `0.7rem 1rem 0.22rem ${indent ? '13px' : 0}`}; 57 | 58 | display: inline-flex; 59 | display: -moz-box; 60 | white-space: nowrap; 61 | overflow: hidden; 62 | text-overflow: ellipsis; 63 | } 64 | 65 | a:focus { 66 | outline: none; 67 | } 68 | 69 | a.active { 70 | color: #0057d8; 71 | } 72 | `; 73 | 74 | type SideNavGroupProps = { 75 | basePathSagments: Array; 76 | link: GroupSideNavLink | SingleSideNavLink; 77 | }; 78 | 79 | type SideNavGroupState = { 80 | open: boolean; 81 | lastOpenReference: string | undefined; 82 | }; 83 | 84 | class SideNavGroup extends React.PureComponent { 85 | private static Item = styled.div` 86 | display: flex; 87 | justify-content: flex-start; 88 | align-items: center; 89 | 90 | a { 91 | color: rgb(37, 56, 88); 92 | } 93 | 94 | a.active { 95 | color: #0057d8; 96 | } 97 | `; 98 | 99 | private static SubItemContainer = styled.ul` 100 | margin: 0px; 101 | padding: 0 0 0 32px; 102 | `; 103 | 104 | private static IconWrap = styled.span` 105 | cursor: pointer; 106 | `; 107 | 108 | private static SingleLinkContainer = styled.div` 109 | line-height: 27px; 110 | padding-left: 24px; 111 | margin: 0; 112 | `; 113 | 114 | UNSAFE_componentWillMount() { 115 | this.setState({ 116 | open: false 117 | }); 118 | } 119 | 120 | render() { 121 | const { basePathSagments, link } = this.props; 122 | const { open } = this.state; 123 | 124 | if (!isGroupSideNavLink(link)) { 125 | return ( 126 | <> 127 | 128 | 129 | 130 | {link.title} 131 | 132 | 133 | 134 | 135 | ); 136 | } 137 | 138 | if (link.reference === undefined && link.children.length === 0) { 139 | return <>; 140 | } 141 | 142 | const subLinks = !isGroupSideNavLink(link) ? [] : link.children.map(childLink => { 143 | return ( 144 | 145 | 146 | {childLink.title} 147 | 148 | 149 | ); 150 | }); 151 | 152 | const icon = open 153 | ? ( 154 | 155 | ) 156 | : ( 157 | 158 | ); 159 | 160 | const groupLink = link.reference !== undefined ? 161 | ( 162 | 163 | {link.title} 164 | 165 | ) : 166 | ( 167 | {link.title} 168 | ); 169 | 170 | return ( 171 | <> 172 | 173 | this.toggleState(!open)}>{icon} 174 | {groupLink} 175 | 176 | {open && {subLinks}} 177 | 178 | ); 179 | } 180 | 181 | private toggleState(open: boolean): void { 182 | // Change the state 183 | this.setState({ 184 | open 185 | }); 186 | } 187 | } 188 | 189 | const SideNavSpacerDiv = styled.div` 190 | min-height: 16px; 191 | `; 192 | 193 | type SideNavLinksProps = { 194 | basePathSegments: Array; 195 | links: Array 196 | }; 197 | 198 | class SideNavLinks extends React.PureComponent { 199 | private static Container = styled.div` 200 | display: none; 201 | ${forSize('tablet-landscape-up', ` 202 | display: block; 203 | flex: auto; 204 | overflow-y: auto; 205 | min-width: 300px; 206 | padding-top: 24px; 207 | `)} 208 | &:focus { 209 | outline: 0; 210 | } 211 | `; 212 | 213 | render() { 214 | const { basePathSegments, links } = this.props; 215 | 216 | const groups = links.map((link, index) => { 217 | if (isSideNavSpace(link)) { 218 | return ; 219 | } 220 | return 221 | }); 222 | 223 | return ( 224 | 225 | {groups} 226 | 227 | ); 228 | } 229 | } 230 | 231 | export type SideNavWithRouterProps = { 232 | basePathSegments: Array; 233 | links: Array 234 | }; 235 | 236 | export class SideNavWithRouter extends React.PureComponent { 237 | private static Container = styled.div` 238 | display: flex; 239 | flex-direction: column; 240 | 241 | ${forSize('tablet-landscape-up', ` 242 | height: 95vh; 243 | width: 300px; 244 | `)} 245 | `; 246 | 247 | render() { 248 | const { basePathSegments, links } = this.props; 249 | 250 | return ( 251 | 252 | 253 | 254 | ); 255 | } 256 | } -------------------------------------------------------------------------------- /src/Start.tsx: -------------------------------------------------------------------------------- 1 | import EmptyState from '@atlaskit/empty-state'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import { RouteComponentProps, withRouter } from 'react-router-dom'; 5 | import TextField from '@atlaskit/textfield'; 6 | import Button from '@atlaskit/button'; 7 | import { Markdown } from './markdown'; 8 | 9 | export type StartProps = RouteComponentProps & { 10 | 11 | }; 12 | 13 | export type StartState = { 14 | urlInput?: string; 15 | } 16 | 17 | const DevelopingSchemaInstructions = ` 18 | ## Developing a JSON Schema 19 | 20 | If you are busy writing or generating a JSON Schema and you want to get a live experience of viewing that schema using 21 | JSON Schema Viewer then you have a few options. 22 | 23 | ### Using a locally running server 24 | 25 | JSON Schema Viewer will work so long as your JSON Schema is accessible via a HTTP Get request from your web browser. 26 | This means that you can just host the file on your own machine and edit it from there. For example: 27 | 28 | 1. In a terminal, go to the directory on your local filesystem that contains your JSON Schema. 29 | 1. In that directory on your local machine, type in: 30 | \`\`\` 31 | npx http-server -p 9876 --cors -c-1 32 | \`\`\` 33 | 1. Navigate to [http://localhost:9876/][1] and click on your JSON Schema link. 34 | 1. Copy the URL for the JSON Schema and paste it into the input above. 35 | 36 | And there you have it! You can now view your JSON Schema here. Using this method, you can also live edit the file 37 | on your local computer and then you can merely refresh JSON Schema Viewer in your browser and your new content will 38 | appear live. 39 | 40 | ### Using Bitbucket Snippets 41 | 42 | To use Bitbucket Snippets: 43 | 44 | 1. Navigate to: [https://bitbucket.org/snippets/new][2] 45 | 1. Paste your JSON Schema into the file text input and fill in the other fields. 46 | 1. Select "Permissions" => "Public" and Create Snippet. 47 | 1. Once the snippet has been created, click on the "Raw" button and copy the raw link to your file. 48 | 1. Paste that link into the input on this page. 49 | 50 | And there you have it! You can now view your JSON Schema here. If you wish to update it, merely go back to your gist, 51 | update the contents, re-copy the raw url (because it will have updated) and load it into JSON Schema viewer again. 52 | 53 | ### Using Github Gists 54 | 55 | To use Github Gists: 56 | 57 | 1. Navigate to: [https://gist.github.com/][3] 58 | 1. Paste your JSON Schema into the file text input and fill in the other fields. 59 | 1. Select "Create public gist" so that the gist can be viewed by anybody. 60 | 1. Once the gist has been created, click on the "Raw" button and copy the raw link to your file. 61 | 1. Paste that link into the input on this page. 62 | 63 | And there you have it! You can now view your JSON Schema here. If you wish to update it, merely go back to your gist, 64 | update the contents, re-copy the raw url (because it will have updated) and load it into JSON Schema viewer again. 65 | 66 | [1]: http://localhost:9876/ 67 | [2]: https://bitbucket.org/snippets/new 68 | [3]: https://gist.github.com/ 69 | `.trim(); 70 | 71 | export class StartWR extends React.PureComponent { 72 | public static InputWidth = styled.div` 73 | min-width: 600px; 74 | `; 75 | 76 | public static Flex = styled.div` 77 | display: flex; 78 | gap: 4px; 79 | align-items: center; 80 | ` 81 | 82 | public static Guide = styled.div` 83 | max-width: 600px; 84 | text-align: left; 85 | margin-top: 100px; 86 | `; 87 | 88 | state: StartState = { 89 | 90 | }; 91 | 92 | render() { 93 | const { history } = this.props; 94 | 95 | const handleOnClick = () => { 96 | history.push(`/view/${encodeURIComponent('#')}?url=${encodeURIComponent(this.state.urlInput || '')}`); 97 | }; 98 | 99 | const onTextChange: React.FormEventHandler = e => { 100 | const currentValue = e.currentTarget.value; 101 | console.log('currentValue', currentValue); 102 | this.setState(() => ({ urlInput: currentValue || '' })); 103 | } 104 | 105 | return ( 106 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | )} 118 | /> 119 | ); 120 | } 121 | } 122 | 123 | export const Start = withRouter(StartWR) 124 | -------------------------------------------------------------------------------- /src/Type.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { colors } from '@atlaskit/theme'; 4 | import { JsonSchema, JsonSchema1 } from './schema'; 5 | import { getSchemaFromResult, Lookup } from './lookup'; 6 | import { intersperse } from './jsx-util'; 7 | import { getOrInferType, isExternalReference, isPrimitiveType } from './type-inference'; 8 | import { findDiscriminant } from './discriminant'; 9 | import { isPresent } from 'ts-is-present'; 10 | 11 | export type TypeClick = (s: JsonSchema) => void; 12 | 13 | export type ClickElementProps = { 14 | fallbackTitle: string; 15 | schema: JsonSchema1; 16 | reference: string; 17 | } 18 | 19 | export type ClickElement = React.ElementType; 20 | 21 | export type TypeProps = { 22 | s: JsonSchema | undefined; 23 | reference: string; 24 | lookup: Lookup; 25 | clickElement: ClickElement; 26 | }; 27 | 28 | class LookupContext { 29 | readonly lookup: Lookup; 30 | readonly clickElement: ClickElement; 31 | readonly discriminate: string | undefined; 32 | 33 | private constructor(lookup: Lookup, clickElement: ClickElement, discriminate: string | undefined) { 34 | this.lookup = lookup; 35 | this.clickElement = clickElement; 36 | this.discriminate = discriminate; 37 | } 38 | 39 | public static root(lookup: Lookup, clickElement: ClickElement): LookupContext { 40 | return new LookupContext(lookup, clickElement, undefined); 41 | } 42 | 43 | public clone(discriminate: string | undefined): LookupContext { 44 | if (this.discriminate === discriminate) { 45 | return this; 46 | } 47 | return new LookupContext(this.lookup, this.clickElement, discriminate); 48 | } 49 | } 50 | 51 | function hasCompositeDefinition(s: JsonSchema1): boolean { 52 | return (s.anyOf !== undefined && s.anyOf.length > 0) || 53 | (s.oneOf !== undefined && s.oneOf.length > 0) || 54 | (s.allOf !== undefined && s.allOf.length > 0) || 55 | (s.not !== undefined); 56 | } 57 | 58 | function hasProperties(s: JsonSchema1): boolean { 59 | return s.properties !== undefined && Object.keys(s.properties).length > 0; 60 | } 61 | 62 | function hasPatternProperties(s: JsonSchema1): boolean { 63 | return s.patternProperties !== undefined && Object.keys(s.patternProperties).length > 0; 64 | } 65 | 66 | function hasAdditionalProperties(s: JsonSchema1): boolean { 67 | return !(typeof s.additionalProperties === 'boolean' && s.additionalProperties === false); 68 | } 69 | 70 | export function isClickable(s: JsonSchema): boolean { 71 | if (typeof s === 'boolean') { 72 | return false; 73 | } 74 | 75 | const type = getOrInferType(s); 76 | 77 | return hasProperties(s) || hasPatternProperties(s) || (type === 'object' && hasAdditionalProperties(s)); 78 | } 79 | 80 | function schemaHasCompositeType(s: JsonSchema1): boolean { 81 | return (s.allOf !== undefined && s.allOf.length > 0) 82 | || (s.anyOf !== undefined && s.anyOf.length > 0) 83 | || (s.oneOf !== undefined && s.oneOf.length > 0) 84 | || s.not !== undefined; 85 | } 86 | 87 | const Container = styled.p` 88 | margin: 0; 89 | `; 90 | 91 | const Plain = styled.span` 92 | margin: 0px; 93 | color: ${colors.G400}; 94 | `; 95 | 96 | export const Anything = () => anything; 97 | 98 | type SchemaAndReference = { 99 | schema: JsonSchema | undefined; 100 | reference: string; 101 | } 102 | 103 | function extractSchemaAndReference(propertyName: string, lookup: Lookup, currentReference: string) { 104 | return (schema: JsonSchema, arrayIndex: number): SchemaAndReference => { 105 | const lookupResult = lookup.getSchema(schema); 106 | return ({ 107 | schema: getSchemaFromResult(lookupResult), 108 | reference: lookupResult !== undefined && lookupResult.baseReference || `${currentReference}/${propertyName}/${arrayIndex}` 109 | }); 110 | }; 111 | } 112 | 113 | function onlyKeyPresent(schema: JsonSchema1, key: keyof JsonSchema1): boolean { 114 | return Object.keys(schema).every(schemaKey => schemaKey !== key || schema[schemaKey] !== undefined); 115 | } 116 | 117 | function getObjectName(s: JsonSchema1, context: LookupContext): string { 118 | if (s.title !== undefined) { 119 | return s.title; 120 | } 121 | 122 | if (context.discriminate !== undefined && s.properties !== undefined) { 123 | const propertyName = context.discriminate; 124 | const propertyLookupResult = context.lookup.getSchema(s.properties[propertyName]); 125 | if (propertyLookupResult !== undefined) { 126 | const property = propertyLookupResult.schema; 127 | 128 | if (property !== undefined && typeof property !== 'boolean' && property.enum !== undefined && property.enum.length === 1) { 129 | return `${propertyName}: ${property.enum[0]}`; 130 | } 131 | } 132 | } 133 | 134 | return 'object'; 135 | } 136 | 137 | function findDis(sr: Array, context: LookupContext): string | undefined { 138 | return findDiscriminant(sr.map(s => s.schema).filter(isPresent), context.lookup); 139 | } 140 | 141 | const getTypeText = (initialSchema: JsonSchema | undefined, initialReference: string, context: LookupContext): JSX.Element => { 142 | const lookup = context.lookup; 143 | const Click = context.clickElement; 144 | 145 | if (initialSchema === undefined) { 146 | return ; 147 | } 148 | 149 | if (typeof initialSchema === 'boolean') { 150 | return {initialSchema === true ? 'anything' : 'nothing'} 151 | } 152 | 153 | if (isExternalReference(initialSchema)) { 154 | return ; 155 | } 156 | 157 | const lookupResult = lookup.getSchema(initialSchema); 158 | if (lookupResult === undefined) { 159 | return ; 160 | } 161 | 162 | const s = lookupResult.schema; 163 | const currentReference = lookupResult.baseReference || initialReference; 164 | 165 | if (typeof s === 'boolean') { 166 | return getTypeText(s, currentReference, context.clone(undefined)); 167 | } 168 | 169 | const type = getOrInferType(s); 170 | 171 | if (isClickable(s)) { 172 | return ; 173 | } 174 | 175 | if (schemaHasCompositeType(s)) { 176 | const compositeTypes: JSX.Element[] = new Array(); 177 | 178 | if (s.anyOf !== undefined && s.anyOf.length > 0) { 179 | const schemas = s.anyOf.map(extractSchemaAndReference('anyOf', lookup, currentReference)); 180 | // const schemas = mergeCompositesWithParent(s, s.anyOf.map(sx => lookup.getSchema(sx))); 181 | 182 | if (schemas.find(sx => sx.schema === undefined)) { 183 | // If you have an anything in an anyOf then you should just simplify to anything 184 | return ; 185 | } else { 186 | const renderedSchemas = schemas.map(sx => getTypeText(sx.schema, sx.reference, context.clone(undefined))); 187 | if (renderedSchemas.length === 1) { 188 | compositeTypes.push(renderedSchemas[0]); 189 | } else { 190 | const joined = intersperse(renderedSchemas, ', '); 191 | 192 | compositeTypes.push(anyOf [{joined}]); 193 | } 194 | } 195 | } 196 | 197 | if (s.oneOf !== undefined && s.oneOf.length > 0) { 198 | const schemas = s.oneOf.map(extractSchemaAndReference('oneOf', lookup, currentReference)); 199 | 200 | const renderedSchemas = schemas.map(sx => getTypeText(sx.schema, sx.reference, context.clone(findDis(schemas, context)))); 201 | if (renderedSchemas.length === 1) { 202 | compositeTypes.push(renderedSchemas[0]); 203 | } else { 204 | const joined = intersperse(renderedSchemas, ', '); 205 | 206 | compositeTypes.push(oneOf [{joined}]); 207 | } 208 | } 209 | 210 | if (s.allOf !== undefined && s.allOf.length > 0) { 211 | const schemas = s.allOf.map(extractSchemaAndReference('allOf', lookup, currentReference)); 212 | 213 | const renderedSchemas = schemas.map(sx => getTypeText(sx.schema, sx.reference, context.clone(findDis(schemas, context)))); 214 | if (renderedSchemas.length === 1) { 215 | compositeTypes.push(renderedSchemas[0]); 216 | } else { 217 | const joined = intersperse(renderedSchemas, ', '); 218 | 219 | compositeTypes.push(allOf [{joined}]); 220 | } 221 | } 222 | 223 | if (s.not !== undefined) { 224 | const lookupResult = lookup.getSchema(s.not); 225 | const inside = getTypeText(lookupResult?.schema, lookupResult?.baseReference || `${currentReference}/not`, context.clone(undefined)); 226 | compositeTypes.push(not ({inside})); 227 | } 228 | 229 | if (compositeTypes.length === 1) { 230 | return compositeTypes[0]; 231 | } else if (compositeTypes.length > 1) { 232 | return {intersperse(compositeTypes, ' AND ')}; 233 | } 234 | } else if (isPrimitiveType(type)) { 235 | if (Array.isArray(type)) { 236 | return {type.join(' ∪ ')} 237 | } 238 | return {type}; 239 | } else if (type === 'array') { 240 | if (s.items === undefined) { 241 | return Array<anything>; 242 | } else if (!Array.isArray(s.items)) { 243 | return Array<{getTypeText(s.items, `${currentReference}/items`, context.clone(undefined))}>; 244 | } else if (s.items.length === 0) { 245 | return Array<anything>; 246 | } else if (s.items.length === 1) { 247 | return Array<{getTypeText(s.items[0], `${currentReference}/items/0`, context.clone(undefined))}>; 248 | } else { 249 | const items = s.items; 250 | const renderedItems = items.map((item, i) => getTypeText(item, `${currentReference}/items/${i}`, context.clone(findDiscriminant(items, lookup)))); 251 | const joined = intersperse(renderedItems, ', '); 252 | 253 | return Array<anyOf [{joined}]>; 254 | } 255 | } else if (type === 'object') { 256 | const name = getObjectName(s, context); 257 | if (isClickable(s)) { 258 | return ; 259 | } else { 260 | return {name}; 261 | } 262 | } else if (Array.isArray(type)) { 263 | if (type.length === 0) { 264 | return ; 265 | } else if (type.length === 1) { 266 | return getTypeText({ ...s, type: type[0] }, currentReference, context.clone(undefined)); 267 | } else { 268 | const splitSchemas = type.map(t => ({...s, type: t})); 269 | 270 | const renderedSchemas = splitSchemas.map(splitSchema => getTypeText(splitSchema, currentReference, context.clone(findDiscriminant(splitSchemas, lookup)))); 271 | const joined = intersperse(renderedSchemas, ', '); 272 | 273 | return anyOf [{joined}]; 274 | } 275 | } else if (s.required !== undefined && onlyKeyPresent(s, 'required')) { 276 | return required: {s.required.join(' ∩ ')} 277 | } 278 | 279 | return ; 280 | }; 281 | 282 | export const Type: React.FunctionComponent = ({ s, lookup, reference, clickElement }) => { 283 | return {getTypeText(s, reference, LookupContext.root(lookup, clickElement))}; 284 | }; -------------------------------------------------------------------------------- /src/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md'; -------------------------------------------------------------------------------- /src/breakpoints.ts: -------------------------------------------------------------------------------- 1 | import { assertExhaustive } from './exhaustiveness-assertion'; 2 | 3 | export type BreakpointSize = 4 | 'phone-only' | 5 | 'tablet-portrait-up' | 6 | 'tablet-landscape-up' | 7 | 'desktop-up' | 8 | 'big-desktop-up'; 9 | 10 | export function forSize(size: BreakpointSize, content: string): string { 11 | switch (size) { 12 | case 'phone-only': 13 | return `@media (max-width: 599px) { ${content} }`; 14 | case 'tablet-portrait-up': 15 | return `@media (min-width: 600px) { ${content} }`; 16 | case 'tablet-landscape-up': 17 | return `@media (min-width: 900px) { ${content} }`; 18 | case 'desktop-up': 19 | return `@media (min-width: 1200px) { ${content} }`; 20 | case 'big-desktop-up': 21 | return `@media (min-width: 1800px) { ${content} }`; 22 | default: 23 | assertExhaustive(size); 24 | return ''; 25 | } 26 | } -------------------------------------------------------------------------------- /src/code-block-with-copy/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { CodeBlock } from '@atlaskit/code'; 4 | import CopyIcon from '@atlaskit/icon/glyph/copy'; 5 | import EditorSuccessIcon from '@atlaskit/icon/glyph/editor/success'; 6 | import CopyToClipboard from 'react-copy-to-clipboard'; 7 | 8 | /** 9 | * Hiding the copy button needs to be done in a Screen Reader Compliant way. We use the approach from this page: 10 | * https://webaim.org/techniques/css/invisiblecontent/ 11 | */ 12 | const Container = styled.div` 13 | position: relative; 14 | 15 | &:not(:hover) .copy { 16 | position:absolute; 17 | left:-10000px; 18 | top:auto; 19 | width:1px; 20 | height:1px; 21 | overflow:hidden; 22 | } 23 | `; 24 | 25 | const CopyStyled = styled.div` 26 | position: absolute; 27 | top: 0px; 28 | right: 0px; 29 | z-index: 10; 30 | background-color: rgb(64, 64, 64); 31 | padding: .2rem .3rem .2rem .2rem; 32 | border-radius: 2px; 33 | 34 | cursor: copy; 35 | color: white; 36 | `; 37 | 38 | const CodeContainer = styled.div` 39 | position: relative; 40 | top: 0px; 41 | left: 0px; 42 | width: 100%; 43 | 44 | z-index: 0; 45 | `; 46 | 47 | const CopyContainer = styled.div` 48 | font-size: 12px; 49 | min-width: 64px; 50 | text-align: center; 51 | 52 | & span:first-child { 53 | margin-right: 2px; 54 | position: relative; 55 | top: 1px; 56 | } 57 | `; 58 | 59 | export type CodeBlockWithCopyState = { 60 | showCopied: boolean; 61 | }; 62 | 63 | type CopyComponentProps = { 64 | text: string; 65 | language?: string; 66 | onCopy: () => void; 67 | }; 68 | 69 | const CopyComponent: React.FC = (props) => { 70 | return ( 71 | 72 | 76 | Copy 77 | 78 | 79 | ); 80 | }; 81 | 82 | const CopySuccessSFC: React.FC = () => ( 83 | 84 | Copied 85 | 86 | ); 87 | 88 | export type CodeBlockWithCopyProps = { 89 | text: string; 90 | language?: string; 91 | }; 92 | 93 | export class CodeBlockWithCopy extends React.PureComponent { 94 | UNSAFE_componentWillMount() { 95 | this.setState({ 96 | showCopied: false 97 | }); 98 | } 99 | 100 | render() { 101 | const { 102 | text, 103 | language, 104 | } = this.props; 105 | 106 | const copyContent = this.getCopyContent(); 107 | 108 | return ( 109 | 110 | {copyContent} 111 | 112 | 113 | 114 | 115 | ); 116 | } 117 | 118 | private getCopyContent(): JSX.Element { 119 | const { showCopied } = this.state; 120 | 121 | if (showCopied) { 122 | return ; 123 | } 124 | 125 | const { text, language } = this.props; 126 | 127 | return ( 128 | this.onCopy()} 130 | text={text} 131 | language={language} 132 | /> 133 | ); 134 | } 135 | 136 | private onCopy() { 137 | // Change the state 138 | this.setState({ 139 | showCopied: true 140 | }); 141 | 142 | // Set the timeout 143 | setTimeout( 144 | () => { 145 | this.setState({ 146 | showCopied: false 147 | }); 148 | }, 149 | 2000 150 | ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/discriminant.ts: -------------------------------------------------------------------------------- 1 | import { isPresent } from "ts-is-present"; 2 | import { Lookup } from "./lookup"; 3 | import { JsonSchema } from "./schema"; 4 | import { isPrimitiveType, jsonTypeToSchemaType } from "./type-inference"; 5 | 6 | function isNotString(v: A | string): v is A { 7 | return typeof v !== 'string'; 8 | } 9 | 10 | /** 11 | * For every schema given, it scans through the object schemas and, if there is a property with 12 | * the same name, with a single enum in each, then we return that property. Otherwise, we return 13 | * undefined. 14 | * @param schemas 15 | * @param lookup 16 | */ 17 | export function findDiscriminant(rawSchemas: Array, lookup: Lookup): string | undefined { 18 | const findResults = rawSchemas.map(rawSchema => findPotentialDiscriminants(rawSchema, lookup)).filter(isNotString); 19 | 20 | if (findResults.length === 0) { 21 | return undefined; 22 | } 23 | 24 | const [firstResult, ...remainders] = findResults; 25 | 26 | return Array.from(firstResult).find(propertyName => remainders.every(remainder => remainder.has(propertyName))); 27 | } 28 | 29 | function findPotentialDiscriminants(rawSchema: JsonSchema, lookup: Lookup): Set | 'not-object' { 30 | const lookupResult = lookup.getSchema(rawSchema); 31 | 32 | if (lookupResult === undefined) { 33 | return 'not-object'; 34 | } 35 | 36 | const { schema } = lookupResult; 37 | 38 | if (typeof schema === 'boolean' || schema.properties === undefined) { 39 | return 'not-object'; 40 | } 41 | 42 | const { properties } = schema; 43 | const resolvedProperties = Object.keys(properties).map(propertyName => { 44 | const lookupResult = lookup.getSchema(properties[propertyName]); 45 | if (lookupResult === undefined) { 46 | return undefined; 47 | } 48 | 49 | return { propertyName, lookupResult }; 50 | }).filter(isPresent); 51 | 52 | return new Set( 53 | resolvedProperties.filter(property => { 54 | const propertySchema = property.lookupResult.schema; 55 | 56 | if (typeof propertySchema !== 'boolean' && propertySchema.enum !== undefined && propertySchema.enum.length === 1) { 57 | const enumValue = propertySchema.enum[0]; 58 | 59 | const jsonType = jsonTypeToSchemaType(enumValue); 60 | if (jsonType !== undefined) { 61 | return isPrimitiveType(jsonType); 62 | } 63 | } 64 | 65 | return false; 66 | }).map(property => property.propertyName) 67 | ); 68 | } -------------------------------------------------------------------------------- /src/docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Welcome to the JSON Schema Viewer, the best way to view JSON Schema! 4 | 5 | ## History 6 | 7 | Atlassian originally wrote a REST API documentation viewer for developer.atlassian.com, 8 | as part of this REST API documentation viewer there was a requirement to show request 9 | and response payloads in an easily understandible manner. 10 | 11 | The developer.atlassian.com team identified that we needed an easier and more reliable 12 | way to view the request and response payloads and built a Schema Explorer to handle that 13 | use case. You can see that schema explorer if you view the REST API documentation in 14 | developer.atlassian.com and view request and response objects: 15 | 16 | * [JIRA REST API](https://developer.atlassian.com/cloud/jira/platform/rest/) 17 | * [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/) 18 | 19 | Due to the similarities between the Schema object in Swagger / OpenAPI and the JSON Schema 20 | format, we realised that we could probably open source the Schema Explorer and make it 21 | available to everybody. It would act as a reusable component that everybody could enjoy. 22 | 23 | Robert Massaioli took this realisation and created JSON Schema Viewer to externalise this 24 | JSON Schema Viewer. Atlassian agreed, as part of its [open source program][3], that this would 25 | be an excellent project to Open Source and [so it was][4] as part of the Labs project. 26 | 27 | I hope that you get great usage from the JSON Schema Viewer and that it helps you consume or 28 | share JSON Schemas. 29 | 30 | ## Original Authors 31 | 32 | The developer that release JSON Schema Viewer to the world was [Robert Massaioli][2], however 33 | the code that made up JSON Schema Viewer was developed by the [developer.atlassian.com][1] team 34 | at Atlassian. 35 | 36 | [1]: https://developer.atlassian.com 37 | [2]: https://keybase.io/robertmassaioli 38 | [3]: https://developer.atlassian.com/platform/open-source/ 39 | [4]: https://github.com/atlassian-labs/json-schema-viewer -------------------------------------------------------------------------------- /src/docs/usage.md: -------------------------------------------------------------------------------- 1 | # Using JSON Schema Viewer 2 | 3 | In order to use the JSON Schema Viewer you must have: 4 | 5 | * **A valid JSON Schema that supports Draft-07 of the JSON Schema format** 6 | Earlier versions of JSON Schema are probably supported, however they are, as of yet, not fully tested. Later 7 | JSON Schema versions are also not tested. 8 | * **The JSON Schema must be available on public the internet** 9 | Or, at the very least, available via a HTTP GET request to all of the people that you want to be able to use 10 | JSON Schema Viewer to see your schema. For example, if you made a JSON Schema available to be downloaded only 11 | by people on your companies internal network, they should still be able to use this tool to view that JSON Schema. 12 | * **The JSON Schema must not have any external references** 13 | If you have a JSON Schema that you provide to this app, you must make sure that it has no external references. 14 | [External references are not supported][4]. If you want to generate a fully resolved JSON Schema then please use a 15 | tool like [![npm][2]][3]. 16 | 17 | Once you have loaded your JSON Schema into the JSON Schema Viewer App (a purely client side experience with no Server backend) 18 | you will then be provided in the UI with a "Permalink" that you can use to share direct links to specific parts of your schema 19 | with other people. 20 | 21 | And that's all that there is to it! If you want to contribute fixes back to the project, or raise issues, the 22 | [source code for this project is in GitHub][1]. 23 | 24 | [1]: https://github.com/atlassian-labs/json-schema-viewer 25 | [2]: https://img.shields.io/npm/v/@apidevtools/json-schema-ref-parser?label=@apidevtools/json-schema-ref-parser&logo=npm 26 | [3]: https://www.npmjs.com/package/@apidevtools/json-schema-ref-parser 27 | [4]: https://github.com/atlassian-labs/json-schema-viewer/issues/7 28 | -------------------------------------------------------------------------------- /src/enum-extraction.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema, JsonSchema1 } from './schema'; 2 | import { getSchemaFromResult, Lookup } from './lookup'; 3 | import { getTypesFromEnum, isPrimitiveType } from './type-inference'; 4 | 5 | function extractEnumDirectly(schema?: JsonSchema): JsonSchema1['enum'] { 6 | if (schema === undefined || typeof schema === 'boolean') { 7 | return undefined; 8 | } 9 | 10 | if (schema.enum !== undefined) { 11 | const enumTypes = getTypesFromEnum(schema.enum); 12 | if (enumTypes !== undefined && isPrimitiveType(enumTypes)) { 13 | return schema.enum; 14 | } 15 | } 16 | 17 | return undefined; 18 | } 19 | 20 | function extractArrayEnum(schema: JsonSchema, lookup: Lookup): JsonSchema1['enum'] { 21 | if (typeof schema !== 'boolean' && schema.type === 'array' && schema.items !== undefined && !Array.isArray(schema.items)) { 22 | return extractEnumDirectly(getSchemaFromResult(lookup.getSchema(schema.items))); 23 | } 24 | return undefined; 25 | } 26 | 27 | function runUntilFirstResult(inputFunctions: ((a: A) => B | undefined)[], value: A): B | undefined { 28 | for (let i = 0; i < inputFunctions.length; i++) { 29 | const potentialResult = inputFunctions[i](value); 30 | if (typeof potentialResult !== 'undefined') { 31 | return potentialResult; 32 | } 33 | } 34 | 35 | return undefined; 36 | } 37 | 38 | export function extractEnum(schema: JsonSchema, lookup: Lookup): JsonSchema1['enum'] { 39 | const extractors: ((s: JsonSchema) => JsonSchema1['enum'])[] = [ 40 | extractEnumDirectly, 41 | s => extractArrayEnum(s, lookup) 42 | ]; 43 | 44 | return runUntilFirstResult(extractors, schema); 45 | } -------------------------------------------------------------------------------- /src/example-schemas.ts: -------------------------------------------------------------------------------- 1 | type ExampleSchemaSection = Record 2 | export const exampleSchemas: Record = { 3 | "Atlassian schema examples": { 4 | "Atlassian Forge": "https://unpkg.com/@forge/manifest@latest/out/schema/manifest-schema.json", 5 | "Atlassian Connect - Confluence": "https://bitbucket.org/atlassian/connect-schemas/raw/master/confluence-global-schema-strict.json", 6 | "Atlassian Connect - Jira": "https://bitbucket.org/atlassian/connect-schemas/raw/master/jira-global-schema-strict.json" 7 | }, 8 | "Schema examples": { 9 | "OpenAPI (v3)": "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/3.0.3/schemas/v3.0/schema.json", 10 | "Swagger (v2)": "https://json.schemastore.org/swagger-2.0", 11 | "NPM (package.json)": "https://json.schemastore.org/package" 12 | }, 13 | "JSON Schema Meta Schemas": { 14 | "Draft-07": "https://json-schema.org/draft-07/schema", 15 | "Draft-04": "https://json-schema.org/draft-04/schema" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/example.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema, JsonSchema1 } from './schema'; 2 | import { getSchemaFromResult, Lookup } from './lookup'; 3 | import { getOrInferType } from './type-inference'; 4 | import { Stage, shouldShowInStage } from './stage'; 5 | 6 | export class Example { 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | private _value: any; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | public static of(value: any) { 12 | return new Example(value); 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | constructor(value: any) { 17 | this._value = value; 18 | } 19 | 20 | get value() { 21 | return this._value; 22 | } 23 | } 24 | 25 | export type ErrorReason 26 | = 'missing-schema' 27 | | 'schema-not-supported' 28 | | 'infinite-prop-loop' 29 | | 'all-of-mismatched-types' 30 | | 'example-of-nothing-is-impossible' 31 | | 'type-array-was-empty' 32 | | 'ran-out-of-memory'; 33 | 34 | export class Error { 35 | private _reason: ErrorReason; 36 | private _message: string; 37 | 38 | constructor(reason: ErrorReason, message: string) { 39 | this._reason = reason; 40 | this._message = message; 41 | } 42 | 43 | get reason() { 44 | return this._reason; 45 | } 46 | 47 | get message() { 48 | return this._message; 49 | } 50 | } 51 | 52 | export class Errors { 53 | private _errors: Error[]; 54 | 55 | public static from(...manyErrors: Errors[]) { 56 | return new Errors( 57 | manyErrors 58 | .map(errs => errs.errors) 59 | .reduce( 60 | (prev, curr) => { 61 | prev.push(...curr); 62 | return prev; 63 | }, 64 | [] 65 | ) 66 | ); 67 | } 68 | 69 | public static of(...errors: Error[]) { 70 | return new Errors(errors); 71 | } 72 | 73 | constructor(errors: Error[]) { 74 | this._errors = errors; 75 | } 76 | 77 | get errors() { 78 | return this._errors; 79 | } 80 | 81 | get length() { 82 | return this._errors.length; 83 | } 84 | } 85 | 86 | // If you find a reference that is a parent of the current chain then you need to return a cascading failure, you can't 87 | // generate an example for this for infinite recursion. If it's not required, just ignore the field. 88 | 89 | class ChainContext { 90 | private _resolvedReferences: Set; 91 | private _lookup: Lookup; 92 | private _depth: number; 93 | private _stage: Stage; 94 | private _parent: JsonSchema | undefined; 95 | 96 | constructor( 97 | resolvedReferences: Set, 98 | lookup: Lookup, 99 | depth: number, 100 | stage: Stage, 101 | parent: JsonSchema | undefined) { 102 | this._resolvedReferences = resolvedReferences; 103 | this._lookup = lookup; 104 | this._depth = depth; 105 | this._stage = stage; 106 | this._parent = parent; 107 | } 108 | 109 | get lookup(): Lookup { 110 | return this._lookup; 111 | } 112 | 113 | get depth(): number { 114 | return this._depth; 115 | } 116 | 117 | get stage(): Stage { 118 | return this._stage; 119 | } 120 | 121 | get parent(): JsonSchema | undefined { 122 | return this._parent; 123 | } 124 | 125 | public registerReference(ref: NonNullable) { 126 | this._resolvedReferences.add(ref); 127 | } 128 | 129 | public seenBefore(ref: NonNullable): boolean { 130 | return this._resolvedReferences.has(ref); 131 | } 132 | 133 | public clone(currentParent: JsonSchema): ChainContext { 134 | return new ChainContext(new Set(this._resolvedReferences), this._lookup, this._depth + 1, this._stage, currentParent); 135 | } 136 | } 137 | 138 | type NameAndExample = { 139 | name: string; 140 | example: Example | Errors; 141 | }; 142 | 143 | function missingSchema(schemaOrRef: JsonSchema): Error { 144 | return new Error('missing-schema', `Could not find a schema for: ${JSON.stringify(schemaOrRef)}`); 145 | } 146 | 147 | function notSupported(message: string): Error { 148 | return new Error('schema-not-supported', message); 149 | } 150 | 151 | function infinitePropLoopForObject(propName: string, ref: NonNullable, schema: JsonSchema1): Error { 152 | return new Error( 153 | 'infinite-prop-loop', 154 | `The reference to '${ref}' in the property '${propName}' in the schema '${schema.title || 'object'}' 155 | causes an infinite loop.` 156 | ); 157 | } 158 | 159 | function allOfMismatchedTypes(allTypes: (string | null)[]): Error { 160 | return new Error( 161 | 'all-of-mismatched-types', 162 | `There was an allOf that evaluated to examples of mismatched types: ${JSON.stringify(allTypes)}` 163 | ); 164 | } 165 | 166 | function nothing(parentSchema: JsonSchema | undefined): Error { 167 | const renderedParent = parentSchema === undefined ? 'root' : JSON.stringify(parentSchema); 168 | return new Error('example-of-nothing-is-impossible', `Can't generate an example of the 'nothing' type for a child of: ${renderedParent}`); 169 | } 170 | 171 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 172 | export function isExample(t: any): t is Example { 173 | return t instanceof Example; 174 | } 175 | 176 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 177 | export function isErrors(t: any): t is Errors { 178 | return t instanceof Errors; 179 | } 180 | 181 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 182 | function isError(t: any): t is Error { 183 | return t instanceof Error; 184 | } 185 | 186 | function isSchema(t: JsonSchema | Error): t is JsonSchema { 187 | return !isError(t); 188 | } 189 | 190 | type IgnoredProperty = { 191 | name: string; 192 | }; 193 | 194 | function isNameAndExample(t: NameAndExample | IgnoredProperty): t is NameAndExample { 195 | return 'example' in t; 196 | } 197 | 198 | function inferExample(schema: JsonSchema1, typeMatcher: (x: any) => boolean, defaultExample: () => Example): Example { 199 | const match = (schema.examples || []).find(typeMatcher); 200 | if (match !== undefined) { 201 | return Example.of(match); 202 | } 203 | 204 | if (schema.enum !== undefined && schema.enum.length > 0 && typeMatcher(schema.enum[0])) { 205 | return Example.of(schema.enum[0]); 206 | } 207 | return defaultExample(); 208 | } 209 | 210 | function getSchemaNameForError(schemaOrRef: JsonSchema): string { 211 | if (typeof schemaOrRef === 'boolean') { 212 | return '' 213 | } 214 | 215 | if (schemaOrRef.$ref !== undefined) { 216 | return schemaOrRef.$ref; 217 | } 218 | 219 | return schemaOrRef.title === undefined ? 'object' : schemaOrRef.title; 220 | } 221 | 222 | function generateJsonExampleForHelper(context: ChainContext, schemaOrRef: JsonSchema) 223 | : Example | Errors { 224 | const { lookup } = context; 225 | const schema = getSchemaFromResult(lookup.getSchema(schemaOrRef)); 226 | if (schema === undefined) { 227 | return Errors.of(missingSchema(schemaOrRef)); 228 | } 229 | 230 | if (typeof schemaOrRef !== 'boolean' && schemaOrRef.$ref !== undefined) { 231 | context.registerReference(schemaOrRef.$ref); 232 | } 233 | 234 | if (typeof schema === 'boolean') { 235 | if (schema) { 236 | // We have no examples, so let's just return an empty object 237 | return Example.of({}); 238 | } else { 239 | return Errors.of(nothing(context.parent)); 240 | } 241 | } 242 | 243 | if (Object.keys(schema).length === 0) { 244 | // You accept anything in this slot, so let's just return an empty object. 245 | return Example.of({}); 246 | } 247 | 248 | let type = getOrInferType(schema); 249 | 250 | if (Array.isArray(type)) { 251 | if (type.length >= 1) { 252 | type = type[0]; 253 | } else { 254 | return Errors.of(new Error('type-array-was-empty', `The type was an empty array for: ${JSON.stringify(schemaOrRef)}`)); 255 | } 256 | } 257 | 258 | if (type !== undefined) { 259 | if (type === 'boolean') { 260 | return inferExample( 261 | schema, 262 | x => typeof x === 'boolean', 263 | () => Example.of(true) 264 | ); 265 | } else if (type === 'integer' || type === 'number') { 266 | return inferExample( 267 | schema, 268 | x => typeof x === 'number' || typeof x === 'bigint', 269 | () => Example.of(schema.description ? schema.description.length : 2154) 270 | ); 271 | } else if (type === 'string') { 272 | return inferExample( 273 | schema, 274 | x => typeof x === 'string', 275 | () => Example.of('') 276 | ); 277 | } else if (type === 'array') { 278 | const match = (schema.examples ||[]).find(Array.isArray); 279 | if (match !== undefined) { 280 | return Example.of(match); 281 | } 282 | 283 | if (schema.items === undefined) { 284 | return Example.of([]); 285 | } 286 | 287 | const chosenItem = Array.isArray(schema.items) ? schema.items[0] : schema.items; 288 | const itemSchema = schema.items === undefined ? undefined : getSchemaFromResult(lookup.getSchema(chosenItem)); 289 | 290 | if (itemSchema === undefined) { 291 | return Example.of([]); 292 | } else { 293 | // Setup the next context 294 | let nextContext = context; 295 | if (typeof chosenItem !== 'boolean' && chosenItem.$ref !== undefined) { 296 | if (context.seenBefore(chosenItem.$ref)) { 297 | // If it's an infinite loop then just return no elements. Magic! 298 | return Example.of([]); 299 | } 300 | nextContext = context.clone(chosenItem); 301 | nextContext.registerReference(chosenItem.$ref); 302 | } 303 | 304 | const itemExample = generateJsonExampleForHelper(nextContext, itemSchema); 305 | 306 | if (isErrors(itemExample)) { 307 | return Example.of([]); 308 | } 309 | 310 | const itemsToRender = schema.uniqueItems ? 1 : (schema.minItems || 1); 311 | return Example.of(Array(itemsToRender).fill(itemExample.value)); 312 | } 313 | } else { 314 | const match = (schema.examples ||[]).find(x => typeof x === 'object'); 315 | if (match !== undefined) { 316 | return Example.of(match); 317 | } 318 | 319 | const { properties, required } = schema; 320 | const requiredPropNames = new Set(required || []); 321 | if (properties === undefined) { 322 | // Return an empty object because no properties are allowed 323 | return Example.of({}); 324 | } else { 325 | const props = Object.keys(properties) 326 | .filter(name => { 327 | const propSchema = getSchemaFromResult(context.lookup.getSchema(properties[name])); 328 | if (propSchema === undefined) { 329 | return true; 330 | } 331 | return shouldShowInStage(context.stage, propSchema); 332 | }) 333 | .map(name => { 334 | const propOrRef = properties[name]; 335 | 336 | if (context.depth >= 1 && !requiredPropNames.has(name)) { 337 | return { name }; 338 | } 339 | 340 | if (typeof propOrRef === 'boolean') { 341 | if (propOrRef) { 342 | // We have no examples, so let's just return an empty object 343 | return { name, example: Example.of({}) }; 344 | } else { 345 | return { name, example: Errors.of(nothing(schema)) }; 346 | } 347 | } 348 | 349 | // Setup the next context 350 | let nextContext = context; 351 | if (propOrRef.$ref !== undefined) { 352 | if (context.seenBefore(propOrRef.$ref)) { 353 | return requiredPropNames.has(name) 354 | ? { name, example: Errors.of(infinitePropLoopForObject(name, propOrRef.$ref, schema)) } 355 | : { name }; 356 | } 357 | nextContext = context.clone(propOrRef); 358 | nextContext.registerReference(propOrRef.$ref); 359 | } 360 | 361 | const prop = getSchemaFromResult(lookup.getSchema(propOrRef)); 362 | if (prop === undefined) { 363 | return { name, example: Errors.of(missingSchema(propOrRef)) }; 364 | } 365 | 366 | const generatedExample = generateJsonExampleForHelper(nextContext, prop); 367 | if (isErrors(generatedExample) && !requiredPropNames.has(name)) { 368 | return { name }; 369 | } 370 | 371 | return { 372 | name, 373 | example: generatedExample 374 | }; 375 | }); 376 | 377 | const nonIgnoredProps = props.filter(isNameAndExample); 378 | 379 | // If there were errors then just return the errors 380 | const e = Errors.from(...nonIgnoredProps.map(p => p.example).filter(isErrors)); 381 | if (e.length > 0) { 382 | return e; 383 | } 384 | 385 | // Otherwise, just make the example 386 | let example: Record = {}; 387 | nonIgnoredProps.forEach(prop => { 388 | if (isExample(prop.example)) { 389 | example[prop.name] = prop.example.value; 390 | } 391 | }); 392 | return Example.of(example); 393 | } 394 | } 395 | } else { 396 | if (schema.anyOf !== undefined && schema.anyOf.length > 0) { 397 | return generateJsonExampleForHelper(context, schema.anyOf[0]); 398 | } else if (schema.oneOf !== undefined && schema.oneOf.length > 0) { 399 | return generateJsonExampleForHelper(context, schema.oneOf[0]); 400 | } else if (schema.allOf !== undefined && schema.allOf.length > 0) { 401 | let nextContext = context.clone(schema); 402 | const potentialSchemas = schema.allOf.map(s => { 403 | const ps = getSchemaFromResult(lookup.getSchema(s)); 404 | if (typeof s !== 'boolean' && s.$ref !== undefined) { 405 | nextContext.registerReference(s.$ref); 406 | } 407 | return ps === undefined ? missingSchema(s) : ps; 408 | }); 409 | 410 | let errors = potentialSchemas.filter(isError); 411 | if (errors.length > 0) { 412 | return new Errors(errors); 413 | } 414 | 415 | const exs = potentialSchemas.filter(isSchema).map(s => generateJsonExampleForHelper(nextContext, s)); 416 | 417 | const errs = exs.filter(isErrors); 418 | if (errs.length > 0) { 419 | return Errors.from(...errs); 420 | } 421 | 422 | const examples = exs.filter(isExample).map(e => e.value); 423 | const allExampleTypes = examples.map(e => typeof e); 424 | const matchedType = allExampleTypes.reduce((a, b) => a === b ? a : null); 425 | if (matchedType === null) { 426 | return Errors.of(allOfMismatchedTypes(allExampleTypes)); 427 | } 428 | 429 | if (matchedType === 'object') { 430 | const example = Object.assign({}, ...examples); 431 | return Example.of(example); 432 | } else if (matchedType === 'string' || matchedType === 'number' || matchedType === 'boolean') { 433 | return Example.of(examples[0]); 434 | } 435 | } 436 | 437 | const schemaName = getSchemaNameForError(schemaOrRef); 438 | 439 | return Errors.of( 440 | notSupported(`Support schemas without a "type" has not been written yet. Source: ${schemaName}. Parent ${JSON.stringify(context.parent)}`) 441 | ); 442 | } 443 | } 444 | 445 | export function generateJsonExampleFor( 446 | schemaOrRef: JsonSchema, 447 | lookup: Lookup, 448 | stage: Stage): Example | Errors { 449 | try { 450 | return generateJsonExampleForHelper(new ChainContext(new Set(), lookup, 0, stage, undefined), schemaOrRef); 451 | } catch(e) { 452 | return Errors.of( 453 | new Error('ran-out-of-memory', `Ran out of memory: ${e}`) 454 | ); 455 | } 456 | } -------------------------------------------------------------------------------- /src/exhaustiveness-assertion.ts: -------------------------------------------------------------------------------- 1 | // By passing the expression being evaluated into this function 2 | // for the default case, we we can assert at compile time that the 3 | // check is exhaustive, because if we missed a case, the type of the 4 | // expression will be something other than `never`. 5 | // https://stackoverflow.com/a/39419171 6 | 7 | export function assertExhaustive(_: never): never { 8 | return _; 9 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { App } from './BrowserApp'; 4 | import './style.css'; 5 | 6 | /* global __webpack_nonce__ */ // eslint-disable-line no-unused-vars 7 | 8 | // CSP: Set a special variable to add `nonce` attributes to all styles/script tags 9 | // See https://github.com/webpack/webpack/pull/3210 10 | // @ts-expect-error 11 | __webpack_nonce__ = (window as any).NONCE_ID; // eslint-disable-line no-global-assign, camelcase 12 | 13 | ReactDOM.render(React.createElement(App), document.getElementById('root')); 14 | -------------------------------------------------------------------------------- /src/jsx-util.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function intersperse(arr: T[], sep: string): (JSX.Element | string)[] { 4 | let results: Array = new Array(); 5 | let first = true; 6 | let keyGen = 0; 7 | arr.forEach(a => { 8 | if (!first) { 9 | results.push({sep}); 10 | keyGen++; 11 | } else { 12 | first = false; 13 | } 14 | results.push({a}); 15 | keyGen++; 16 | }); 17 | return results; 18 | } -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 38 | 42 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/lookup/index.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema, JsonSchema1 } from '../schema'; 2 | import { get as pointerGet } from 'jsonpointer'; 3 | 4 | export function getSchemaFromReference(reference: string, lookup: Lookup): JsonSchema | undefined { 5 | return getSchemaFromResult(loadReference(reference, lookup)); 6 | } 7 | 8 | export function loadReference(reference: string, lookup: Lookup): LookupResult { 9 | return lookup.getSchema({ $ref: reference }); 10 | } 11 | 12 | export function getSchemaFromResult(result: LookupResult): JsonSchema | undefined { 13 | return result === undefined ? undefined : result.schema; 14 | } 15 | 16 | export type LookupResult = undefined | { 17 | schema: JsonSchema; 18 | baseReference?: string; 19 | } 20 | 21 | export interface Lookup { 22 | getSchema: (s: JsonSchema) => LookupResult; 23 | } 24 | 25 | function isReference(s: JsonSchema1): boolean { 26 | return s.$ref !== undefined; 27 | } 28 | 29 | export class IdLookup implements Lookup { 30 | public getSchema(schema: JsonSchema): LookupResult { 31 | if (typeof schema === 'boolean') { 32 | return { schema }; 33 | } 34 | 35 | if (isReference(schema)) { 36 | return undefined; 37 | } 38 | 39 | return { schema }; 40 | } 41 | } 42 | 43 | export class InternalLookup implements Lookup { 44 | private schema: JsonSchema; 45 | 46 | constructor(schema: JsonSchema) { 47 | this.schema = schema; 48 | } 49 | 50 | public getSchema(schema: JsonSchema): LookupResult { 51 | if (typeof schema === 'boolean') { 52 | return { schema }; 53 | } 54 | 55 | if (schema.$ref === undefined) { 56 | return { schema }; 57 | } 58 | 59 | const ref = schema.$ref; 60 | if (!ref.startsWith('#')) { 61 | // This class does not support non-internal references 62 | return undefined; 63 | } 64 | 65 | const result = pointerGet(this.schema, ref.slice(1)); 66 | 67 | if (result === undefined) { 68 | return undefined; 69 | } 70 | 71 | // TODO add in a type check on the result to ensure that it is of the right type 72 | 73 | const subResult = this.getSchema(result); 74 | 75 | if (subResult === undefined) { 76 | return undefined; 77 | } 78 | 79 | return { 80 | schema: subResult.schema, 81 | baseReference: subResult.baseReference || ref 82 | }; 83 | } 84 | } -------------------------------------------------------------------------------- /src/markdown/custom-renderers/Code.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Code } from '@atlaskit/code'; 4 | import { gridSize } from '@atlaskit/theme'; 5 | import { CodeBlockWithCopy } from '../../code-block-with-copy'; 6 | import type { CodeComponent } from 'react-markdown/lib/ast-to-react'; 7 | 8 | const CodeBlockWrapper = styled.div` 9 | margin: ${gridSize() * 2}px 0; 10 | padding: 0; 11 | max-height: 500px; 12 | overflow: auto; 13 | `; 14 | 15 | const detectLanguage = (code?: string) => { 16 | try { 17 | JSON.parse(code || ''); 18 | return 'json'; 19 | } catch (e) { 20 | return 'text'; 21 | } 22 | }; 23 | 24 | function getCodeAndLanguage( 25 | children: ReactNode, 26 | className?: string 27 | ): { 28 | code: string; 29 | language: string; 30 | } { 31 | const code = Array.isArray(children) ? children[0] : children; 32 | return { 33 | code: code || '', 34 | language: className ? className.replace('language-', '') : detectLanguage(code), 35 | }; 36 | } 37 | 38 | export const BlockCodeRenderer: CodeComponent = ({ children, className }) => { 39 | const { code, language } = getCodeAndLanguage(children, className); 40 | return ( 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | const BreakWord = styled.span` 48 | overflow-wrap: break-word; 49 | word-wrap: break-word; 50 | `; 51 | 52 | export const InlineCodeRenderer: CodeComponent = ({ children, className }) => { 53 | const { code, language } = getCodeAndLanguage(children, className); 54 | return ( 55 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/markdown/custom-renderers/Link.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import OpenIcon from '@atlaskit/icon/glyph/open'; 3 | import { Components } from "react-markdown"; 4 | 5 | export const isExternalLink = (href: string): boolean => { 6 | return ![ 7 | () => href.startsWith('/'), 8 | () => href.startsWith('.'), 9 | () => href.startsWith('#'), 10 | ].some(match => match()); 11 | }; 12 | 13 | export const LinkRenderer: Components['a'] = ({ href, children }) => { 14 | const isExternal = href ? isExternalLink(href) : false; 15 | 16 | return ( 17 | 22 | {children} 23 | {isExternal && } 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/markdown/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactMarkdown, { Components } from 'react-markdown'; 3 | import styled from 'styled-components'; 4 | import { colors, gridSize } from '@atlaskit/theme'; 5 | import { BlockCodeRenderer, InlineCodeRenderer } from './custom-renderers/Code'; 6 | import { LinkRenderer } from './custom-renderers/Link'; 7 | import rehypeSanitize from 'rehype-sanitize'; 8 | import rehypeRaw from 'rehype-raw'; 9 | 10 | export type MarkdownProps = { 11 | source: string; 12 | }; 13 | 14 | export const blockQuoteStyles = ` 15 | padding: ${gridSize()}px ${gridSize() * 2}px 0 ${gridSize() * 2}px; 16 | border-left: 1px solid ${colors.N50}; 17 | margin: 0 0 ${gridSize() * 2}px; 18 | color: ${colors.N80}; 19 | &:after, &:before { 20 | content: ''; 21 | } 22 | `; 23 | 24 | const StyledBlockquote = styled.blockquote` 25 | ${blockQuoteStyles} 26 | `; 27 | 28 | const BlockQuoteRenderer: Components['blockquote'] = (props) => ( 29 | {props.children} 30 | ); 31 | 32 | const StyledHorizontalRule = styled.hr` 33 | border: 0; 34 | border-bottom: 1px solid ${colors.N40}; 35 | height: 1px; 36 | margin: ${gridSize() * 2}px 0; 37 | `; 38 | 39 | const HorizontalRuleRenderer: React.ElementType<{}> = () => ; 40 | 41 | function doesNotRequireFullBlownRenderer(input: string): boolean { 42 | return input.match(/^[a-zA-Z\d\s\.,\'\"\/]*$/g) !== null; 43 | } 44 | 45 | export const Markdown: React.FC = (props: MarkdownProps) => { 46 | const { 47 | source, 48 | } = props; 49 | 50 | if (doesNotRequireFullBlownRenderer(source)) { 51 | return

{source}

; 52 | } 53 | 54 | return ( 55 | 60 | props.inline ? : , 61 | a: LinkRenderer, 62 | blockquote: BlockQuoteRenderer, 63 | hr: HorizontalRuleRenderer, 64 | }} 65 | /> 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/monaco-helpers.ts: -------------------------------------------------------------------------------- 1 | /* We need to import directly from these files to bypass Webpack parse issues */ 2 | import { editor } from 'monaco-editor/esm/vs/editor/editor.api'; 3 | export { MarkerSeverity } from 'monaco-editor/esm/vs/editor/editor.api'; 4 | 5 | export const ScrollType = editor.ScrollType; 6 | -------------------------------------------------------------------------------- /src/recently-viewed.ts: -------------------------------------------------------------------------------- 1 | import { isPresent } from "ts-is-present"; 2 | 3 | export type RecentlyViewedLink = { 4 | title: string; 5 | url: string; 6 | }; 7 | 8 | type StorageRoot = { 9 | links: Array; 10 | }; 11 | 12 | const LOCAL_STORAGE_KEY = 'recently-viewed.v1'; 13 | const MAXIMUM_RECENT = 10; 14 | 15 | function loadStorageRoot(): StorageRoot | undefined { 16 | const { localStorage } = window; 17 | 18 | const recentlyViewed = localStorage?.getItem(LOCAL_STORAGE_KEY); 19 | if (!isPresent(recentlyViewed)) { 20 | return undefined; 21 | } 22 | 23 | try { 24 | const root: StorageRoot = JSON.parse(recentlyViewed); 25 | 26 | return root; 27 | } catch (e) { 28 | return undefined; 29 | } 30 | } 31 | 32 | function saveStorageRoot(root: StorageRoot): void { 33 | window.localStorage?.setItem(LOCAL_STORAGE_KEY, JSON.stringify(root, null, 2)); 34 | } 35 | 36 | export function getRecentlyViewedLinks(): Array | undefined { 37 | return loadStorageRoot()?.links; 38 | } 39 | 40 | export function addRecentlyViewedLink(link: RecentlyViewedLink): void { 41 | let root = loadStorageRoot(); 42 | 43 | if (!isPresent(root)) { 44 | root = { 45 | links: [link] 46 | }; 47 | } else { 48 | const previousLinks = root.links; 49 | const linksWithoutCurrent = previousLinks.filter(prevLink => link.url !== prevLink.url); 50 | root = { 51 | links: [link, ...linksWithoutCurrent].slice(0, MAXIMUM_RECENT) 52 | }; 53 | } 54 | 55 | saveStorageRoot(root); 56 | } -------------------------------------------------------------------------------- /src/route-path.ts: -------------------------------------------------------------------------------- 1 | export type PathElement = { 2 | title: string; 3 | reference: string; 4 | }; 5 | 6 | export function linkTo(basePathSegments: Array, references: Array): string { 7 | const firstSection = basePathSegments.length === 0 ? '/' : '/' + basePathSegments.join('/') + '/'; 8 | return firstSection + references.map(ref => encodeURIComponent(ref)).join('/'); 9 | } 10 | 11 | function linkToComponentInUrl(basePathSegments: Array, component: string, url: string): string { 12 | return `${linkTo(basePathSegments, [component])}?url=${encodeURIComponent(url)}`; 13 | } 14 | 15 | export function linkToRoot(basePathSegments: Array, url: string): string { 16 | return linkToComponentInUrl(basePathSegments, '#', url); 17 | } 18 | 19 | export function externalLinkTo(basePathSegments: Array, externalRef: string): string | null { 20 | try { 21 | const parsedUrl = new URL(externalRef); 22 | 23 | if (parsedUrl.protocol === 'http:') { 24 | // In production, since we host on https, without this you would get mixed content errors when attempting to 25 | // fetch. This may be surprising behaviour. 26 | parsedUrl.protocol = 'https:'; 27 | } 28 | 29 | const pathSegment = parsedUrl.hash.startsWith('#') ? parsedUrl.hash : '#'; 30 | parsedUrl.hash = ''; 31 | const url = parsedUrl.toString(); 32 | return linkToComponentInUrl(basePathSegments, pathSegment, url); 33 | } catch(e) { 34 | return null; 35 | } 36 | } -------------------------------------------------------------------------------- /src/search-preserving-link.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, LinkProps, NavLink, NavLinkProps } from 'react-router-dom'; 3 | import H from 'history'; 4 | 5 | export type LinkPreservingSearchProps = Omit, 'to'> & { 6 | to: string; 7 | }; 8 | 9 | export const LinkPreservingSearch: React.FC = props => { 10 | const { to, ...remainder } = props; 11 | return ( 12 | ({ ...currentLocation, pathname: to })} /> 13 | ); 14 | }; 15 | 16 | export type NavLinkPreservingSearchProps = Omit, 'to'> & { 17 | to: string; 18 | }; 19 | 20 | export const NavLinkPreservingSearch: React.FC = props => { 21 | const { to, ...remainder } = props; 22 | return ( 23 | ({ ...currentLocation, pathname: props.to })} /> 24 | ); 25 | }; -------------------------------------------------------------------------------- /src/side-nav-loader.ts: -------------------------------------------------------------------------------- 1 | import { Lookup } from "./lookup"; 2 | import { JsonSchema } from "./schema"; 3 | import { GroupSideNavLink, SideNavLink, SingleSideNavLink, Spacer } from "./SideNavWithRouter"; 4 | import { getTitle } from "./title"; 5 | 6 | export function extractLinks(schema: JsonSchema, lookup: Lookup): Array { 7 | const links = new Array(); 8 | 9 | if (typeof schema === 'boolean') { 10 | return [{ 11 | title: 'Root', 12 | reference: '#' 13 | }]; 14 | } 15 | 16 | links.push({ 17 | title: getTitle('#', schema), 18 | reference: '#' 19 | }); 20 | links.push(Spacer); 21 | 22 | if (schema.definitions !== undefined) { 23 | const children = new Array(); 24 | for (const key in schema.definitions) { 25 | if (Object.prototype.hasOwnProperty.call(schema.definitions, key)) { 26 | const definition = schema.definitions[key]; 27 | const reference = `#/definitions/${key}`; 28 | 29 | children.push({ 30 | title: typeof definition === 'boolean' ? key : getTitle(reference, definition), 31 | reference 32 | }); 33 | } 34 | } 35 | 36 | const topDefinitionsGroup: GroupSideNavLink = { 37 | title: 'Root definitions', 38 | reference: undefined, 39 | children 40 | }; 41 | 42 | links.push(topDefinitionsGroup); 43 | } 44 | 45 | return links; 46 | } -------------------------------------------------------------------------------- /src/stage.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema } from './schema'; 2 | 3 | export type Stage = 'read' | 'write' | 'both'; 4 | 5 | export function shouldShowInStage(stage: Stage, schema: JsonSchema): boolean { 6 | if (typeof schema === 'boolean') { 7 | return true; 8 | } 9 | 10 | if (stage === 'both') { 11 | return true; 12 | } 13 | 14 | const readOnly = !!schema.readOnly; 15 | const writeOnly = !!schema.writeOnly; 16 | 17 | if (readOnly === writeOnly) { 18 | return true; 19 | } 20 | 21 | if (stage === 'read' && readOnly) { 22 | return true; 23 | } 24 | 25 | if (stage === 'write' && writeOnly) { 26 | return true; 27 | } 28 | 29 | return false; 30 | } -------------------------------------------------------------------------------- /src/stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './button.css'; 3 | 4 | export interface ButtonProps { 5 | /** 6 | * Is this the principal call to action on the page? 7 | */ 8 | primary?: boolean; 9 | /** 10 | * What background color to use 11 | */ 12 | backgroundColor?: string; 13 | /** 14 | * How large should the button be? 15 | */ 16 | size?: 'small' | 'medium' | 'large'; 17 | /** 18 | * Button contents 19 | */ 20 | label: string; 21 | /** 22 | * Optional click handler 23 | */ 24 | onClick?: () => void; 25 | } 26 | 27 | /** 28 | * Primary UI component for user interaction 29 | */ 30 | export const Button: React.FC = ({ 31 | primary = false, 32 | size = 'medium', 33 | backgroundColor, 34 | label, 35 | ...props 36 | }) => { 37 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; 38 | return ( 39 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/stories/DebuggingMemoryRouter.ts: -------------------------------------------------------------------------------- 1 | import { MemoryRouter, MemoryRouterProps } from 'react-router'; 2 | 3 | export class DebuggingMemoryRouter extends MemoryRouter { 4 | constructor(props: MemoryRouterProps) { 5 | super(props); 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | (this as any).history.listen((location: any, action: any) => { // tslint:disable-line:no-any 8 | console.log( 9 | `The current URL is ${location.pathname}${location.search}${location.hash}` 10 | ); 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | console.log(`The last navigation action was ${action}`, JSON.stringify((this as any).history, null, 2)); 13 | }); 14 | } 15 | } -------------------------------------------------------------------------------- /src/stories/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Button } from './Button'; 4 | import './header.css'; 5 | 6 | export interface HeaderProps { 7 | user?: {}; 8 | onLogin: () => void; 9 | onLogout: () => void; 10 | onCreateAccount: () => void; 11 | } 12 | 13 | export const Header: React.FC = ({ user, onLogin, onLogout, onCreateAccount }) => ( 14 |
15 |
16 |
17 | 18 | 19 | 23 | 27 | 31 | 32 | 33 |

Acme

34 |
35 |
36 | {user ? ( 37 |
45 |
46 |
47 | ); 48 | -------------------------------------------------------------------------------- /src/stories/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Header } from './Header'; 4 | import './page.css'; 5 | 6 | export interface PageProps { 7 | user?: {}; 8 | onLogin: () => void; 9 | onLogout: () => void; 10 | onCreateAccount: () => void; 11 | } 12 | 13 | export const Page: React.FC = ({ user, onLogin, onLogout, onCreateAccount }) => ( 14 |
15 |
16 | 17 |
18 |

Pages in Storybook

19 |

20 | We recommend building UIs with a{' '} 21 | 22 | component-driven 23 | {' '} 24 | process starting with atomic components and ending with pages. 25 |

26 |

27 | Render pages with mock data. This makes it easy to build and review page states without 28 | needing to navigate to them in your app. Here are some handy patterns for managing page data 29 | in Storybook: 30 |

31 |
    32 |
  • 33 | Use a higher-level connected component. Storybook helps you compose such data from the 34 | "args" of child component stories 35 |
  • 36 |
  • 37 | Assemble data in the page component from your services. You can mock these services out 38 | using Storybook. 39 |
  • 40 |
41 |

42 | Get a guided tutorial on component-driven development at{' '} 43 | 44 | Learn Storybook 45 | 46 | . Read more in the{' '} 47 | 48 | docs 49 | 50 | . 51 |

52 |
53 | Tip Adjust the width of the canvas with the{' '} 54 | 55 | 56 | 61 | 62 | 63 | Viewports addon in the toolbar 64 |
65 |
66 |
67 | ); 68 | -------------------------------------------------------------------------------- /src/stories/ParameterMetadata.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | 4 | import { IdLookup } from '../lookup'; 5 | import { ParameterMetadata, ParameterMetadataProps } from '../ParameterMetadata'; 6 | 7 | export default { 8 | title: 'JsonSchema/ParameterMetadata', 9 | component: ParameterMetadata, 10 | argTypes: { 11 | }, 12 | } as Meta; 13 | 14 | const defaultProps = { 15 | lookup: new IdLookup() 16 | }; 17 | 18 | const Template: Story = (args) => ; 19 | 20 | export const ItemRestrictors = Template.bind({}); 21 | ItemRestrictors.args = { 22 | ...defaultProps, 23 | schema: { 24 | minItems: 1, 25 | maxItems: 10, 26 | uniqueItems: true 27 | } 28 | }; 29 | 30 | export const NoMaxItem = Template.bind({}); 31 | NoMaxItem.args = { 32 | ...defaultProps, 33 | schema: { 34 | minItems: 1, 35 | uniqueItems: true 36 | } 37 | }; 38 | 39 | export const NoMinItem = Template.bind({}); 40 | NoMinItem.args = { 41 | ...defaultProps, 42 | schema: { 43 | maxItems: 10, 44 | uniqueItems: true 45 | } 46 | }; 47 | 48 | export const NumericalRestrictors = Template.bind({}); 49 | NumericalRestrictors.args = { 50 | ...defaultProps, 51 | schema: { 52 | multipleOf: 5, 53 | minimum: 10, 54 | maximum: 10000, 55 | exclusiveMinimum: false, 56 | exclusiveMaximum: true 57 | } 58 | }; 59 | 60 | export const NumericalRestrictorsWithOverride = Template.bind({}); 61 | NumericalRestrictorsWithOverride.args = { 62 | ...defaultProps, 63 | schema: { 64 | minimum: 10, 65 | exclusiveMinimum: 10, 66 | } 67 | }; 68 | 69 | export const StringRestrictors = Template.bind({}); 70 | StringRestrictors.args = { 71 | ...defaultProps, 72 | schema: { 73 | minLength: 5, 74 | maxLength: 60, 75 | pattern: '^https://.*$', 76 | format: 'uri' 77 | } 78 | }; 79 | 80 | export const ObjectRestrictors = Template.bind({}); 81 | ObjectRestrictors.args = { 82 | ...defaultProps, 83 | schema: { 84 | minProperties: 5, 85 | maxProperties: 60 86 | } 87 | }; 88 | 89 | export const EnumWithoutExpand = Template.bind({}); 90 | EnumWithoutExpand.args = { 91 | ...defaultProps, 92 | schema: { 93 | type: 'string', 94 | enum: ['one', 'two', 'four', 'three', 'hello', 'there'] 95 | } 96 | }; 97 | 98 | export const HideComplexEnumValues = Template.bind({}); 99 | HideComplexEnumValues.args = { 100 | ...defaultProps, 101 | schema: { 102 | type: 'object', 103 | enum: [{ hello: 'world' }, { hello: 'there' }, { howdy: 'partner' }] 104 | } 105 | }; 106 | 107 | function seq(len: number): [number, ...number[]] { 108 | const result: [number, ...number[]] = [0]; 109 | for (let i = 1; i < len; i++) { 110 | result.push(i); 111 | } 112 | return result; 113 | }; 114 | 115 | export const EnumWithExpand = Template.bind({}); 116 | EnumWithExpand.args = { 117 | ...defaultProps, 118 | schema: { 119 | type: 'number', 120 | enum: seq(100) 121 | } 122 | }; 123 | 124 | export const ShowEnumFromArrayItems = Template.bind({}); 125 | ShowEnumFromArrayItems.args = { 126 | ...defaultProps, 127 | schema: { 128 | type: 'array', 129 | items: { 130 | type: 'string', 131 | enum: ['one', 'two', 'four', 'three', 'hello', 'there'] 132 | } 133 | } 134 | }; 135 | -------------------------------------------------------------------------------- /src/stories/SchemaApp.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { DebuggingMemoryRouter } from './DebuggingMemoryRouter'; 4 | import { SchemaApp } from '../SchemaApp'; 5 | 6 | export default { 7 | title: 'JsonSchema/SchemaApp', 8 | component: SchemaApp, 9 | argTypes: { 10 | }, 11 | } as Meta; 12 | 13 | const Template: Story<{}> = () => ( 14 | 15 | 16 | 17 | ); 18 | 19 | export const RootPage = Template.bind({}); 20 | RootPage.storyName = 'Default view'; 21 | RootPage.args = {}; 22 | -------------------------------------------------------------------------------- /src/stories/SchemaExplorer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // also exported from '@storybook/react' if you can deal with breaking changes in 6.1 3 | import { Story, Meta } from '@storybook/react'; 4 | import { action } from '@storybook/addon-actions'; 5 | import { JsonSchema, JsonSchema1 } from '../schema'; 6 | import { SchemaExplorer, SchemaExplorerProps } from '../SchemaExplorer'; 7 | import { SchemaReturnerLookup } from './schema-returner-lookup'; 8 | import { IdLookup, InternalLookup } from '../lookup'; 9 | import { Schema as PackageJson } from './package.json'; 10 | import { DebuggingMemoryRouter } from './DebuggingMemoryRouter'; 11 | 12 | const schemaForContext: JsonSchema = { 13 | type: 'object', 14 | properties: { 15 | 'responseOnly': { 16 | description: 'This should only show up if readOnly is true', 17 | type: 'string', 18 | readOnly: true 19 | }, 20 | 'both': { 21 | description: 'This should show up in both examples', 22 | type: 'string' 23 | }, 24 | 'requestOnly': { 25 | description: 'This should only show up if writeOnly is true.', 26 | type: 'string', 27 | writeOnly: true 28 | } 29 | } 30 | }; 31 | 32 | export default { 33 | title: 'JsonSchema/SchemaExplorer', 34 | component: SchemaExplorer, 35 | argTypes: { 36 | }, 37 | } as Meta; 38 | 39 | const Template: Story = (args) => ; 40 | 41 | const defaultArgs: Partial = { 42 | basePathSegments: ['base'], 43 | path: [{ title: 'object', reference: '#' }], 44 | lookup: new IdLookup(), 45 | stage: 'both', 46 | validationResults: [] 47 | }; 48 | 49 | export const DefaultView = Template.bind({}); 50 | (() => { 51 | const userRef: JsonSchema1 = { 52 | '$ref': '#/components/schemas/User' 53 | }; 54 | const detailsRef: JsonSchema1 = { 55 | '$ref': '#/components/schemas/UserDetails' 56 | }; 57 | const userSchema: JsonSchema = { 58 | title: 'User', 59 | description: 'A Confluence User, that everybody wants to see.', 60 | type: 'object', 61 | required: [ 62 | 'type', 63 | 'username', 64 | 'userKey', 65 | 'accountId', 66 | 'profilePicture', 67 | 'displayName', 68 | '_expandable', 69 | '_links' 70 | ], 71 | additionalProperties: false, 72 | properties: { 73 | type: { 74 | type: 'string', 75 | 'enum': [ 76 | 'known', 77 | 'unknown', 78 | 'anonymous', 79 | 'user' 80 | ] 81 | }, 82 | username: { 83 | type: 'string', 84 | description: 'The name of the user.' 85 | }, 86 | userKey: { 87 | type: 'string' 88 | }, 89 | accountId: { 90 | type: 'string' 91 | }, 92 | profilePicture: { 93 | $ref: '#/components/schemas/Icon' 94 | }, 95 | displayName: { 96 | type: 'string' 97 | }, 98 | operations: { 99 | type: 'array', 100 | items: { 101 | $ref: '#/components/schemas/OperationCheckResult' 102 | } 103 | }, 104 | details: detailsRef, 105 | _expandable: { 106 | type: 'object', 107 | additionalProperties: false, 108 | properties: { 109 | operations: { 110 | type: 'string' 111 | }, 112 | details: { 113 | type: 'string' 114 | } 115 | } 116 | }, 117 | _links: { 118 | $ref: '#/components/schemas/GenericLinks' 119 | } 120 | } 121 | }; 122 | 123 | const userDetailsSchema: JsonSchema = { 124 | type: 'object', 125 | additionalProperties: false, 126 | properties: { 127 | business: { 128 | type: 'object', 129 | required: [ 130 | 'position', 131 | 'department', 132 | 'location' 133 | ], 134 | additionalProperties: false, 135 | properties: { 136 | position: { 137 | type: 'string' 138 | }, 139 | department: { 140 | type: 'string' 141 | }, 142 | location: { 143 | type: 'string' 144 | } 145 | } 146 | }, 147 | personal: { 148 | type: 'object', 149 | required: [ 150 | 'phone', 151 | 'im', 152 | 'website', 153 | 'email' 154 | ], 155 | additionalProperties: false, 156 | properties: { 157 | phone: { 158 | type: 'string' 159 | }, 160 | im: { 161 | type: 'string' 162 | }, 163 | website: { 164 | type: 'string' 165 | }, 166 | email: { 167 | type: 'string' 168 | } 169 | } 170 | } 171 | } 172 | }; 173 | 174 | const lookup = new SchemaReturnerLookup({ 175 | [`${userRef.$ref}`]: userSchema, 176 | [`${detailsRef.$ref}`]: userDetailsSchema, 177 | [`${userRef.$ref}/properties/details`]: userDetailsSchema 178 | }); 179 | 180 | DefaultView.args = { 181 | ...defaultArgs, 182 | path: [{ 183 | title: userSchema.title || 'object', 184 | reference: userRef.$ref || '' 185 | }], 186 | lookup, 187 | schema: userSchema, 188 | stage: 'both' 189 | } 190 | })(); 191 | 192 | export const PackageJsonRoot = Template.bind({}); 193 | PackageJsonRoot.storyName = 'package.json (root)'; 194 | PackageJsonRoot.args = { 195 | ...defaultArgs, 196 | lookup: new InternalLookup(PackageJson), 197 | schema: PackageJson, 198 | stage: 'both' 199 | }; 200 | 201 | export const BothContext = Template.bind({}); 202 | BothContext.args = { 203 | ...defaultArgs, 204 | lookup: new InternalLookup(schemaForContext), 205 | schema: schemaForContext, 206 | stage: 'both' 207 | }; 208 | 209 | export const WriteContext = Template.bind({}); 210 | WriteContext.args = { 211 | ...defaultArgs, 212 | lookup: new InternalLookup(schemaForContext), 213 | schema: schemaForContext, 214 | stage: 'write' 215 | }; 216 | 217 | export const ReadContext = Template.bind({}); 218 | ReadContext.args = { 219 | ...defaultArgs, 220 | lookup: new InternalLookup(schemaForContext), 221 | schema: schemaForContext, 222 | stage: 'read' 223 | }; 224 | 225 | export const AdditionalPropertiesIsTrue = Template.bind({}); 226 | AdditionalPropertiesIsTrue.storyName = 'Additional properties (true)'; 227 | AdditionalPropertiesIsTrue.args = { 228 | ...defaultArgs, 229 | schema: { additionalProperties: true }, 230 | }; 231 | 232 | export const AdditionalPropertiesPrimitive = Template.bind({}); 233 | AdditionalPropertiesPrimitive.storyName = 'Additional properties (primitive)'; 234 | AdditionalPropertiesPrimitive.args = { 235 | ...defaultArgs, 236 | schema: { additionalProperties: { type: 'number' } } 237 | }; 238 | 239 | export const AdditionalPropertiesComplex = Template.bind({}); 240 | AdditionalPropertiesComplex.storyName = 'Additional properties (complex)'; 241 | AdditionalPropertiesComplex.args = { 242 | ...defaultArgs, 243 | schema: { 244 | additionalProperties: { 245 | type: 'array', 246 | items: { 247 | anyOf: [ 248 | { type: 'string' }, 249 | { type: 'boolean' } 250 | ] 251 | } 252 | } 253 | } 254 | }; 255 | 256 | export const AdditionalPropertiesClickable = Template.bind({}); 257 | AdditionalPropertiesClickable.storyName = 'Additional properties (clickable)'; 258 | AdditionalPropertiesClickable.args = { 259 | ...defaultArgs, 260 | schema: { 261 | additionalProperties: { 262 | type: 'object', 263 | title: 'PropName', 264 | properties: { 265 | one: { type: 'string' }, 266 | two: { type: 'number' } 267 | } 268 | } 269 | } 270 | }; 271 | 272 | export const SingletonAllOf = Template.bind({}); 273 | SingletonAllOf.storyName = 'Singleton allOf'; 274 | (() => { 275 | const componentSchema: JsonSchema = { 276 | 'type': 'object', 277 | 'properties': { 278 | 'lead': { 279 | 'type': 'object', 280 | 'description': "The user details for the component's lead user.", 281 | 'readOnly': true, 282 | 'allOf': [ 283 | { 284 | '$ref': '#/components/schemas/User' 285 | } 286 | ] 287 | } 288 | } 289 | }; 290 | 291 | const userSchema: JsonSchema = { 292 | 'type': 'object', 293 | 'properties': { 294 | 'self': { 295 | 'type': 'string', 296 | 'format': 'uri', 297 | 'description': 'The URL of the user.', 298 | 'readOnly': true 299 | }, 300 | 'key': { 301 | 'type': 'string', 302 | // tslint:disable-next-line:max-line-length 303 | 'description': 'This property has been deprecated in favour of `accountId` due to privacy changes. See the [migration guide](https://developer.atlassian.com/cloud/jira/platform/deprecation-notice-user-privacy-api-migration-guide/) for details. \nThe key of the user. In requests, required unless `accountId` or `key` is specified.' 304 | }, 305 | 'accountId': { 306 | 'type': 'string', 307 | // tslint:disable-next-line:max-line-length 308 | 'description': 'The accountId of the user, which uniquely identifies the user across all Atlassian products. For example, _384093:32b4d9w0-f6a5-3535-11a3-9c8c88d10192_. In requests, required unless `name` or `key` is specified.' 309 | }, 310 | 'name': { 311 | 'type': 'string', 312 | // tslint:disable-next-line:max-line-length 313 | 'description': 'This property has been deprecated in favour of `accountId` due to privacy changes. See the [migration guide](https://developer.atlassian.com/cloud/jira/platform/deprecation-notice-user-privacy-api-migration-guide/) for details. \nThe username of the user. In requests, required unless `accountId` or `name` is specified.' 314 | }, 315 | 'emailAddress': { 316 | 'type': 'string', 317 | // tslint:disable-next-line:max-line-length 318 | 'description': 'The email address of the user. Depending on the user’s privacy setting, this may be returned as null.', 319 | 'readOnly': true 320 | }, 321 | 'displayName': { 322 | 'type': 'string', 323 | // tslint:disable-next-line:max-line-length 324 | 'description': 'The display name of the user. Depending on the user’s privacy setting, this may return an alternative value.', 325 | 'readOnly': true 326 | }, 327 | 'active': { 328 | 'type': 'boolean', 329 | 'description': 'Indicates whether the user is active.', 330 | 'readOnly': true 331 | }, 332 | 'timeZone': { 333 | 'type': 'string', 334 | // tslint:disable-next-line:max-line-length 335 | 'description': "The time zone specified in the user's profile. Depending on the user’s privacy setting, this may be returned as null.", 336 | 'readOnly': true 337 | }, 338 | 'locale': { 339 | 'type': 'string', 340 | // tslint:disable-next-line:max-line-length 341 | 'description': 'The locale of the user. Depending on the user’s privacy setting, this may be returned as null.', 342 | 'readOnly': true 343 | }, 344 | 'expand': { 345 | 'type': 'string', 346 | 'xml': { 347 | 'attribute': true 348 | }, 349 | 'description': 'Details of expands available for the user details.', 350 | 'readOnly': true 351 | } 352 | }, 353 | 'xml': { 354 | 'name': 'user' 355 | }, 356 | 'description': 'A user.' 357 | }; 358 | 359 | const lookup = new SchemaReturnerLookup({ 360 | '#/components/schemas/User': userSchema 361 | }); 362 | 363 | SingletonAllOf.args = { 364 | ...defaultArgs, 365 | lookup, 366 | stage: 'read', 367 | schema: componentSchema 368 | }; 369 | })(); 370 | -------------------------------------------------------------------------------- /src/stories/SchemaView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { SchemaView, SchemaViewProps } from '../SchemaView'; 4 | import { Schema as PackageJson } from './package.json'; 5 | import { Schema as OpenAPISchema } from './openapi.json'; 6 | import { DebuggingMemoryRouter } from './DebuggingMemoryRouter'; 7 | 8 | export default { 9 | title: 'JsonSchema/SchemaView', 10 | component: SchemaView, 11 | argTypes: { 12 | }, 13 | } as Meta; 14 | 15 | const Template: Story = (args) => ( 16 | 17 | 18 | 19 | ); 20 | 21 | const defaultArgs: Partial = { 22 | basePathSegments: ['base'], 23 | stage: 'both' 24 | }; 25 | 26 | export const DefaultView = Template.bind({}); 27 | DefaultView.args = { 28 | ...defaultArgs, 29 | schema: { 30 | type: 'object', 31 | additionalProperties: false, 32 | required: ['A'], 33 | properties: { 34 | A: { type: 'string' }, 35 | B: { type: 'number' } 36 | } 37 | } 38 | }; 39 | 40 | export const PackageJSONStory = Template.bind({}); 41 | PackageJSONStory.storyName = 'package.json'; 42 | PackageJSONStory.args = { 43 | ...defaultArgs, 44 | schema: PackageJson 45 | }; 46 | 47 | export const OpenAPIStory = Template.bind({}); 48 | OpenAPIStory.storyName = 'openapi.json'; 49 | OpenAPIStory.args = { 50 | ...defaultArgs, 51 | schema: OpenAPISchema 52 | }; 53 | -------------------------------------------------------------------------------- /src/stories/SideNavWithRouter.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { action } from '@storybook/addon-actions'; 4 | import { SideNavWithRouter, SideNavWithRouterProps, GroupSideNavLink, SingleSideNavLink, SideNavLink } from '../SideNavWithRouter'; 5 | import { DebuggingMemoryRouter } from './DebuggingMemoryRouter'; 6 | 7 | export default { 8 | title: 'JsonSchema/SideNav', 9 | component: SideNavWithRouter, 10 | argTypes: { 11 | }, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => ( 15 | 16 | 17 | 18 | ); 19 | 20 | export const NoLinks = Template.bind({}); 21 | NoLinks.args = { 22 | basePathSegments: ['base'], 23 | links: [] 24 | }; 25 | 26 | export const SinglesExample = Template.bind({}); 27 | SinglesExample.args = { 28 | basePathSegments: ['base'], 29 | links: [{ 30 | title: 'One', 31 | reference: '#/definitions/one' 32 | }, { 33 | title: 'Two', 34 | reference: '#/definitions/two' 35 | }, { 36 | title: 'Three', 37 | reference: '#/definitions/three' 38 | }] 39 | }; 40 | 41 | function createGroup(title: string, reference: string, childCount: number): GroupSideNavLink { 42 | const children = Array.from(Array(childCount).keys()).map (n => ({ 43 | title: `Item ${n}`, 44 | reference: `${reference}/item-${n}` 45 | })); 46 | 47 | return { 48 | title, 49 | reference, 50 | children 51 | }; 52 | } 53 | 54 | function createGroups(groupCount: number, childCount: number): SideNavLink[] { 55 | return Array.from(Array(groupCount).keys()).map(n => createGroup(`Group ${n}`, `#/group-${n}`, childCount)); 56 | } 57 | 58 | export const GroupsExample = Template.bind({}); 59 | GroupsExample.args = { 60 | basePathSegments: ['base'], 61 | links: createGroups(3, 3) 62 | }; 63 | 64 | export const SingleAndGroupExample = Template.bind({}); 65 | SingleAndGroupExample.args = { 66 | basePathSegments: ['base'], 67 | links: [{ 68 | title: 'One', 69 | reference: '#/one' 70 | }, ...createGroups(1, 4), 71 | { 72 | title: 'Two', 73 | reference: '#/two' 74 | }, ...createGroups(3, 3), { 75 | title: 'Three', 76 | reference: '#/three' 77 | }] 78 | }; 79 | -------------------------------------------------------------------------------- /src/stories/Type.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | 4 | import { IdLookup } from '../lookup'; 5 | import { Type, TypeProps } from '../Type'; 6 | import { SchemaArray, SimpleTypes } from '../schema'; 7 | 8 | export default { 9 | title: 'JsonSchema/Type', 10 | component: Type, 11 | argTypes: { 12 | }, 13 | } as Meta; 14 | 15 | const noOp = () => { 16 | // NoOp 17 | }; 18 | 19 | const defaultProps: Partial = { 20 | lookup: new IdLookup(), 21 | clickElement: (props) => {props.fallbackTitle} 22 | }; 23 | 24 | const Template: Story = (args) => ; 25 | 26 | export const EmptySchema = Template.bind({}); 27 | EmptySchema.args = { 28 | ...defaultProps, 29 | s: {} 30 | }; 31 | 32 | export const PrimitiveBoolean = Template.bind({}); 33 | PrimitiveBoolean.args = { 34 | ...defaultProps, 35 | s: { type: 'boolean' } 36 | }; 37 | 38 | export const PrimitiveNull = Template.bind({}); 39 | PrimitiveNull.args = { 40 | ...defaultProps, 41 | s: { type: 'null' } 42 | }; 43 | 44 | export const PrimitiveNumber = Template.bind({}); 45 | PrimitiveNumber.args = { 46 | ...defaultProps, 47 | s: { type: 'number' } 48 | }; 49 | 50 | export const PrimitiveInteger = Template.bind({}); 51 | PrimitiveInteger.args = { 52 | ...defaultProps, 53 | s: { type: 'integer' } 54 | }; 55 | 56 | export const PrimitiveString = Template.bind({}); 57 | PrimitiveString.args = { 58 | ...defaultProps, 59 | s: { type: 'string' } 60 | }; 61 | 62 | export const ArrayOfAnythingType1 = Template.bind({}); 63 | ArrayOfAnythingType1.args = { 64 | ...defaultProps, 65 | s: { type: 'array', items: {} } 66 | }; 67 | 68 | export const ArrayOfAnythingType2 = Template.bind({}); 69 | ArrayOfAnythingType2.args = { 70 | ...defaultProps, 71 | s: { type: 'array', items: true } 72 | }; 73 | 74 | export const ArrayOfAnythingType3 = Template.bind({}); 75 | ArrayOfAnythingType3.args = { 76 | ...defaultProps, 77 | s: { type: 'array' } 78 | }; 79 | 80 | export const ArrayOfNothing = Template.bind({}); 81 | ArrayOfNothing.args = { 82 | ...defaultProps, 83 | s: { type: 'array', items: false } 84 | }; 85 | 86 | export const ArrayOfInteger = Template.bind({}); 87 | ArrayOfInteger.args = { 88 | ...defaultProps, 89 | s: { type: 'array', items: { type: 'integer' } } 90 | }; 91 | 92 | export const NestedArrays = Template.bind({}); 93 | NestedArrays.args = { 94 | ...defaultProps, 95 | s: { 96 | type: 'array', 97 | items: { 98 | type: 'array', 99 | items: { 100 | type: 'array', 101 | items: { 102 | type: 'string' 103 | } 104 | } 105 | } 106 | } 107 | }; 108 | 109 | export const AnonymousObject = Template.bind({}); 110 | AnonymousObject.args = { 111 | ...defaultProps, 112 | s: { type: 'object' } 113 | }; 114 | 115 | export const NamedObject = Template.bind({}); 116 | NamedObject.args = { 117 | ...defaultProps, 118 | s: { 119 | title: 'MySpecialNamedType', 120 | type: 'object', 121 | } 122 | }; 123 | 124 | export const AnyOf = Template.bind({}); 125 | AnyOf.args = { 126 | ...defaultProps, 127 | s: { 128 | anyOf: [ 129 | { type: 'string' }, 130 | { type: 'integer' } 131 | ] 132 | } 133 | }; 134 | 135 | export const AnyOfSingleton = Template.bind({}); 136 | AnyOfSingleton.args = { 137 | ...defaultProps, 138 | s: { 139 | anyOf: [ 140 | { type: 'string' } 141 | ] 142 | } 143 | }; 144 | 145 | export const ZeroLengthAnyOf = Template.bind({}); 146 | ZeroLengthAnyOf.args = { 147 | ...defaultProps, 148 | s: { 149 | type: 'object', 150 | anyOf: ([] as unknown) as SchemaArray, 151 | additionalProperties: false 152 | } 153 | }; 154 | 155 | export const OneOf = Template.bind({}); 156 | OneOf.args = { 157 | ...defaultProps, 158 | s: { 159 | oneOf: [ 160 | { type: 'string' }, 161 | { type: 'integer' } 162 | ] 163 | } 164 | }; 165 | 166 | export const OneOfSingleton = Template.bind({}); 167 | OneOfSingleton.args = { 168 | ...defaultProps, 169 | s: { 170 | oneOf: [ 171 | { type: 'string' } 172 | ] 173 | } 174 | }; 175 | 176 | export const ZeroLengthOneOf = Template.bind({}); 177 | ZeroLengthOneOf.args = { 178 | ...defaultProps, 179 | s: { 180 | type: 'object', 181 | oneOf: ([] as unknown) as SchemaArray, 182 | additionalProperties: false 183 | } 184 | }; 185 | 186 | export const AllOf = Template.bind({}); 187 | AllOf.args = { 188 | ...defaultProps, 189 | s: { 190 | allOf: [ 191 | { type: 'string' }, 192 | { type: 'integer' } 193 | ] 194 | } 195 | }; 196 | 197 | export const AllOfSingleton = Template.bind({}); 198 | AllOfSingleton.args = { 199 | ...defaultProps, 200 | s: { 201 | allOf: [ 202 | { type: 'string' } 203 | ] 204 | } 205 | }; 206 | 207 | export const ZeroLengthAllOf = Template.bind({}); 208 | ZeroLengthAllOf.args = { 209 | ...defaultProps, 210 | s: { 211 | type: 'object', 212 | allOf: ([] as unknown) as SchemaArray, 213 | additionalProperties: false 214 | } 215 | }; 216 | 217 | export const PrimitiveNot = Template.bind({}); 218 | PrimitiveNot.args = { 219 | ...defaultProps, 220 | s: { 221 | not: { 222 | type: 'boolean' 223 | } 224 | } 225 | }; 226 | 227 | export const NotOnPrimitiveArray = Template.bind({}); 228 | NotOnPrimitiveArray.args = { 229 | ...defaultProps, 230 | s: { 231 | not: { 232 | type: 'array', 233 | items: { 234 | type: 'boolean' 235 | } 236 | } 237 | } 238 | }; 239 | 240 | export const SimpleObjectNot = Template.bind({}); 241 | SimpleObjectNot.args = { 242 | ...defaultProps, 243 | s: { 244 | not: { 245 | type: 'object', 246 | properties: { 247 | a: { type: 'string' } 248 | } 249 | } 250 | } 251 | }; 252 | 253 | export const NotAllOf = Template.bind({}); 254 | NotAllOf.args = { 255 | ...defaultProps, 256 | s: { 257 | not: { 258 | allOf: [ 259 | { 260 | title: 'One', 261 | type: 'object', 262 | properties: { 263 | a: { type: 'string' } 264 | } 265 | }, 266 | { 267 | title: 'Two', 268 | type: 'object', 269 | properties: { 270 | b: { type: 'integer' } 271 | } 272 | } 273 | ] 274 | } 275 | } 276 | }; 277 | 278 | export const AllOfAndAnyOfConjunction = Template.bind({}); 279 | AllOfAndAnyOfConjunction.args = { 280 | ...defaultProps, 281 | s: { 282 | anyOf: [ 283 | { 284 | type: 'array', 285 | items: { 286 | type: 'string' 287 | } 288 | }, 289 | { 290 | type: 'array', 291 | items: { 292 | type: 'integer' 293 | } 294 | } 295 | ], 296 | allOf: [ 297 | { minItems: 0 }, 298 | { maxItems: 10 } 299 | ] 300 | } 301 | }; 302 | 303 | export const MultipleComplexJoins = Template.bind({}); 304 | MultipleComplexJoins.args = { 305 | ...defaultProps, 306 | s: { 307 | allOf: [ 308 | { 309 | title: 'One', 310 | oneOf: [ 311 | { type: 'integer' }, 312 | { type: 'boolean' } 313 | ] 314 | }, 315 | { 316 | title: 'Two', 317 | not: { 318 | anyOf: [ 319 | { 320 | title: 'NotThis', 321 | type: 'object', 322 | }, 323 | { 324 | type: 'array', 325 | items: { 326 | type: 'string' 327 | } 328 | } 329 | ] 330 | } 331 | } 332 | ] 333 | } 334 | }; 335 | 336 | export const MultipleCompositeCommands = Template.bind({}); 337 | MultipleCompositeCommands.args = { 338 | ...defaultProps, 339 | s: { 340 | allOf: [ 341 | { type: 'array', items: {} }, 342 | { type: 'object' } 343 | ], oneOf: [ 344 | { type: 'object' }, 345 | { type: 'array', items: { type: 'string' } } 346 | ], not: { 347 | type: 'number' 348 | }, anyOf: [ 349 | { type: 'object', properties: { a: { type: 'string' } } }, 350 | { type: 'object', properties: { a: { type: 'number' } } } 351 | ] 352 | } 353 | }; 354 | 355 | export const ArrayComposite = Template.bind({}); 356 | ArrayComposite.args = { 357 | ...defaultProps, 358 | s: { 359 | type: 'array', 360 | anyOf: [ 361 | { type: 'array', items: { type: 'number' }}, 362 | { type: 'array', items: { type: 'string' }} 363 | ] 364 | } 365 | }; 366 | 367 | export const MultiplePrimitiveTypes = Template.bind({}); 368 | MultiplePrimitiveTypes.args = { 369 | ...defaultProps, 370 | s: { 371 | type: ['string', 'number', 'null'] 372 | } 373 | }; 374 | 375 | export const MultiplePrimitiveTypesWithNamedObject = Template.bind({}); 376 | MultiplePrimitiveTypesWithNamedObject.args = { 377 | ...defaultProps, 378 | s: { 379 | type: ['object', 'number', 'null'], 380 | title: 'NamedObject' 381 | } 382 | }; 383 | 384 | export const TypesArrayWithSingleton = Template.bind({}); 385 | TypesArrayWithSingleton.args = { 386 | ...defaultProps, 387 | s: { 388 | type: ['null'], 389 | } 390 | }; 391 | 392 | export const EmptyTypesArray = Template.bind({}); 393 | EmptyTypesArray.args = { 394 | ...defaultProps, 395 | s: { 396 | type: ([] as unknown) as SimpleTypes 397 | } 398 | }; 399 | 400 | export const ArrayItemsOfDifferentTypes = Template.bind({}); 401 | ArrayItemsOfDifferentTypes.args = { 402 | ...defaultProps, 403 | s: { 404 | type: 'array', 405 | items: [ 406 | { type: 'string' }, 407 | { type: 'number' } 408 | ] 409 | } 410 | }; 411 | 412 | export const ArrayItemsSingleton = Template.bind({}); 413 | ArrayItemsSingleton.args = { 414 | ...defaultProps, 415 | s: { 416 | type: 'array', 417 | items: [ 418 | { type: 'string' } 419 | ] 420 | } 421 | }; 422 | 423 | export const ArrayItemsEmptyArray = Template.bind({}); 424 | ArrayItemsEmptyArray.args = { 425 | ...defaultProps, 426 | s: { 427 | type: 'array', 428 | items: ([] as unknown) as SchemaArray 429 | } 430 | }; 431 | -------------------------------------------------------------------------------- /src/stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 900; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | -------------------------------------------------------------------------------- /src/stories/page.css: -------------------------------------------------------------------------------- 1 | section { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 14px; 4 | line-height: 24px; 5 | padding: 48px 20px; 6 | margin: 0 auto; 7 | max-width: 600px; 8 | color: #333; 9 | } 10 | 11 | h2 { 12 | font-weight: 900; 13 | font-size: 32px; 14 | line-height: 1; 15 | margin: 0 0 4px; 16 | display: inline-block; 17 | vertical-align: top; 18 | } 19 | 20 | p { 21 | margin: 1em 0; 22 | } 23 | 24 | a { 25 | text-decoration: none; 26 | color: #1ea7fd; 27 | } 28 | 29 | ul { 30 | padding-left: 30px; 31 | margin: 1em 0; 32 | } 33 | 34 | li { 35 | margin-bottom: 8px; 36 | } 37 | 38 | .tip { 39 | display: inline-block; 40 | border-radius: 1em; 41 | font-size: 11px; 42 | line-height: 12px; 43 | font-weight: 700; 44 | background: #e7fdd8; 45 | color: #66bf3c; 46 | padding: 4px 12px; 47 | margin-right: 10px; 48 | vertical-align: top; 49 | } 50 | 51 | .tip-wrapper { 52 | font-size: 13px; 53 | line-height: 20px; 54 | margin-top: 40px; 55 | margin-bottom: 40px; 56 | } 57 | 58 | .tip-wrapper svg { 59 | display: inline-block; 60 | height: 12px; 61 | width: 12px; 62 | margin-right: 4px; 63 | vertical-align: top; 64 | margin-top: 3px; 65 | } 66 | 67 | .tip-wrapper svg path { 68 | fill: #1ea7fd; 69 | } 70 | -------------------------------------------------------------------------------- /src/stories/schema-returner-lookup.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema } from '../schema'; 2 | import { Lookup, LookupResult} from '../lookup'; 3 | 4 | export type RefMap = { 5 | [path: string]: JsonSchema 6 | }; 7 | 8 | export class SchemaReturnerLookup implements Lookup { 9 | private map: RefMap; 10 | constructor(map: RefMap) { 11 | this.map = map; 12 | } 13 | 14 | public getSchema(s: JsonSchema): LookupResult { 15 | console.log(this.map); 16 | if (typeof s === 'boolean') { 17 | return { 18 | schema: s 19 | }; 20 | } 21 | 22 | const ref = s.$ref; 23 | if (ref === undefined) { 24 | return { schema: s }; 25 | } 26 | 27 | const schema = this.map[ref]; 28 | 29 | if (schema === undefined) { 30 | return undefined; 31 | } 32 | 33 | return { 34 | schema, 35 | baseReference: s.$ref 36 | }; 37 | } 38 | } -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import "@atlaskit/css-reset/dist/bundle.css" -------------------------------------------------------------------------------- /src/test/markdown-renderer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import { Markdown } from '../markdown'; 5 | 6 | describe('markdown renderer', () => { 7 | test('safely displays HTML but protects against XSS', async () => { 8 | render( 9 | This is some safe text!

13 | 14 | 15 | 16 | `} 17 | /> 18 | ); 19 | 20 | // It renders markdown 21 | expect(screen.getByText('Hello world')).toBeTruthy(); 22 | // and paragraphs 23 | expect(screen.getByTitle('para')).toHaveTextContent('This is some safe text!'); 24 | // but blocks attributes that can run code 25 | expect(screen.getByTitle('xssimg')).not.toHaveAttribute('onerror'); 26 | // and blocks script and style tags outright 27 | expect(screen.queryByTitle('xss')).toBeNull(); 28 | expect(screen.queryByTitle('xsscss')).toBeNull(); 29 | }); 30 | }) 31 | -------------------------------------------------------------------------------- /src/title.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema1 } from "./schema"; 2 | 3 | export function getTitle(reference: string, schema: JsonSchema1): string { 4 | return findTitle(reference, schema) || 'object'; 5 | } 6 | 7 | export function findTitle(reference: string, schema: JsonSchema1): string | undefined { 8 | if (schema.title !== undefined) { 9 | return schema.title; 10 | } 11 | 12 | const rs = reference.split('/'); 13 | const last = rs[rs.length - 1]; 14 | const secondLast = rs[rs.length - 2]; 15 | const thirdLast = rs[rs.length - 3]; 16 | if (['properties', 'definitions'].includes(secondLast)) { 17 | return last; 18 | } else if (last === 'additionalProperties') { 19 | return '(Additional properties)'; 20 | } else if (last === 'items' && ['properties', 'definitions'].includes(thirdLast)) { 21 | return secondLast + ' items'; 22 | } 23 | 24 | return undefined; 25 | } -------------------------------------------------------------------------------- /src/type-inference.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema1, SimpleTypes } from './schema'; 2 | import { isPresent } from 'ts-is-present'; 3 | 4 | function hasNumericalRestrictor(s: JsonSchema1): boolean { 5 | return [ 6 | s.minimum, 7 | s.maximum, 8 | s.exclusiveMaximum, 9 | s.exclusiveMinimum, 10 | s.multipleOf 11 | ].some(v => v !== undefined); 12 | } 13 | 14 | function hasStringRestrictor(s: JsonSchema1): boolean { 15 | return [ 16 | s.minLength, 17 | s.maxLength, 18 | s.pattern 19 | ].some(v => v !== undefined); 20 | } 21 | 22 | function hasObjectRestrictor(s: JsonSchema1): boolean { 23 | return [ 24 | s.properties, 25 | s.additionalProperties, 26 | s.minProperties, 27 | s.maxProperties 28 | ].some(v => v !== undefined); 29 | } 30 | 31 | function hasArrayRestrictor(s: JsonSchema1): boolean { 32 | return [ 33 | s.items, 34 | s.minItems, 35 | s.maxItems, 36 | s.uniqueItems 37 | ].some(v => v !== undefined); 38 | } 39 | 40 | export function jsonTypeToSchemaType(someType: unknown): SimpleTypes | undefined { 41 | switch (typeof someType) { 42 | case 'boolean': 43 | return 'boolean'; 44 | case 'string': 45 | return 'string'; 46 | case 'number': 47 | return 'number'; 48 | case 'bigint': 49 | return 'integer'; 50 | case 'object': 51 | return 'object'; 52 | default: 53 | return undefined; 54 | } 55 | } 56 | 57 | export function getTypesFromEnum(enumValue: NonNullable): JsonSchema1['type'] | undefined { 58 | const types = Array.from(new Set(enumValue.map(jsonTypeToSchemaType).filter(isPresent))); 59 | if (types.length === 0) { 60 | return undefined; 61 | } else if (types.length === 1) { 62 | return types[0]; 63 | } 64 | 65 | return [types[0], ...types.slice(1)]; 66 | } 67 | 68 | export function getOrInferType(schema: JsonSchema1): JsonSchema1['type'] | undefined { 69 | // If the type exists, then just get it 70 | if (schema.type !== undefined) { 71 | return schema.type; 72 | } 73 | 74 | // Otherwise, infer the type from the other restrictors 75 | if (hasObjectRestrictor(schema)) { 76 | return 'object'; 77 | } 78 | 79 | if (hasArrayRestrictor(schema)) { 80 | return 'array'; 81 | } 82 | 83 | if (hasNumericalRestrictor(schema)) { 84 | return 'number'; 85 | } 86 | 87 | if (hasStringRestrictor(schema)) { 88 | return 'string'; 89 | } 90 | 91 | if (schema.enum !== undefined) { 92 | const enumType = getTypesFromEnum(schema.enum); 93 | if (enumType !== undefined) { 94 | return enumType; 95 | } 96 | } 97 | 98 | return undefined; 99 | } 100 | 101 | const primitiveTypes: Array = [ 102 | 'boolean', 103 | 'integer', 104 | 'null', 105 | 'number', 106 | 'string' 107 | ] 108 | 109 | export function isPrimitiveType(type: JsonSchema1['type']): boolean { 110 | if (Array.isArray(type)) { 111 | return type.every(t => primitiveTypes.includes(t)); 112 | } 113 | return primitiveTypes.includes(type); 114 | } 115 | 116 | export function isExternalReference(schema: JsonSchema1): boolean { 117 | return schema.$ref !== undefined && schema.$ref.startsWith('http'); 118 | } -------------------------------------------------------------------------------- /templates/acm-certificate.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: ACFS3 - Certificate creation 3 | 4 | Parameters: 5 | DomainName: 6 | Type: String 7 | Region: 8 | Type: String 9 | Default: 'us-east-1' 10 | CFNCustomProvider: 11 | Type: String 12 | CopyFunction: 13 | Type: String 14 | SubDomain: 15 | Type: String 16 | CreateApex: 17 | Type: String 18 | 19 | Conditions: 20 | CreateApexConfig: !Equals 21 | - !Ref CreateApex 22 | - 'yes' 23 | 24 | Resources: 25 | CopyCustomResource: 26 | Type: "AWS::CloudFormation::CustomResource" 27 | Properties: 28 | ServiceToken: !Ref CopyFunction 29 | 30 | Certificate: 31 | Type: Custom::Certificate 32 | Properties: 33 | DomainName: !Sub '${SubDomain}.${DomainName}' 34 | SubjectAlternativeNames: 35 | Fn::If: 36 | - CreateApexConfig 37 | - - Ref: DomainName 38 | - Ref: AWS::NoValue 39 | Region: !Ref Region 40 | ValidationMethod: DNS 41 | ServiceToken: !Ref 'CFNCustomProvider' 42 | 43 | IssuedCertificate: 44 | Type: Custom::IssuedCertificate 45 | Properties: 46 | CertificateArn: !Ref Certificate 47 | ServiceToken: !Ref 'CFNCustomProvider' 48 | 49 | CertificateDNSRecord: 50 | Type: Custom::CertificateDNSRecord 51 | Properties: 52 | CertificateArn: !Ref Certificate 53 | DomainName: !Sub '${SubDomain}.${DomainName}' 54 | ServiceToken: !Ref 'CFNCustomProvider' 55 | 56 | apexCertificateDNSRecord: 57 | Type: Custom::CertificateDNSRecord 58 | Condition: CreateApexConfig 59 | Properties: 60 | CertificateArn: !Ref Certificate 61 | DomainName: !Ref DomainName 62 | ServiceToken: !Ref 'CFNCustomProvider' 63 | 64 | DomainValidationRecord: 65 | Type: AWS::Route53::RecordSetGroup 66 | Properties: 67 | HostedZoneName: !Sub '${DomainName}.' 68 | RecordSets: 69 | - Name: !GetAtt CertificateDNSRecord.Name 70 | Type: !GetAtt CertificateDNSRecord.Type 71 | TTL: 60 72 | Weight: 1 73 | SetIdentifier: !Ref Certificate 74 | ResourceRecords: 75 | - !GetAtt CertificateDNSRecord.Value 76 | 77 | apexDomainValidationRecord: 78 | Type: AWS::Route53::RecordSetGroup 79 | Condition: CreateApexConfig 80 | Properties: 81 | HostedZoneName: !Sub '${DomainName}.' 82 | RecordSets: 83 | - Name: !GetAtt apexCertificateDNSRecord.Name 84 | Type: !GetAtt apexCertificateDNSRecord.Type 85 | TTL: 60 86 | Weight: 1 87 | SetIdentifier: !Ref Certificate 88 | ResourceRecords: 89 | - !GetAtt apexCertificateDNSRecord.Value 90 | 91 | Outputs: 92 | DNSRecord: 93 | Description: DNS record 94 | Value: !Sub '${CertificateDNSRecord.Name} ${CertificateDNSRecord.Type} ${CertificateDNSRecord.Value}' 95 | 96 | CertificateArn: 97 | Description: Issued certificate 98 | Value: !Ref Certificate -------------------------------------------------------------------------------- /templates/cloudfront-site.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: ACFS3 - CloudFront with Header Security and site content 3 | Transform: 'AWS::Serverless-2016-10-31' 4 | 5 | Parameters: 6 | CertificateArn: 7 | Description: Certificate locater 8 | Type: String 9 | DomainName: 10 | Description: Apex domain 11 | Type: String 12 | SubDomain: 13 | Description: Subdomain 14 | Type: String 15 | S3BucketLogs: 16 | Description: Logging Bucket 17 | Type: String 18 | S3BucketRoot: 19 | Description: Content Bucket 20 | Type: String 21 | S3BucketLogsName: 22 | Description: Logging Bucket 23 | Type: String 24 | S3BucketRootName: 25 | Description: Content Bucket 26 | Type: String 27 | S3BucketRootArn: 28 | Description: Content Bucket locator 29 | Type: String 30 | CreateApex: 31 | Type: String 32 | 33 | Conditions: 34 | CreateApexConfig: !Equals 35 | - !Ref CreateApex 36 | - 'yes' 37 | 38 | Resources: 39 | S3BucketPolicy: 40 | Type: AWS::S3::BucketPolicy 41 | Properties: 42 | Bucket: !Ref 'S3BucketRoot' 43 | PolicyDocument: 44 | Version: '2012-10-17' 45 | Statement: 46 | - Action: 47 | - s3:GetObject 48 | Effect: Allow 49 | Resource: !Sub '${S3BucketRootArn}/*' 50 | Principal: 51 | CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId 52 | 53 | LambdaEdgeFunction: 54 | DeletionPolicy: Retain 55 | Type: AWS::Serverless::Function 56 | Properties: 57 | Description: The custom headers lambda function. 58 | Handler: index.handler 59 | Role: !GetAtt LambdaEdgeFunctionRole.Arn 60 | CodeUri: ../s-headers.zip 61 | Runtime: 'nodejs12.x' 62 | Timeout: 25 63 | 64 | Lambdaversion: 65 | Type: AWS::Lambda::Version 66 | Properties: 67 | FunctionName: !Ref LambdaEdgeFunction 68 | Description: v1 69 | 70 | LambdaEdgeFunctionRole: 71 | Type: AWS::IAM::Role 72 | Properties: 73 | Path: '/' 74 | ManagedPolicyArns: 75 | - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' 76 | AssumeRolePolicyDocument: 77 | Version: '2012-10-17' 78 | Statement: 79 | - 80 | Sid: 'AllowLambdaServiceToAssumeRole' 81 | Effect: 'Allow' 82 | Action: 83 | - 'sts:AssumeRole' 84 | Principal: 85 | Service: 86 | - 'lambda.amazonaws.com' 87 | - 'edgelambda.amazonaws.com' 88 | Tags: 89 | - Key: Solution 90 | Value: ACFS3 91 | 92 | CloudFrontDistribution: 93 | Type: AWS::CloudFront::Distribution 94 | Properties: 95 | DistributionConfig: 96 | Aliases: 97 | - !Sub '${SubDomain}.${DomainName}' 98 | - !If [ CreateApexConfig, !Ref DomainName, !Ref 'AWS::NoValue' ] 99 | DefaultCacheBehavior: 100 | Compress: true 101 | DefaultTTL: 86400 102 | ForwardedValues: 103 | QueryString: true 104 | MaxTTL: 31536000 105 | TargetOriginId: !Sub 'S3-${AWS::StackName}-root' 106 | LambdaFunctionAssociations: 107 | - 108 | EventType: origin-response 109 | LambdaFunctionARN: !Ref Lambdaversion 110 | ViewerProtocolPolicy: 'redirect-to-https' 111 | CustomErrorResponses: 112 | - ErrorCachingMinTTL: 60 113 | ErrorCode: 404 114 | ResponseCode: 200 115 | ResponsePagePath: '/index.html' 116 | - ErrorCachingMinTTL: 60 117 | ErrorCode: 403 118 | ResponseCode: 200 119 | ResponsePagePath: '/index.html' 120 | - ErrorCachingMinTTL: 60 121 | ErrorCode: 400 122 | ResponseCode: 200 123 | ResponsePagePath: '/index.html' 124 | Enabled: true 125 | HttpVersion: 'http2' 126 | DefaultRootObject: 'index.html' 127 | IPV6Enabled: true 128 | Logging: 129 | Bucket: !Ref 'S3BucketLogsName' 130 | IncludeCookies: false 131 | Prefix: 'cdn/' 132 | Origins: 133 | - DomainName: !Ref 'S3BucketRootName' 134 | Id: !Sub 'S3-${AWS::StackName}-root' 135 | S3OriginConfig: 136 | OriginAccessIdentity: 137 | !Join ['', ['origin-access-identity/cloudfront/', !Ref CloudFrontOriginAccessIdentity]] 138 | PriceClass: 'PriceClass_All' 139 | ViewerCertificate: 140 | AcmCertificateArn: !Ref 'CertificateArn' 141 | MinimumProtocolVersion: 'TLSv1.1_2016' 142 | SslSupportMethod: 'sni-only' 143 | Tags: 144 | - Key: Solution 145 | Value: ACFS3 146 | 147 | CloudFrontOriginAccessIdentity: 148 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 149 | Properties: 150 | CloudFrontOriginAccessIdentityConfig: 151 | Comment: !Sub 'CloudFront OAI for ${SubDomain}.${DomainName}' 152 | 153 | Route53RecordSetGroup: 154 | Type: AWS::Route53::RecordSetGroup 155 | Properties: 156 | HostedZoneName: !Sub '${DomainName}.' 157 | RecordSets: 158 | - Name: !Sub '${SubDomain}.${DomainName}' 159 | Type: 'A' 160 | AliasTarget: 161 | DNSName: !GetAtt 'CloudFrontDistribution.DomainName' 162 | EvaluateTargetHealth: false 163 | # The following HosteZoneId is always used for alias records pointing to CF. 164 | HostedZoneId: 'Z2FDTNDATAQYW2' 165 | 166 | ApexRoute53RecordSetGroup: 167 | Condition: CreateApexConfig 168 | Type: AWS::Route53::RecordSetGroup 169 | Properties: 170 | HostedZoneName: !Sub '${DomainName}.' 171 | RecordSets: 172 | - Name: !Ref 'DomainName' 173 | Type: 'A' 174 | AliasTarget: 175 | DNSName: !GetAtt 'CloudFrontDistribution.DomainName' 176 | EvaluateTargetHealth: false 177 | # The following HosteZoneId is always used for alias records pointing to CF. 178 | HostedZoneId: 'Z2FDTNDATAQYW2' 179 | 180 | Outputs: 181 | LambdaEdgeFunctionVersion: 182 | Description: Security Lambda version 183 | Value: !Ref Lambdaversion 184 | 185 | CloudFrontDistribution: 186 | Description: CloudFront distribution 187 | Value: !GetAtt CloudFrontDistribution.DomainName 188 | 189 | CloudFrontDomainName: 190 | Description: Website address 191 | Value: !Sub '${SubDomain}.${DomainName}' -------------------------------------------------------------------------------- /templates/custom-resource.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: ACFS3 - Cert Provider with DNS validation 3 | Transform: AWS::Serverless-2016-10-31 4 | 5 | Resources: 6 | LambdaPermission: 7 | Type: AWS::Lambda::Permission 8 | Properties: 9 | Action: lambda:InvokeFunction 10 | FunctionName: !GetAtt CFNCustomProvider.Arn 11 | Principal: !GetAtt LambdaRole.Arn 12 | 13 | LambdaPolicy: 14 | Type: AWS::IAM::Policy 15 | DependsOn: 16 | - LambdaRole 17 | Properties: 18 | PolicyName: CFNCertificateDomainResourceRecordProvider 19 | PolicyDocument: 20 | Version: '2012-10-17' 21 | Statement: 22 | - Effect: Allow 23 | Action: 24 | - acm:RequestCertificate 25 | - acm:DescribeCertificate 26 | - acm:UpdateCertificateOptions 27 | - acm:DeleteCertificate 28 | Resource: 29 | - '*' 30 | - Effect: Allow 31 | Action: 32 | - logs:* 33 | Resource: arn:aws:logs:*:*:* 34 | Roles: 35 | - !Ref LambdaRole 36 | 37 | LambdaRole: 38 | Type: AWS::IAM::Role 39 | Properties: 40 | AssumeRolePolicyDocument: 41 | Version: '2012-10-17' 42 | Statement: 43 | - Action: 44 | - sts:AssumeRole 45 | Effect: Allow 46 | Principal: 47 | Service: 48 | - lambda.amazonaws.com 49 | Tags: 50 | - Key: Solution 51 | Value: ACFS3 52 | 53 | CFNCustomProviderLogGroup: 54 | Type: AWS::Logs::LogGroup 55 | Properties: 56 | RetentionInDays: 7 57 | LogGroupName: !Sub '/aws/lambda/${CFNCustomProvider}' 58 | DependsOn: 59 | - CFNCustomProvider 60 | 61 | CFNCustomProvider: 62 | Type: AWS::Serverless::Function 63 | Properties: 64 | CodeUri: s3://binxio-public-us-east-1/lambdas/cfn-certificate-provider-0.2.4.zip 65 | Description: CFN Certificate Domain Resource Record Provider (v1) 66 | MemorySize: 128 67 | Handler: provider.handler 68 | Timeout: 300 69 | Role: !GetAtt LambdaRole.Arn 70 | Runtime: python3.6 71 | 72 | S3BucketLogs: 73 | Type: AWS::S3::Bucket 74 | DeletionPolicy: Retain 75 | Properties: 76 | AccessControl: LogDeliveryWrite 77 | BucketEncryption: 78 | ServerSideEncryptionConfiguration: 79 | - ServerSideEncryptionByDefault: 80 | SSEAlgorithm: AES256 81 | Tags: 82 | - Key: Solution 83 | Value: ACFS3 84 | 85 | S3BucketRoot: 86 | Type: AWS::S3::Bucket 87 | DeletionPolicy: Retain 88 | Properties: 89 | BucketEncryption: 90 | ServerSideEncryptionConfiguration: 91 | - ServerSideEncryptionByDefault: 92 | SSEAlgorithm: AES256 93 | LoggingConfiguration: 94 | DestinationBucketName: !Ref 'S3BucketLogs' 95 | LogFilePrefix: 'origin/' 96 | Tags: 97 | - Key: Solution 98 | Value: ACFS3 99 | 100 | CopyLayerVersion: 101 | Type: "AWS::Serverless::LayerVersion" 102 | Properties: 103 | ContentUri: ../witch.zip 104 | CompatibleRuntimes: 105 | - nodejs12.x 106 | 107 | CopyRole: 108 | Type: "AWS::IAM::Role" 109 | Properties: 110 | AssumeRolePolicyDocument: 111 | Statement: 112 | - Effect: Allow 113 | Principal: 114 | Service: lambda.amazonaws.com 115 | Action: 116 | - sts:AssumeRole 117 | ManagedPolicyArns: 118 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 119 | Policies: 120 | - PolicyName: S3CopyPolicy 121 | PolicyDocument: 122 | Version: '2012-10-17' 123 | Statement: 124 | - Effect: Allow 125 | Action: 126 | - s3:GetObject 127 | - s3:ListBucket 128 | - s3:PutObject 129 | - s3:PutObjectAcl 130 | Resource: 131 | - !Sub 132 | - arn:aws:s3:::${TargetBucket}/* 133 | - TargetBucket: !Ref S3BucketRoot 134 | - !Sub 135 | - arn:aws:s3:::${TargetBucket} 136 | - TargetBucket: !Ref S3BucketRoot 137 | 138 | 139 | CopyFunction: 140 | Type: AWS::Serverless::Function 141 | Properties: 142 | CodeUri: ../dist 143 | Environment: 144 | Variables: 145 | BUCKET: !Ref S3BucketRoot 146 | Handler: witch.staticHandler 147 | Layers: 148 | - !Ref CopyLayerVersion 149 | Role: !GetAtt CopyRole.Arn 150 | Runtime: nodejs12.x 151 | Timeout: 300 152 | 153 | Outputs: 154 | S3BucketRoot: 155 | Description: Website bucket 156 | Value: !Ref S3BucketRoot 157 | S3BucketRootName: 158 | Description: Website bucket name 159 | Value: !GetAtt S3BucketRoot.DomainName 160 | S3BucketRootArn: 161 | Description: Website bucket locator 162 | Value: !GetAtt S3BucketRoot.Arn 163 | S3BucketLogs: 164 | Description: Logging bucket 165 | Value: !Ref S3BucketLogs 166 | S3BucketLogsName: 167 | Description: Logging bucket Name 168 | Value: !GetAtt S3BucketLogs.DomainName 169 | CFNCustomProvider: 170 | Description: ACM helper function 171 | Value: !GetAtt CFNCustomProvider.Arn 172 | CopyFunction: 173 | Description: S3 helper function 174 | Value: !GetAtt CopyFunction.Arn -------------------------------------------------------------------------------- /templates/main.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: ACFS3 - S3 Static site with CF and ACM (uksb-1qnk6ni7b) (version:v0.5) 3 | 4 | Metadata: 5 | AWS::CloudFormation::Interface: 6 | ParameterGroups: 7 | - Label: 8 | default: Domain 9 | Parameters: 10 | - SubDomain 11 | - DomainName 12 | 13 | Mappings: 14 | Solution: 15 | Constants: 16 | Version: 'v0.5' 17 | 18 | Parameters: 19 | SubDomain: 20 | Description: The part of a website address before your DomainName - e.g. www or img 21 | Type: String 22 | Default: www 23 | AllowedPattern: ^[^.]*$ 24 | DomainName: 25 | Description: The part of a website address after your SubDomain - e.g. example.com 26 | Type: String 27 | CreateApex: 28 | Description: Create an Apex Alias in CloudFront distribution - yes/no 29 | Type: String 30 | Default: 'no' 31 | AllowedValues: ['yes','no'] 32 | 33 | Resources: 34 | CustomResourceStack: 35 | Type: AWS::CloudFormation::Stack 36 | Properties: 37 | TemplateURL: ./custom-resource.yaml 38 | Tags: 39 | - Key: Solution 40 | Value: ACFS3 41 | 42 | AcmCertificateStack: 43 | Type: AWS::CloudFormation::Stack 44 | Properties: 45 | TemplateURL: ./acm-certificate.yaml 46 | Parameters: 47 | SubDomain: !Ref SubDomain 48 | DomainName: !Ref DomainName 49 | CFNCustomProvider: !GetAtt CustomResourceStack.Outputs.CFNCustomProvider 50 | CopyFunction: !GetAtt CustomResourceStack.Outputs.CopyFunction 51 | CreateApex: !Ref CreateApex 52 | Tags: 53 | - Key: Solution 54 | Value: ACFS3 55 | 56 | CloudFrontStack: 57 | Type: AWS::CloudFormation::Stack 58 | Properties: 59 | TemplateURL: ./cloudfront-site.yaml 60 | Parameters: 61 | CertificateArn: !GetAtt AcmCertificateStack.Outputs.CertificateArn 62 | DomainName: !Ref DomainName 63 | SubDomain: !Ref SubDomain 64 | CreateApex: !Ref CreateApex 65 | S3BucketRoot: !GetAtt CustomResourceStack.Outputs.S3BucketRoot 66 | S3BucketRootName: !GetAtt CustomResourceStack.Outputs.S3BucketRootName 67 | S3BucketRootArn: !GetAtt CustomResourceStack.Outputs.S3BucketRootArn 68 | S3BucketLogs: !GetAtt CustomResourceStack.Outputs.S3BucketLogs 69 | S3BucketLogsName: !GetAtt CustomResourceStack.Outputs.S3BucketLogsName 70 | Tags: 71 | - Key: Solution 72 | Value: ACFS3 73 | 74 | Outputs: 75 | SolutionVersion: 76 | Value: !FindInMap [Solution, Constants, Version] 77 | CFNCustomProvider: 78 | Description: ACM helper function 79 | Value: !GetAtt CustomResourceStack.Outputs.CFNCustomProvider 80 | CopyFunction: 81 | Description: S3 helper function 82 | Value: !GetAtt CustomResourceStack.Outputs.CopyFunction 83 | S3BucketLogs: 84 | Description: Logging bucket 85 | Value: !GetAtt CustomResourceStack.Outputs.S3BucketLogs 86 | S3BucketRoot: 87 | Description: Website bucket 88 | Value: !GetAtt CustomResourceStack.Outputs.S3BucketRoot 89 | S3BucketLogsName: 90 | Description: Logging bucket name 91 | Value: !GetAtt CustomResourceStack.Outputs.S3BucketLogsName 92 | S3BucketRootName: 93 | Description: Website bucket name 94 | Value: !GetAtt CustomResourceStack.Outputs.S3BucketRootName 95 | CertificateArn: 96 | Description: Issued certificate 97 | Value: !GetAtt AcmCertificateStack.Outputs.CertificateArn 98 | CFDistributionName: 99 | Description: CloudFront distribution 100 | Value: !GetAtt CloudFrontStack.Outputs.CloudFrontDistribution 101 | LambdaEdgeFunctionVersion: 102 | Description: Security Lambda version 103 | Value: !GetAtt CloudFrontStack.Outputs.LambdaEdgeFunctionVersion 104 | CloudFrontDomainName: 105 | Description: Website address 106 | Value: !GetAtt CloudFrontStack.Outputs.CloudFrontDomainName -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": [ 10 | "es2017", 11 | "dom" 12 | ], /* Specify library files to be included in the compilation. */ 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./dist", /* Redirect output structure to the directory. */ 21 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 22 | // "composite": true, /* Enable project compilation */ 23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true, /* Enable all strict type-checking options. */ 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 39 | 40 | /* Additional Checks */ 41 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 45 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 46 | 47 | /* Module Resolution Options */ 48 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 52 | // "typeRoots": [], /* List of folders to include type definitions from. */ 53 | "types": ["jest"], /* Type declaration files to be included in compilation. */ 54 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 55 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 58 | 59 | /* Source Map Options */ 60 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 63 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 64 | 65 | /* Experimental Options */ 66 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const CspHtmlWebpackPlugin = require('csp-html-webpack-plugin'); 5 | const FaviconsWebpackPlugin = require('favicons-webpack-plugin') 6 | 7 | module.exports = { 8 | entry: './src/index.ts', 9 | target: 'web', 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.tsx?$/, 14 | use: 'babel-loader', 15 | exclude: /node_modules/, 16 | }, 17 | { 18 | test: /\.css$/i, 19 | use: ["style-loader", "css-loader"], 20 | }, 21 | { 22 | test: /\.md$/i, 23 | type: 'asset/resource' 24 | }, 25 | ], 26 | }, 27 | resolve: { 28 | extensions: [ '.tsx', '.ts', '.js' ], 29 | }, 30 | output: { 31 | filename: 'bundle.[chunkhash].js', 32 | path: path.resolve(__dirname, 'dist'), 33 | }, 34 | plugins: [ 35 | new HtmlWebpackPlugin({ 36 | title: 'JSON Schema Viewer', 37 | template: 'index.html', 38 | publicPath: '/', 39 | filename: 'index.html' 40 | }), 41 | new CspHtmlWebpackPlugin({ 42 | 'script-src': ["'strict-dynamic'", "https://cdn.jsdelivr.net/npm/monaco-editor@0.36.1/"], 43 | 'style-src': ["'unsafe-inline'", "'self'", "https://cdn.jsdelivr.net/npm/monaco-editor@0.36.1/"] 44 | }), 45 | new webpack.DefinePlugin({ 46 | 'process': undefined, 47 | 'process.release': null 48 | }), 49 | new webpack.EnvironmentPlugin({ 50 | ANALYTICS_NEXT_MODERN_CONTEXT: true 51 | }), 52 | new FaviconsWebpackPlugin({ 53 | logo: './src/logo.svg', 54 | publicPath: '/', 55 | prefix: 'auto/[contenthash]' 56 | }) 57 | ] 58 | }; 59 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | devServer: { 8 | historyApiFallback: true, 9 | }, 10 | ignoreWarnings: [ 11 | // Known issue with Atlaskit 12 | { 13 | module: /@atlaskit/, 14 | message: /Should not import the named export/, 15 | }, 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production' 6 | }); --------------------------------------------------------------------------------